@@ -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 */}
-