feat: implement sidebar docked call panel with participant management and sync status handling

This commit is contained in:
2026-02-28 00:54:55 +11:00
parent 29c409fad8
commit b185e0b129
14 changed files with 625 additions and 77 deletions

View File

@@ -4,6 +4,7 @@ import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css'; import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
type PageRootProps = { type PageRootProps = {
nav: ReactNode; nav: ReactNode;
@@ -39,6 +40,7 @@ export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNa
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{children} {children}
<SidebarDockedCallPanel />
</Box> </Box>
</Box> </Box>
); );

View File

@@ -75,3 +75,33 @@ export const TitleBarDragRegion = style({
WebkitAppRegion: 'drag', WebkitAppRegion: 'drag',
color: color.Surface.OnContainer, 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,
});

View File

@@ -1,7 +1,7 @@
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react'; import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Box, Text } from 'folds'; import { Box, Text } from 'folds';
import { invoke } from '@tauri-apps/api/core'; 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 { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { WindowControls } from './WindowControls'; import { WindowControls } from './WindowControls';
@@ -17,6 +17,7 @@ import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreference
import { AccountDataEvent } from '../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode'; import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import { isRoomId } from '../../utils/matrix'; import { isRoomId } from '../../utils/matrix';
import { useSyncState } from '../../hooks/useSyncState';
type NewMessageNotification = { type NewMessageNotification = {
id: string; id: string;
@@ -42,6 +43,28 @@ export function TitleBar({ mx, children }: TitleBarProps) {
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom); 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 // Notification-related state - only populated when mx is available
const [notificationData, setNotificationData] = useState<{ const [notificationData, setNotificationData] = useState<{
preferences: { mute: Set<string>; specialMessages: Set<string>; allMessages: Set<string> }; preferences: { mute: Set<string>; specialMessages: Set<string>; allMessages: Set<string> };
@@ -591,8 +614,38 @@ export function TitleBar({ mx, children }: TitleBarProps) {
return null; 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 = (
<div className={`${css.SyncStatusBadge} ${css.SyncStatusSuccess}`}>
Connecting...
</div>
);
} else if (syncStateData.current === SyncState.Reconnecting) {
syncStatusBadge = (
<div className={`${css.SyncStatusBadge} ${css.SyncStatusWarning}`}>
Reconnecting...
</div>
);
} else if (syncStateData.current === SyncState.Error) {
syncStatusBadge = (
<div className={`${css.SyncStatusBadge} ${css.SyncStatusCritical}`}>
Connection Lost
</div>
);
}
}
return ( return (
<Box className={css.TitleBar} alignItems="Center" justifyContent="SpaceBetween"> <Box className={css.TitleBar} alignItems="Center" justifyContent="SpaceBetween">
{syncStatusBadge}
<Box <Box
className={css.TitleBarDragRegion} className={css.TitleBarDragRegion}
alignItems="Center" alignItems="Center"

View File

@@ -54,6 +54,53 @@ export const DockIndicator = style({
borderLeft: `2px dashed ${color.Primary.Main}`, borderLeft: `2px dashed ${color.Primary.Main}`,
}); });
export const SidebarDockIndicator = style({
position: 'fixed',
left: toRem(68), // After space panel (68px)
bottom: 0,
width: toRem(256), // Room list panel width
height: toRem(120),
backgroundColor: color.Primary.Container,
opacity: 0.5,
zIndex: 9998,
pointerEvents: 'none',
borderTop: `2px dashed ${color.Primary.Main}`,
borderRight: `2px dashed ${color.Primary.Main}`,
});
export const SidebarDockedCallContainer = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
padding: config.space.S200,
backgroundColor: color.Surface.Container,
borderTop: `1px solid ${color.Surface.ContainerLine}`,
marginTop: 'auto',
});
export const SidebarDockedCallHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: config.space.S100,
color: color.Surface.OnContainer,
cursor: 'grab',
padding: config.space.S100,
borderRadius: config.radii.R200,
selectors: {
'&:hover': {
backgroundColor: color.Surface.ContainerHover,
},
},
});
export const SidebarDockedCallControls = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S100,
});
export const DockedCallPanelLayout = style({ export const DockedCallPanelLayout = style({
width: toRem(266), width: toRem(266),
height: '100%', height: '100%',

View File

@@ -28,10 +28,16 @@ interface DragPosition {
/** Threshold in pixels for docking detection */ /** Threshold in pixels for docking detection */
const DOCK_THRESHOLD = 50; const DOCK_THRESHOLD = 50;
/** Combined width of space panel (68px) + room list panel (256px) for dock detection */
const ROOM_LIST_EDGE = 324;
/** Dock zone type */
type DockZone = 'none' | 'right' | 'sidebar';
/** Custom hook for draggable functionality with viewport constraints and docking detection */ /** Custom hook for draggable functionality with viewport constraints and docking detection */
function useDraggable( function useDraggable(
onDockRequest?: () => void, onDockRight?: () => void,
onDockSidebar?: () => void,
onUndockRequest?: () => void, onUndockRequest?: () => void,
isDocked?: boolean, isDocked?: boolean,
pendingDrag?: PendingDragState | null, pendingDrag?: PendingDragState | null,
@@ -39,7 +45,7 @@ function useDraggable(
) { ) {
const [position, setPosition] = useState<DragPosition>({ x: 0, y: 0 }); const [position, setPosition] = useState<DragPosition>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isNearDockZone, setIsNearDockZone] = useState(false); const [nearDockZone, setNearDockZone] = useState<DockZone>('none');
const dragRef = useRef<HTMLDivElement>(null); const dragRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null); const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
@@ -145,9 +151,20 @@ function useDraggable(
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !dragRef.current) return; if (!dragStartRef.current || !dragRef.current) return;
// Check if near right edge for docking // Check dock zones
const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD; 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 docked and dragging away from edge, trigger undock
if (isDocked) { if (isDocked) {
@@ -191,12 +208,20 @@ function useDraggable(
}; };
const handleMouseUp = (e: MouseEvent) => { const handleMouseUp = (e: MouseEvent) => {
// Check if should dock on mouse release // Check which dock zone to use
if (!isDocked && e.clientX > window.innerWidth - DOCK_THRESHOLD) { const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD;
onDockRequest?.(); 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); setIsDragging(false);
setIsNearDockZone(false); setNearDockZone('none');
dragStartRef.current = null; dragStartRef.current = null;
}; };
@@ -207,9 +232,9 @@ function useDraggable(
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); 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 // Docking state
const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked'); const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
const [, setDockPosition] = useSetting(settingsAtom, 'callPanelDockPosition');
// Pending drag state from undocking // Pending drag state from undocking
const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom); const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom);
@@ -695,16 +721,23 @@ export function CallOverlay() {
setPendingDrag(null); setPendingDrag(null);
}, [setPendingDrag]); }, [setPendingDrag]);
const handleDockRequest = useCallback(() => { const handleDockRight = useCallback(() => {
setDockPosition('right');
setIsDocked(true); setIsDocked(true);
}, [setIsDocked]); }, [setIsDocked, setDockPosition]);
const handleDockSidebar = useCallback(() => {
setDockPosition('sidebar');
setIsDocked(true);
}, [setIsDocked, setDockPosition]);
const handleUndockRequest = useCallback(() => { const handleUndockRequest = useCallback(() => {
setIsDocked(false); setIsDocked(false);
}, [setIsDocked]); }, [setIsDocked]);
const { position, isDragging, isNearDockZone, handleMouseDown, dragRef } = useDraggable( const { position, isDragging, nearDockZone, handleMouseDown, dragRef } = useDraggable(
handleDockRequest, handleDockRight,
handleDockSidebar,
handleUndockRequest, handleUndockRequest,
isDocked, isDocked,
pendingDrag, pendingDrag,
@@ -1047,8 +1080,9 @@ export function CallOverlay() {
return ( return (
<> <>
{/* Dock indicator shown when dragging near the right edge */} {/* Dock indicators shown when dragging near dock zones */}
{isNearDockZone && !isDocked && <div className={css.DockIndicator} />} {nearDockZone === 'right' && !isDocked && <div className={css.DockIndicator} />}
{nearDockZone === 'sidebar' && !isDocked && <div className={css.SidebarDockIndicator} />}
<div <div
ref={setContainerRef} ref={setContainerRef}
className={containerClass} className={containerClass}

View File

@@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
import { Box, Line } from 'folds'; import { Box, Line } from 'folds';
import { useIsCallDocked } from './useDockedCall'; import { useIsCallDockedRight } from './useDockedCall';
import { DockedCallContent } from './DockedCallContent'; import { DockedCallContent } from './DockedCallContent';
import * as css from './CallOverlay.css'; import * as css from './CallOverlay.css';
/** /**
* Component that renders the docked call panel in the layout flow * Component that renders the docked call panel in the layout flow (right side)
* Only renders when there's an active call AND docking is enabled * Only renders when there's an active call AND docking is enabled for right position
*/ */
export function DockedCallPanel() { export function DockedCallPanel() {
const isCallDocked = useIsCallDocked(); const isCallDockedRight = useIsCallDockedRight();
if (!isCallDocked) { if (!isCallDockedRight) {
return null; return null;
} }

View File

@@ -0,0 +1,299 @@
import React, { useEffect, useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider, color } from 'folds';
import { useSetAtom } from 'jotai';
import { useCall } from './useCall';
import { useRoomCallMembers } from './useRoomCallMembers';
import { CallState } from './types';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { pendingUndockDragAtom } from './pendingDragState';
import { useIsCallDockedSidebar } from './useDockedCall';
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import * as css from './CallOverlay.css';
/**
* Formats call duration in MM:SS format
*/
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const secsStr = secs < 10 ? `0${secs}` : `${secs}`;
return `${mins}:${secsStr}`;
}
/**
* Sidebar docked call panel - renders at bottom of room list
*/
export function SidebarDockedCallPanel() {
const isCallDockedSidebar = useIsCallDockedSidebar();
const { activeCall, endCall, toggleMute, toggleDeafen } = useCall();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selectedRoomId = useSelectedRoom();
const { navigateRoom } = useRoomNavigate();
const [duration, setDuration] = useState(0);
const [showParticipants, setShowParticipants] = useState(true);
const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
const setPendingDrag = useSetAtom(pendingUndockDragAtom);
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
const myUserId = mx.getUserId() ?? '';
useEffect(() => {
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 (
<div className={css.SidebarDockedCallContainer}>
{/* Header - drag to undock */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.SidebarDockedCallHeader}
onMouseDown={handleHeaderMouseDown}
>
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
<Icon size="100" src={Icons.Phone} />
<Text size="T300" truncate>
{isConnecting ? 'Connecting...' : room?.name ?? 'Call'}
</Text>
{selectedRoomId === activeCall.roomId && (
<Icon size="100" src={Icons.Check} style={{ opacity: 0.7 }} />
)}
</Box>
<Box alignItems="Center" gap="100">
<Text size="T200" style={{ opacity: 0.7 }}>
{formatDuration(duration)}
</Text>
{selectedRoomId !== activeCall.roomId && (
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Go to Room</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={() => navigateRoom(activeCall.roomId)}
variant="Secondary"
size="300"
>
<Icon size="100" src={Icons.Link} />
</IconButton>
)}
</TooltipProvider>
)}
</Box>
</div>
{/* Participants */}
<div className={css.ParticipantsSection}>
<button
type="button"
className={css.ParticipantsHeader}
onClick={() => setShowParticipants(!showParticipants)}
>
<Icon size="100" src={Icons.User} style={{ opacity: 0.7 }} />
<Text size="T200" className={css.ParticipantsHeaderText}>
{participantCount} Participant{participantCount !== 1 ? 's' : ''}
</Text>
<Icon
size="100"
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
style={{ opacity: 0.7 }}
/>
</button>
{showParticipants && room && (
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '150px' }}>
<div className={css.ParticipantsList}>
{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 (
<div key={participantId} className={css.ParticipantItem}>
<div
className={`${css.ParticipantAvatarWrapper} ${isSpeaking ? css.ParticipantSpeaking : ''}`}
>
<UserAvatar
className={css.ParticipantAvatar}
userId={participantId}
src={avatarUrl}
alt={displayName}
renderFallback={() => (
<Text size="H6" style={{ color: color.Surface.Container }}>
{displayName.charAt(0).toUpperCase()}
</Text>
)}
/>
</div>
<Box direction="Column" style={{ minWidth: 0, flex: 1 }}>
<Text size="T200" truncate>
{displayName}
{isMe && <span className={css.YouBadge}> (You)</span>}
</Text>
</Box>
{isMuted && (
<Icon size="100" src={Icons.MicMute} style={{ opacity: 0.5 }} />
)}
</div>
);
})}
</div>
</Scroll>
)}
</div>
{/* Controls */}
<div className={css.SidebarDockedCallControls}>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={toggleMute}
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
size="300"
>
<Icon size="200" src={activeCall.isMuted ? Icons.MicMute : Icons.Mic} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{activeCall.isDeafened ? 'Undeafen' : 'Deafen'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={toggleDeafen}
variant={activeCall.isDeafened ? 'Critical' : 'Secondary'}
size="300"
>
<Icon size="200" src={activeCall.isDeafened ? Icons.VolumeMute : Icons.VolumeHigh} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>End Call</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={endCall}
variant="Critical"
size="300"
>
<Icon size="200" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
</div>
</div>
);
}

View File

@@ -10,3 +10,4 @@ export * from './useRoomCallMembers';
export * from './useDockedCall'; export * from './useDockedCall';
export * from './DockedCallPanel'; export * from './DockedCallPanel';
export * from './DockedCallContent'; export * from './DockedCallContent';
export * from './SidebarDockedCallPanel';

View File

@@ -1,5 +1,5 @@
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom, CallPanelDockPosition } from '../../state/settings';
import { useCall } from './useCall'; import { useCall } from './useCall';
import { CallState } from './types'; import { CallState } from './types';
@@ -16,3 +16,31 @@ export function useIsCallDocked(): boolean {
return Boolean(hasActiveCall && isDocked); 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;
}

View File

@@ -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}`,
});

View File

@@ -7,6 +7,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useCall } from '../call/useCall'; import { useCall } from '../call/useCall';
import * as css from './CallParticipantsIndicator.css';
const MAX_VISIBLE_PARTICIPANTS = 8; const MAX_VISIBLE_PARTICIPANTS = 8;
@@ -20,6 +21,7 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
const { callMembers, isCallActive } = useRoomCallMembers(roomId); const { callMembers, isCallActive } = useRoomCallMembers(roomId);
const { activeCall } = useCall(); const { activeCall } = useCall();
const myUserId = mx.getUserId();
if (!isCallActive || callMembers.length === 0 || !room) { if (!isCallActive || callMembers.length === 0 || !room) {
return null; return null;
@@ -38,14 +40,37 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; 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 => { const isParticipantMuted = (userId: string): boolean => {
if (!activeCall) return false; if (!activeCall) return false;
// For local user, check activeCall.isMuted // 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) // 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); const member = callMembers.find((m) => m.userId === userId);
if (!member) return false; 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 ( return (
<Box <Box
direction="Column" direction="Column"
@@ -67,45 +110,58 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
width: '100%', width: '100%',
}} }}
> >
{visibleMembers.map((member) => ( {visibleMembers.map((member) => {
<Box const isSpeaking = isParticipantSpeaking(member.userId);
key={member.userId} const isMuted = isParticipantMuted(member.userId);
alignItems="Center" const isDeafened = isLocalUserDeafened(member.userId);
gap="100"
style={{ return (
opacity: config.opacity.P300, <Box
}} key={member.userId}
> alignItems="Center"
<Avatar size="200" radii="Pill"> gap="100"
{getParticipantAvatar(member.userId) ? ( style={{
<img opacity: config.opacity.P300,
src={getParticipantAvatar(member.userId)} }}
alt={getParticipantName(member.userId)} >
style={{ <Avatar
width: '100%', size="200"
height: '100%', radii="Pill"
objectFit: 'cover', className={isSpeaking ? css.ParticipantSpeaking : undefined}
borderRadius: 'inherit', >
}} {getParticipantAvatar(member.userId) ? (
<img
src={getParticipantAvatar(member.userId)}
alt={getParticipantName(member.userId)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 'inherit',
}}
/>
) : (
<Text as="span" size="H6">
{nameInitials(getParticipantName(member.userId))}
</Text>
)}
</Avatar>
<Box alignItems="Center" gap="100" grow="Yes">
<Icon
src={getAudioIcon(member.userId)}
size="50"
style={{
opacity: config.opacity.P500,
color: isDeafened ? 'var(--color-Critical-Main)' : isMuted ? 'var(--color-Critical-Main)' : undefined
}}
/> />
) : ( <Text as="span" size="T200" truncate>
<Text as="span" size="H6"> {getParticipantName(member.userId)}
{nameInitials(getParticipantName(member.userId))}
</Text> </Text>
)} </Box>
</Avatar>
<Box alignItems="Center" gap="100" grow="Yes">
<Icon
src={isParticipantMuted(member.userId) ? Icons.MicMute : Icons.Mic}
size="50"
style={{ opacity: config.opacity.P500 }}
/>
<Text as="span" size="T200" truncate>
{getParticipantName(member.userId)}
</Text>
</Box> </Box>
</Box> );
))} })}
{remainingCount > 0 && ( {remainingCount > 0 && (
<Box <Box
alignItems="Center" alignItems="Center"

View File

@@ -80,12 +80,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} /> <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
<Text size="T300" truncate> <Text size="T300" truncate>
{names.length === 1 && ( {names.length === 1 && (
<>
<b>{names[0]}</b> <b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is following the conversation.'}
</Text>
</>
)} )}
{names.length === 2 && ( {names.length === 2 && (
<> <>
@@ -94,9 +89,6 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
{' and '} {' and '}
</Text> </Text>
<b>{names[1]}</b> <b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are following the conversation.'}
</Text>
</> </>
)} )}
{names.length === 3 && ( {names.length === 3 && (
@@ -110,9 +102,6 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
{' and '} {' and '}
</Text> </Text>
<b>{names[2]}</b> <b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are following the conversation.'}
</Text>
</> </>
)} )}
{names.length > 3 && ( {names.length > 3 && (
@@ -130,9 +119,6 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
{' and '} {' and '}
</Text> </Text>
<b>{names.length - 3} others</b> <b>{names.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are following the conversation.'}
</Text>
</> </>
)} )}
</Text> </Text>

View File

@@ -32,7 +32,6 @@ import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState'; import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions'; import { getFallbackSession } from '../../state/sessions';
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper'; import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
@@ -282,7 +281,6 @@ export function ClientRoot({ children }: ClientRootProps) {
return ( return (
<SpecVersions baseUrl={baseUrl!}> <SpecVersions baseUrl={baseUrl!}>
<TitleBar mx={mx} /> <TitleBar mx={mx} />
{mx && <SyncStatus mx={mx} />}
{loading && <ClientRootOptions mx={mx} />} {loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
<SplashScreen> <SplashScreen>

View File

@@ -15,6 +15,8 @@ export enum EmojiStyle {
Twemoji = 'twemoji', Twemoji = 'twemoji',
} }
export type CallPanelDockPosition = 'right' | 'sidebar';
export interface Settings { export interface Settings {
themeId?: string; themeId?: string;
useSystemTheme: boolean; useSystemTheme: boolean;
@@ -30,6 +32,7 @@ export interface Settings {
isPeopleDrawer: boolean; isPeopleDrawer: boolean;
isCallPanelDocked: boolean; isCallPanelDocked: boolean;
callPanelDockPosition: CallPanelDockPosition;
memberSortFilterIndex: number; memberSortFilterIndex: number;
enterForNewline: boolean; enterForNewline: boolean;
messageLayout: MessageLayout; messageLayout: MessageLayout;
@@ -72,6 +75,7 @@ const defaultSettings: Settings = {
isPeopleDrawer: true, isPeopleDrawer: true,
isCallPanelDocked: false, isCallPanelDocked: false,
callPanelDockPosition: 'right',
memberSortFilterIndex: 0, memberSortFilterIndex: 0,
enterForNewline: false, enterForNewline: false,
messageLayout: 0, messageLayout: 0,