feat(mobile): admin reach + post-mount routing + RegistrationPanel modals → Modal

MobileInstancePanel gains Registration and Federation entries (parity with
desktop's six sub-tabs). MobileShell registers the matching screenMap
wrappers; the Federation entry surfaces the live approval-count badge via
a new uiStore slot and a wrapper that forwards FederationPanel's
onApprovalCountChange.

MobileShell's deep-link reconstruction now reacts to post-mount pathname
changes (was [] / mount-only) with an idempotency guard that skips the
push when the topmost stack entry already represents the new URL —
prevents the pushMobileScreen → history.pushState → location-effect
double-push.

RegistrationPanel's four portaled modals (CreateInvite, EditInvite,
ReinstateInvite, Redemptions) now render through the shared <Modal>
with mobileStyle="fullscreen", portaled to document.body to escape the
parent settings dialog's backdrop-filter containing block. Desktop
appearance preserved (same maxWidth, same sticky action bar).
This commit is contained in:
Jannis Braun
2026-05-05 23:23:37 +02:00
parent 69cd2dc537
commit e2d938e6c0
6 changed files with 638 additions and 514 deletions
+1 -1
View File
@@ -467,7 +467,7 @@ Owns the two independent registration gates and the admin invite-link CRUD surfa
- **Active rows:** `Copy link`, `Edit`, `Revoke`, kebab → `Delete permanently`, `View redemptions`. - **Active rows:** `Copy link`, `Edit`, `Revoke`, kebab → `Delete permanently`, `View redemptions`.
- **Archived rows:** `Reinstate`, kebab → `Delete permanently`, `View redemptions`. - **Archived rows:** `Reinstate`, kebab → `Delete permanently`, `View redemptions`.
**Modals:** **Modals:** Create / Edit / Reinstate / Redemptions all use the shared [`Modal`](../../packages/web/src/components/ui/Modal.tsx) component with `mobileStyle="fullscreen"`, portaled to `document.body` (the parent settings dialog uses `glass-modal`'s `backdrop-filter`, which establishes a containing block — portaling escapes it so the child modal renders viewport-relative). On desktop they appear as the standard centered dialog with `max-w-md` (Redemptions: `max-w-lg`); on mobile they slide in fullscreen with the Modal's standard close button + safe-area padding. The decorative lavender/sky icon chip and helper paragraph live inside `children` (Modal's `title` prop renders the heading + close X).
- **Create Invite** — Name (1-64 chars), Max uses (radio: Unlimited / `[N >= 1]`), Expires (preset: `1 hour` / `24 hours` / `7 days` / `30 days` / `Never` / `Custom…`). Defaults: `maxUses: null`, `expiresAt: now + 7 days`. On success the URL is auto-copied to clipboard and the new row animates in at the top of the active list. - **Create Invite** — Name (1-64 chars), Max uses (radio: Unlimited / `[N >= 1]`), Expires (preset: `1 hour` / `24 hours` / `7 days` / `30 days` / `Never` / `Custom…`). Defaults: `maxUses: null`, `expiresAt: now + 7 days`. On success the URL is auto-copied to clipboard and the new row animates in at the top of the active list.
- **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back). - **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back).
+40 -8
View File
@@ -11,7 +11,7 @@ Source files:
- `packages/web/src/components/layout/MobileSpacesScreen.tsx` — Space strip + channel list (split-pane), folder support, voice user rows - `packages/web/src/components/layout/MobileSpacesScreen.tsx` — Space strip + channel list (split-pane), folder support, voice user rows
- `packages/web/src/components/layout/MobileYouScreen.tsx` — User profile card, action rows, logout - `packages/web/src/components/layout/MobileYouScreen.tsx` — User profile card, action rows, logout
- `packages/web/src/components/layout/MobileSettingsScreen.tsx` — Settings hub and direct-panel rendering via `initialPanel` prop - `packages/web/src/components/layout/MobileSettingsScreen.tsx` — Settings hub and direct-panel rendering via `initialPanel` prop
- `packages/web/src/components/layout/MobileInstancePanel.tsx` — Admin-only instance settings hub (General, Streaming, Storage, Users) - `packages/web/src/components/layout/MobileInstancePanel.tsx` — Admin-only instance settings hub (General, Registration, Federation, Streaming, Storage, Users; surfaces federation approval-count badge)
- `packages/web/src/components/layout/MobileMembersScreen.tsx` — Space member list grouped by role, with activity cards - `packages/web/src/components/layout/MobileMembersScreen.tsx` — Space member list grouped by role, with activity cards
- `packages/web/src/components/layout/MobileVoiceFullScreen.tsx` — Full-screen voice call view with participant grid and control bar - `packages/web/src/components/layout/MobileVoiceFullScreen.tsx` — Full-screen voice call view with participant grid and control bar
- `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls - `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls
@@ -110,18 +110,41 @@ This means the hardware/browser back button pops the mobile screen stack. `popMo
### Deep Link Reconstruction ### Deep Link Reconstruction
On mount, `MobileShell` checks `location.pathname` for `/channels/:spaceId/:channelId` and pushes a `channel-chat` screen if the stack is empty: `MobileShell` watches `location.pathname` for `/channels/:spaceId/:channelId` and pushes a `channel-chat` screen when the URL changes to a channel route — both on mount (deep link / refresh) and on subsequent programmatic navigations (e.g. SpaceInviteCard Join button, joinByCode flows, any `useNavigate(...)` call).
```ts ```ts
useEffect(() => { useEffect(() => {
const path = location.pathname; const path = location.pathname;
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/); const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
if (match && mobileStack.length === 0) { if (!match) return;
pushMobileScreen('channel-chat', { channelId, spaceId }); const spaceId = match[1] ?? '';
const channelId = match[2] ?? '';
const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId;
// Idempotency guard — read stack imperatively to avoid re-firing on stack changes
const currentStack = useUIStore.getState().mobileStack;
const top = currentStack[currentStack.length - 1];
if (
top &&
top.screen === 'channel-chat' &&
top.params?.channelId === channelId &&
top.params?.spaceId === normalizedSpaceId
) {
return;
} }
}, []); // Mount only
pushMobileScreen('channel-chat', { channelId, spaceId: normalizedSpaceId });
}, [location.pathname, pushMobileScreen]);
``` ```
**Why these dependencies and the idempotency guard exist:**
- The dep array intentionally excludes `mobileStack`. The current stack is read imperatively via `useUIStore.getState()` so that pushing an unrelated screen (e.g. `settings`) does not re-trigger this effect — otherwise we would re-push `channel-chat` on top of every newly pushed screen because pathname is still `/channels/...`.
- The guard catches the common in-app case where `MobileSpacesScreen` calls both `pushMobileScreen('channel-chat', …)` AND `navigate('/channels/…')`. The push happens first (no pathname change since `pushMobileScreen` calls `history.pushState` with no URL), then `navigate` mutates pathname → this effect re-runs → top already matches → skip.
- The guard also catches the popstate path: browser back pops both the history entry and the mobile stack; if the new pathname is a channel route already represented by the new top entry, we skip.
In-app navigation that lands on a different channel (e.g. tapping a `SpaceInviteCard` Join button while inside another chat) stacks the new `channel-chat` on top so back returns to the originating chat.
### User Profile Mobile Override ### User Profile Mobile Override
`uiStore:openUserProfile` detects `isMobile` and pushes a `user-profile` screen instead of showing a positioned popout: `uiStore:openUserProfile` detects `isMobile` and pushes a `user-profile` screen instead of showing a positioned popout:
@@ -280,6 +303,8 @@ Event options: `touchstart` is `{ passive: true }`, `touchmove` is `{ passive: f
| `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` | | `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` |
| `settings-instance` | `MobileInstancePanel` | — | | `settings-instance` | `MobileInstancePanel` | — |
| `settings-instance-general` | `GeneralPanel` (wrapped) | — | | `settings-instance-general` | `GeneralPanel` (wrapped) | — |
| `settings-instance-registration` | `RegistrationPanel` (wrapped) | — |
| `settings-instance-federation` | `FederationPanel` (wrapped, forwards `onApprovalCountChange``uiStore.setFederationApprovalCount`) | — |
| `settings-instance-streaming` | `StreamingPanel` (wrapped) | — | | `settings-instance-streaming` | `StreamingPanel` (wrapped) | — |
| `settings-instance-storage` | `StoragePanel` (wrapped) | — | | `settings-instance-storage` | `StoragePanel` (wrapped) | — |
| `settings-instance-users` | `UsersPanel` (wrapped) | — | | `settings-instance-users` | `UsersPanel` (wrapped) | — |
@@ -335,7 +360,7 @@ Split-pane layout: 60px `glass-strip` space strip on the left + channel list on
- Settings gear in header (pushes `settings`) - Settings gear in header (pushes `settings`)
- Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio - Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio
- Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Audio - Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Video
- Log Out button with `ConfirmDialog` - Log Out button with `ConfirmDialog`
--- ---
@@ -356,12 +381,19 @@ Params: `{ channelId, spaceId }`
Two modes controlled by `initialPanel` prop: Two modes controlled by `initialPanel` prop:
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Audio, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`. 1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Video, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`.
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header. 2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
### MobileInstancePanel ### MobileInstancePanel
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists four sub-sections (General, Streaming, Storage, Users), each pushing `settings-instance-{id}`. Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists six sub-sections (General, Registration, Federation, Streaming, Storage, Users), each pushing `settings-instance-{id}`. Mirrors the desktop `InstancePanel` exactly.
The Federation row carries a numeric badge driven by `uiStore.federationApprovalCount` (capped at `99+`, styled like the unread-DM badge in `MobileBottomNav`). The badge source has two paths:
1. **Initial / standalone fetch:** `MobileInstancePanel` calls `api.federation.approvalRequests()` on mount and re-fetches when `onFederationPeersChanged` fires (mirrors what `FederationPanel`'s internal `PendingApprovals` component does). This makes the badge accurate before the admin enters the Federation panel.
2. **Live updates while inside the panel:** the wrapper around `FederationPanel` in `MobileShell.tsx` forwards the panel's `onApprovalCountChange` callback into `uiStore.setFederationApprovalCount`. As the admin approves/denies requests inside the panel, the count drops and the badge in the parent hub stays in sync.
`MobileInstancePanel` is rendered behind a top-level `isAdmin` guard from `MobileSettingsScreen` — non-admin users cannot reach it.
### MobileMembersScreen ### MobileMembersScreen
@@ -1,9 +1,17 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useSettingsStore } from '../../stores/settingsStore'; import { useSettingsStore } from '../../stores/settingsStore';
import { MobileScreenHeader } from './MobileScreenHeader'; import { MobileScreenHeader } from './MobileScreenHeader';
import { api } from '../../api/client';
import { onFederationPeersChanged } from '../../hooks/useWebSocket';
const sections = [ type SectionDef = {
id: 'general' | 'registration' | 'federation' | 'streaming' | 'storage' | 'users';
label: string;
icon: React.ReactNode;
};
const sections: SectionDef[] = [
{ {
id: 'general', id: 'general',
label: 'General', label: 'General',
@@ -14,6 +22,26 @@ const sections = [
</svg> </svg>
), ),
}, },
{
id: 'registration',
label: 'Registration',
icon: (
// Heroicon: ticket — represents invite-style/registration management
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
),
},
{
id: 'federation',
label: 'Federation',
icon: (
// Heroicon: globe-alt — represents cross-instance federation
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
),
},
{ {
id: 'streaming', id: 'streaming',
label: 'Streaming', label: 'Streaming',
@@ -43,6 +71,54 @@ const sections = [
}, },
]; ];
/**
* Federation approval-count badge source.
*
* Mirrors the desktop InstancePanel/FederationPanel contract: the count comes
* from `api.federation.approvalRequests()` and is kept fresh by the
* `onFederationPeersChanged` WebSocket signal that FederationPanel itself
* subscribes to. We can't read FederationPanel's internal state from here, so
* we fetch the same endpoint independently — and we wire MobileShell to forward
* `onApprovalCountChange` callbacks from FederationPanel into a shared store
* slot so the badge updates live while the admin is inside the panel.
*/
function useFederationApprovalCount(enabled: boolean) {
const liveCount = useUIStore((s) => s.federationApprovalCount);
const setLiveCount = useUIStore((s) => s.setFederationApprovalCount);
const refetch = useCallback(async () => {
if (!enabled) return;
try {
const result = await api.federation.approvalRequests();
setLiveCount(result.requests.length);
} catch {
// Silently ignore — badge simply won't update on transient failures
}
}, [enabled, setLiveCount]);
useEffect(() => {
refetch();
}, [refetch]);
// Re-fetch when peer state changes (mirrors FederationPanel's own listener)
useEffect(() => {
if (!enabled) return;
let timeout: ReturnType<typeof setTimeout> | undefined;
const unsub = onFederationPeersChanged(() => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
refetch();
}, 500);
});
return () => {
unsub();
if (timeout) clearTimeout(timeout);
};
}, [enabled, refetch]);
return liveCount;
}
export function MobileInstancePanel() { export function MobileInstancePanel() {
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
@@ -55,11 +131,15 @@ export function MobileInstancePanel() {
fetchStreamingLimits(); fetchStreamingLimits();
}, [fetchInstanceSettings, fetchStreamingLimits]); }, [fetchInstanceSettings, fetchStreamingLimits]);
const approvalCount = useFederationApprovalCount(true);
return ( return (
<div className="flex flex-col h-full bg-surface-base"> <div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Instance" /> <MobileScreenHeader title="Instance" />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{sections.map((section) => ( {sections.map((section) => {
const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null;
return (
<button <button
key={section.id} key={section.id}
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)} onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
@@ -67,11 +147,17 @@ export function MobileInstancePanel() {
> >
{section.icon} {section.icon}
<span className="text-sm text-txt-primary flex-1">{section.label}</span> <span className="text-sm text-txt-primary flex-1">{section.label}</span>
{badge !== null && (
<span className="min-w-[18px] h-[18px] px-1.5 bg-notification text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{badge > 99 ? '99+' : badge}
</span>
)}
<svg className="w-4 h-4 text-txt-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-4 h-4 text-txt-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
</button> </button>
))} );
})}
</div> </div>
</div> </div>
); );
@@ -20,10 +20,30 @@ import { FriendsPage } from '../chat/FriendsPage';
import { ExplorePage } from '../chat/ExplorePage'; import { ExplorePage } from '../chat/ExplorePage';
import { UserProfileModal } from '../modals/UserProfileModal'; import { UserProfileModal } from '../modals/UserProfileModal';
import { GeneralPanel } from '../modals/instanceSettingsPanels/GeneralPanel'; import { GeneralPanel } from '../modals/instanceSettingsPanels/GeneralPanel';
import { RegistrationPanel } from '../modals/instanceSettingsPanels/RegistrationPanel';
import { FederationPanel } from '../modals/instanceSettingsPanels/FederationPanel';
import { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel'; import { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel';
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel'; import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel'; import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel';
/**
* Wrapper for the Federation sub-panel that forwards FederationPanel's
* approval-count callback into the shared uiStore slot read by
* MobileInstancePanel — this keeps the badge live while the admin is inside
* the panel approving/denying requests.
*/
function MobileFederationPanelWrapper() {
const setApprovalCount = useUIStore((s) => s.setFederationApprovalCount);
return (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Federation" />
<div className="flex-1 overflow-y-auto p-4">
<FederationPanel onApprovalCountChange={setApprovalCount} />
</div>
</div>
);
}
const screenMap: Record<string, (params?: Record<string, string>) => React.ReactNode> = { const screenMap: Record<string, (params?: Record<string, string>) => React.ReactNode> = {
'channel-chat': (params) => <MobileChatScreen params={params} />, 'channel-chat': (params) => <MobileChatScreen params={params} />,
'friends': () => <FriendsPage mobile />, 'friends': () => <FriendsPage mobile />,
@@ -39,6 +59,13 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
<div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div> <div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
</div> </div>
), ),
'settings-instance-registration': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Registration" />
<div className="flex-1 overflow-y-auto p-4"><RegistrationPanel /></div>
</div>
),
'settings-instance-federation': () => <MobileFederationPanelWrapper />,
'settings-instance-streaming': () => ( 'settings-instance-streaming': () => (
<div className="flex flex-col h-full bg-surface-base"> <div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Streaming" /> <MobileScreenHeader title="Streaming" />
@@ -88,21 +115,52 @@ export function MobileShell() {
enabled: mobileStack.length > 0, enabled: mobileStack.length > 0,
}); });
// Reconstruct mobile stack from URL on mount (deep link / refresh support) // Reconstruct mobile stack from URL on mount AND on subsequent pathname
// changes (deep link, refresh, programmatic navigate from SpaceInviteCard
// Join, joinByCode flows, etc.).
//
// Subscribes to `location.pathname` only — NOT to `mobileStack`. This is
// important because pushing an unrelated screen (e.g. settings) must not
// re-trigger this effect; otherwise we would re-push the channel-chat on
// top of every newly-pushed screen, since pathname is still `/channels/...`.
// We read the current stack imperatively via `useUIStore.getState()` for
// the idempotency guard.
//
// Idempotency guard: callers like MobileSpacesScreen call BOTH
// `pushMobileScreen('channel-chat', …)` AND `navigate('/channels/…')`.
// The pushMobileScreen call alone doesn't change pathname (history.pushState
// with no URL preserves it), but the navigate call does — and that pathname
// change re-runs this effect after the screen is already on top. The guard
// below catches that case by inspecting the topmost stack entry. We also
// guard against the popstate path: when the user navigates back, popstate
// pops both the browser history AND our stack; the resulting pathname change
// matches the new top entry, so we skip.
useEffect(() => { useEffect(() => {
const path = location.pathname; const path = location.pathname;
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/); const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
if (match && mobileStack.length === 0) { if (!match) return;
const spaceId = match[1] ?? ''; const spaceId = match[1] ?? '';
const channelId = match[2] ?? ''; const channelId = match[2] ?? '';
if (spaceId === '@me') { const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId;
pushMobileScreen('channel-chat', { channelId, spaceId: '@me' });
} else { // Read current stack imperatively to avoid re-firing on stack changes.
pushMobileScreen('channel-chat', { channelId, spaceId }); const currentStack = useUIStore.getState().mobileStack;
const top = currentStack[currentStack.length - 1];
if (
top &&
top.screen === 'channel-chat' &&
top.params?.channelId === channelId &&
top.params?.spaceId === normalizedSpaceId
) {
return;
} }
}
// eslint-disable-next-line react-hooks/exhaustive-deps // If the stack has channel-chat entries for OTHER channels, we still push
}, []); // Only on mount // — this preserves back-stack semantics for in-app navigation (e.g. tapping
// a SpaceInviteCard Join button while inside a chat should stack the new
// channel on top so back returns to the originating chat).
pushMobileScreen('channel-chat', { channelId, spaceId: normalizedSpaceId });
}, [location.pathname, pushMobileScreen]);
// Sync browser back button with mobile stack // Sync browser back button with mobile stack
useEffect(() => { useEffect(() => {
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../../stores/settingsStore';
import { useUIStore } from '../../../stores/uiStore'; import { useUIStore } from '../../../stores/uiStore';
import { Toggle } from '../../ui/Toggle'; import { Toggle } from '../../ui/Toggle';
import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { ConfirmDialog } from '../../ui/ConfirmDialog';
import { Modal } from '../../ui/Modal';
interface RegistrationDraft { interface RegistrationDraft {
registrationOpen: boolean; registrationOpen: boolean;
@@ -394,16 +395,11 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
nameInputRef.current?.focus(); nameInputRef.current?.focus();
}, []); }, []);
// Escape closes the modal (capture phase so it fires before parent handlers) // Block close (Escape / backdrop click) while a submit is in flight; the shared
useEffect(() => { // Modal wires Escape + backdrop-click to onClose, so we gate them here rather
const handleKey = (e: KeyboardEvent) => { // than in a separate keydown listener.
if (e.key === 'Escape' && !submitting) { const handleClose = useCallback(() => {
e.stopPropagation(); if (!submitting) onClose();
onClose();
}
};
document.addEventListener('keydown', handleKey, true);
return () => document.removeEventListener('keydown', handleKey, true);
}, [onClose, submitting]); }, [onClose, submitting]);
const handleCreate = async () => { const handleCreate = async () => {
@@ -455,40 +451,34 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
}; };
return createPortal( return createPortal(
<div <Modal
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in" isOpen
onClick={!submitting ? onClose : undefined} onClose={handleClose}
title="Create invite link"
mobileStyle="fullscreen"
maxWidth="max-w-md"
> >
{/* Backdrop */} {/* Decorative icon + helper text — kept inside children so the visual
<div className="absolute inset-0 bg-black/50" /> identity (lavender icon chip + descriptive paragraph) is preserved
across desktop and mobile fullscreen. */}
{/* Modal panel — stop propagation so backdrop click doesn't fire inside */} <div className="flex items-start gap-3 -mt-1 mb-4">
<div
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg> </svg>
</div> </div>
<div className="min-w-0"> <p className="text-[13px] text-txt-secondary leading-snug min-w-0">
<h3 className="text-[16px] font-bold text-txt-primary">Create invite link</h3>
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires. Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires.
</p> </p>
</div> </div>
</div>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleCreate(); handleCreate();
}} }}
className="px-5 pt-4 pb-5 space-y-5" className="space-y-5"
> >
{/* Name */} {/* Name */}
<div> <div>
@@ -508,7 +498,7 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
{/* Max uses */} {/* Max uses */}
<div> <div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Max uses</div> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Max uses</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<input <input
type="radio" type="radio"
@@ -585,8 +575,7 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
</div> </div>
</div> </div>
</form> </form>
</div> </Modal>,
</div>,
document.body, document.body,
); );
} }
@@ -617,16 +606,10 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
nameInputRef.current?.focus(); nameInputRef.current?.focus();
}, []); }, []);
// Escape closes the modal (capture phase so it fires before the parent settings modal handler) // Block close (Escape / backdrop click) while a save is in flight; the shared
useEffect(() => { // Modal wires both to onClose for us.
const handleKey = (e: KeyboardEvent) => { const handleClose = useCallback(() => {
if (e.key === 'Escape' && !submitting) { if (!submitting) onClose();
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKey, true);
return () => document.removeEventListener('keydown', handleKey, true);
}, [onClose, submitting]); }, [onClose, submitting]);
const handleSave = async () => { const handleSave = async () => {
@@ -693,38 +676,31 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
const maxUsesMin = Math.max(1, invite.usedCount); const maxUsesMin = Math.max(1, invite.usedCount);
return createPortal( return createPortal(
<div <Modal
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in" isOpen
onClick={!submitting ? onClose : undefined} onClose={handleClose}
title={`Edit "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-md"
> >
<div className="absolute inset-0 bg-black/50" /> <div className="flex items-start gap-3 -mt-1 mb-4">
<div
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg> </svg>
</div> </div>
<div className="min-w-0"> <p className="text-[13px] text-txt-secondary leading-snug min-w-0">
<h3 className="text-[16px] font-bold text-txt-primary">Edit "{invite.name}"</h3>
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
Adjust the limits on this invite link. The URL stays the same anyone who already has it can still redeem under the new constraints. Adjust the limits on this invite link. The URL stays the same anyone who already has it can still redeem under the new constraints.
</p> </p>
</div> </div>
</div>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleSave(); handleSave();
}} }}
className="px-5 pt-4 pb-5 space-y-5" className="space-y-5"
> >
{/* Name */} {/* Name */}
<div> <div>
@@ -745,7 +721,7 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2"> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
Max uses <span className="normal-case font-normal text-txt-tertiary">({invite.usedCount} used)</span> Max uses <span className="normal-case font-normal text-txt-tertiary">({invite.usedCount} used)</span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<input <input
type="radio" type="radio"
@@ -819,8 +795,7 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
</div> </div>
</div> </div>
</form> </form>
</div> </Modal>,
</div>,
document.body, document.body,
); );
} }
@@ -858,16 +833,10 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
const [customDateTime, setCustomDateTime] = useState<string>(''); const [customDateTime, setCustomDateTime] = useState<string>('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Escape closes (capture phase to avoid bubbling into parent settings modal) // Block close (Escape / backdrop click) while reinstate is in flight; the
useEffect(() => { // shared Modal wires both to onClose.
const handleKey = (e: KeyboardEvent) => { const handleClose = useCallback(() => {
if (e.key === 'Escape' && !submitting) { if (!submitting) onClose();
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKey, true);
return () => document.removeEventListener('keydown', handleKey, true);
}, [onClose, submitting]); }, [onClose, submitting]);
const maxUsesMin = invite.usedCount + 1; const maxUsesMin = invite.usedCount + 1;
@@ -931,28 +900,21 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
: 'This invite has lapsed. Reinstating reactivates the same URL — anyone who saved it will be able to use it again.'; : 'This invite has lapsed. Reinstating reactivates the same URL — anyone who saved it will be able to use it again.';
return createPortal( return createPortal(
<div <Modal
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in" isOpen
onClick={!submitting ? onClose : undefined} onClose={handleClose}
title={`Reinstate "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-md"
> >
<div className="absolute inset-0 bg-black/50" /> <div className="flex items-start gap-3 -mt-1 mb-4">
<div
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg> </svg>
</div> </div>
<div className="min-w-0"> <p className="text-[13px] text-txt-secondary leading-snug min-w-0">{subtitle}</p>
<h3 className="text-[16px] font-bold text-txt-primary">Reinstate "{invite.name}"</h3>
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">{subtitle}</p>
</div>
</div> </div>
<form <form
@@ -960,7 +922,7 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
e.preventDefault(); e.preventDefault();
handleReinstate(); handleReinstate();
}} }}
className="px-5 pt-4 pb-5 space-y-5" className="space-y-5"
> >
{/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */} {/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */}
{isRevoked && ( {isRevoked && (
@@ -974,7 +936,7 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2"> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span> Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<input <input
type="radio" type="radio"
@@ -1053,8 +1015,7 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
</div> </div>
</div> </div>
</form> </form>
</div> </Modal>,
</div>,
document.body, document.body,
); );
} }
@@ -1080,18 +1041,6 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
const [redemptions, setRedemptions] = useState<InviteRedemption[] | null>(null); const [redemptions, setRedemptions] = useState<InviteRedemption[] | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
// Escape closes (capture phase to avoid bubbling into parent settings modal)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKey, true);
return () => document.removeEventListener('keydown', handleKey, true);
}, [onClose]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api.invites api.invites
@@ -1111,40 +1060,31 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
}, [invite.id, addToast]); }, [invite.id, addToast]);
return createPortal( return createPortal(
<div <Modal
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in" isOpen
onClick={onClose} onClose={onClose}
title={`Redemptions for "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-lg"
> >
<div className="absolute inset-0 bg-black/50" /> <div className="flex items-start gap-3 -mt-1 mb-4">
<div
className="relative w-full max-w-lg mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden flex flex-col max-h-[80vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3 flex-shrink-0">
<div className="w-10 h-10 rounded-lg bg-accent-sky/15 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-accent-sky/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-accent-sky"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-accent-sky">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg> </svg>
</div> </div>
<div className="min-w-0 flex-1"> <p className="text-[13px] text-txt-secondary leading-snug min-w-0">
<h3 className="text-[16px] font-bold text-txt-primary">Redemptions for "{invite.name}"</h3>
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
Users who registered using this invite link, in the order they signed up. Users who registered using this invite link, in the order they signed up.
</p> </p>
</div> </div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="w-8 h-8 rounded-md text-txt-tertiary hover:text-txt-primary hover:bg-white/[0.06] flex items-center justify-center flex-shrink-0 transition-colors"
>
×
</button>
</div>
<div className="px-5 pt-4 pb-3 space-y-3"> {/* Sticky summary band — pins at top of scroll area so the count + revoked
warning stay visible while the list scrolls under them. The negative
horizontal margin + padding compensates the body's `p-4` so the
background can extend edge-to-edge. */}
<div
className="sticky top-0 z-10 -mx-4 px-4 pb-3 space-y-3 bg-[var(--glass-modal-bg)] backdrop-blur-md"
>
{invite.status === 'revoked' && ( {invite.status === 'revoked' && (
<div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed"> <div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed">
This invite was revoked This invite was revoked
@@ -1162,7 +1102,7 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto px-3 pb-4"> <div className="-mx-1">
{redemptions === null ? ( {redemptions === null ? (
<div className="text-sm text-txt-tertiary px-3 py-2">Loading</div> <div className="text-sm text-txt-tertiary px-3 py-2">Loading</div>
) : redemptions.length === 0 ? ( ) : redemptions.length === 0 ? (
@@ -1199,8 +1139,7 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
</div> </div>
)} )}
</div> </div>
</div> </Modal>,
</div>,
document.body, document.body,
); );
} }
+9
View File
@@ -72,6 +72,12 @@ interface UIState {
setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void; setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void;
pushMobileScreen: (screen: string, params?: Record<string, string>) => void; pushMobileScreen: (screen: string, params?: Record<string, string>) => void;
popMobileScreen: () => void; popMobileScreen: () => void;
// Federation approval-count badge (surfaced by MobileInstancePanel; kept fresh
// by the FederationPanel via `onApprovalCountChange` whenever an admin
// approves/denies a request from inside the panel)
federationApprovalCount: number;
setFederationApprovalCount: (count: number) => void;
} }
export const useUIStore = create<UIState>()( export const useUIStore = create<UIState>()(
@@ -172,6 +178,9 @@ export const useUIStore = create<UIState>()(
// Note: do NOT call history.back() here if triggered by popstate event. // Note: do NOT call history.back() here if triggered by popstate event.
// The MobileShell popstate handler manages this — see Task 5. // The MobileShell popstate handler manages this — see Task 5.
}, },
federationApprovalCount: 0,
setFederationApprovalCount: (count) => set({ federationApprovalCount: count }),
}), }),
{ {
name: 'backspace-ui-settings', name: 'backspace-ui-settings',