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 ? (
+

+ ) : (
+
{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]);
}