feat(mobile): Wave 4 — RegisterPage responsive + TransferIndicator on settings + MessageInput sheets

- RegisterPage: scroll envelope, iOS-zoom-safe inputs, ≥44px tap targets, mobile-friendly invite chip + swatch row
- TransferIndicator: mounted via MobileScreenHeader.rightActions across every settings/instance screen and inline in MobileChatScreen; touch-close listener; viewport-safe panel width
- MessageInput popovers (emoji/GIF/mention) now route through new InputPopover wrapper — desktop popover, mobile bottom-sheet
- EmojiPicker mobile branch: dynamicWidth + scoped CSS (.emoji-picker-wrapper--mobile) so the <em-emoji-picker> custom element fills the sheet; bigger touch targets (perLine 8, button 40, emoji 28); desktop unchanged
- MessageInput trigger row: mobile-first sizing with md: desktop overrides (40×40 buttons + gap on mobile, 34×34 unchanged on desktop)
- Spec updates: docs/systems/auth.md (RegisterPage responsive contract), docs/systems/uploads.md (TransferIndicator mobile surfaces)
This commit is contained in:
Jannis Braun
2026-05-07 22:58:57 +02:00
parent 8544f83225
commit fb9fe326c4
13 changed files with 439 additions and 135 deletions
@@ -4,9 +4,16 @@ import data from '@emoji-mart/data';
interface EmojiPickerProps {
onEmojiSelect: (emoji: { native: string }) => void;
/**
* Mobile rendering: stretch the picker to fill its container width
* (the parent bottom-sheet provides the viewport-wide bounds), use
* larger touch targets, and let the picker's own scroll area expand
* to consume the available height of the sheet.
*/
mobile?: boolean;
}
export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
@@ -18,8 +25,24 @@ export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
return () => el.removeEventListener('keydown', stop);
}, []);
// emoji-mart's Picker computes its own internal width from
// `perLine * emojiButtonSize` UNLESS `dynamicWidth` is set, in which case
// it stretches to its parent's width. On mobile we want full-viewport.
// The mobile sheet wraps this picker in `flex-1 min-h-0 flex flex-col`,
// so we make the wrapper fill that space and tell the picker to expand.
//
// The `<em-emoji-picker>` custom element has no intrinsic stretch behavior:
// even with `dynamicWidth: true` it ships with `display: flex` but no
// `width: 100%`, so it shrinks to its perLine*emojiButtonSize content width
// unless we force it to fill. The `emoji-picker-wrapper--mobile` modifier
// applies that override (see globals.css) — desktop keeps the legacy
// intrinsic-width sizing.
const wrapperClass = mobile
? 'emoji-picker-wrapper emoji-picker-wrapper--mobile flex-1 min-h-0 w-full overflow-hidden'
: 'emoji-picker-wrapper';
return (
<div ref={containerRef} className="emoji-picker-wrapper">
<div ref={containerRef} className={wrapperClass}>
<Picker
data={data}
onEmojiSelect={onEmojiSelect}
@@ -28,10 +51,11 @@ export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
skinTonePosition="search"
previewPosition="none"
navPosition="bottom"
perLine={10}
perLine={mobile ? 8 : 10}
maxFrequentRows={2}
emojiSize={24}
emojiButtonSize={32}
emojiSize={mobile ? 28 : 24}
emojiButtonSize={mobile ? 40 : 32}
dynamicWidth={mobile ? true : false}
categories={['frequent', 'people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols', 'flags']}
/>
</div>
+18 -5
View File
@@ -4,9 +4,14 @@ import type { GifResult } from '@backspace/shared';
interface GifPickerProps {
onGifSelect: (url: string) => void;
/**
* Mobile rendering: drop the desktop fixed dimensions and let the picker
* fill its parent (a bottom sheet that controls width + max-height).
*/
mobile?: boolean;
}
export function GifPicker({ onGifSelect }: GifPickerProps) {
export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
@@ -78,17 +83,25 @@ export function GifPicker({ onGifSelect }: GifPickerProps) {
e.stopPropagation();
};
// Mobile: fill parent (sheet sets width + max-height). Desktop: fixed dims
// matching the legacy popover footprint.
const rootClass = mobile
? 'flex flex-col flex-1 min-h-0 w-full'
: 'flex flex-col h-[390px] w-[390px]';
return (
<div className="flex flex-col h-[390px] w-[390px]" onKeyDown={handleKeyDown}>
<div className={rootClass} onKeyDown={handleKeyDown}>
{/* Search */}
<div className="px-3 pt-2 pb-1.5">
<div className="px-3 pt-2 pb-1.5 shrink-0">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search GIFs"
className="input-search w-full"
autoFocus
// Auto-focus only on desktop. On mobile this would force the OS
// keyboard up the moment the sheet opens, hiding most of the grid.
autoFocus={!mobile}
/>
</div>
@@ -141,7 +154,7 @@ export function GifPicker({ onGifSelect }: GifPickerProps) {
</div>
{/* Attribution */}
<div className="px-3 py-1 text-[10px] text-txt-tertiary text-right">
<div className="px-3 py-1 text-[10px] text-txt-tertiary text-right shrink-0">
Powered by Klipy
</div>
</div>
+120 -36
View File
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker';
import { useUIStore } from '../../stores/uiStore';
export type InputPopoverTab = 'emoji' | 'gif';
@@ -15,7 +16,38 @@ interface InputPopoverProps {
onTabChange: (tab: InputPopoverTab) => void;
}
export function InputPopover({
interface SharedTabProps {
activeTab: InputPopoverTab;
availableTabs: { key: InputPopoverTab; label: string }[];
onTabChange: (tab: InputPopoverTab) => void;
}
function TabBar({ activeTab, availableTabs, onTabChange }: SharedTabProps) {
if (availableTabs.length <= 1) return null;
return (
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
{availableTabs.map((t) => (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
activeTab === t.key
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
>
{t.label}
</button>
))}
</div>
);
}
interface DesktopPopoverProps extends InputPopoverProps {
availableTabs: { key: InputPopoverTab; label: string }[];
}
function DesktopPopover({
activeTab,
onClose,
onEmojiSelect,
@@ -23,7 +55,8 @@ export function InputPopover({
anchorRef,
gifEnabled,
onTabChange,
}: InputPopoverProps) {
availableTabs,
}: DesktopPopoverProps) {
const floatingRef = useRef<HTMLDivElement>(null);
// Position above the anchor
@@ -93,15 +126,6 @@ export function InputPopover({
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' },
];
if (gifEnabled) {
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
}
const showTabs = availableTabs.length > 1;
return createPortal(
<div
ref={floatingRef}
@@ -109,36 +133,96 @@ export function InputPopover({
style={{ top: -9999, left: -9999 }}
>
<div className="glass rounded-xl overflow-hidden flex flex-col w-fit max-h-[435px]">
{/* Tab bar */}
{showTabs && (
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
{availableTabs.map((t) => (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
activeTab === t.key
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
>
{t.label}
</button>
))}
</div>
)}
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && (
<EmojiPicker onEmojiSelect={onEmojiSelect} />
)}
{activeTab === 'gif' && gifEnabled && (
<GifPicker onGifSelect={onGifSelect} />
)}
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
</div>
</div>
</div>,
document.body,
);
}
interface MobileSheetProps extends InputPopoverProps {
availableTabs: { key: InputPopoverTab; label: string }[];
}
function MobileSheet({
activeTab,
onClose,
onEmojiSelect,
onGifSelect,
gifEnabled,
onTabChange,
availableTabs,
}: MobileSheetProps) {
// Escape to close (parity with desktop)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return createPortal(
<>
{/* Backdrop — single tap (mousedown OR touchstart) closes */}
<div
className="fixed inset-0 z-[300] bg-black/30"
onMouseDown={onClose}
onTouchStart={onClose}
/>
{/* Sheet */}
<div
className="fixed left-0 right-0 z-[301] rounded-t-2xl glass-modal animate-slide-up-sheet flex flex-col"
style={{
// Sit at the bottom of the visible viewport. On iOS 16.4+ the
// `keyboard-inset-height` env var lifts us above the soft keyboard;
// on older iOS the 100dvh-based MobileShell layout already shrinks
// the visual viewport when the keyboard is open, so bottom:0 lands
// just above the keyboard naturally.
bottom: 'env(keyboard-inset-height, 0px)',
paddingBottom: 'env(safe-area-inset-bottom)',
maxHeight: 'min(60dvh, 60vh)',
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
{/* Tab bar (only when multiple tabs available) */}
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
</div>
</div>
</>,
document.body,
);
}
export function InputPopover(props: InputPopoverProps) {
const isMobile = useUIStore((s) => s.isMobile);
const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' },
];
if (props.gifEnabled) {
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
}
if (isMobile) {
return <MobileSheet {...props} availableTabs={availableTabs} />;
}
return <DesktopPopover {...props} availableTabs={availableTabs} />;
}
@@ -5,6 +5,7 @@ import { Avatar } from '../ui/Avatar';
import { useSpaceStore } from '../../stores/spaceStore';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { useUIStore } from '../../stores/uiStore';
const MAX_RESULTS = 8;
@@ -14,33 +15,39 @@ function MentionMemberRow({
selectedRef,
onSelect,
roleColor,
mobile,
}: {
member: MemberWithUser;
isSelected: boolean;
selectedRef: React.RefObject<HTMLDivElement>;
onSelect: (member: MemberWithUser) => void;
roleColor: string | undefined;
mobile: boolean;
}) {
const canonical = useCanonicalUserView(member.user);
const displayName = canonical.displayName ?? canonical.username;
// Mobile: ≥44 px tap target per Apple HIG; desktop: compact list.
const rowSizing = mobile
? 'gap-3 px-3 py-2.5 min-h-[44px]'
: 'gap-2.5 px-2 py-1.5';
return (
<div
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(member)}
className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${
className={`flex items-center mx-1 rounded cursor-pointer transition-colors ${rowSizing} ${
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
}`}
>
<Avatar
src={canonical.avatar}
name={displayName}
size={24}
size={mobile ? 28 : 24}
status={canonical.status}
userId={canonical.homeUserId ?? canonical.id}
user={canonical}
/>
<span
className="text-[14px] font-medium truncate"
className={`${mobile ? 'text-[15px]' : 'text-[14px]'} font-medium truncate`}
style={roleColor ? { color: roleColor } : undefined}
>
{displayName}
@@ -61,12 +68,144 @@ interface MentionPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
}
interface ResolvedListProps {
filtered: MemberWithUser[];
selectedIndex: number;
selectedRef: React.RefObject<HTMLDivElement>;
onSelect: (member: MemberWithUser) => void;
getMemberColor: (member: MemberWithUser) => string | undefined;
mobile: boolean;
}
function MemberList({
filtered,
selectedIndex,
selectedRef,
onSelect,
getMemberColor,
mobile,
}: ResolvedListProps) {
return (
<>
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members
</div>
{filtered.map((member, i) => (
<MentionMemberRow
key={member.userId}
member={member}
isSelected={i === selectedIndex}
selectedRef={selectedRef}
onSelect={onSelect}
roleColor={getMemberColor(member)}
mobile={mobile}
/>
))}
</>
);
}
interface DesktopMentionProps extends MentionPopoverProps {
filtered: MemberWithUser[];
getMemberColor: (member: MemberWithUser) => string | undefined;
}
function DesktopMention({
filtered,
selectedIndex,
onSelect,
anchorRef,
getMemberColor,
}: DesktopMentionProps) {
const selectedRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: 'top',
offset: 4,
enabled: filtered.length > 0,
});
// Scroll selected item into view
useEffect(() => {
selectedRef.current?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
return createPortal(
<div ref={floatingRef} style={style} className="w-[280px]">
<div className="glass rounded-lg overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<MemberList
filtered={filtered}
selectedIndex={selectedIndex}
selectedRef={selectedRef}
onSelect={onSelect}
getMemberColor={getMemberColor}
mobile={false}
/>
</div>
</div>,
document.body,
);
}
interface MobileMentionProps extends MentionPopoverProps {
filtered: MemberWithUser[];
getMemberColor: (member: MemberWithUser) => string | undefined;
}
function MobileMention({
filtered,
selectedIndex,
onSelect,
getMemberColor,
}: MobileMentionProps) {
const selectedRef = useRef<HTMLDivElement>(null);
// Scroll selected item into view as the user types/arrows
useEffect(() => {
selectedRef.current?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
// Mention is auto-driven by composer text. Tapping the backdrop should NOT
// commit a selection; it should simply let the user keep typing. The mention
// popover dismisses naturally when the @-token is deleted/closed by composer
// logic. We render a transparent backdrop only to visually scrim, and rely
// on the composer's own dismissal flow rather than a click-to-close handler.
return createPortal(
<>
<div className="fixed inset-0 z-[300] bg-black/30 pointer-events-none" />
<div
className="fixed left-0 right-0 z-[301] rounded-t-2xl glass-modal animate-slide-up-sheet flex flex-col"
style={{
bottom: 'env(keyboard-inset-height, 0px)',
paddingBottom: 'env(safe-area-inset-bottom)',
maxHeight: 'min(50dvh, 50vh)',
}}
>
{/* Drag handle */}
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin">
<MemberList
filtered={filtered}
selectedIndex={selectedIndex}
selectedRef={selectedRef}
onSelect={onSelect}
getMemberColor={getMemberColor}
mobile={true}
/>
</div>
</div>
</>,
document.body,
);
}
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) {
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const selectedRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const isMobile = useUIStore((s) => s.isMobile);
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
@@ -81,17 +220,6 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
.slice(0, MAX_RESULTS);
}, [members, query]);
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: 'top',
offset: 4,
enabled: filtered.length > 0,
});
// Scroll selected item into view
useEffect(() => {
selectedRef.current?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (filtered.length === 0) return null;
const getMemberColor = (member: MemberWithUser): string | undefined => {
@@ -103,24 +231,27 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
return undefined;
};
return createPortal(
<div ref={floatingRef} style={style} className="w-[280px]">
<div className="glass rounded-lg overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members
</div>
{filtered.map((member, i) => (
<MentionMemberRow
key={member.userId}
member={member}
isSelected={i === selectedIndex}
selectedRef={selectedRef}
onSelect={onSelect}
roleColor={getMemberColor(member)}
/>
))}
</div>
</div>,
document.body,
if (isMobile) {
return (
<MobileMention
query={query}
selectedIndex={selectedIndex}
onSelect={onSelect}
anchorRef={anchorRef}
filtered={filtered}
getMemberColor={getMemberColor}
/>
);
}
return (
<DesktopMention
query={query}
selectedIndex={selectedIndex}
onSelect={onSelect}
anchorRef={anchorRef}
filtered={filtered}
getMemberColor={getMemberColor}
/>
);
}
@@ -710,12 +710,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</div>
)}
<div className="flex items-center pl-[10px] pr-1">
<div className="flex items-center gap-1 md:gap-0 pl-2 md:pl-[10px] pr-2 md:pr-1">
{/* File attach button */}
{canAttachFiles && (
<button
onClick={() => fileInputRef.current?.click()}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file"
aria-label="Attach file"
>
@@ -783,7 +783,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
{gifEnabled && (
<button
onClick={() => togglePopover('gif')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="GIF"
@@ -798,7 +798,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
{/* Emoji button */}
<button
onClick={() => togglePopover('emoji')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Emoji"
@@ -814,7 +814,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<button
onClick={() => void handleSubmit()}
disabled={anyUnshippable}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message"
title="Send"
>