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`.
- **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).
+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/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
@@ -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 = [
</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',
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() {
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
@@ -55,11 +131,15 @@ export function MobileInstancePanel() {
fetchStreamingLimits();
}, [fetchInstanceSettings, fetchStreamingLimits]);
const approvalCount = useFederationApprovalCount(true);
return (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Instance" />
<div className="flex-1 overflow-y-auto">
{sections.map((section) => (
{sections.map((section) => {
const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null;
return (
<button
key={section.id}
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
@@ -67,11 +147,17 @@ export function MobileInstancePanel() {
>
{section.icon}
<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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
))}
);
})}
</div>
</div>
);
@@ -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 (
<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> = {
'channel-chat': (params) => <MobileChatScreen params={params} />,
'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>
),
'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': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="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) {
if (!match) return;
const spaceId = match[1] ?? '';
const channelId = match[2] ?? '';
if (spaceId === '@me') {
pushMobileScreen('channel-chat', { channelId, spaceId: '@me' });
} else {
pushMobileScreen('channel-chat', { channelId, spaceId });
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(() => {
@@ -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,40 +451,34 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
};
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
onClick={!submitting ? onClose : undefined}
<Modal
isOpen
onClose={handleClose}
title="Create invite link"
mobileStyle="fullscreen"
maxWidth="max-w-md"
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" />
{/* Modal panel — stop propagation so backdrop click doesn't fire inside */}
<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">
{/* Decorative icon + helper text — kept inside children so the visual
identity (lavender icon chip + descriptive paragraph) is preserved
across desktop and mobile fullscreen. */}
<div className="flex items-start gap-3 -mt-1 mb-4">
<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">
<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" />
</svg>
</div>
<div className="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">
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
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>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
className="px-5 pt-4 pb-5 space-y-5"
className="space-y-5"
>
{/* Name */}
<div>
@@ -508,7 +498,7 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
{/* 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">
<input
type="radio"
@@ -585,8 +575,7 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
</div>
</div>
</form>
</div>
</div>,
</Modal>,
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,38 +676,31 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
const maxUsesMin = Math.max(1, invite.usedCount);
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
onClick={!submitting ? onClose : undefined}
<Modal
isOpen
onClose={handleClose}
title={`Edit "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-md"
>
<div className="absolute inset-0 bg-black/50" />
<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="flex items-start gap-3 -mt-1 mb-4">
<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">
<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" />
</svg>
</div>
<div className="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">
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
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>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
className="px-5 pt-4 pb-5 space-y-5"
className="space-y-5"
>
{/* Name */}
<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">
Max uses <span className="normal-case font-normal text-txt-tertiary">({invite.usedCount} used)</span>
</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">
<input
type="radio"
@@ -819,8 +795,7 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
</div>
</div>
</form>
</div>
</div>,
</Modal>,
document.body,
);
}
@@ -858,16 +833,10 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
const [customDateTime, setCustomDateTime] = useState<string>('');
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,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.';
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
onClick={!submitting ? onClose : undefined}
<Modal
isOpen
onClose={handleClose}
title={`Reinstate "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-md"
>
<div className="absolute inset-0 bg-black/50" />
<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="flex items-start gap-3 -mt-1 mb-4">
<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">
<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" />
</svg>
</div>
<div className="min-w-0">
<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>
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">{subtitle}</p>
</div>
<form
@@ -960,7 +922,7 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
e.preventDefault();
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 */}
{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">
Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span>
</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">
<input
type="radio"
@@ -1053,8 +1015,7 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
</div>
</div>
</form>
</div>
</div>,
</Modal>,
document.body,
);
}
@@ -1080,18 +1041,6 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
const [redemptions, setRedemptions] = useState<InviteRedemption[] | null>(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,40 +1060,31 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
}, [invite.id, addToast]);
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
onClick={onClose}
<Modal
isOpen
onClose={onClose}
title={`Redemptions for "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-lg"
>
<div className="absolute inset-0 bg-black/50" />
<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="flex items-start gap-3 -mt-1 mb-4">
<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">
<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>
</div>
<div className="min-w-0 flex-1">
<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">
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
Users who registered using this invite link, in the order they signed up.
</p>
</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' && (
<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
@@ -1162,7 +1102,7 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 pb-4">
<div className="-mx-1">
{redemptions === null ? (
<div className="text-sm text-txt-tertiary px-3 py-2">Loading</div>
) : redemptions.length === 0 ? (
@@ -1199,8 +1139,7 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
</div>
)}
</div>
</div>
</div>,
</Modal>,
document.body,
);
}
+9
View File
@@ -72,6 +72,12 @@ interface UIState {
setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void;
pushMobileScreen: (screen: string, params?: Record<string, string>) => 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>()(
@@ -172,6 +178,9 @@ export const useUIStore = create<UIState>()(
// 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',