From fb9fe326c477c2d4cee808fa785656b5847caae8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 7 May 2026 22:58:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20Wave=204=20=E2=80=94=20Register?= =?UTF-8?q?Page=20responsive=20+=20TransferIndicator=20on=20settings=20+?= =?UTF-8?q?=20MessageInput=20sheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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) --- docs/systems/auth.md | 2 + docs/systems/uploads.md | 12 ++ .../web/src/components/auth/RegisterPage.tsx | 62 ++++-- .../web/src/components/chat/EmojiPicker.tsx | 34 ++- .../web/src/components/chat/GifPicker.tsx | 23 +- .../web/src/components/chat/InputPopover.tsx | 156 ++++++++++---- .../src/components/chat/MentionPopover.tsx | 201 +++++++++++++++--- .../web/src/components/chat/MessageInput.tsx | 10 +- .../components/layout/MobileInstancePanel.tsx | 3 +- .../layout/MobileSettingsScreen.tsx | 21 +- .../web/src/components/layout/MobileShell.tsx | 13 +- .../components/layout/TransferIndicator.tsx | 24 ++- packages/web/src/styles/globals.css | 13 ++ 13 files changed, 439 insertions(+), 135 deletions(-) diff --git a/docs/systems/auth.md b/docs/systems/auth.md index 0015f7f7..1eb9d44f 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -563,6 +563,8 @@ Called by `useAuth()` hook when token exists but user object is null: **Auth-token source of truth.** During the step-2 avatar upload, the JWT lives in `localStorage` only -- `authStore.token` (Zustand) is still null because step 3 hasn't fired. Both the home `api` client (`api/client.ts`) and the home-origin branch of `setTokenForOriginResolver` in `instanceStore.ts` therefore read the home JWT from `localStorage.getItem('backspace_token')`, never from `authStore.token`. This keeps `transferStore.startUpload` (and any other path that resolves a home-origin bearer) authenticated during the registration window. The two stores are written together everywhere else (`initSession`/`logout`), so the divergence only matters between steps 1 and 3 here. +**Responsive contract.** Auth pages render outside `MobileShell` (they are pre-layout). The `RegisterPage` outer wrapper is a self-contained scroll container -- `h-full overflow-y-auto` on the outermost `
` because `#root` is `h-full overflow-hidden` (see `globals.css`). An inner `min-h-full flex items-center justify-center` wrapper centers the card vertically when content fits, and falls back to top-aligned scroll when content exceeds the viewport (as on iOS Safari with the keyboard up, where the visible viewport shrinks by ~300 px). Card width is `max-w-[480px]` with `px-4` outer gutters, `p-6 md:p-8` inner padding (smaller on mobile to reclaim 16 px content area at 360 px viewports). All `` elements override the shared `input-standard` class's `text-sm` with `text-base md:text-sm` -- iOS Safari auto-zooms when an input has font-size <16 px. Primary submit buttons use `py-3 md:py-2.5` to satisfy Apple HIG's ≥44 px tap-target rule on mobile. The closed-registration URL-token chip switches from `inline-flex` (desktop pill) to `flex` (mobile full-width banner) so longer error copy ("Invalid invite link -- please request a new one") wraps cleanly inside a 360 px viewport instead of forcing a single-line pill that overflows. The avatar color swatch row uses `gap-2 md:gap-2.5` so the 7 swatches fit within the 360 px content area. **Note:** `LoginPage` does NOT yet apply the same scroll/iOS-zoom/tap-target treatment; this is a known follow-up since LoginPage's shorter form is less likely to clip. Update both together if revisiting. + --- ## 8. Federation-Aware Identity Utilities diff --git a/docs/systems/uploads.md b/docs/systems/uploads.md index 27e41868..bf339f81 100644 --- a/docs/systems/uploads.md +++ b/docs/systems/uploads.md @@ -203,6 +203,18 @@ When a user sets an avatar/banner or a space sets an icon/banner: The attachment record for profile images is intentionally deleted -- the authoritative reference moves to the `users.avatar`/`users.banner` or `spaces.icon`/`spaces.banner` column. +### Mobile Transfer-Chrome Surfaces + +In-flight transfers (chat attachments, profile/banner uploads) are surfaced via the shared `TransferIndicator` component. Mount points: + +- **Chat header (desktop):** every `MainContent.tsx` header variant — DM, group DM, space text channel, voice. +- **Chat header (mobile):** `MobileChatScreen.tsx` mounts `` in its custom header. +- **Settings/Instance screens (mobile):** mounted via `MobileScreenHeader.tsx`'s `rightActions` slot on every settings screen — `MobileSettingsScreen` (hub + each direct panel), `MobileInstancePanel`, and the six `settings-instance-*` sub-panel wrappers in `MobileShell.tsx`. This guarantees that a profile-picture or banner upload triggered from settings has visible progress chrome and an abort affordance regardless of the user's current screen. + +The component is lightweight when idle — the underlying `transferStore` Map subscription is a single `useMemo` over `Array.from(...).filter(t => t.tray)`, and the rendered button is a small icon (no badge) until at least one transfer is active. Safe to mount on every settings screen without performance impact. + +The dropdown panel uses `touchstart` + `mousedown` listeners for click-outside dismissal so a single tap on iOS Safari closes the tray. Panel width is `min(300px, calc(100vw - 16px))` to prevent right-edge clipping on narrow viewports while keeping the desktop panel size unchanged. + --- ## 5. Thumbnail Generation Details diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 745e0337..7823e6c7 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -398,9 +398,16 @@ export function RegisterPage() { (inviteRequired && !inviteValid); return ( -
-
-
+ // Outer scroll container — root is `h-full overflow-hidden`, so this page must own + // its own scroll. Without it, mobile users with the keyboard up cannot reach the + // submit button on Step 2 (avatar + color picker + display name + buttons exceeds + // the visible viewport once the iOS keyboard claims ~300 px). `h-full` (rather than + // `min-h-full`) makes this element exactly viewport-height; the inner flex wrapper + // uses `min-h-full` so short content still centers vertically. +
+
+
+
{/* Progress dots */}
@@ -432,7 +439,9 @@ export function RegisterPage() { value={manualInviteToken} onChange={(e) => setManualInviteToken(e.target.value)} placeholder="Invite code or link" - className="input-standard w-full px-3 py-2 text-sm" + // text-base on mobile prevents iOS Safari from auto-zooming + // when the field is focused (any with font-size <16px triggers zoom). + className="input-standard w-full px-3 py-2 text-base md:text-sm" aria-label="Invite code or link" autoComplete="off" /> @@ -458,30 +467,33 @@ export function RegisterPage() { )} {/* URL-token chip — shown only when registration is closed AND a URL token was provided. - Per spec §4.4: open-registration instances silently ignore the ?invite= param. */} + Per spec §4.4: open-registration instances silently ignore the ?invite= param. + Layout: inline pill on desktop, full-width banner on mobile so the longer + error copy ("Invalid invite link — please request a new one") wraps cleanly + inside a 360 px viewport instead of forcing a single-line pill that overflows. */} {urlInviteToken && instanceInfo && !instanceInfo.registrationOpen && ( -
+
{inviteChecking ? ( <> - + - Validating invite... + Validating invite... ) : inviteCheck?.valid === true ? ( <> - + - Using invite: {inviteCheck.name} + Using invite: {inviteCheck.name} ) : inviteCheck?.valid === false ? ( <> - + - Invalid invite link — please request a new one + Invalid invite link — please request a new one ) : ( <>Validating invite... @@ -497,7 +509,8 @@ export function RegisterPage() { type="text" value={username} onChange={(e) => setUsername(e.target.value.toLowerCase())} - className="input-standard w-full py-2.5" + // text-base on mobile prevents iOS Safari zoom-on-focus (<16px triggers it). + className="input-standard w-full py-2.5 text-base md:text-sm" autoFocus autoComplete="username" /> @@ -536,7 +549,7 @@ export function RegisterPage() { type="password" value={password} onChange={(e) => setPassword(e.target.value)} - className="input-standard w-full py-2.5" + className="input-standard w-full py-2.5 text-base md:text-sm" autoComplete="new-password" />
@@ -549,7 +562,7 @@ export function RegisterPage() { type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} - className="input-standard w-full py-2.5" + className="input-standard w-full py-2.5 text-base md:text-sm" autoComplete="new-password" />
@@ -557,7 +570,9 @@ export function RegisterPage() { @@ -643,7 +658,7 @@ export function RegisterPage() { value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={username.trim() || 'Display name'} - className="input-standard w-full py-2.5" + className="input-standard w-full py-2.5 text-base md:text-sm" autoComplete="name" />
@@ -653,7 +668,10 @@ export function RegisterPage() { -
+ {/* Color swatch row: gap tightens on narrow viewports so the 7 swatches + fit inside a 360 px viewport (p-6 inner content area is ~280 px; + 7×32 + 6×10 = 284 px would overflow with gap-2.5). */} +
{AVATAR_COLORS.map((key) => { const entry = AVATAR_GRADIENT_MAP[key]; return ( @@ -679,7 +697,7 @@ export function RegisterPage() { type="button" onClick={() => handleRegister(false)} disabled={isDisabled} - className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > {retryAfter > 0 ? `Try again in ${retryAfter}s` @@ -693,7 +711,8 @@ export function RegisterPage() { type="button" onClick={() => { setError(''); setRetryAfter(0); setDirection('back'); setStep(1); }} disabled={isRegistering} - className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50" + // py-2 px-1 widens the tap area on mobile while keeping the visual link style. + className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1" > Back @@ -701,13 +720,14 @@ export function RegisterPage() { type="button" onClick={() => handleRegister(true)} disabled={isDisabled} - className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50" + className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1" > Skip for now
)} +
{/* Image Crop Modal */} diff --git a/packages/web/src/components/chat/EmojiPicker.tsx b/packages/web/src/components/chat/EmojiPicker.tsx index cb3df94f..c827b039 100644 --- a/packages/web/src/components/chat/EmojiPicker.tsx +++ b/packages/web/src/components/chat/EmojiPicker.tsx @@ -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(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 `` 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 ( -
+
diff --git a/packages/web/src/components/chat/GifPicker.tsx b/packages/web/src/components/chat/GifPicker.tsx index a917a982..c106d4d5 100644 --- a/packages/web/src/components/chat/GifPicker.tsx +++ b/packages/web/src/components/chat/GifPicker.tsx @@ -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([]); @@ -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 ( -
+
{/* Search */} -
+
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} />
@@ -141,7 +154,7 @@ export function GifPicker({ onGifSelect }: GifPickerProps) {
{/* Attribution */} -
+
Powered by Klipy
diff --git a/packages/web/src/components/chat/InputPopover.tsx b/packages/web/src/components/chat/InputPopover.tsx index e8b0f71c..f65bc56a 100644 --- a/packages/web/src/components/chat/InputPopover.tsx +++ b/packages/web/src/components/chat/InputPopover.tsx @@ -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 ( +
+ {availableTabs.map((t) => ( + + ))} +
+ ); +} + +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(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(
- {/* Tab bar */} - {showTabs && ( -
- {availableTabs.map((t) => ( - - ))} -
- )} - + {/* Content */}
- {activeTab === 'emoji' && ( - - )} - {activeTab === 'gif' && gifEnabled && ( - - )} + {activeTab === 'emoji' && } + {activeTab === 'gif' && gifEnabled && }
, 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 */} +
+ {/* Sheet */} +
e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + {/* Drag handle */} +
+ + {/* Tab bar (only when multiple tabs available) */} + + + {/* Content */} +
+ {activeTab === 'emoji' && } + {activeTab === 'gif' && gifEnabled && } +
+
+ , + 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 ; + } + return ; +} diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index 1cef4539..37b07beb 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -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; 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 (
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' }`} > {displayName} @@ -61,12 +68,144 @@ interface MentionPopoverProps { anchorRef: React.RefObject; } +interface ResolvedListProps { + filtered: MemberWithUser[]; + selectedIndex: number; + selectedRef: React.RefObject; + onSelect: (member: MemberWithUser) => void; + getMemberColor: (member: MemberWithUser) => string | undefined; + mobile: boolean; +} + +function MemberList({ + filtered, + selectedIndex, + selectedRef, + onSelect, + getMemberColor, + mobile, +}: ResolvedListProps) { + return ( + <> +
+ Members +
+ {filtered.map((member, i) => ( + + ))} + + ); +} + +interface DesktopMentionProps extends MentionPopoverProps { + filtered: MemberWithUser[]; + getMemberColor: (member: MemberWithUser) => string | undefined; +} + +function DesktopMention({ + filtered, + selectedIndex, + onSelect, + anchorRef, + getMemberColor, +}: DesktopMentionProps) { + const selectedRef = useRef(null); + const floatingRef = useRef(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( +
+
+ +
+
, + document.body, + ); +} + +interface MobileMentionProps extends MentionPopoverProps { + filtered: MemberWithUser[]; + getMemberColor: (member: MemberWithUser) => string | undefined; +} + +function MobileMention({ + filtered, + selectedIndex, + onSelect, + getMemberColor, +}: MobileMentionProps) { + const selectedRef = useRef(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( + <> +
+
+ {/* Drag handle */} +
+ +
+ +
+
+ , + 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(null); - const floatingRef = useRef(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( -
-
-
- Members -
- {filtered.map((member, i) => ( - - ))} -
-
, - document.body, + if (isMobile) { + return ( + + ); + } + + return ( + ); } diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 91abcc97..4572841c 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -710,12 +710,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
)} -
+
{/* File attach button */} {canAttachFiles && ( -

{panel.title}

- + } />
{panel.component}
@@ -84,14 +78,7 @@ export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps return (
-
- -

Settings

-
+ } />
{sections.map((section) => ( {open && ( -
+ // Desktop: anchor below the trigger via `absolute right-0 top-full`. + // Mobile (<768px): switch to `fixed` so the panel pins to the viewport + // right edge with an 8px margin instead of inheriting the trigger's + // x-position. This prevents left-edge clipping on narrow viewports + // (e.g. 320px) where the trigger sits well to the left of the + // viewport's right edge — `right-0` would otherwise anchor the + // panel to the trigger's right and let the 300px panel extend off + // the left of the viewport. +
{visible.length} transfer{visible.length === 1 ? '' : 's'} diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 39993747..3b32be01 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -320,6 +320,19 @@ max-height: 400px; } +/* Mobile bottom-sheet variant: stretch the custom element to fill the + sheet's width (so `dynamicWidth: true` can recompute the grid against + the real container). Height is left to emoji-mart's own layout — the + picker manages its internal scroll region against its measured height + and forcing `height: 100%` here would collapse its category nav off- + screen because the custom element doesn't propagate flex sizing into + its shadow root. The 400px desktop cap is fine for mobile too; the + sheet's `max-height: min(60dvh, 60vh)` gives us ~440-500px to play + with on common phone viewports. */ +.emoji-picker-wrapper--mobile em-emoji-picker { + width: 100%; +} + /* Animations */ @keyframes fadeIn { from { opacity: 0; }