This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user