{
+ 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) ? (
-
{
+ const isSpeaking = isParticipantSpeaking(member.userId);
+ const isMuted = isParticipantMuted(member.userId);
+ const isDeafened = isLocalUserDeafened(member.userId);
+
+ return (
+
+
+ {getParticipantAvatar(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,