1.0.90
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-15 20:51:49 -05:00
parent 26dd013b05
commit 0153444817
12 changed files with 338 additions and 142 deletions

View File

@@ -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.80" versionName "1.0.90"
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.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/electron", "name": "@discord-clone/electron",
"private": true, "private": true,
"version": "1.0.80", "version": "1.0.90",
"description": "Brycord - Electron app", "description": "Brycord - Electron app",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "type": "module",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/web", "name": "@discord-clone/web",
"private": true, "private": true,
"version": "1.0.80", "version": "1.0.90",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"private": true, "private": true,
"version": "1.0.80", "version": "1.0.90",
"type": "module", "type": "module",
"main": "src/App.tsx", "main": "src/App.tsx",
"dependencies": { "dependencies": {

View File

@@ -18,8 +18,9 @@ import styles from './ChannelView.module.css';
* either the voice-join prompt, an active voice call, or a text chat * either the voice-join prompt, an active voice call, or a text chat
* (header + messages + textarea + members panel). * (header + messages + textarea + members panel).
*/ */
export function ChannelView() { export function ChannelView({ channelId: channelIdProp }: { channelId?: string } = {}) {
const { channelId } = useParams<{ channelId: string }>(); const params = useParams<{ channelId: string }>();
const channelId = channelIdProp || params.channelId;
const voice = useVoice(); const voice = useVoice();
const [membersVisible, setMembersVisible] = useState(true); const [membersVisible, setMembersVisible] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);

View File

@@ -1,15 +1,41 @@
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { useEffect, useRef, useState } from 'react';
import { MagnifyingGlass } from '@phosphor-icons/react'; import { MagnifyingGlass } from '@phosphor-icons/react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../../../../convex/_generated/api'; import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { getUserPref, setUserPref } from '../../utils/userPreferences';
import { DMListItem } from './DMListItem'; import { DMListItem } from './DMListItem';
import styles from './DMLayout.module.css'; import styles from './DMLayout.module.css';
export function DMLayout() { export function DMLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams<{ channelId?: string }>(); const params = useParams<{ channelId?: string }>();
const platform = usePlatform() as any;
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; 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 ( return (
<div className={styles.container}> <div className={styles.container}>

View File

@@ -3,7 +3,6 @@ import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { PresenceProvider } from '../../contexts/PresenceContext'; import { PresenceProvider } from '../../contexts/PresenceContext';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { getUserPref, setUserPref } from '../../utils/userPreferences'; import { getUserPref, setUserPref } from '../../utils/userPreferences';
import { useMobileSwipeNav } from '../../hooks/useMobileSwipeNav';
import { triggerBack, useBackHandler } from '../../hooks/useBackHandler'; 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';
@@ -30,7 +29,6 @@ export function AppLayout() {
const location = useLocation(); const location = useLocation();
const platform = usePlatform() as any; const platform = usePlatform() as any;
const hasRestoredRef = useRef(false); 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

View File

@@ -59,6 +59,14 @@ export function GuildList(_props: GuildListProps) {
<div className={styles.list}> <div className={styles.list}>
<div className={styles.guildItem}> <div className={styles.guildItem}>
<GuildIndicator active={isHomeSelected} /> <GuildIndicator active={isHomeSelected} />
{isMobile ? (
<button
className={`${styles.guildIcon} ${styles.homeIcon} ${isHomeSelected ? styles.selected : ''}`}
onClick={handleHomeClick}
>
<ChatCircleDots size={28} weight="fill" />
</button>
) : (
<Tooltip content="Direct Messages" placement="right"> <Tooltip content="Direct Messages" placement="right">
<button <button
className={`${styles.guildIcon} ${styles.homeIcon} ${isHomeSelected ? styles.selected : ''}`} className={`${styles.guildIcon} ${styles.homeIcon} ${isHomeSelected ? styles.selected : ''}`}
@@ -67,12 +75,33 @@ export function GuildList(_props: GuildListProps) {
<ChatCircleDots size={28} weight="fill" /> <ChatCircleDots size={28} weight="fill" />
</button> </button>
</Tooltip> </Tooltip>
)}
</div> </div>
<div className={styles.divider} /> <div className={styles.divider} />
<div className={styles.guildItem}> <div className={styles.guildItem}>
<GuildIndicator active={isServerSelected} /> <GuildIndicator active={isServerSelected} />
{isMobile ? (
<div
className={`${styles.guildIcon} ${isServerSelected ? styles.selected : ''}`}
role="button"
tabIndex={0}
onClick={handleServerClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleServerClick();
}
}}
>
{serverIconUrl ? (
<img src={serverIconUrl} alt={serverName} className={styles.guildImage} />
) : (
<span className={styles.guildInitials}>{initials}</span>
)}
</div>
) : (
<Tooltip content={serverName} placement="right"> <Tooltip content={serverName} placement="right">
<div <div
className={`${styles.guildIcon} ${isServerSelected ? styles.selected : ''}`} className={`${styles.guildIcon} ${isServerSelected ? styles.selected : ''}`}
@@ -93,6 +122,7 @@ export function GuildList(_props: GuildListProps) {
)} )}
</div> </div>
</Tooltip> </Tooltip>
)}
</div> </div>
{/* Add-a-server button removed — this build is a single-server {/* Add-a-server button removed — this build is a single-server

View File

@@ -28,6 +28,8 @@ import { useLocation, useNavigate } from 'react-router-dom';
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 { useVoice } from '../../contexts/VoiceContext'; import { useVoice } from '../../contexts/VoiceContext';
import { usePlatform } from '../../platform';
import { getUserPref, setUserPref } from '../../utils/userPreferences';
import { GuildHeaderDropdown } from './GuildHeaderDropdown'; import { GuildHeaderDropdown } from './GuildHeaderDropdown';
import { ChannelListContextMenu } from './ChannelListContextMenu'; import { ChannelListContextMenu } from './ChannelListContextMenu';
import { MobileServerActionsSheet } from './MobileServerActionsSheet'; import { MobileServerActionsSheet } from './MobileServerActionsSheet';
@@ -64,10 +66,66 @@ export function GuildNavbar() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const segments = location.pathname.split('/').filter(Boolean); const segments = location.pathname.split('/').filter(Boolean);
const selectedChannelId = segments[0] === 'channels' ? segments[2] || null : null; const urlChannelId = segments[0] === 'channels' ? segments[2] || null : null;
const serverSettings = useQuery(api.serverSettings.get); const isMobile = useIsMobile();
const categoriesRaw = useQuery(api.categories.list); // On mobile nav mode the URL has no channelId, but the swipe hook
const channelsRaw = useQuery(api.channels.list); // 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 voice = useVoice();
const voiceStates: Record<string, VoiceParticipant[]> = const voiceStates: Record<string, VoiceParticipant[]> =
@@ -99,13 +157,6 @@ export function GuildNavbar() {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
// ── Unread tracking ───────────────────────────────────────────── // ── 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( const readStates = useQuery(
api.readState.getAllReadStates, api.readState.getAllReadStates,
userId ? { userId: userId as any } : 'skip', userId ? { userId: userId as any } : 'skip',
@@ -143,7 +194,10 @@ export function GuildNavbar() {
return out; return out;
}, [readStates, latestTimestamps]); }, [readStates, latestTimestamps]);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set()); const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(() => {
const saved = getUserPref(userId, 'collapsedCategories', null);
return Array.isArray(saved) ? new Set(saved) : new Set();
});
const headerRef = useRef<HTMLButtonElement>(null); const headerRef = useRef<HTMLButtonElement>(null);
const [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(null); const [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(null);
const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false); const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false);
@@ -151,7 +205,6 @@ export function GuildNavbar() {
{ x: number; y: number; userId: string; username: string } | null { x: number; y: number; userId: string; username: string } | null
>(null); >(null);
const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null); const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null);
const isMobile = useIsMobile();
useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false)); useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
useBackHandler(!!voiceMenu, () => setVoiceMenu(null)); useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null)); useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
@@ -186,6 +239,7 @@ export function GuildNavbar() {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
setUserPref(userId, 'collapsedCategories', [...next], platform?.settings);
return next; return next;
}); });
}; };
@@ -220,6 +274,7 @@ export function GuildNavbar() {
} else if (e.key === 'ArrowRight' && isCollapsed) { } else if (e.key === 'ArrowRight' && isCollapsed) {
next.delete(catId); next.delete(catId);
} }
setUserPref(userId, 'collapsedCategories', [...next], platform?.settings);
return next; return next;
}); });
}; };
@@ -294,7 +349,9 @@ export function GuildNavbar() {
// each category's channel list + the uncategorized list are separate // each category's channel list + the uncategorized list are separate
// sortable contexts. // sortable contexts.
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(PointerSensor, {
activationConstraint: { distance: isMobile ? 1e6 : 4 },
}),
); );
const handleDragEndCategories = (event: DragEndEvent) => { const handleDragEndCategories = (event: DragEndEvent) => {

View File

@@ -112,19 +112,22 @@
/* ── Mobile layout (≤768px) ────────────────────────────────────────── /* ── Mobile layout (≤768px) ──────────────────────────────────────────
Fluxer/Discord-style single-column mobile: EITHER the nav column The mobile layout keeps BOTH the nav column (guild rail + channel
(guild list rail + channel list) OR the chat column is visible, never list) AND the chat column rendered at all times, positioned side-by-
both. Which one shows is decided in GuildsLayout.tsx by parsing the side inside a 200vw-wide tray. Swiping horizontally translates the
current URL — a channel in the URL means chat mode, no channel means tray in real time, and the URL change on release triggers a CSS
nav mode. The data-mobile-mode attribute switches between them. transition that snaps to the final position.
The bottom user area (avatar + mute + deafen + settings gear) is The `data-mobile-mode` attribute (set from the URL in GuildsLayout.tsx)
hidden in both modes on mobile; its functionality is reachable via controls which panel is active via a `translateX` on `.container`.
the avatar popout from the top of the channel list instead. `[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) { @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 { .sidebarDivider {
display: none; display: none;
} }
@@ -133,54 +136,56 @@
display: none; 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 { .guildList {
padding-bottom: var(--spacing-2); padding-bottom: var(--spacing-2);
flex-shrink: 0;
} }
.sidebar { .sidebar {
padding-bottom: 0; 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), /* ── Tray layout: nav (100vw) + chat (100vw) side-by-side ────── */
chat area hidden. */ .container {
.wrapper[data-mobile-mode='nav'] .sidebar { width: 200vw;
display: flex; flex-shrink: 0;
flex: 1 1 auto; flex-wrap: nowrap;
width: auto; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0; will-change: transform;
} }
.wrapper[data-mobile-mode='nav'] .content { /* Suppress the CSS transition while a finger-drag is in progress
display: none; 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. .content {
The bottom nav is also hidden — the chat has its own back button flex: none;
in the header, so the tab bar would be redundant and would just width: 100vw;
take vertical space away from the input. */ min-width: 100vw;
.wrapper[data-mobile-mode='chat'] .guildList {
display: none;
} }
.wrapper[data-mobile-mode='chat'] .sidebar { /* Nav mode: tray at X=0 (nav columns visible). */
display: none; .wrapper[data-mobile-mode='nav'] .container {
transform: translateX(0);
} }
.wrapper[data-mobile-mode='chat'] .content { /* Chat mode: tray slid left so the content column fills viewport. */
flex: 1 1 auto; .wrapper[data-mobile-mode='chat'] .container {
min-width: 0; transform: translateX(-100vw);
} }
/* Hide bottom nav in chat mode — the chat header has a back button. */
.wrapper[data-mobile-mode='chat'] > nav { .wrapper[data-mobile-mode='chat'] > nav {
display: none; display: none;
} }
/* You mode: identical column-hiding to chat mode (guild rail + /* ── "You" tab ── falls back to display:none toggling since it
sidebar hidden, content fills), but the MobileBottomNav stays doesn't participate in the horizontal swipe tray. */
visible underneath — that's the whole point of the tab. */
.wrapper[data-mobile-mode='you'] .guildList { .wrapper[data-mobile-mode='you'] .guildList {
display: none; display: none;
} }
@@ -191,7 +196,12 @@
.wrapper[data-mobile-mode='you'] .content { .wrapper[data-mobile-mode='you'] .content {
flex: 1 1 auto; flex: 1 1 auto;
width: auto;
min-width: 0; min-width: 0;
background-color: var(--background-primary); background-color: var(--background-primary);
} }
.wrapper[data-mobile-mode='you'] .container {
width: auto;
}
} }

View File

@@ -1,8 +1,11 @@
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import type { ReactNode } from 'react'; import { useRef, type ReactNode } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { api } from '../../../../../convex/_generated/api'; import { api } from '../../../../../convex/_generated/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileSwipeNav } from '../../hooks/useMobileSwipeNav';
import { FileUploadDropZone } from '../channel/FileUploadDropZone'; import { FileUploadDropZone } from '../channel/FileUploadDropZone';
import { ChannelView } from '../channel/ChannelView';
import { DMLayout } from '../dm/DMLayout'; import { DMLayout } from '../dm/DMLayout';
import { GuildList } from './GuildList'; import { GuildList } from './GuildList';
import { GuildNavbar } from './GuildNavbar'; import { GuildNavbar } from './GuildNavbar';
@@ -39,6 +42,26 @@ export function GuildsLayout({ children }: GuildsLayoutProps) {
const hasServer = !!serverId && serverId !== '@me' && serverId !== 'you'; const hasServer = !!serverId && serverId !== '@me' && serverId !== 'you';
const mobileMode = detectMobileMode(location.pathname); const mobileMode = detectMobileMode(location.pathname);
const isMobile = useIsMobile();
const wrapperRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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 // Look up the current channel's name + type so the drop overlay
// reads "Upload to #channel-name". Drops are disabled when there's // reads "Upload to #channel-name". Drops are disabled when there's
// no channel in the URL (DM home, `/you` profile) and on voice // no channel in the URL (DM home, `/you` profile) and on voice
@@ -64,8 +87,8 @@ export function GuildsLayout({ children }: GuildsLayoutProps) {
); );
}} }}
> >
<div className={styles.wrapper} data-mobile-mode={mobileMode}> <div className={styles.wrapper} data-mobile-mode={mobileMode} ref={wrapperRef}>
<div className={styles.container}> <div className={styles.container} ref={containerRef}>
<div className={styles.guildList}> <div className={styles.guildList}>
<GuildList /> <GuildList />
</div> </div>
@@ -73,7 +96,13 @@ export function GuildsLayout({ children }: GuildsLayoutProps) {
{hasServer ? <GuildNavbar /> : <DMLayout />} {hasServer ? <GuildNavbar /> : <DMLayout />}
</div> </div>
<div className={styles.sidebarDivider} /> <div className={styles.sidebarDivider} />
<div className={styles.content}>{children}</div> <div className={styles.content}>
{rememberedChannelId ? (
<ChannelView channelId={rememberedChannelId} />
) : (
children
)}
</div>
<div className={styles.userAreaWrapper}> <div className={styles.userAreaWrapper}>
<UserArea /> <UserArea />
</div> </div>

View File

@@ -1,36 +1,33 @@
import { useEffect } from 'react'; import { useEffect, useRef, type RefObject } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; 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`), * The tray (`containerRef`) is 200vw wide on mobile. At rest it sits
* a left→right swipe that starts inside the left edge zone navigates to * at `translateX(0)` (nav visible) or `translateX(-100vw)` (chat
* the channel list (the parent path). * visible), controlled by the `data-mobile-mode` CSS attribute.
* - 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 * During a touch gesture:
* horizontal scroll inside the chat (code blocks, carousels) and prevents * 1. `data-mobile-swiping` is set on the wrapper — CSS suppresses the
* accidental swipes when the user is just tapping around. * `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 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) { function parseLocation(pathname: string) {
const segments = pathname.split('/').filter(Boolean); 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 }; return { scope: null as string | null, channelId: null as string | null };
} return { scope: segments[1] ?? null, channelId: segments[2] ?? null };
const scope = segments[1] ?? null;
const channelId = segments[2] ?? null;
return { scope, channelId };
} }
function rememberChannel(scope: string, channelId: string) { function rememberChannel(scope: string, channelId: string) {
@@ -47,51 +44,59 @@ function recallChannel(scope: string): string | null {
} }
} }
export function useMobileSwipeNav() { export function useMobileSwipeNav(
containerRef: RefObject<HTMLDivElement | null>,
wrapperRef: RefObject<HTMLDivElement | null>,
) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const locationRef = useRef(location.pathname);
locationRef.current = location.pathname;
// Remember the active channel per scope so nav → chat swipes know // Remember last channel per scope.
// where to go. Runs every path change.
useEffect(() => { useEffect(() => {
const { scope, channelId } = parseLocation(location.pathname); const { scope, channelId } = parseLocation(location.pathname);
if (scope && channelId) { if (scope && channelId) rememberChannel(scope, channelId);
rememberChannel(scope, channelId);
}
}, [location.pathname]); }, [location.pathname]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; 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 startX = 0;
let startY = 0; let startY = 0;
let startTime = 0;
let tracking = false; 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) => { const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return; 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]; const touch = e.touches[0];
startX = touch.clientX; startX = touch.clientX;
startY = touch.clientY; startY = touch.clientY;
tracking = false; startTime = Date.now();
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; tracking = true;
direction = 'back'; locked = false;
} else if (!inChat && startX >= vw - EDGE_ZONE) {
const remembered = recallChannel(scope);
if (remembered) {
tracking = true;
direction = 'forward';
}
}
}; };
const onTouchMove = (e: TouchEvent) => { const onTouchMove = (e: TouchEvent) => {
@@ -99,55 +104,95 @@ export function useMobileSwipeNav() {
const touch = e.touches[0]; const touch = e.touches[0];
const dx = touch.clientX - startX; const dx = touch.clientX - startX;
const dy = touch.clientY - startY; const dy = touch.clientY - startY;
if (Math.abs(dy) > MAX_DY) {
// Vertical scroll — abandon the swipe. // 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; tracking = false;
direction = null;
return; return;
} }
// Block the horizontal scroll parent from hijacking us while locked = true;
// the gesture is in progress. wrapper.setAttribute('data-mobile-swiping', '');
if (Math.abs(dx) > 8) { }
// 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 { try {
e.preventDefault(); e.preventDefault();
} catch {} } catch {}
}
}; };
const onTouchEnd = (e: TouchEvent) => { const onTouchEnd = (e: TouchEvent) => {
if (!tracking) return; if (!tracking || !locked) {
tracking = false; tracking = false;
const touch = e.changedTouches[0]; return;
if (!touch) return; }
const dx = touch.clientX - startX; tracking = false;
const dy = touch.clientY - startY; wrapper.removeAttribute('data-mobile-swiping');
if (Math.abs(dy) > MAX_DY) return;
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 (!scope) return;
if (direction === 'back' && dx >= MIN_DX) { if (inChat && dx > 0) {
// chat → nav: drop the channel segment. // Chat → nav.
navigate(`/channels/${scope}`); navigate(`/channels/${scope}`);
} else if (direction === 'forward' && dx <= -MIN_DX && !channelId) { } else if (!inChat && dx < 0 && !channelId) {
const remembered = recallChannel(scope); const remembered = recallChannel(scope);
if (remembered) { if (remembered) navigate(`/channels/${scope}/${remembered}`);
navigate(`/channels/${scope}/${remembered}`);
} }
};
const onTouchCancel = () => {
if (tracking && locked) {
wrapper.removeAttribute('data-mobile-swiping');
container.style.transform = '';
} }
direction = null; tracking = false;
}; };
document.addEventListener('touchstart', onTouchStart, { passive: true }); document.addEventListener('touchstart', onTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: true }); document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true }); document.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => { return () => {
window.removeEventListener('resize', onResize);
document.removeEventListener('touchstart', onTouchStart); document.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd); document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd); document.removeEventListener('touchcancel', onTouchCancel);
}; };
}, [location.pathname, navigate]); }, [containerRef, wrapperRef, navigate]);
} }