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