This commit is contained in:
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.70"
|
versionName "1.0.80"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.70",
|
"version": "1.0.80",
|
||||||
"description": "Brycord - Electron app",
|
"description": "Brycord - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.70",
|
"version": "1.0.80",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -147,6 +147,37 @@ if (window.Capacitor?.isNativePlatform?.()) {
|
|||||||
webPlatform.systemBars = { setColors: (opts) => SystemBars.setColors(opts) };
|
webPlatform.systemBars = { setColors: (opts) => SystemBars.setColors(opts) };
|
||||||
webPlatform.features.hasSystemBars = true;
|
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;
|
export default webPlatform;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.70",
|
"version": "1.0.80",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.tsx",
|
"main": "src/App.tsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -216,11 +216,20 @@ interface LinkEmbedProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LinkEmbed({ url, onOpenGif }: 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);
|
const directType = isDirectMedia(url);
|
||||||
if (directType) {
|
if (directType) {
|
||||||
return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
|
return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
|
||||||
}
|
}
|
||||||
|
return <UrlPreviewEmbed url={url} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlPreviewEmbed({ url }: { url: string }) {
|
||||||
const preview = useUrlPreview(url);
|
const preview = useUrlPreview(url);
|
||||||
|
|
||||||
// If the platform has no metadata fetcher (or it returned null / threw),
|
// If the platform has no metadata fetcher (or it returned null / threw),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { PresenceProvider } from '../../contexts/PresenceContext';
|
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 { KeybindProvider } from '../../contexts/KeybindContext';
|
||||||
import { UserSettingsModal } from '../settings/UserSettingsModal';
|
import { UserSettingsModal } from '../settings/UserSettingsModal';
|
||||||
import { ServerSettingsModal } from '../settings/ServerSettingsModal';
|
import { ServerSettingsModal } from '../settings/ServerSettingsModal';
|
||||||
@@ -23,6 +27,10 @@ import { UpdateBanner } from './UpdateBanner';
|
|||||||
*/
|
*/
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const platform = usePlatform() as any;
|
||||||
|
const hasRestoredRef = useRef(false);
|
||||||
|
useMobileSwipeNav();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
// Optional tab request from the dispatcher — e.g. Edit Profile
|
// Optional tab request from the dispatcher — e.g. Edit Profile
|
||||||
// buttons pass `{ tab: 'account' }` to jump straight in, while the
|
// buttons pass `{ tab: 'account' }` to jump straight in, while the
|
||||||
@@ -47,6 +55,39 @@ export function AppLayout() {
|
|||||||
return () => window.removeEventListener('brycord:navigate', onNavigate);
|
return () => window.removeEventListener('brycord:navigate', onNavigate);
|
||||||
}, [navigate]);
|
}, [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
|
// User settings trigger — fired by UserArea's gear button + other entry points
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpenSettings = (e: Event) => {
|
const onOpenSettings = (e: Event) => {
|
||||||
@@ -86,6 +127,46 @@ export function AppLayout() {
|
|||||||
window.removeEventListener('brycord:open-channel-settings', onOpen);
|
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
|
// Global keybind handlers. Voice bindings are wired from UserArea
|
||||||
// via its own `useVoice()` access so the dispatch arrives where the
|
// via its own `useVoice()` access so the dispatch arrives where the
|
||||||
// mute / deafen state already lives.
|
// mute / deafen state already lives.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { GuildHeaderDropdown } from './GuildHeaderDropdown';
|
|||||||
import { ChannelListContextMenu } from './ChannelListContextMenu';
|
import { ChannelListContextMenu } from './ChannelListContextMenu';
|
||||||
import { MobileServerActionsSheet } from './MobileServerActionsSheet';
|
import { MobileServerActionsSheet } from './MobileServerActionsSheet';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import { useBackHandler } from '../../hooks/useBackHandler';
|
||||||
import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu';
|
import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu';
|
||||||
import { MemberProfileModal } from '../member/MemberProfileModal';
|
import { MemberProfileModal } from '../member/MemberProfileModal';
|
||||||
import styles from './GuildNavbar.module.css';
|
import styles from './GuildNavbar.module.css';
|
||||||
@@ -151,6 +152,9 @@ export function GuildNavbar() {
|
|||||||
>(null);
|
>(null);
|
||||||
const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null);
|
const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
|
||||||
|
useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
|
||||||
|
useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
|
||||||
|
|
||||||
const categories = useMemo<CategoryDoc[]>(
|
const categories = useMemo<CategoryDoc[]>(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Gear, PencilSimple } from '@phosphor-icons/react';
|
|||||||
import { Avatar } from '@discord-clone/ui';
|
import { Avatar } from '@discord-clone/ui';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import { MobileSetStatusSheet } from './MobileSetStatusSheet';
|
import { MobileSetStatusSheet } from './MobileSetStatusSheet';
|
||||||
|
import { useBackHandler } from '../../hooks/useBackHandler';
|
||||||
import styles from './MobileYouPage.module.css';
|
import styles from './MobileYouPage.module.css';
|
||||||
|
|
||||||
function openUserSettings(tab?: 'account') {
|
function openUserSettings(tab?: 'account') {
|
||||||
@@ -51,6 +52,7 @@ export function MobileYouPage() {
|
|||||||
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
||||||
const me = allUsers.find((u) => u.id === userId);
|
const me = allUsers.find((u) => u.id === userId);
|
||||||
const [statusSheetOpen, setStatusSheetOpen] = useState(false);
|
const [statusSheetOpen, setStatusSheetOpen] = useState(false);
|
||||||
|
useBackHandler(statusSheetOpen, () => setStatusSheetOpen(false));
|
||||||
|
|
||||||
const displayName = me?.displayName || username || 'User';
|
const displayName = me?.displayName || username || 'User';
|
||||||
const realName = me?.username || username || displayName;
|
const realName = me?.username || username || displayName;
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
|
|||||||
return () => document.removeEventListener('keydown', onKey);
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
}, [isOpen, onClose]);
|
}, [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
|
// Mobile gets the full-screen overlay with a category list ↔ panel
|
||||||
// flow, desktop gets the two-column modal below. Pass the raw
|
// flow, desktop gets the two-column modal below. Pass the raw
|
||||||
// `initialTab` (NOT the resolved version) so mobile lands on the
|
// `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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const active = TABS.find((t) => t.id === activeTab) ?? TABS[0];
|
const active = TABS.find((t) => t.id === activeTab) ?? TABS[0];
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ export function RecordingRecoveryModal() {
|
|||||||
};
|
};
|
||||||
}, [hasRecording, recording]);
|
}, [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;
|
if (!hasRecording) return null;
|
||||||
|
|
||||||
const dismiss = () => {
|
const dismiss = () => {
|
||||||
@@ -139,12 +148,6 @@ export function RecordingRecoveryModal() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && sessions.length === 0) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}, [isOpen, sessions.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal.Root isOpen={isOpen} onClose={dismiss} size="medium">
|
<Modal.Root isOpen={isOpen} onClose={dismiss} size="medium">
|
||||||
<Modal.Header title="Recover voice recordings" onClose={dismiss} />
|
<Modal.Header title="Recover voice recordings" onClose={dismiss} />
|
||||||
|
|||||||
58
packages/shared/src/hooks/useBackHandler.ts
Normal file
58
packages/shared/src/hooks/useBackHandler.ts
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
153
packages/shared/src/hooks/useMobileSwipeNav.ts
Normal file
153
packages/shared/src/hooks/useMobileSwipeNav.ts
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
* @property {boolean} hasSearch
|
* @property {boolean} hasSearch
|
||||||
* @property {boolean} hasVoiceService
|
* @property {boolean} hasVoiceService
|
||||||
* @property {boolean} hasSystemBars
|
* @property {boolean} hasSystemBars
|
||||||
|
* @property {boolean} [hasBackButton]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ export function BottomSheet({
|
|||||||
return () => document.removeEventListener('keydown', handleEscape);
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
}, [isOpen, 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
|
// Lock body scroll while the sheet is open — keeps the underlying page
|
||||||
// from scrolling behind the drawer on mobile.
|
// from scrolling behind the drawer on mobile.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ function ModalRoot({ isOpen, onClose, size = 'small', children, className, zInde
|
|||||||
return () => document.removeEventListener('keydown', handleEscape);
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
}, [isOpen, 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(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
Reference in New Issue
Block a user