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:
@@ -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 `<div>` 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 `<input>` 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
|
||||
|
||||
@@ -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 `<TransferIndicator />` 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
|
||||
|
||||
@@ -398,9 +398,16 @@ export function RegisterPage() {
|
||||
(inviteRequired && !inviteValid);
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 overflow-hidden">
|
||||
// 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.
|
||||
<div className="h-full overflow-y-auto bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)] pointer-events-none" />
|
||||
<div className="min-h-full flex items-center justify-center px-4 py-6 md:py-10 relative z-10">
|
||||
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-6 md:p-8 shadow-elevation-high overflow-hidden">
|
||||
{/* Progress dots */}
|
||||
<div className="flex justify-center gap-2 mb-5">
|
||||
<div className={`w-2 h-2 rounded-full transition-colors duration-300 ${step === 1 ? 'bg-accent-primary' : 'bg-txt-tertiary/30'}`} />
|
||||
@@ -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 <input> 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 && (
|
||||
<div className="mb-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent-primary/10 text-accent-primary text-xs">
|
||||
<div className="mb-4 flex md:inline-flex items-start md:items-center gap-2 px-3 py-1.5 rounded-md md:rounded-full bg-accent-primary/10 text-accent-primary text-xs">
|
||||
{inviteChecking ? (
|
||||
<>
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<svg className="w-3 h-3 animate-spin flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Validating invite...
|
||||
<span>Validating invite...</span>
|
||||
</>
|
||||
) : inviteCheck?.valid === true ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Using invite: {inviteCheck.name}
|
||||
<span className="break-all">Using invite: {inviteCheck.name}</span>
|
||||
</>
|
||||
) : inviteCheck?.valid === false ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Invalid invite link — please request a new one
|
||||
<span>Invalid invite link — please request a new one</span>
|
||||
</>
|
||||
) : (
|
||||
<>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"
|
||||
/>
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -557,7 +570,9 @@ export function RegisterPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={continueDisabled}
|
||||
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"
|
||||
// py-3 on mobile yields ≥44 px tap target (Apple HIG); py-2.5 keeps the
|
||||
// tighter desktop look from before.
|
||||
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"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -653,7 +668,10 @@ export function RegisterPage() {
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Avatar Color
|
||||
</label>
|
||||
<div className="flex gap-2.5 justify-center">
|
||||
{/* 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). */}
|
||||
<div className="flex gap-2 md:gap-2.5 justify-center">
|
||||
{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
|
||||
</button>
|
||||
@@ -701,7 +720,7 @@ 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
|
||||
</button>
|
||||
@@ -709,6 +728,7 @@ export function RegisterPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Crop Modal */}
|
||||
{avatarCropSrc && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileMention
|
||||
query={query}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
roleColor={getMemberColor(member)}
|
||||
anchorRef={anchorRef}
|
||||
filtered={filtered}
|
||||
getMemberColor={getMemberColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopMention
|
||||
query={query}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
anchorRef={anchorRef}
|
||||
filtered={filtered}
|
||||
getMemberColor={getMemberColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||
import { TransferIndicator } from './TransferIndicator';
|
||||
import { api } from '../../api/client';
|
||||
import { onFederationPeersChanged } from '../../hooks/useWebSocket';
|
||||
|
||||
@@ -135,7 +136,7 @@ export function MobileInstancePanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Instance" />
|
||||
<MobileScreenHeader title="Instance" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sections.map((section) => {
|
||||
const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AccountPanel } from '../modals/settingsPanels/AccountPanel';
|
||||
import { VoicePanel } from '../modals/settingsPanels/VoicePanel';
|
||||
import { ConnectionsPanel } from '../modals/settingsPanels/ConnectionsPanel';
|
||||
import { PrivacyPanel } from '../modals/settingsPanels/PrivacyPanel';
|
||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||
import { TransferIndicator } from './TransferIndicator';
|
||||
|
||||
interface MobileSettingsScreenProps {
|
||||
initialPanel?: string;
|
||||
@@ -46,7 +48,6 @@ const sectionIcons: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps) {
|
||||
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
|
||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||
const isAdmin = useAuthStore((s) => s.user?.isAdmin);
|
||||
|
||||
@@ -58,14 +59,7 @@ export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<header className="h-12 flex items-center gap-2 px-3 border-b border-border-soft shrink-0">
|
||||
<button onClick={popMobileScreen} className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-txt-primary">{panel.title}</h1>
|
||||
</header>
|
||||
<MobileScreenHeader title={panel.title} rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{panel.component}
|
||||
</div>
|
||||
@@ -84,14 +78,7 @@ export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<header className="h-12 flex items-center gap-2 px-3 border-b border-border-soft shrink-0">
|
||||
<button onClick={popMobileScreen} className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-txt-primary">Settings</h1>
|
||||
</header>
|
||||
<MobileScreenHeader title="Settings" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MobileChatScreen } from './MobileChatScreen';
|
||||
import { MobileSettingsScreen } from './MobileSettingsScreen';
|
||||
import { MobileInstancePanel } from './MobileInstancePanel';
|
||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||
import { TransferIndicator } from './TransferIndicator';
|
||||
import { MobileVoiceMiniBar } from './MobileVoiceMiniBar';
|
||||
import { MobileVoiceFullScreen } from './MobileVoiceFullScreen';
|
||||
import { MobileMembersScreen } from './MobileMembersScreen';
|
||||
@@ -36,7 +37,7 @@ function MobileFederationPanelWrapper() {
|
||||
const setApprovalCount = useUIStore((s) => s.setFederationApprovalCount);
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Federation" />
|
||||
<MobileScreenHeader title="Federation" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<FederationPanel onApprovalCountChange={setApprovalCount} />
|
||||
</div>
|
||||
@@ -55,32 +56,32 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
|
||||
'settings-instance': () => <MobileInstancePanel />,
|
||||
'settings-instance-general': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="General" />
|
||||
<MobileScreenHeader title="General" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
|
||||
</div>
|
||||
),
|
||||
'settings-instance-registration': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Registration" />
|
||||
<MobileScreenHeader title="Registration" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4"><RegistrationPanel /></div>
|
||||
</div>
|
||||
),
|
||||
'settings-instance-federation': () => <MobileFederationPanelWrapper />,
|
||||
'settings-instance-streaming': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Streaming" />
|
||||
<MobileScreenHeader title="Streaming" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4"><StreamingPanel /></div>
|
||||
</div>
|
||||
),
|
||||
'settings-instance-storage': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Storage" />
|
||||
<MobileScreenHeader title="Storage" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4"><StoragePanel /></div>
|
||||
</div>
|
||||
),
|
||||
'settings-instance-users': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Users" />
|
||||
<MobileScreenHeader title="Users" rightActions={<TransferIndicator />} />
|
||||
<div className="flex-1 overflow-y-auto p-4"><UsersPanel /></div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -30,13 +30,21 @@ export function TransferIndicator() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click-outside-to-close. iOS Safari does not synthesize `mousedown` from
|
||||
// touch reliably, so we listen for `touchstart` alongside `mousedown` so the
|
||||
// panel dismisses on the first tap on touch devices. Mirrors the pattern
|
||||
// used in AudioInputSection.tsx.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const onPointerDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('touchstart', onPointerDown, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('touchstart', onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const active = visible.filter(
|
||||
@@ -69,7 +77,15 @@ export function TransferIndicator() {
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-[300px] max-w-[calc(100vw-1rem)] glass z-50 rounded-lg overflow-hidden">
|
||||
// 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.
|
||||
<div className="fixed right-2 top-14 md:absolute md:right-0 md:top-full md:mt-2 w-[min(300px,calc(100vw-16px))] glass z-50 rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border-soft text-xs flex justify-between items-center">
|
||||
<span className="text-txt-secondary">
|
||||
{visible.length} transfer{visible.length === 1 ? '' : 's'}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user