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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user