From b185e0b129ec560ad271c5ec3e40fe8dffc48a13 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sat, 28 Feb 2026 00:54:55 +1100 Subject: [PATCH] feat: implement sidebar docked call panel with participant management and sync status handling --- src/app/components/page/Page.tsx | 2 + src/app/components/title-bar/TitleBar.css.ts | 30 ++ src/app/components/title-bar/TitleBar.tsx | 55 +++- src/app/features/call/CallOverlay.css.ts | 47 +++ src/app/features/call/CallOverlay.tsx | 66 +++- src/app/features/call/DockedCallPanel.tsx | 10 +- .../features/call/SidebarDockedCallPanel.tsx | 299 ++++++++++++++++++ src/app/features/call/index.ts | 1 + src/app/features/call/useDockedCall.ts | 30 +- .../room-nav/CallParticipantsIndicator.css.ts | 10 + .../room-nav/CallParticipantsIndicator.tsx | 132 +++++--- src/app/features/room/RoomViewFollowing.tsx | 14 - src/app/pages/client/ClientRoot.tsx | 2 - src/app/state/settings.ts | 4 + 14 files changed, 625 insertions(+), 77 deletions(-) create mode 100644 src/app/features/call/SidebarDockedCallPanel.tsx create mode 100644 src/app/features/room-nav/CallParticipantsIndicator.css.ts diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index a545638..70c6ad3 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; import * as css from './style.css'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel'; type PageRootProps = { nav: ReactNode; @@ -39,6 +40,7 @@ export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNa > {children} + ); diff --git a/src/app/components/title-bar/TitleBar.css.ts b/src/app/components/title-bar/TitleBar.css.ts index d0caf42..6a8c770 100644 --- a/src/app/components/title-bar/TitleBar.css.ts +++ b/src/app/components/title-bar/TitleBar.css.ts @@ -75,3 +75,33 @@ export const TitleBarDragRegion = style({ WebkitAppRegion: 'drag', color: color.Surface.OnContainer, }); + +export const SyncStatusBadge = style({ + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + padding: `${config.space.S100} ${config.space.S300}`, + borderRadius: config.radii.R300, + fontSize: '11px', + fontWeight: 500, + whiteSpace: 'nowrap', + zIndex: 1001, + pointerEvents: 'none', + WebkitAppRegion: 'no-drag', +}); + +export const SyncStatusSuccess = style({ + backgroundColor: color.Success.Container, + color: color.Success.OnContainer, +}); + +export const SyncStatusWarning = style({ + backgroundColor: color.Warning.Container, + color: color.Warning.OnContainer, +}); + +export const SyncStatusCritical = style({ + backgroundColor: color.Critical.Container, + color: color.Critical.OnContainer, +}); diff --git a/src/app/components/title-bar/TitleBar.tsx b/src/app/components/title-bar/TitleBar.tsx index 3c8c017..1e1adf2 100644 --- a/src/app/components/title-bar/TitleBar.tsx +++ b/src/app/components/title-bar/TitleBar.tsx @@ -1,7 +1,7 @@ import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react'; import { Box, Text } from 'folds'; import { invoke } from '@tauri-apps/api/core'; -import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules } from 'matrix-js-sdk'; +import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; import { WindowControls } from './WindowControls'; @@ -17,6 +17,7 @@ import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreference import { AccountDataEvent } from '../../../types/matrix/accountData'; import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode'; import { isRoomId } from '../../utils/matrix'; +import { useSyncState } from '../../hooks/useSyncState'; type NewMessageNotification = { id: string; @@ -42,6 +43,28 @@ export function TitleBar({ mx, children }: TitleBarProps) { const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); + // Sync status state + const [syncStateData, setSyncStateData] = useState<{ + current: SyncState | null; + previous: SyncState | null | undefined; + }>({ + current: null, + previous: undefined, + }); + + // Track sync state changes + useSyncState( + mx, + useCallback((current, previous) => { + setSyncStateData((s) => { + if (s.current === current && s.previous === previous) { + return s; + } + return { current, previous }; + }); + }, []) + ); + // Notification-related state - only populated when mx is available const [notificationData, setNotificationData] = useState<{ preferences: { mute: Set; specialMessages: Set; allMessages: Set }; @@ -591,8 +614,38 @@ export function TitleBar({ mx, children }: TitleBarProps) { return null; } + // Determine sync status badge + let syncStatusBadge: ReactNode = null; + if (mx) { + if ( + (syncStateData.current === SyncState.Prepared || + syncStateData.current === SyncState.Syncing || + syncStateData.current === SyncState.Catchup) && + syncStateData.previous !== SyncState.Syncing + ) { + syncStatusBadge = ( +
+ Connecting... +
+ ); + } else if (syncStateData.current === SyncState.Reconnecting) { + syncStatusBadge = ( +
+ Reconnecting... +
+ ); + } else if (syncStateData.current === SyncState.Error) { + syncStatusBadge = ( +
+ Connection Lost +
+ ); + } + } + return ( + {syncStatusBadge} void, + onDockRight?: () => void, + onDockSidebar?: () => void, onUndockRequest?: () => void, isDocked?: boolean, pendingDrag?: PendingDragState | null, @@ -39,7 +45,7 @@ function useDraggable( ) { const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); - const [isNearDockZone, setIsNearDockZone] = useState(false); + const [nearDockZone, setNearDockZone] = useState('none'); const dragRef = useRef(null); const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null); @@ -145,9 +151,20 @@ function useDraggable( const handleMouseMove = (e: MouseEvent) => { if (!dragStartRef.current || !dragRef.current) return; - // Check if near right edge for docking + // Check dock zones const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD; - setIsNearDockZone(nearRightEdge && !isDocked); + const nearSidebarZone = e.clientX < ROOM_LIST_EDGE + DOCK_THRESHOLD && + e.clientY > window.innerHeight - 150; + + if (!isDocked) { + if (nearSidebarZone) { + setNearDockZone('sidebar'); + } else if (nearRightEdge) { + setNearDockZone('right'); + } else { + setNearDockZone('none'); + } + } // If docked and dragging away from edge, trigger undock if (isDocked) { @@ -191,12 +208,20 @@ function useDraggable( }; const handleMouseUp = (e: MouseEvent) => { - // Check if should dock on mouse release - if (!isDocked && e.clientX > window.innerWidth - DOCK_THRESHOLD) { - onDockRequest?.(); + // Check which dock zone to use + const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD; + const nearSidebarZone = e.clientX < ROOM_LIST_EDGE + DOCK_THRESHOLD && + e.clientY > window.innerHeight - 150; + + if (!isDocked) { + if (nearSidebarZone) { + onDockSidebar?.(); + } else if (nearRightEdge) { + onDockRight?.(); + } } setIsDragging(false); - setIsNearDockZone(false); + setNearDockZone('none'); dragStartRef.current = null; }; @@ -207,9 +232,9 @@ function useDraggable( document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - }, [isDragging, position, isDocked, onDockRequest, onUndockRequest]); + }, [isDragging, position, isDocked, onDockRight, onDockSidebar, onUndockRequest]); - return { position, isDragging, isNearDockZone, handleMouseDown, dragRef }; + return { position, isDragging, nearDockZone, handleMouseDown, dragRef }; } /** @@ -687,6 +712,7 @@ export function CallOverlay() { // Docking state const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked'); + const [, setDockPosition] = useSetting(settingsAtom, 'callPanelDockPosition'); // Pending drag state from undocking const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom); @@ -695,16 +721,23 @@ export function CallOverlay() { setPendingDrag(null); }, [setPendingDrag]); - const handleDockRequest = useCallback(() => { + const handleDockRight = useCallback(() => { + setDockPosition('right'); setIsDocked(true); - }, [setIsDocked]); + }, [setIsDocked, setDockPosition]); + + const handleDockSidebar = useCallback(() => { + setDockPosition('sidebar'); + setIsDocked(true); + }, [setIsDocked, setDockPosition]); const handleUndockRequest = useCallback(() => { setIsDocked(false); }, [setIsDocked]); - const { position, isDragging, isNearDockZone, handleMouseDown, dragRef } = useDraggable( - handleDockRequest, + const { position, isDragging, nearDockZone, handleMouseDown, dragRef } = useDraggable( + handleDockRight, + handleDockSidebar, handleUndockRequest, isDocked, pendingDrag, @@ -1047,8 +1080,9 @@ export function CallOverlay() { return ( <> - {/* Dock indicator shown when dragging near the right edge */} - {isNearDockZone && !isDocked &&
} + {/* Dock indicators shown when dragging near dock zones */} + {nearDockZone === 'right' && !isDocked &&
} + {nearDockZone === 'sidebar' && !isDocked &&
}
{ + if (!activeCall || activeCall.state !== CallState.Connected) { + setDuration(0); + return undefined; + } + + const { startTime } = activeCall; + const updateDuration = () => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + setDuration(elapsed); + }; + + updateDuration(); + const interval = setInterval(updateDuration, 1000); + + return () => clearInterval(interval); + }, [activeCall]); + + /** + * Checks if a participant is currently speaking + */ + const isParticipantSpeaking = (userId: string): boolean => { + if (!activeCall) return false; + if (activeCall.activeSpeakers.has(userId)) return true; + + const member = callMembers.find((m) => m.userId === userId); + if (!member) return false; + + return Array.from(activeCall.activeSpeakers).some((speakerId) => { + const underscoreIndex = speakerId.lastIndexOf('_'); + const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId; + return deviceId === member.deviceId; + }); + }; + + /** + * Checks if a participant is currently muted + */ + const isParticipantMuted = (userId: string): boolean => { + if (!activeCall) return false; + if (userId === myUserId) return activeCall.isMuted; + + const member = callMembers.find((m) => m.userId === userId); + if (!member) return false; + + return Array.from(activeCall.mutedParticipants).some((mutedId) => { + const underscoreIndex = mutedId.lastIndexOf('_'); + const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId; + return deviceId === member.deviceId; + }); + }; + + /** + * Handle mousedown on header to start drag-to-undock + */ + const handleHeaderMouseDown = async (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + + if (document.fullscreenElement) { + try { + await document.exitFullscreen(); + } catch { + // Ignore + } + } + + const header = e.currentTarget as HTMLElement; + const rect = header.getBoundingClientRect(); + + setPendingDrag({ + startX: e.clientX, + startY: e.clientY, + offsetX: e.clientX - rect.left, + offsetY: e.clientY - rect.top, + timestamp: Date.now(), + }); + + setIsDocked(false); + }; + + if (!isCallDockedSidebar || !activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) { + return null; + } + + const isConnecting = activeCall.state === CallState.Connecting; + const room = mx.getRoom(activeCall.roomId); + const allParticipants = callMembers.map((m) => m.userId); + const participantCount = allParticipants.length; + + return ( +
+ {/* Header - drag to undock */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ + + + {isConnecting ? 'Connecting...' : room?.name ?? 'Call'} + + {selectedRoomId === activeCall.roomId && ( + + )} + + + + {formatDuration(duration)} + + {selectedRoomId !== activeCall.roomId && ( + Go to Room} + > + {(triggerRef) => ( + navigateRoom(activeCall.roomId)} + variant="Secondary" + size="300" + > + + + )} + + )} + +
+ + {/* Participants */} +
+ + {showParticipants && room && ( + +
+ {allParticipants.map((participantId) => { + const avatarMxc = getMemberAvatarMxc(room, participantId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined + : undefined; + const displayName = getMemberDisplayName(room, participantId) + ?? getMxIdLocalPart(participantId) + ?? participantId; + const isMe = participantId === myUserId; + const isMuted = isParticipantMuted(participantId); + const isSpeaking = isParticipantSpeaking(participantId); + + return ( +
+
+ ( + + {displayName.charAt(0).toUpperCase()} + + )} + /> +
+ + + {displayName} + {isMe && (You)} + + + {isMuted && ( + + )} +
+ ); + })} +
+
+ )} +
+ + {/* Controls */} +
+ {activeCall.isMuted ? 'Unmute' : 'Mute'}} + > + {(triggerRef) => ( + + + + )} + + + {activeCall.isDeafened ? 'Undeafen' : 'Deafen'}} + > + {(triggerRef) => ( + + + + )} + + + End Call} + > + {(triggerRef) => ( + + + + )} + +
+
+ ); +} diff --git a/src/app/features/call/index.ts b/src/app/features/call/index.ts index 5b01a8d..4444aa6 100644 --- a/src/app/features/call/index.ts +++ b/src/app/features/call/index.ts @@ -10,3 +10,4 @@ export * from './useRoomCallMembers'; export * from './useDockedCall'; export * from './DockedCallPanel'; export * from './DockedCallContent'; +export * from './SidebarDockedCallPanel'; diff --git a/src/app/features/call/useDockedCall.ts b/src/app/features/call/useDockedCall.ts index 9c0d588..8fe18e7 100644 --- a/src/app/features/call/useDockedCall.ts +++ b/src/app/features/call/useDockedCall.ts @@ -1,5 +1,5 @@ import { useSetting } from '../../state/hooks/settings'; -import { settingsAtom } from '../../state/settings'; +import { settingsAtom, CallPanelDockPosition } from '../../state/settings'; import { useCall } from './useCall'; import { CallState } from './types'; @@ -16,3 +16,31 @@ export function useIsCallDocked(): boolean { return Boolean(hasActiveCall && isDocked); } + +/** + * Hook to determine if the call panel is docked to the right side + */ +export function useIsCallDockedRight(): boolean { + const isCallDocked = useIsCallDocked(); + const [dockPosition] = useSetting(settingsAtom, 'callPanelDockPosition'); + + return isCallDocked && dockPosition === 'right'; +} + +/** + * Hook to determine if the call panel is docked in the sidebar + */ +export function useIsCallDockedSidebar(): boolean { + const isCallDocked = useIsCallDocked(); + const [dockPosition] = useSetting(settingsAtom, 'callPanelDockPosition'); + + return isCallDocked && dockPosition === 'sidebar'; +} + +/** + * Hook to get the current dock position setting + */ +export function useCallDockPosition(): CallPanelDockPosition { + const [dockPosition] = useSetting(settingsAtom, 'callPanelDockPosition'); + return dockPosition; +} diff --git a/src/app/features/room-nav/CallParticipantsIndicator.css.ts b/src/app/features/room-nav/CallParticipantsIndicator.css.ts new file mode 100644 index 0000000..70e23fb --- /dev/null +++ b/src/app/features/room-nav/CallParticipantsIndicator.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; +import { color } from 'folds'; + +/** + * Style for participants who are currently speaking + * Adds a green glow effect to indicate voice activity + */ +export const ParticipantSpeaking = style({ + boxShadow: `0 0 0 2px ${color.Success.Main}`, +}); diff --git a/src/app/features/room-nav/CallParticipantsIndicator.tsx b/src/app/features/room-nav/CallParticipantsIndicator.tsx index cb112b0..ffef8ea 100644 --- a/src/app/features/room-nav/CallParticipantsIndicator.tsx +++ b/src/app/features/room-nav/CallParticipantsIndicator.tsx @@ -7,6 +7,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { nameInitials } from '../../utils/common'; import { useCall } from '../call/useCall'; +import * as css from './CallParticipantsIndicator.css'; const MAX_VISIBLE_PARTICIPANTS = 8; @@ -20,6 +21,7 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP const room = mx.getRoom(roomId); const { callMembers, isCallActive } = useRoomCallMembers(roomId); const { activeCall } = useCall(); + const myUserId = mx.getUserId(); if (!isCallActive || callMembers.length === 0 || !room) { return null; @@ -38,14 +40,37 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; }; + /** + * Check if a participant is currently speaking + */ + const isParticipantSpeaking = (userId: string): boolean => { + if (!activeCall) return false; + + // Direct check for Matrix user ID (works for local user) + if (activeCall.activeSpeakers.has(userId)) return true; + + // For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp) + const member = callMembers.find((m) => m.userId === userId); + if (!member) return false; + + // Check if any active speaker has this user's deviceId + return Array.from(activeCall.activeSpeakers).some((speakerId) => { + const underscoreIndex = speakerId.lastIndexOf('_'); + const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId; + return deviceId === member.deviceId; + }); + }; + + /** + * Check if a participant is currently muted + */ const isParticipantMuted = (userId: string): boolean => { if (!activeCall) return false; // For local user, check activeCall.isMuted - if (userId === mx.getUserId()) return activeCall.isMuted; + if (userId === myUserId) return activeCall.isMuted; // For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp) - // We need to find the deviceId for this user and check if any matching identity is muted const member = callMembers.find((m) => m.userId === userId); if (!member) return false; @@ -57,6 +82,24 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP }); }; + /** + * Check if the local user is deafened + */ + const isLocalUserDeafened = (userId: string): boolean => { + if (!activeCall || userId !== myUserId) return false; + return activeCall.isDeafened; + }; + + /** + * Get the appropriate audio icon for a participant + */ + const getAudioIcon = (userId: string): string => { + if (isLocalUserDeafened(userId)) { + return Icons.VolumeMute; + } + return isParticipantMuted(userId) ? Icons.MicMute : Icons.Mic; + }; + return ( - {visibleMembers.map((member) => ( - - - {getParticipantAvatar(member.userId) ? ( - {getParticipantName(member.userId)} { + const isSpeaking = isParticipantSpeaking(member.userId); + const isMuted = isParticipantMuted(member.userId); + const isDeafened = isLocalUserDeafened(member.userId); + + return ( + + + {getParticipantAvatar(member.userId) ? ( + {getParticipantName(member.userId)} + ) : ( + + {nameInitials(getParticipantName(member.userId))} + + )} + + + - ) : ( - - {nameInitials(getParticipantName(member.userId))} + + {getParticipantName(member.userId)} - )} - - - - - {getParticipantName(member.userId)} - + - - ))} + ); + })} {remainingCount > 0 && ( ( {names.length === 1 && ( - <> {names[0]} - - {' is following the conversation.'} - - )} {names.length === 2 && ( <> @@ -94,9 +89,6 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( {' and '} {names[1]} - - {' are following the conversation.'} - )} {names.length === 3 && ( @@ -110,9 +102,6 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( {' and '} {names[2]} - - {' are following the conversation.'} - )} {names.length > 3 && ( @@ -130,9 +119,6 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( {' and '} {names.length - 3} others - - {' are following the conversation.'} - )} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index b21d67e..c2547f8 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -32,7 +32,6 @@ import { SpecVersions } from './SpecVersions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; -import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; import { CallProviderWrapper } from '../../features/call/CallProviderWrapper'; @@ -282,7 +281,6 @@ export function ClientRoot({ children }: ClientRootProps) { return ( - {mx && } {loading && } {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 0406d1e..fae7eb6 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -15,6 +15,8 @@ export enum EmojiStyle { Twemoji = 'twemoji', } +export type CallPanelDockPosition = 'right' | 'sidebar'; + export interface Settings { themeId?: string; useSystemTheme: boolean; @@ -30,6 +32,7 @@ export interface Settings { isPeopleDrawer: boolean; isCallPanelDocked: boolean; + callPanelDockPosition: CallPanelDockPosition; memberSortFilterIndex: number; enterForNewline: boolean; messageLayout: MessageLayout; @@ -72,6 +75,7 @@ const defaultSettings: Settings = { isPeopleDrawer: true, isCallPanelDocked: false, + callPanelDockPosition: 'right', memberSortFilterIndex: 0, enterForNewline: false, messageLayout: 0,