feat: Introduce swipe navigation between new Chat and Sidebar components and establish initial multi-platform project structure.
All checks were successful
Build and Release / build-and-release (push) Successful in 10m7s

This commit is contained in:
Bryan1029384756
2026-02-20 03:36:36 -06:00
parent 4e1a6225e1
commit e7f10aa5f0
11 changed files with 402 additions and 44 deletions

View File

@@ -1385,7 +1385,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
};
const renderServerView = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="channel-panel" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>{serverName}</span>
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">

View File

@@ -0,0 +1,233 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
const EDGE_THRESHOLD = 30;
const SNAP_THRESHOLD = 0.4;
const VELOCITY_THRESHOLD = 0.5;
export function useSwipeNavigation({ enabled, canSwipeToChat }) {
const [activeView, setActiveView] = useState('sidebar');
const trayRef = useRef(null);
// Refs so touch handlers always read current values
const activeViewRef = useRef(activeView);
const canSwipeToChatRef = useRef(canSwipeToChat);
const screenWidthRef = useRef(window.innerWidth);
useEffect(() => { activeViewRef.current = activeView; }, [activeView]);
useEffect(() => { canSwipeToChatRef.current = canSwipeToChat; }, [canSwipeToChat]);
// Track swiping state for pointer-events disabling
const [isSwiping, setIsSwiping] = useState(false);
// Programmatic navigation with smooth transition
const goToChat = useCallback(() => {
const tray = trayRef.current;
if (tray) {
tray.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
tray.style.transform = `translateX(-${screenWidthRef.current}px)`;
}
setActiveView('chat');
}, []);
const goToSidebar = useCallback(() => {
const tray = trayRef.current;
if (tray) {
tray.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
tray.style.transform = 'translateX(0px)';
}
setActiveView('sidebar');
}, []);
useEffect(() => {
if (!enabled) return;
const handleResize = () => {
const tray = trayRef.current;
const panel = tray?.querySelector('.mobile-swipe-panel');
screenWidthRef.current = panel?.offsetWidth || window.innerWidth;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [enabled]);
useEffect(() => {
if (!enabled) return;
const tray = trayRef.current;
if (!tray) return;
// Measure actual rendered panel width (CSS 100vw) instead of window.innerWidth
const panel = tray.querySelector('.mobile-swipe-panel');
if (panel) {
screenWidthRef.current = panel.offsetWidth;
}
let tracking = false;
let decided = false; // whether we've decided horizontal vs vertical
let startX = 0;
let startY = 0;
let startTime = 0;
let baseOffset = 0; // starting translateX when touch began
let lastX = 0;
let lastTime = 0;
function getCurrentTranslateX() {
const style = window.getComputedStyle(tray);
const matrix = style.transform;
if (!matrix || matrix === 'none') return 0;
// matrix(a, b, c, d, tx, ty)
const match = matrix.match(/matrix\(([^)]+)\)/);
if (match) {
const values = match[1].split(',').map(Number);
return values[4] || 0;
}
return 0;
}
function onTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const sw = screenWidthRef.current;
const view = activeViewRef.current;
// Edge detection: only start from edges
const fromLeftEdge = touch.clientX <= EDGE_THRESHOLD;
const fromRightEdge = touch.clientX >= sw - EDGE_THRESHOLD;
if (view === 'chat' && fromLeftEdge) {
// Potential swipe to sidebar
} else if (view === 'sidebar' && fromRightEdge && canSwipeToChatRef.current) {
// Potential swipe to chat
} else {
return; // Not an edge touch
}
// Capture current visual position (handles mid-animation interruption)
tray.style.transition = 'none';
baseOffset = getCurrentTranslateX();
tray.style.transform = `translateX(${baseOffset}px)`;
startX = touch.clientX;
startY = touch.clientY;
startTime = Date.now();
lastX = touch.clientX;
lastTime = startTime;
tracking = false;
decided = false;
}
function onTouchMove(e) {
if (e.touches.length !== 1) return;
if (decided && !tracking) return; // Decided vertical, ignore
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
// If we haven't decided intent yet
if (!decided) {
const absDx = Math.abs(deltaX);
const absDy = Math.abs(deltaY);
// Need some movement to decide
if (absDx < 5 && absDy < 5) return;
decided = true;
if (absDx > absDy * 1.5) {
tracking = true;
setIsSwiping(true);
} else {
// Vertical — let it scroll
return;
}
}
if (!tracking) return;
e.preventDefault();
const sw = screenWidthRef.current;
const view = activeViewRef.current;
let newOffset = baseOffset + deltaX;
// Clamp: tray can only be between -sw (chat) and 0 (sidebar)
if (view === 'chat') {
// On chat, base is -sw. Allow swiping right (toward 0) but not past
newOffset = Math.max(-sw, Math.min(0, newOffset));
} else {
// On sidebar, base is 0. Allow swiping left (toward -sw) but not past
newOffset = Math.max(-sw, Math.min(0, newOffset));
}
tray.style.transform = `translateX(${newOffset}px)`;
lastX = touch.clientX;
lastTime = Date.now();
}
function onTouchEnd(e) {
if (!tracking) {
decided = false;
return;
}
tracking = false;
decided = false;
setIsSwiping(false);
const sw = screenWidthRef.current;
const view = activeViewRef.current;
const currentOffset = getCurrentTranslateX();
const elapsed = Date.now() - lastTime;
const velocity = elapsed > 0 ? Math.abs(lastX - startX) / (Date.now() - startTime) : 0;
let shouldTransition = false;
if (view === 'chat') {
// Swiping right to sidebar: progress is how far from -sw toward 0
const progress = (currentOffset - (-sw)) / sw;
shouldTransition = progress > SNAP_THRESHOLD || velocity > VELOCITY_THRESHOLD;
} else {
// Swiping left to chat: progress is how far from 0 toward -sw
const progress = Math.abs(currentOffset) / sw;
shouldTransition = progress > SNAP_THRESHOLD || velocity > VELOCITY_THRESHOLD;
}
tray.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
if (shouldTransition) {
if (view === 'chat') {
tray.style.transform = 'translateX(0px)';
setActiveView('sidebar');
} else {
tray.style.transform = `translateX(-${sw}px)`;
setActiveView('chat');
}
} else {
// Snap back
if (view === 'chat') {
tray.style.transform = `translateX(-${sw}px)`;
} else {
tray.style.transform = 'translateX(0px)';
}
}
}
tray.addEventListener('touchstart', onTouchStart, { passive: true });
tray.addEventListener('touchmove', onTouchMove, { passive: false });
tray.addEventListener('touchend', onTouchEnd, { passive: true });
return () => {
tray.removeEventListener('touchstart', onTouchStart);
tray.removeEventListener('touchmove', onTouchMove);
tray.removeEventListener('touchend', onTouchEnd);
};
}, [enabled]);
// Resting style: set initial position without transition on mount / view change
const trayStyle = useMemo(() => {
if (!enabled) return {};
return {
transform: activeView === 'chat' ? `translateX(-${screenWidthRef.current}px)` : 'translateX(0px)',
};
}, [enabled, activeView]);
return { activeView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping };
}

View File

@@ -701,7 +701,7 @@ body {
}
.date-divider span {
background-color: var(--bg-primary);
/* background-color: var(--bg-primary); */
padding: 0 8px;
color: var(--text-muted);
font-size: 12px;
@@ -3288,12 +3288,40 @@ body {
height: 100dvh;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-sizing: border-box;
overflow: hidden;
display: block;
}
/* Sidebar fills entire screen on mobile */
.is-mobile .sidebar {
/* Sliding tray for swipe navigation */
.mobile-swipe-tray {
display: flex;
flex-direction: row;
width: 200vw;
height: 100%;
touch-action: pan-y;
will-change: transform;
}
.mobile-swipe-panel {
width: 100vw;
min-width: 100vw;
height: 100%;
flex-shrink: 0;
overflow: hidden;
position: relative;
overscroll-behavior-x: none;
}
/* Disable pointer events on children during active swipe */
.mobile-swipe-tray.is-swiping .mobile-swipe-panel > * {
pointer-events: none;
}
/* Sidebar fills its panel on mobile */
.is-mobile .sidebar {
width: 100%;
min-width: 100%;
height: 100%;
}
/* Hide members list on mobile (also enforced in JS) */
@@ -3345,7 +3373,8 @@ body {
/* Chat container takes full width */
.is-mobile .chat-container {
width: 100vw;
width: 100%;
height: 100%;
}
/* Channel topic - hide on very small screens (also hidden via JS) */
@@ -3355,7 +3384,8 @@ body {
/* FriendsView takes full width */
.is-mobile .friends-view {
width: 100vw;
width: 100%;
height: 100%;
}
/* Search panel full-width on mobile */
@@ -3364,6 +3394,12 @@ body {
right: 0;
border-radius: 0;
}
}
/* Remove border between server-list and channel-list on mobile */
.is-mobile .server-list {
border-right: none;
}
/* ============================================

View File

@@ -16,6 +16,7 @@ import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
import { useIsMobile } from '../hooks/useIsMobile';
import { useSwipeNavigation } from '../hooks/useSwipeNavigation';
import IncomingCallUI from '../components/IncomingCallUI';
import Avatar from '../components/Avatar';
import callRingSound from '../assets/sounds/default_call_sound.mp3';
@@ -36,7 +37,12 @@ const Chat = () => {
const [activeDMChannel, setActiveDMChannel] = useState(null);
const [showMembers, setShowMembers] = useState(true);
const [showPinned, setShowPinned] = useState(false);
const [mobileView, setMobileView] = useState('sidebar');
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping } =
useSwipeNavigation({
enabled: isMobile,
canSwipeToChat: activeChannel !== null || activeDMChannel !== null || view === 'me'
});
// Jump-to-message state (for search result clicks)
const [jumpToMessageId, setJumpToMessageId] = useState(null);
@@ -190,22 +196,22 @@ const Chat = () => {
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
if (isMobile) setMobileView('chat');
if (isMobile) goToChat();
} catch (err) {
console.error('Error opening DM:', err);
}
}, [convex, isMobile]);
}, [convex, isMobile, goToChat]);
const handleSelectChannel = useCallback((channelId) => {
setActiveChannel(channelId);
setShowPinned(false);
if (isMobile) setMobileView('chat');
}, [isMobile]);
if (isMobile) goToChat();
}, [isMobile, goToChat]);
const handleMobileBack = useCallback(() => {
setMobileView('sidebar');
}, []);
goToSidebar();
}, [goToSidebar]);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { watchingStreamOf } = useVoice();
@@ -686,13 +692,13 @@ const Chat = () => {
const handleSetActiveDMChannel = useCallback((dm) => {
setActiveDMChannel(dm);
if (isMobile && dm) setMobileView('chat');
}, [isMobile]);
if (isMobile && dm) goToChat();
}, [isMobile, goToChat]);
const handleViewChange = useCallback((newView) => {
setView(newView);
if (isMobile) setMobileView('sidebar');
}, [isMobile]);
if (isMobile) goToSidebar();
}, [isMobile, goToSidebar]);
if (!userId) {
return (
@@ -704,34 +710,50 @@ const Chat = () => {
);
}
const showSidebar = !isMobile || mobileView === 'sidebar';
const showMainContent = !isMobile || mobileView === 'chat';
const sidebarElement = (
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={handleViewChange}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={handleSetActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
serverName={serverName}
serverIconUrl={serverIconUrl}
isMobile={isMobile}
onStartCallWithUser={handleStartCallWithUser}
/>
);
return (
<PresenceProvider userId={userId}>
<div className={`app-container${isMobile ? ' is-mobile' : ''}`}>
{showSidebar && (
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={handleViewChange}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={handleSetActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
serverName={serverName}
serverIconUrl={serverIconUrl}
isMobile={isMobile}
onStartCallWithUser={handleStartCallWithUser}
/>
{isMobile ? (
<div
ref={trayRef}
className={`mobile-swipe-tray${isSwiping ? ' is-swiping' : ''}`}
style={trayStyle}
>
<div className="mobile-swipe-panel">
{sidebarElement}
</div>
<div className="mobile-swipe-panel">
{renderMainContent()}
</div>
</div>
) : (
<>
{sidebarElement}
{renderMainContent()}
</>
)}
{showMainContent && renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
{showSearchDropdown && !isMobile && (
<SearchDropdown

View File

@@ -370,3 +370,43 @@
--text-feedback-warning: hsl(38.455, 100%, 43.137%);
}
/* ============================================
MOBILE OVERRIDES Dark theme only
Darker, more unified backgrounds when .is-mobile is active.
Override --bg-tertiary so inline styles using var(--bg-tertiary)
(e.g. .channel-list in Sidebar.jsx) also pick up the new color.
============================================ */
/* Override the variable so all var(--bg-tertiary) usages resolve to #1C1D22 */
.theme-dark .is-mobile {
--bg-tertiary: #1C1D22;
}
/* Server list needs a different (darker) color than --bg-tertiary */
.theme-dark .is-mobile .server-list {
background-color: #141318;
}
/* Remove borders on channel panel and DM view (inline styles, needs !important) */
.theme-dark .is-mobile .channel-panel,
.theme-dark .is-mobile .channel-list {
border-left: none !important;
}
/* Chat area/header use var(--bg-primary), not --bg-tertiary, so override explicitly */
.theme-dark .is-mobile .chat-container,
.theme-dark .is-mobile .chat-area,
.theme-dark .is-mobile .chat-header,
.theme-dark .is-mobile .chat-input-form {
background-color: #1C1D22;
}
.theme-dark .is-mobile .chat-header {
border-bottom: 1px solid var(--app-frame-border);
}
.theme-dark .is-mobile .chat-input-wrapper {
background-color: #26262E;
}