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 && (