This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/web",
|
||||
"private": true,
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.90",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.container}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,14 @@ export function GuildList(_props: GuildListProps) {
|
||||
<div className={styles.list}>
|
||||
<div className={styles.guildItem}>
|
||||
<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">
|
||||
<button
|
||||
className={`${styles.guildIcon} ${styles.homeIcon} ${isHomeSelected ? styles.selected : ''}`}
|
||||
@@ -67,12 +75,33 @@ export function GuildList(_props: GuildListProps) {
|
||||
<ChatCircleDots size={28} weight="fill" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.guildItem}>
|
||||
<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">
|
||||
<div
|
||||
className={`${styles.guildIcon} ${isServerSelected ? styles.selected : ''}`}
|
||||
@@ -93,6 +122,7 @@ export function GuildList(_props: GuildListProps) {
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-a-server button removed — this build is a single-server
|
||||
|
||||
@@ -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<string, VoiceParticipant[]> =
|
||||
@@ -99,13 +157,6 @@ export function GuildNavbar() {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(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<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 [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(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<string | null>(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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
// 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) {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className={styles.wrapper} data-mobile-mode={mobileMode}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.wrapper} data-mobile-mode={mobileMode} ref={wrapperRef}>
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<div className={styles.guildList}>
|
||||
<GuildList />
|
||||
</div>
|
||||
@@ -73,7 +96,13 @@ export function GuildsLayout({ children }: GuildsLayoutProps) {
|
||||
{hasServer ? <GuildNavbar /> : <DMLayout />}
|
||||
</div>
|
||||
<div className={styles.sidebarDivider} />
|
||||
<div className={styles.content}>{children}</div>
|
||||
<div className={styles.content}>
|
||||
{rememberedChannelId ? (
|
||||
<ChannelView channelId={rememberedChannelId} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.userAreaWrapper}>
|
||||
<UserArea />
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement | null>,
|
||||
wrapperRef: RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
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) {
|
||||
startTime = Date.now();
|
||||
tracking = true;
|
||||
direction = 'back';
|
||||
} else if (!inChat && startX >= vw - EDGE_ZONE) {
|
||||
const remembered = recallChannel(scope);
|
||||
if (remembered) {
|
||||
tracking = true;
|
||||
direction = 'forward';
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
// 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;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
// Block the horizontal scroll parent from hijacking us while
|
||||
// the gesture is in progress.
|
||||
if (Math.abs(dx) > 8) {
|
||||
locked = true;
|
||||
wrapper.setAttribute('data-mobile-swiping', '');
|
||||
}
|
||||
|
||||
// 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;
|
||||
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;
|
||||
return;
|
||||
}
|
||||
tracking = false;
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
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('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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user