From e2d938e6c09718f465a2faca2a3573c255e9c59c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 23:23:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20admin=20reach=20+=20post-mount?= =?UTF-8?q?=20routing=20+=20RegistrationPanel=20modals=20=E2=86=92=20Modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- docs/systems/admin.md | 2 +- docs/systems/mobile-ui.md | 48 +- .../components/layout/MobileInstancePanel.tsx | 116 ++- .../web/src/components/layout/MobileShell.tsx | 80 +- .../RegistrationPanel.tsx | 897 ++++++++---------- packages/web/src/stores/uiStore.ts | 9 + 6 files changed, 638 insertions(+), 514 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index 8ee0d61e..e6047956 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -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`. - **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. - **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back). diff --git a/docs/systems/mobile-ui.md b/docs/systems/mobile-ui.md index ddd13265..5c7c7801 100644 --- a/docs/systems/mobile-ui.md +++ b/docs/systems/mobile-ui.md @@ -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/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/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/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 @@ -110,18 +110,41 @@ This means the hardware/browser back button pops the mobile screen stack. `popMo ### 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 useEffect(() => { const path = location.pathname; const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/); - if (match && mobileStack.length === 0) { - pushMobileScreen('channel-chat', { channelId, spaceId }); + if (!match) return; + 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 `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-instance` | `MobileInstancePanel` | — | | `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-storage` | `StoragePanel` (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`) - 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` --- @@ -356,12 +381,19 @@ Params: `{ channelId, spaceId }` 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. ### 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 diff --git a/packages/web/src/components/layout/MobileInstancePanel.tsx b/packages/web/src/components/layout/MobileInstancePanel.tsx index 6fe44571..7909e4e7 100644 --- a/packages/web/src/components/layout/MobileInstancePanel.tsx +++ b/packages/web/src/components/layout/MobileInstancePanel.tsx @@ -1,9 +1,17 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { useUIStore } from '../../stores/uiStore'; import { useSettingsStore } from '../../stores/settingsStore'; 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', label: 'General', @@ -14,6 +22,26 @@ const sections = [ ), }, + { + id: 'registration', + label: 'Registration', + icon: ( + // Heroicon: ticket — represents invite-style/registration management + + + + ), + }, + { + id: 'federation', + label: 'Federation', + icon: ( + // Heroicon: globe-alt — represents cross-instance federation + + + + ), + }, { id: '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 | 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() { const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); @@ -55,23 +131,33 @@ export function MobileInstancePanel() { fetchStreamingLimits(); }, [fetchInstanceSettings, fetchStreamingLimits]); + const approvalCount = useFederationApprovalCount(true); + return (
- {sections.map((section) => ( - - ))} + {sections.map((section) => { + const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null; + return ( + + ); + })}
); diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx index 584d9cdf..0822bcb4 100644 --- a/packages/web/src/components/layout/MobileShell.tsx +++ b/packages/web/src/components/layout/MobileShell.tsx @@ -20,10 +20,30 @@ import { FriendsPage } from '../chat/FriendsPage'; import { ExplorePage } from '../chat/ExplorePage'; import { UserProfileModal } from '../modals/UserProfileModal'; 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 { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel'; 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 ( +
+ +
+ +
+
+ ); +} + const screenMap: Record) => React.ReactNode> = { 'channel-chat': (params) => , 'friends': () => , @@ -39,6 +59,13 @@ const screenMap: Record) => React.React
), + 'settings-instance-registration': () => ( +
+ +
+
+ ), + 'settings-instance-federation': () => , 'settings-instance-streaming': () => (
@@ -88,21 +115,52 @@ export function MobileShell() { 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(() => { const path = location.pathname; const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/); - if (match && mobileStack.length === 0) { - const spaceId = match[1] ?? ''; - const channelId = match[2] ?? ''; - if (spaceId === '@me') { - pushMobileScreen('channel-chat', { channelId, spaceId: '@me' }); - } else { - pushMobileScreen('channel-chat', { channelId, spaceId }); - } + if (!match) return; + const spaceId = match[1] ?? ''; + const channelId = match[2] ?? ''; + const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId; + + // Read current 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; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only on mount + + // If the stack has channel-chat entries for OTHER channels, we still push + // — 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 useEffect(() => { diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 82dcbec6..96c8699b 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; import { ConfirmDialog } from '../../ui/ConfirmDialog'; +import { Modal } from '../../ui/Modal'; interface RegistrationDraft { registrationOpen: boolean; @@ -394,16 +395,11 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { nameInputRef.current?.focus(); }, []); - // Escape closes the modal (capture phase so it fires before parent handlers) - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !submitting) { - e.stopPropagation(); - onClose(); - } - }; - document.addEventListener('keydown', handleKey, true); - return () => document.removeEventListener('keydown', handleKey, true); + // Block close (Escape / backdrop click) while a submit is in flight; the shared + // Modal wires Escape + backdrop-click to onClose, so we gate them here rather + // than in a separate keydown listener. + const handleClose = useCallback(() => { + if (!submitting) onClose(); }, [onClose, submitting]); const handleCreate = async () => { @@ -455,138 +451,131 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { }; return createPortal( -
- {/* Backdrop */} -
+ {/* Decorative icon + helper text — kept inside children so the visual + identity (lavender icon chip + descriptive paragraph) is preserved + across desktop and mobile fullscreen. */} +
+
+ + + + +
+

+ 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. +

+
- {/* Modal panel — stop propagation so backdrop click doesn't fire inside */} -
e.stopPropagation()} +
{ + e.preventDefault(); + handleCreate(); + }} + className="space-y-5" > - {/* Header */} -
-
- - - - -
-
-

Create invite link

-

- 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. -

+ {/* Name */} +
+
Name
+ setName(e.target.value)} + maxLength={64} + placeholder="e.g. Friends batch 1" + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+
Max uses
+
+ +
- { - e.preventDefault(); - handleCreate(); - }} - className="px-5 pt-4 pb-5 space-y-5" - > - {/* Name */} -
-
Name
- setName(e.target.value)} - maxLength={64} - placeholder="e.g. Friends batch 1" - className="input-standard w-full" + {/* Expiry */} +
+
Expires
+ { + // Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot + // be returned because never renders it. + if (v === 'keep') return; + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={false} + disabled={submitting} + /> +
+ + {/* Actions */} +
+
+
- - {/* Max uses */} -
-
Max uses
-
- - -
-
- - {/* Expiry */} -
-
Expires
- { - // Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot - // be returned because never renders it. - if (v === 'keep') return; - setExpiryId(v); - setCustomDateTime(dt); - }} - showKeep={false} + className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50" + > + Cancel + +
- - {/* Actions */} -
-
- - -
-
- -
-
, +
+ + , document.body, ); } @@ -617,16 +606,10 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) { nameInputRef.current?.focus(); }, []); - // Escape closes the modal (capture phase so it fires before the parent settings modal handler) - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !submitting) { - e.stopPropagation(); - onClose(); - } - }; - document.addEventListener('keydown', handleKey, true); - return () => document.removeEventListener('keydown', handleKey, true); + // Block close (Escape / backdrop click) while a save is in flight; the shared + // Modal wires both to onClose for us. + const handleClose = useCallback(() => { + if (!submitting) onClose(); }, [onClose, submitting]); const handleSave = async () => { @@ -693,134 +676,126 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) { const maxUsesMin = Math.max(1, invite.usedCount); return createPortal( -
-
+
+
+ + + + +
+

+ Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints. +

+
-
e.stopPropagation()} +
{ + e.preventDefault(); + handleSave(); + }} + className="space-y-5" > - {/* Header */} -
-
- - - - + {/* Name */} +
+
Name
+ setName(e.target.value)} + maxLength={64} + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+
+ Max uses ({invite.usedCount} used)
-
-

Edit "{invite.name}"

-

- Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints. -

+
+ +
- { - e.preventDefault(); - handleSave(); - }} - className="px-5 pt-4 pb-5 space-y-5" - > - {/* Name */} -
-
Name
- setName(e.target.value)} - maxLength={64} - className="input-standard w-full" + {/* Expiry */} +
+
Expires
+ { + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={true} + disabled={submitting} + /> +
+ + {/* Actions */} +
+
+
- - {/* Max uses */} -
-
- Max uses ({invite.usedCount} used) -
-
- - -
-
- - {/* Expiry */} -
-
Expires
- { - setExpiryId(v); - setCustomDateTime(dt); - }} - showKeep={true} + className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50" + > + Cancel + +
- - {/* Actions */} -
-
- - -
-
- -
-
, +
+ + , document.body, ); } @@ -858,16 +833,10 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite const [customDateTime, setCustomDateTime] = useState(''); const [submitting, setSubmitting] = useState(false); - // Escape closes (capture phase to avoid bubbling into parent settings modal) - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !submitting) { - e.stopPropagation(); - onClose(); - } - }; - document.addEventListener('keydown', handleKey, true); - return () => document.removeEventListener('keydown', handleKey, true); + // Block close (Escape / backdrop click) while reinstate is in flight; the + // shared Modal wires both to onClose. + const handleClose = useCallback(() => { + if (!submitting) onClose(); }, [onClose, submitting]); const maxUsesMin = invite.usedCount + 1; @@ -931,130 +900,122 @@ 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.'; return createPortal( -
-
+
+
+ + + + +
+

{subtitle}

+
-
e.stopPropagation()} +
{ + e.preventDefault(); + handleReinstate(); + }} + className="space-y-5" > - {/* Header */} -
-
- - - - + {/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */} + {isRevoked && ( +
+ A new link will be generated. Anyone who had the old URL will not be able to use it.
-
-

Reinstate "{invite.name}"

-

{subtitle}

+ )} + + {/* Max uses */} +
+
+ Max uses (current: {invite.maxUses ?? '∞'}, used: {invite.usedCount}) +
+
+ +
- { - e.preventDefault(); - handleReinstate(); - }} - className="px-5 pt-4 pb-5 space-y-5" - > - {/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */} - {isRevoked && ( -
- A new link will be generated. Anyone who had the old URL will not be able to use it. -
- )} + {/* Expiry */} +
+
Expires
+ { + if (v === 'keep') return; // unreachable: showKeep={false} + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={false} + disabled={submitting} + /> +
- {/* Max uses */} -
-
- Max uses (current: {invite.maxUses ?? '∞'}, used: {invite.usedCount}) -
-
- - -
-
- - {/* Expiry */} -
-
Expires
- { - if (v === 'keep') return; // unreachable: showKeep={false} - setExpiryId(v); - setCustomDateTime(dt); - }} - showKeep={false} + {/* Actions */} +
+
+ +
- - {/* Actions */} -
-
- - -
-
- -
-
, +
+ + , document.body, ); } @@ -1080,18 +1041,6 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { const [redemptions, setRedemptions] = useState(null); 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(() => { let cancelled = false; api.invites @@ -1111,96 +1060,86 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { }, [invite.id, addToast]); return createPortal( -
-
+
+
+ + + +
+

+ Users who registered using this invite link, in the order they signed up. +

+
+ {/* 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. */}
e.stopPropagation()} + className="sticky top-0 z-10 -mx-4 px-4 pb-3 space-y-3 bg-[var(--glass-modal-bg)] backdrop-blur-md" > - {/* Header */} -
-
- - - + {invite.status === 'revoked' && ( +
+ This invite was revoked + {invite.revokedAt + ? ` ${new Date(invite.revokedAt).toLocaleDateString()}` + : ''} + . The redemptions below represent users who registered before revocation.
-
-

Redemptions for "{invite.name}"

-

- Users who registered using this invite link, in the order they signed up. -

-
- -
+ )} -
- {invite.status === 'revoked' && ( -
- This invite was revoked - {invite.revokedAt - ? ` ${new Date(invite.revokedAt).toLocaleDateString()}` - : ''} - . The redemptions below represent users who registered before revocation. -
- )} - -
- {invite.usedCount} - {invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use - {invite.usedCount === 1 ? '' : 's'} -
-
- -
- {redemptions === null ? ( -
Loading…
- ) : redemptions.length === 0 ? ( -
- {error ? 'Could not load redemptions.' : 'No redemptions yet.'} -
- ) : ( -
- {redemptions.map((r) => { - const showCurrent = - !r.isDeleted && - r.currentUsername !== null && - r.currentUsername !== r.registrantUsername; - return ( -
- - {r.registrantUsername} - {(r.isDeleted || showCurrent) && ( - - {' '} - (now {r.isDeleted ? 'Deleted User' : r.currentUsername}) - - )} - - - {new Date(r.redeemedAt).toLocaleString()} - -
- ); - })} -
- )} +
+ {invite.usedCount} + {invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use + {invite.usedCount === 1 ? '' : 's'}
-
, + +
+ {redemptions === null ? ( +
Loading…
+ ) : redemptions.length === 0 ? ( +
+ {error ? 'Could not load redemptions.' : 'No redemptions yet.'} +
+ ) : ( +
+ {redemptions.map((r) => { + const showCurrent = + !r.isDeleted && + r.currentUsername !== null && + r.currentUsername !== r.registrantUsername; + return ( +
+ + {r.registrantUsername} + {(r.isDeleted || showCurrent) && ( + + {' '} + (now {r.isDeleted ? 'Deleted User' : r.currentUsername}) + + )} + + + {new Date(r.redeemedAt).toLocaleString()} + +
+ ); + })} +
+ )} +
+ , document.body, ); } diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 5b7596bd..a008f07a 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -72,6 +72,12 @@ interface UIState { setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void; pushMobileScreen: (screen: string, params?: Record) => 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()( @@ -172,6 +178,9 @@ export const useUIStore = create()( // Note: do NOT call history.back() here if triggered by popstate event. // The MobileShell popstate handler manages this — see Task 5. }, + + federationApprovalCount: 0, + setFederationApprovalCount: (count) => set({ federationApprovalCount: count }), }), { name: 'backspace-ui-settings',