From 01534448170824dcc7f8dbf920176621d8de89ec Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:51:49 -0500 Subject: [PATCH] 1.0.90 --- apps/android/android/app/build.gradle | 2 +- apps/electron/package.json | 2 +- apps/web/package.json | 2 +- packages/shared/package.json | 2 +- .../src/components/channel/ChannelView.tsx | 5 +- .../shared/src/components/dm/DMLayout.tsx | 28 ++- .../src/components/layout/AppLayout.tsx | 2 - .../src/components/layout/GuildList.tsx | 38 +++- .../src/components/layout/GuildNavbar.tsx | 85 ++++++-- .../components/layout/GuildsLayout.module.css | 82 ++++---- .../src/components/layout/GuildsLayout.tsx | 37 +++- .../shared/src/hooks/useMobileSwipeNav.ts | 195 +++++++++++------- 12 files changed, 338 insertions(+), 142 deletions(-) diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index ac65ae6..345f2f2 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.80" + versionName "1.0.90" 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 57419a6..3f193ab 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.80", + "version": "1.0.90", "description": "Brycord - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 0856db6..7649e52 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.80", + "version": "1.0.90", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shared/package.json b/packages/shared/package.json index 54afdae..5e3232e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.80", + "version": "1.0.90", "type": "module", "main": "src/App.tsx", "dependencies": { diff --git a/packages/shared/src/components/channel/ChannelView.tsx b/packages/shared/src/components/channel/ChannelView.tsx index 87924f6..bc93eae 100644 --- a/packages/shared/src/components/channel/ChannelView.tsx +++ b/packages/shared/src/components/channel/ChannelView.tsx @@ -18,8 +18,9 @@ import styles from './ChannelView.module.css'; * either the voice-join prompt, an active voice call, or a text chat * (header + messages + textarea + members panel). */ -export function ChannelView() { - const { channelId } = useParams<{ channelId: string }>(); +export function ChannelView({ channelId: channelIdProp }: { channelId?: string } = {}) { + const params = useParams<{ channelId: string }>(); + const channelId = channelIdProp || params.channelId; const voice = useVoice(); const [membersVisible, setMembersVisible] = useState(true); const [detailsOpen, setDetailsOpen] = useState(false); diff --git a/packages/shared/src/components/dm/DMLayout.tsx b/packages/shared/src/components/dm/DMLayout.tsx index 2eca4e1..8851203 100644 --- a/packages/shared/src/components/dm/DMLayout.tsx +++ b/packages/shared/src/components/dm/DMLayout.tsx @@ -1,15 +1,41 @@ import { useQuery } from 'convex/react'; +import { useEffect, useRef, useState } from 'react'; import { MagnifyingGlass } from '@phosphor-icons/react'; import { useNavigate, useParams } from 'react-router-dom'; import { api } from '../../../../../convex/_generated/api'; +import { usePlatform } from '../../platform'; +import { getUserPref, setUserPref } from '../../utils/userPreferences'; import { DMListItem } from './DMListItem'; import styles from './DMLayout.module.css'; export function DMLayout() { const navigate = useNavigate(); const params = useParams<{ channelId?: string }>(); + const platform = usePlatform() as any; const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; - const dms = useQuery(api.dms.listDMs, userId ? { userId: userId as any } : 'skip') ?? []; + + // Read cached DM list on mount so the sidebar renders instantly + // instead of flashing "No direct messages yet" while the Convex + // query resolves. The cache is written to userPreferences (which + // persists via localStorage + platform.settings for disk backup on + // Electron / Android). + const [cached] = useState(() => + userId ? getUserPref(userId, 'dmListCache', null) : null, + ); + + const liveDms = useQuery(api.dms.listDMs, userId ? { userId: userId as any } : 'skip'); + const hasFetchedRef = useRef(false); + + // Write to cache whenever the live query returns data. + useEffect(() => { + if (!userId || liveDms === undefined) return; + if (hasFetchedRef.current && liveDms.length === (cached?.length ?? -1)) return; + hasFetchedRef.current = true; + setUserPref(userId, 'dmListCache', liveDms, platform?.settings); + }, [liveDms, userId, platform, cached?.length]); + + // Use live data once available, fall back to cache, then empty. + const dms = liveDms ?? cached ?? []; return (
diff --git a/packages/shared/src/components/layout/AppLayout.tsx b/packages/shared/src/components/layout/AppLayout.tsx index 7eca3d8..7749aed 100644 --- a/packages/shared/src/components/layout/AppLayout.tsx +++ b/packages/shared/src/components/layout/AppLayout.tsx @@ -3,7 +3,6 @@ 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'; @@ -30,7 +29,6 @@ export function AppLayout() { 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 diff --git a/packages/shared/src/components/layout/GuildList.tsx b/packages/shared/src/components/layout/GuildList.tsx index 269ec28..879a670 100644 --- a/packages/shared/src/components/layout/GuildList.tsx +++ b/packages/shared/src/components/layout/GuildList.tsx @@ -59,21 +59,30 @@ export function GuildList(_props: GuildListProps) {
- + {isMobile ? ( - + ) : ( + + + + )}
- + {isMobile ? (
{initials} )}
-
+ ) : ( + +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleServerClick(); + } + }} + > + {serverIconUrl ? ( + {serverName} + ) : ( + {initials} + )} +
+
+ )}
{/* Add-a-server button removed — this build is a single-server diff --git a/packages/shared/src/components/layout/GuildNavbar.tsx b/packages/shared/src/components/layout/GuildNavbar.tsx index 7b0c6f7..1fdf64f 100644 --- a/packages/shared/src/components/layout/GuildNavbar.tsx +++ b/packages/shared/src/components/layout/GuildNavbar.tsx @@ -28,6 +28,8 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { Avatar } from '@discord-clone/ui'; import { api } from '../../../../../convex/_generated/api'; import { useVoice } from '../../contexts/VoiceContext'; +import { usePlatform } from '../../platform'; +import { getUserPref, setUserPref } from '../../utils/userPreferences'; import { GuildHeaderDropdown } from './GuildHeaderDropdown'; import { ChannelListContextMenu } from './ChannelListContextMenu'; import { MobileServerActionsSheet } from './MobileServerActionsSheet'; @@ -64,10 +66,66 @@ export function GuildNavbar() { const navigate = useNavigate(); const location = useLocation(); const segments = location.pathname.split('/').filter(Boolean); - const selectedChannelId = segments[0] === 'channels' ? segments[2] || null : null; - const serverSettings = useQuery(api.serverSettings.get); - const categoriesRaw = useQuery(api.categories.list); - const channelsRaw = useQuery(api.channels.list); + const urlChannelId = segments[0] === 'channels' ? segments[2] || null : null; + const isMobile = useIsMobile(); + // On mobile nav mode the URL has no channelId, but the swipe hook + // remembers the last channel. Use that so collapsed categories + // still show the "active" channel row instead of hiding everything. + const rememberedId = !urlChannelId && isMobile + ? (() => { + const scope = segments[0] === 'channels' ? segments[1] : null; + if (!scope) return null; + try { return localStorage.getItem(`brycord:lastChannel:${scope}`); } catch { return null; } + })() + : null; + const selectedChannelId = urlChannelId || rememberedId; + const platform = usePlatform() as any; + const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; + + // Local cache for instant render on refresh — same pattern as DMLayout. + const [cached] = useState(() => + userId + ? { + serverSettings: getUserPref(userId, 'serverSettingsCache', null), + categories: getUserPref(userId, 'categoriesCache', null), + channels: getUserPref(userId, 'channelsCache', null), + } + : { serverSettings: null, categories: null, channels: null }, + ); + + const liveServerSettings = useQuery(api.serverSettings.get); + const liveCategoriesRaw = useQuery(api.categories.list); + const liveChannelsRaw = useQuery(api.channels.list); + + const serverSettings = liveServerSettings ?? cached.serverSettings; + const categoriesRaw = liveCategoriesRaw ?? cached.categories; + const channelsRaw = liveChannelsRaw ?? cached.channels; + + // Persist to cache when live data arrives. + const cachedRef = useRef(false); + useEffect(() => { + if (!userId) return; + if (liveServerSettings === undefined && liveCategoriesRaw === undefined && liveChannelsRaw === undefined) return; + if (cachedRef.current) return; + cachedRef.current = true; + if (liveServerSettings !== undefined) setUserPref(userId, 'serverSettingsCache', liveServerSettings, platform?.settings); + if (liveCategoriesRaw !== undefined) setUserPref(userId, 'categoriesCache', liveCategoriesRaw, platform?.settings); + if (liveChannelsRaw !== undefined) setUserPref(userId, 'channelsCache', liveChannelsRaw, platform?.settings); + }, [liveServerSettings, liveCategoriesRaw, liveChannelsRaw, userId, platform]); + + // Re-cache when data changes after initial load. + useEffect(() => { + if (!userId || !cachedRef.current) return; + if (liveServerSettings !== undefined) setUserPref(userId, 'serverSettingsCache', liveServerSettings, platform?.settings); + }, [liveServerSettings, userId, platform]); + useEffect(() => { + if (!userId || !cachedRef.current) return; + if (liveCategoriesRaw !== undefined) setUserPref(userId, 'categoriesCache', liveCategoriesRaw, platform?.settings); + }, [liveCategoriesRaw, userId, platform]); + useEffect(() => { + if (!userId || !cachedRef.current) return; + if (liveChannelsRaw !== undefined) setUserPref(userId, 'channelsCache', liveChannelsRaw, platform?.settings); + }, [liveChannelsRaw, userId, platform]); const voice = useVoice(); const voiceStates: Record = @@ -99,13 +157,6 @@ export function GuildNavbar() { const [contextMenu, setContextMenu] = useState(null); // ── Unread tracking ───────────────────────────────────────────── - // The readState API stores lastReadTimestamp per (user, channel). - // We compare that to the latest message timestamp per channel to - // derive an unread flag. There is no mentionCount tracking on the - // backend yet, so the mention pill is wired up but will only render - // if a future version of the API starts returning one. - const userId = - typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; const readStates = useQuery( api.readState.getAllReadStates, userId ? { userId: userId as any } : 'skip', @@ -143,7 +194,10 @@ export function GuildNavbar() { return out; }, [readStates, latestTimestamps]); - const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); + const [collapsedCategories, setCollapsedCategories] = useState>(() => { + const saved = getUserPref(userId, 'collapsedCategories', null); + return Array.isArray(saved) ? new Set(saved) : new Set(); + }); const headerRef = useRef(null); const [headerDropdownRect, setHeaderDropdownRect] = useState(null); const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false); @@ -151,7 +205,6 @@ export function GuildNavbar() { { x: number; y: number; userId: string; username: string } | null >(null); const [voiceProfileFor, setVoiceProfileFor] = useState(null); - const isMobile = useIsMobile(); useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false)); useBackHandler(!!voiceMenu, () => setVoiceMenu(null)); useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null)); @@ -186,6 +239,7 @@ export function GuildNavbar() { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); + setUserPref(userId, 'collapsedCategories', [...next], platform?.settings); return next; }); }; @@ -220,6 +274,7 @@ export function GuildNavbar() { } else if (e.key === 'ArrowRight' && isCollapsed) { next.delete(catId); } + setUserPref(userId, 'collapsedCategories', [...next], platform?.settings); return next; }); }; @@ -294,7 +349,9 @@ export function GuildNavbar() { // each category's channel list + the uncategorized list are separate // sortable contexts. const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(PointerSensor, { + activationConstraint: { distance: isMobile ? 1e6 : 4 }, + }), ); const handleDragEndCategories = (event: DragEndEvent) => { diff --git a/packages/shared/src/components/layout/GuildsLayout.module.css b/packages/shared/src/components/layout/GuildsLayout.module.css index 0c59259..5c35132 100644 --- a/packages/shared/src/components/layout/GuildsLayout.module.css +++ b/packages/shared/src/components/layout/GuildsLayout.module.css @@ -112,19 +112,22 @@ /* ── Mobile layout (≤768px) ────────────────────────────────────────── - Fluxer/Discord-style single-column mobile: EITHER the nav column - (guild list rail + channel list) OR the chat column is visible, never - both. Which one shows is decided in GuildsLayout.tsx by parsing the - current URL — a channel in the URL means chat mode, no channel means - nav mode. The data-mobile-mode attribute switches between them. + The mobile layout keeps BOTH the nav column (guild rail + channel + list) AND the chat column rendered at all times, positioned side-by- + side inside a 200vw-wide tray. Swiping horizontally translates the + tray in real time, and the URL change on release triggers a CSS + transition that snaps to the final position. - The bottom user area (avatar + mute + deafen + settings gear) is - hidden in both modes on mobile; its functionality is reachable via - the avatar popout from the top of the channel list instead. + The `data-mobile-mode` attribute (set from the URL in GuildsLayout.tsx) + controls which panel is active via a `translateX` on `.container`. + `[data-mobile-swiping]` is set during an active touch gesture so the + CSS transition is suppressed — the JS handler drives the transform + directly at 60 fps instead. + + The "you" tab is a special case — it re-uses the content column with + the nav columns hidden, same as before. */ @media (max-width: 768px) { - /* Common: no divider, no bottom user area strip, no padding reserved - for it. Guild list rail shrinks to an icon column. */ .sidebarDivider { display: none; } @@ -133,54 +136,56 @@ display: none; } - /* Guild list keeps its default 72px layout width on mobile too — - the previous 56px override made the icon column feel cramped - and pushed the icons off-center relative to their tooltips. */ .guildList { padding-bottom: var(--spacing-2); + flex-shrink: 0; } .sidebar { padding-bottom: 0; + flex-shrink: 0; + width: calc(100vw - var(--layout-guild-list-width)); + min-width: calc(100vw - var(--layout-guild-list-width)); } - /* Nav mode: guild rail + full-width sidebar (channel list / DM list), - chat area hidden. */ - .wrapper[data-mobile-mode='nav'] .sidebar { - display: flex; - flex: 1 1 auto; - width: auto; - min-width: 0; + /* ── Tray layout: nav (100vw) + chat (100vw) side-by-side ────── */ + .container { + width: 200vw; + flex-shrink: 0; + flex-wrap: nowrap; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform; } - .wrapper[data-mobile-mode='nav'] .content { - display: none; + /* Suppress the CSS transition while a finger-drag is in progress + so the tray follows the pointer at 60 fps without a 300ms lag. */ + .wrapper[data-mobile-swiping] .container { + transition: none; } - /* Chat mode: hide nav columns entirely, chat fills the viewport. - The bottom nav is also hidden — the chat has its own back button - in the header, so the tab bar would be redundant and would just - take vertical space away from the input. */ - .wrapper[data-mobile-mode='chat'] .guildList { - display: none; + .content { + flex: none; + width: 100vw; + min-width: 100vw; } - .wrapper[data-mobile-mode='chat'] .sidebar { - display: none; + /* Nav mode: tray at X=0 (nav columns visible). */ + .wrapper[data-mobile-mode='nav'] .container { + transform: translateX(0); } - .wrapper[data-mobile-mode='chat'] .content { - flex: 1 1 auto; - min-width: 0; + /* Chat mode: tray slid left so the content column fills viewport. */ + .wrapper[data-mobile-mode='chat'] .container { + transform: translateX(-100vw); } + /* Hide bottom nav in chat mode — the chat header has a back button. */ .wrapper[data-mobile-mode='chat'] > nav { display: none; } - /* You mode: identical column-hiding to chat mode (guild rail + - sidebar hidden, content fills), but the MobileBottomNav stays - visible underneath — that's the whole point of the tab. */ + /* ── "You" tab ── falls back to display:none toggling since it + doesn't participate in the horizontal swipe tray. */ .wrapper[data-mobile-mode='you'] .guildList { display: none; } @@ -191,7 +196,12 @@ .wrapper[data-mobile-mode='you'] .content { flex: 1 1 auto; + width: auto; min-width: 0; background-color: var(--background-primary); } + + .wrapper[data-mobile-mode='you'] .container { + width: auto; + } } diff --git a/packages/shared/src/components/layout/GuildsLayout.tsx b/packages/shared/src/components/layout/GuildsLayout.tsx index 7c79f36..170f615 100644 --- a/packages/shared/src/components/layout/GuildsLayout.tsx +++ b/packages/shared/src/components/layout/GuildsLayout.tsx @@ -1,8 +1,11 @@ import { useQuery } from 'convex/react'; -import type { ReactNode } from 'react'; +import { useRef, type ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { api } from '../../../../../convex/_generated/api'; +import { useIsMobile } from '../../hooks/useIsMobile'; +import { useMobileSwipeNav } from '../../hooks/useMobileSwipeNav'; import { FileUploadDropZone } from '../channel/FileUploadDropZone'; +import { ChannelView } from '../channel/ChannelView'; import { DMLayout } from '../dm/DMLayout'; import { GuildList } from './GuildList'; import { GuildNavbar } from './GuildNavbar'; @@ -39,6 +42,26 @@ export function GuildsLayout({ children }: GuildsLayoutProps) { const hasServer = !!serverId && serverId !== '@me' && serverId !== 'you'; const mobileMode = detectMobileMode(location.pathname); + const isMobile = useIsMobile(); + const wrapperRef = useRef(null); + const containerRef = useRef(null); + useMobileSwipeNav(containerRef, wrapperRef); + + // On mobile nav mode, pre-render the last-viewed channel so the + // swipe preview shows actual chat content instead of the "Select a + // channel" placeholder. The remembered channelId comes from the + // same localStorage key the swipe hook writes. + const rememberedChannelId = + isMobile && mobileMode === 'nav' && serverId + ? (() => { + try { + return localStorage.getItem(`brycord:lastChannel:${serverId}`); + } catch { + return null; + } + })() + : null; + // Look up the current channel's name + type so the drop overlay // reads "Upload to #channel-name". Drops are disabled when there's // no channel in the URL (DM home, `/you` profile) and on voice @@ -64,8 +87,8 @@ export function GuildsLayout({ children }: GuildsLayoutProps) { ); }} > -
-
+
+
@@ -73,7 +96,13 @@ export function GuildsLayout({ children }: GuildsLayoutProps) { {hasServer ? : }
-
{children}
+
+ {rememberedChannelId ? ( + + ) : ( + children + )} +
diff --git a/packages/shared/src/hooks/useMobileSwipeNav.ts b/packages/shared/src/hooks/useMobileSwipeNav.ts index 98dc88e..b0b7318 100644 --- a/packages/shared/src/hooks/useMobileSwipeNav.ts +++ b/packages/shared/src/hooks/useMobileSwipeNav.ts @@ -1,36 +1,33 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, type RefObject } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; /** - * Mobile horizontal-swipe navigation. + * Mobile swipe navigation — drives a horizontal tray so the user can + * physically slide between the channel list and the chat view. * - * - 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. + * The tray (`containerRef`) is 200vw wide on mobile. At rest it sits + * at `translateX(0)` (nav visible) or `translateX(-100vw)` (chat + * visible), controlled by the `data-mobile-mode` CSS attribute. * - * 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. + * During a touch gesture: + * 1. `data-mobile-swiping` is set on the wrapper — CSS suppresses the + * `transition` so the JS-driven transform is instant (60 fps). + * 2. The tray's `transform` follows the finger in real time. + * 3. On release, if the drag exceeded a threshold or was fast enough, + * we `navigate()` to commit the mode change; otherwise we snap + * back. Either way CSS transitions take over for the final snap. */ -const EDGE_ZONE = 48; -const MIN_DX = 70; -const MAX_DY = 48; -const STORAGE_KEY_PREFIX = 'brycord:lastChannel:'; const MOBILE_QUERY = '(max-width: 768px)'; +const SNAP_THRESHOLD = 0.3; +const VELOCITY_THRESHOLD = 0.4; +const STORAGE_KEY_PREFIX = 'brycord:lastChannel:'; function parseLocation(pathname: string) { const segments = pathname.split('/').filter(Boolean); - if (segments[0] !== 'channels') { + 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 }; + return { scope: segments[1] ?? null, channelId: segments[2] ?? null }; } function rememberChannel(scope: string, channelId: string) { @@ -47,51 +44,59 @@ function recallChannel(scope: string): string | null { } } -export function useMobileSwipeNav() { +export function useMobileSwipeNav( + containerRef: RefObject, + wrapperRef: RefObject, +) { const location = useLocation(); const navigate = useNavigate(); + const locationRef = useRef(location.pathname); + locationRef.current = location.pathname; - // Remember the active channel per scope so nav → chat swipes know - // where to go. Runs every path change. + // Remember last channel per scope. useEffect(() => { const { scope, channelId } = parseLocation(location.pathname); - if (scope && channelId) { - rememberChannel(scope, channelId); - } + if (scope && channelId) rememberChannel(scope, channelId); }, [location.pathname]); useEffect(() => { if (typeof window === 'undefined') return; - if (!window.matchMedia(MOBILE_QUERY).matches) return; + const mql = window.matchMedia(MOBILE_QUERY); + if (!mql.matches) return; + + const container = containerRef.current; + const wrapper = wrapperRef.current; + if (!container || !wrapper) return; let startX = 0; let startY = 0; + let startTime = 0; let tracking = false; - let direction: 'back' | 'forward' | null = null; + let locked = false; + let inChat = false; + let vw = window.innerWidth; + + const onResize = () => { + vw = window.innerWidth; + }; + window.addEventListener('resize', onResize); const onTouchStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; + const { scope, channelId } = parseLocation(locationRef.current); + if (!scope || scope === 'you') return; + + inChat = !!channelId; + + // In nav mode, require a remembered channel to swipe to. + if (!inChat && !recallChannel(scope)) 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'; - } - } + startTime = Date.now(); + tracking = true; + locked = false; }; const onTouchMove = (e: TouchEvent) => { @@ -99,55 +104,95 @@ export function useMobileSwipeNav() { 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; + + // Lock direction after 8px of movement. + if (!locked) { + if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; + if (Math.abs(dy) > Math.abs(dx)) { + // Vertical scroll — bail out entirely. + tracking = false; + return; + } + locked = true; + wrapper.setAttribute('data-mobile-swiping', ''); } - // Block the horizontal scroll parent from hijacking us while - // the gesture is in progress. - if (Math.abs(dx) > 8) { - try { - e.preventDefault(); - } catch {} + + // Clamp: in nav mode only allow leftward (negative dx), + // in chat mode only allow rightward (positive dx). + let clamped = dx; + if (inChat) { + clamped = Math.max(0, Math.min(vw, dx)); + } else { + clamped = Math.min(0, Math.max(-vw, dx)); } + + const base = inChat ? -vw : 0; + container.style.transform = `translateX(${base + clamped}px)`; + + // Prevent vertical scroll while we're tracking horizontally. + try { + e.preventDefault(); + } catch {} }; const onTouchEnd = (e: TouchEvent) => { - if (!tracking) return; + if (!tracking || !locked) { + tracking = false; + 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; + wrapper.removeAttribute('data-mobile-swiping'); - const { scope, channelId } = parseLocation(location.pathname); + const touch = e.changedTouches[0]; + if (!touch) { + container.style.transform = ''; + return; + } + + const dx = touch.clientX - startX; + const elapsed = Math.max(1, Date.now() - startTime); + const velocity = Math.abs(dx) / elapsed; + const progress = Math.abs(dx) / vw; + const shouldCommit = + progress > SNAP_THRESHOLD || velocity > VELOCITY_THRESHOLD; + + // Clear the inline transform so the CSS class-driven + // `translateX` + transition takes over for the snap. + container.style.transform = ''; + + if (!shouldCommit) return; + + const { scope, channelId } = parseLocation(locationRef.current); if (!scope) return; - if (direction === 'back' && dx >= MIN_DX) { - // chat → nav: drop the channel segment. + if (inChat && dx > 0) { + // Chat → nav. navigate(`/channels/${scope}`); - } else if (direction === 'forward' && dx <= -MIN_DX && !channelId) { + } else if (!inChat && dx < 0 && !channelId) { const remembered = recallChannel(scope); - if (remembered) { - navigate(`/channels/${scope}/${remembered}`); - } + if (remembered) navigate(`/channels/${scope}/${remembered}`); } - direction = null; + }; + + const onTouchCancel = () => { + if (tracking && locked) { + wrapper.removeAttribute('data-mobile-swiping'); + container.style.transform = ''; + } + tracking = false; }; document.addEventListener('touchstart', onTouchStart, { passive: true }); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd, { passive: true }); - document.addEventListener('touchcancel', onTouchEnd, { passive: true }); + document.addEventListener('touchcancel', onTouchCancel, { passive: true }); return () => { + window.removeEventListener('resize', onResize); document.removeEventListener('touchstart', onTouchStart); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); - document.removeEventListener('touchcancel', onTouchEnd); + document.removeEventListener('touchcancel', onTouchCancel); }; - }, [location.pathname, navigate]); + }, [containerRef, wrapperRef, navigate]); }