diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle
index 633a956..ac65ae6 100644
--- a/apps/android/android/app/build.gradle
+++ b/apps/android/android/app/build.gradle
@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27
- versionName "1.0.70"
+ versionName "1.0.80"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/apps/electron/package.json b/apps/electron/package.json
index e385b7e..57419a6 100644
--- a/apps/electron/package.json
+++ b/apps/electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@discord-clone/electron",
"private": true,
- "version": "1.0.70",
+ "version": "1.0.80",
"description": "Brycord - Electron app",
"author": "Moyettes",
"type": "module",
diff --git a/apps/web/package.json b/apps/web/package.json
index 675e42f..0856db6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@discord-clone/web",
"private": true,
- "version": "1.0.70",
+ "version": "1.0.80",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/packages/platform-web/src/index.js b/packages/platform-web/src/index.js
index c2d59a6..0091c32 100644
--- a/packages/platform-web/src/index.js
+++ b/packages/platform-web/src/index.js
@@ -147,6 +147,37 @@ if (window.Capacitor?.isNativePlatform?.()) {
webPlatform.systemBars = { setColors: (opts) => SystemBars.setColors(opts) };
webPlatform.features.hasSystemBars = true;
}
+
+ // Android hardware back button → dispatch to the app-level registry.
+ // We expose a `detail.handled` flag so in-tree listeners (see
+ // `useBackHandler`) can claim the event. When nothing claims it, we
+ // fall back to `App.minimizeApp()` so the user lands on their home
+ // screen instead of force-exiting the process.
+ const AppPlugin = window.Capacitor.Plugins.App;
+ if (AppPlugin && typeof AppPlugin.addListener === 'function') {
+ AppPlugin.addListener('backButton', () => {
+ const detail = { handled: false };
+ try {
+ window.dispatchEvent(
+ new CustomEvent('brycord:android-back', { detail }),
+ );
+ } catch (e) {
+ console.warn('[backButton] dispatch failed', e);
+ }
+ if (!detail.handled) {
+ try {
+ if (typeof AppPlugin.minimizeApp === 'function') {
+ AppPlugin.minimizeApp();
+ } else if (typeof AppPlugin.exitApp === 'function') {
+ AppPlugin.exitApp();
+ }
+ } catch (e) {
+ console.warn('[backButton] fallback failed', e);
+ }
+ }
+ });
+ webPlatform.features.hasBackButton = true;
+ }
}
export default webPlatform;
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 39ee6bf..54afdae 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
- "version": "1.0.70",
+ "version": "1.0.80",
"type": "module",
"main": "src/App.tsx",
"dependencies": {
diff --git a/packages/shared/src/components/channel/LinkEmbed.tsx b/packages/shared/src/components/channel/LinkEmbed.tsx
index f3b97ea..0cf62d9 100644
--- a/packages/shared/src/components/channel/LinkEmbed.tsx
+++ b/packages/shared/src/components/channel/LinkEmbed.tsx
@@ -216,11 +216,20 @@ interface LinkEmbedProps {
}
export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
+ // Thin router: direct media gets its own component, rich previews
+ // get theirs. Keeping the two paths in separate component instances
+ // means `useUrlPreview` (which internally wraps `useAction` and
+ // therefore `useMemo`) is never called conditionally — otherwise a
+ // URL change that flipped `isDirectMedia` would mutate the hook
+ // count and trigger React error #310.
const directType = isDirectMedia(url);
if (directType) {
return ;
}
+ return ;
+}
+function UrlPreviewEmbed({ url }: { url: string }) {
const preview = useUrlPreview(url);
// If the platform has no metadata fetcher (or it returned null / threw),
diff --git a/packages/shared/src/components/layout/AppLayout.tsx b/packages/shared/src/components/layout/AppLayout.tsx
index 7c08158..7eca3d8 100644
--- a/packages/shared/src/components/layout/AppLayout.tsx
+++ b/packages/shared/src/components/layout/AppLayout.tsx
@@ -1,6 +1,10 @@
-import { useEffect, useState } from 'react';
-import { Navigate, Outlet, useNavigate } from 'react-router-dom';
+import { useEffect, useRef, useState } from 'react';
+import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { PresenceProvider } from '../../contexts/PresenceContext';
+import { usePlatform } from '../../platform';
+import { getUserPref, setUserPref } from '../../utils/userPreferences';
+import { useMobileSwipeNav } from '../../hooks/useMobileSwipeNav';
+import { triggerBack, useBackHandler } from '../../hooks/useBackHandler';
import { KeybindProvider } from '../../contexts/KeybindContext';
import { UserSettingsModal } from '../settings/UserSettingsModal';
import { ServerSettingsModal } from '../settings/ServerSettingsModal';
@@ -23,6 +27,10 @@ import { UpdateBanner } from './UpdateBanner';
*/
export function AppLayout() {
const navigate = useNavigate();
+ const location = useLocation();
+ const platform = usePlatform() as any;
+ const hasRestoredRef = useRef(false);
+ useMobileSwipeNav();
const [settingsOpen, setSettingsOpen] = useState(false);
// Optional tab request from the dispatcher — e.g. Edit Profile
// buttons pass `{ tab: 'account' }` to jump straight in, while the
@@ -47,6 +55,39 @@ export function AppLayout() {
return () => window.removeEventListener('brycord:navigate', onNavigate);
}, [navigate]);
+ // Restore last-viewed route once on mount. Only fires when the user
+ // landed on the default `/channels/@me` (i.e. a fresh app open or a
+ // post-login redirect) — if they deep-linked to a specific channel
+ // we respect that and skip restoration.
+ useEffect(() => {
+ if (hasRestoredRef.current) return;
+ hasRestoredRef.current = true;
+ const userId = localStorage.getItem('userId');
+ if (!userId) return;
+ if (location.pathname !== '/channels/@me') return;
+ const saved = getUserPref(userId, 'lastViewedRoute', null);
+ if (
+ typeof saved === 'string' &&
+ saved.startsWith('/channels/') &&
+ saved !== location.pathname
+ ) {
+ navigate(saved, { replace: true });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Persist the current route on every change so the next cold-open
+ // can resume where the user left off. Skip the mobile `/channels/you`
+ // profile tab — restoring to it would feel weird after a restart,
+ // and skip paths that aren't inside the authenticated shell.
+ useEffect(() => {
+ if (!location.pathname.startsWith('/channels/')) return;
+ if (location.pathname === '/channels/you') return;
+ const userId = localStorage.getItem('userId');
+ if (!userId) return;
+ setUserPref(userId, 'lastViewedRoute', location.pathname, platform?.settings);
+ }, [location.pathname, platform]);
+
// User settings trigger — fired by UserArea's gear button + other entry points
useEffect(() => {
const onOpenSettings = (e: Event) => {
@@ -86,6 +127,46 @@ export function AppLayout() {
window.removeEventListener('brycord:open-channel-settings', onOpen);
}, []);
+ // Register the non-`Modal.Root` modals (UserSettings / ServerSettings
+ // / CreateServer render their own chrome) with the back-handler stack.
+ // Modals built on `Modal.Root` and `BottomSheet` self-register from
+ // inside those primitives, so they don't need explicit wiring here.
+ useBackHandler(settingsOpen, () => setSettingsOpen(false));
+ useBackHandler(serverSettingsOpen, () => setServerSettingsOpen(false));
+ useBackHandler(createServerOpen, () => setCreateServerOpen(false));
+
+ // Global Android back-button coordinator. Runs registered overlay
+ // handlers first (topmost drawer wins), then falls back to
+ // navigating chat → nav on mobile, then marks the event unhandled
+ // so the Capacitor bridge minimizes the app.
+ useEffect(() => {
+ const onBack = (e: Event) => {
+ const detail = (e as CustomEvent<{ handled?: boolean }>).detail;
+ if (detail?.handled) return;
+
+ if (triggerBack()) {
+ if (detail) detail.handled = true;
+ return;
+ }
+
+ // Mobile fallback: if we're in chat mode, go back to the
+ // channel list instead of exiting the app.
+ const isMobile =
+ typeof window !== 'undefined' &&
+ window.matchMedia('(max-width: 768px)').matches;
+ if (isMobile) {
+ const segments = location.pathname.split('/').filter(Boolean);
+ if (segments[0] === 'channels' && segments.length >= 3 && segments[2]) {
+ navigate(`/channels/${segments[1]}`);
+ if (detail) detail.handled = true;
+ return;
+ }
+ }
+ };
+ window.addEventListener('brycord:android-back', onBack);
+ return () => window.removeEventListener('brycord:android-back', onBack);
+ }, [location.pathname, navigate]);
+
// Global keybind handlers. Voice bindings are wired from UserArea
// via its own `useVoice()` access so the dispatch arrives where the
// mute / deafen state already lives.
diff --git a/packages/shared/src/components/layout/GuildNavbar.tsx b/packages/shared/src/components/layout/GuildNavbar.tsx
index 43c1185..7b0c6f7 100644
--- a/packages/shared/src/components/layout/GuildNavbar.tsx
+++ b/packages/shared/src/components/layout/GuildNavbar.tsx
@@ -32,6 +32,7 @@ import { GuildHeaderDropdown } from './GuildHeaderDropdown';
import { ChannelListContextMenu } from './ChannelListContextMenu';
import { MobileServerActionsSheet } from './MobileServerActionsSheet';
import { useIsMobile } from '../../hooks/useIsMobile';
+import { useBackHandler } from '../../hooks/useBackHandler';
import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu';
import { MemberProfileModal } from '../member/MemberProfileModal';
import styles from './GuildNavbar.module.css';
@@ -151,6 +152,9 @@ export function GuildNavbar() {
>(null);
const [voiceProfileFor, setVoiceProfileFor] = useState(null);
const isMobile = useIsMobile();
+ useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
+ useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
+ useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
const categories = useMemo(
() =>
diff --git a/packages/shared/src/components/layout/MobileYouPage.tsx b/packages/shared/src/components/layout/MobileYouPage.tsx
index ce09ddb..99009c0 100644
--- a/packages/shared/src/components/layout/MobileYouPage.tsx
+++ b/packages/shared/src/components/layout/MobileYouPage.tsx
@@ -18,6 +18,7 @@ import { Gear, PencilSimple } from '@phosphor-icons/react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { MobileSetStatusSheet } from './MobileSetStatusSheet';
+import { useBackHandler } from '../../hooks/useBackHandler';
import styles from './MobileYouPage.module.css';
function openUserSettings(tab?: 'account') {
@@ -51,6 +52,7 @@ export function MobileYouPage() {
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
const me = allUsers.find((u) => u.id === userId);
const [statusSheetOpen, setStatusSheetOpen] = useState(false);
+ useBackHandler(statusSheetOpen, () => setStatusSheetOpen(false));
const displayName = me?.displayName || username || 'User';
const realName = me?.username || username || displayName;
diff --git a/packages/shared/src/components/settings/ServerSettingsModal.tsx b/packages/shared/src/components/settings/ServerSettingsModal.tsx
index 6203f83..ddb0c37 100644
--- a/packages/shared/src/components/settings/ServerSettingsModal.tsx
+++ b/packages/shared/src/components/settings/ServerSettingsModal.tsx
@@ -60,6 +60,17 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
+ // Roles view takes over the whole settings surface — its own
+ // sidebar (back + create + role list) and its own main column
+ // (role editor). We still delegate the back button to flipping
+ // activeTab back to 'overview' so the outer modal stays open.
+ //
+ // IMPORTANT: this hook must run before any conditional early return
+ // (mobile shortcut below, `!isOpen` guard) so the hook count stays
+ // stable across renders when the viewport crosses the mobile
+ // breakpoint. React error #300 if this moves back under `isMobile`.
+ const rolesView = useRolesView({ onBack: () => setActiveTab('overview') });
+
// Mobile gets the full-screen overlay with a category list ↔ panel
// flow, desktop gets the two-column modal below. Pass the raw
// `initialTab` (NOT the resolved version) so mobile lands on the
@@ -74,12 +85,6 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
);
}
- // Roles view takes over the whole settings surface — its own
- // sidebar (back + create + role list) and its own main column
- // (role editor). We still delegate the back button to flipping
- // activeTab back to 'overview' so the outer modal stays open.
- const rolesView = useRolesView({ onBack: () => setActiveTab('overview') });
-
if (!isOpen) return null;
const active = TABS.find((t) => t.id === activeTab) ?? TABS[0];
diff --git a/packages/shared/src/components/voice/RecordingRecoveryModal.tsx b/packages/shared/src/components/voice/RecordingRecoveryModal.tsx
index c6918eb..dadde3e 100644
--- a/packages/shared/src/components/voice/RecordingRecoveryModal.tsx
+++ b/packages/shared/src/components/voice/RecordingRecoveryModal.tsx
@@ -86,6 +86,15 @@ export function RecordingRecoveryModal() {
};
}, [hasRecording, recording]);
+ // Close the modal automatically once every session has been
+ // resolved. Declared here (before the `!hasRecording` early return)
+ // so the hook count stays stable across renders.
+ useEffect(() => {
+ if (isOpen && sessions.length === 0) {
+ setIsOpen(false);
+ }
+ }, [isOpen, sessions.length]);
+
if (!hasRecording) return null;
const dismiss = () => {
@@ -139,12 +148,6 @@ export function RecordingRecoveryModal() {
}
};
- useEffect(() => {
- if (isOpen && sessions.length === 0) {
- setIsOpen(false);
- }
- }, [isOpen, sessions.length]);
-
return (
diff --git a/packages/shared/src/hooks/useBackHandler.ts b/packages/shared/src/hooks/useBackHandler.ts
new file mode 100644
index 0000000..974e9a2
--- /dev/null
+++ b/packages/shared/src/hooks/useBackHandler.ts
@@ -0,0 +1,58 @@
+import { useEffect } from 'react';
+
+/**
+ * Back-button handler registry.
+ *
+ * Components that render a dismissible overlay (modal, drawer, bottom
+ * sheet, popover, image lightbox, …) call `useBackHandler(isOpen, onBack)`
+ * to register a close callback while the overlay is visible. When the
+ * Android hardware back button fires, the most-recently-registered open
+ * handler runs — LIFO order, so the topmost drawer always closes first.
+ *
+ * The stack is stored on `window.__brycordBackStack` so other packages
+ * (notably `@discord-clone/ui`'s Modal / BottomSheet primitives) can push
+ * into the same queue without a direct dependency on this package.
+ */
+
+type BackHandler = () => void;
+
+function getStack(): BackHandler[] {
+ const g = typeof window !== 'undefined' ? (window as any) : {};
+ if (!g.__brycordBackStack) g.__brycordBackStack = [];
+ return g.__brycordBackStack;
+}
+
+export function pushBackHandler(fn: BackHandler): () => void {
+ const stack = getStack();
+ stack.push(fn);
+ return () => {
+ const s = getStack();
+ const i = s.lastIndexOf(fn);
+ if (i !== -1) s.splice(i, 1);
+ };
+}
+
+/** Returns true if a registered handler claimed the event. */
+export function triggerBack(): boolean {
+ const stack = getStack();
+ const top = stack[stack.length - 1];
+ if (!top) return false;
+ try {
+ top();
+ } catch (e) {
+ console.warn('[back] handler threw', e);
+ }
+ return true;
+}
+
+/**
+ * Register a close callback for the duration that `isOpen` is true.
+ * The callback is pushed on open and popped on close (or unmount).
+ */
+export function useBackHandler(isOpen: boolean, onBack: BackHandler) {
+ useEffect(() => {
+ if (!isOpen) return;
+ const pop = pushBackHandler(onBack);
+ return pop;
+ }, [isOpen, onBack]);
+}
diff --git a/packages/shared/src/hooks/useMobileSwipeNav.ts b/packages/shared/src/hooks/useMobileSwipeNav.ts
new file mode 100644
index 0000000..98dc88e
--- /dev/null
+++ b/packages/shared/src/hooks/useMobileSwipeNav.ts
@@ -0,0 +1,153 @@
+import { useEffect } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+
+/**
+ * Mobile horizontal-swipe navigation.
+ *
+ * - In *chat* mode (`/channels/:serverId/:channelId` or `/channels/@me/:dmId`),
+ * a left→right swipe that starts inside the left edge zone navigates to
+ * the channel list (the parent path).
+ * - In *nav* mode (`/channels/:serverId` or `/channels/@me`), a right→left
+ * swipe that starts inside the right edge zone navigates to the *last*
+ * channel the user had open inside this server / DM scope. If nothing is
+ * remembered, the gesture is a no-op.
+ *
+ * Edge-zone activation (first/last 48px) keeps the gesture from fighting
+ * horizontal scroll inside the chat (code blocks, carousels) and prevents
+ * accidental swipes when the user is just tapping around.
+ */
+
+const EDGE_ZONE = 48;
+const MIN_DX = 70;
+const MAX_DY = 48;
+const STORAGE_KEY_PREFIX = 'brycord:lastChannel:';
+const MOBILE_QUERY = '(max-width: 768px)';
+
+function parseLocation(pathname: string) {
+ const segments = pathname.split('/').filter(Boolean);
+ if (segments[0] !== 'channels') {
+ return { scope: null as string | null, channelId: null as string | null };
+ }
+ const scope = segments[1] ?? null;
+ const channelId = segments[2] ?? null;
+ return { scope, channelId };
+}
+
+function rememberChannel(scope: string, channelId: string) {
+ try {
+ localStorage.setItem(`${STORAGE_KEY_PREFIX}${scope}`, channelId);
+ } catch {}
+}
+
+function recallChannel(scope: string): string | null {
+ try {
+ return localStorage.getItem(`${STORAGE_KEY_PREFIX}${scope}`);
+ } catch {
+ return null;
+ }
+}
+
+export function useMobileSwipeNav() {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ // Remember the active channel per scope so nav → chat swipes know
+ // where to go. Runs every path change.
+ useEffect(() => {
+ const { scope, channelId } = parseLocation(location.pathname);
+ if (scope && channelId) {
+ rememberChannel(scope, channelId);
+ }
+ }, [location.pathname]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (!window.matchMedia(MOBILE_QUERY).matches) return;
+
+ let startX = 0;
+ let startY = 0;
+ let tracking = false;
+ let direction: 'back' | 'forward' | null = null;
+
+ const onTouchStart = (e: TouchEvent) => {
+ if (e.touches.length !== 1) return;
+ const touch = e.touches[0];
+ startX = touch.clientX;
+ startY = touch.clientY;
+ tracking = false;
+ direction = null;
+
+ const { scope, channelId } = parseLocation(location.pathname);
+ if (!scope) return;
+ const inChat = !!channelId;
+ const vw = window.innerWidth;
+
+ if (inChat && startX <= EDGE_ZONE) {
+ tracking = true;
+ direction = 'back';
+ } else if (!inChat && startX >= vw - EDGE_ZONE) {
+ const remembered = recallChannel(scope);
+ if (remembered) {
+ tracking = true;
+ direction = 'forward';
+ }
+ }
+ };
+
+ const onTouchMove = (e: TouchEvent) => {
+ if (!tracking) return;
+ const touch = e.touches[0];
+ const dx = touch.clientX - startX;
+ const dy = touch.clientY - startY;
+ if (Math.abs(dy) > MAX_DY) {
+ // Vertical scroll — abandon the swipe.
+ tracking = false;
+ direction = null;
+ return;
+ }
+ // Block the horizontal scroll parent from hijacking us while
+ // the gesture is in progress.
+ if (Math.abs(dx) > 8) {
+ try {
+ e.preventDefault();
+ } catch {}
+ }
+ };
+
+ const onTouchEnd = (e: TouchEvent) => {
+ if (!tracking) return;
+ tracking = false;
+ const touch = e.changedTouches[0];
+ if (!touch) return;
+ const dx = touch.clientX - startX;
+ const dy = touch.clientY - startY;
+ if (Math.abs(dy) > MAX_DY) return;
+
+ const { scope, channelId } = parseLocation(location.pathname);
+ if (!scope) return;
+
+ if (direction === 'back' && dx >= MIN_DX) {
+ // chat → nav: drop the channel segment.
+ navigate(`/channels/${scope}`);
+ } else if (direction === 'forward' && dx <= -MIN_DX && !channelId) {
+ const remembered = recallChannel(scope);
+ if (remembered) {
+ navigate(`/channels/${scope}/${remembered}`);
+ }
+ }
+ direction = null;
+ };
+
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
+ document.addEventListener('touchend', onTouchEnd, { passive: true });
+ document.addEventListener('touchcancel', onTouchEnd, { passive: true });
+
+ return () => {
+ document.removeEventListener('touchstart', onTouchStart);
+ document.removeEventListener('touchmove', onTouchMove);
+ document.removeEventListener('touchend', onTouchEnd);
+ document.removeEventListener('touchcancel', onTouchEnd);
+ };
+ }, [location.pathname, navigate]);
+}
diff --git a/packages/shared/src/platform/types.js b/packages/shared/src/platform/types.js
index b4fbbaf..4131e1e 100644
--- a/packages/shared/src/platform/types.js
+++ b/packages/shared/src/platform/types.js
@@ -108,6 +108,7 @@
* @property {boolean} hasSearch
* @property {boolean} hasVoiceService
* @property {boolean} hasSystemBars
+ * @property {boolean} [hasBackButton]
*/
/**
diff --git a/packages/ui/src/BottomSheet.tsx b/packages/ui/src/BottomSheet.tsx
index e2216bc..7048eb4 100644
--- a/packages/ui/src/BottomSheet.tsx
+++ b/packages/ui/src/BottomSheet.tsx
@@ -95,6 +95,22 @@ export function BottomSheet({
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleEscape]);
+ // Register with the global Android back-button stack so the
+ // hardware back key closes the topmost open sheet. Non-dismissible
+ // sheets opt out. Shared LIFO stack is keyed on `window`.
+ useEffect(() => {
+ if (!isOpen || !dismissible) return;
+ if (typeof window === 'undefined') return;
+ const g = window as any;
+ if (!g.__brycordBackStack) g.__brycordBackStack = [];
+ const stack: Array<() => void> = g.__brycordBackStack;
+ stack.push(onClose);
+ return () => {
+ const i = stack.lastIndexOf(onClose);
+ if (i !== -1) stack.splice(i, 1);
+ };
+ }, [isOpen, dismissible, onClose]);
+
// Lock body scroll while the sheet is open — keeps the underlying page
// from scrolling behind the drawer on mobile.
useEffect(() => {
diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx
index 78e29b1..2fc82b7 100644
--- a/packages/ui/src/Modal.tsx
+++ b/packages/ui/src/Modal.tsx
@@ -62,6 +62,23 @@ function ModalRoot({ isOpen, onClose, size = 'small', children, className, zInde
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleEscape]);
+ // Register with the global back-handler stack so Android hardware
+ // back closes the topmost open modal (shared across packages via
+ // `window.__brycordBackStack`). No-op on environments without a
+ // window (SSR / tests).
+ useEffect(() => {
+ if (!isOpen) return;
+ if (typeof window === 'undefined') return;
+ const g = window as any;
+ if (!g.__brycordBackStack) g.__brycordBackStack = [];
+ const stack: Array<() => void> = g.__brycordBackStack;
+ stack.push(onClose);
+ return () => {
+ const i = stack.lastIndexOf(onClose);
+ if (i !== -1) stack.splice(i, 1);
+ };
+ }, [isOpen, onClose]);
+
return createPortal(
{isOpen && (