feat: implement sidebar docked call panel with participant management and sync status handling
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
299
src/app/features/call/SidebarDockedCallPanel.tsx
Normal file
299
src/app/features/call/SidebarDockedCallPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
10
src/app/features/room-nav/CallParticipantsIndicator.css.ts
Normal file
10
src/app/features/room-nav/CallParticipantsIndicator.css.ts
Normal 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}`,
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user