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,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,23 +131,33 @@ 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) => (
<button
key={section.id}
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-interactive-hover text-left transition-colors"
>
{section.icon}
<span className="text-sm text-txt-primary flex-1">{section.label}</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>
))}
{sections.map((section) => {
const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null;
return (
<button
key={section.id}
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-interactive-hover text-left transition-colors"
>
{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) {
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(() => {