feat(call): add docked call panel and remote cursor overlay

- Implemented DockedCallPanel component to render the docked call interface when an active call is present.
- Created RemoteCursorOverlay component to display remote cursor positions during screen sharing.
- Added hooks for managing docked call state and remote cursor functionality.
- Introduced pending drag state management for seamless transitions between docked and floating states.
- Developed embed filter management components for personal and room-wide settings, allowing users to customize URL embed visibility.
- Implemented auto-joining functionality for rooms within spaces, enhancing user experience during space navigation.
- Added state management for tracking joining progress of rooms in spaces.
This commit is contained in:
2026-02-19 17:49:48 +11:00
parent bfbdf98468
commit 3f6f2134ad
42 changed files with 3801 additions and 219 deletions

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { config, DefaultReset, color } from 'folds';
import { config, DefaultReset, color, toRem } from 'folds';
export const CallOverlayContainer = style([
DefaultReset,
@@ -25,6 +25,53 @@ export const CallOverlayDragging = style({
opacity: 0.95,
});
export const CallOverlayDocked = style({
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: toRem(266),
maxWidth: toRem(266),
minWidth: toRem(266),
borderRadius: 0,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
transform: 'none !important',
borderLeft: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
});
export const DockIndicator = style({
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: toRem(266),
backgroundColor: color.Primary.Container,
opacity: 0.5,
zIndex: 9998,
pointerEvents: 'none',
borderLeft: `2px dashed ${color.Primary.Main}`,
});
export const DockedCallPanelLayout = style({
width: toRem(266),
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
overflow: 'hidden',
});
export const DockedCallPanelContent = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
padding: config.space.S300,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
});
export const CallOverlayFullscreen = style({
position: 'fixed',
top: 0,
@@ -134,6 +181,20 @@ export const ParticipantsSection = style({
borderRadius: config.radii.R300,
backgroundColor: color.Surface.ContainerActive,
overflow: 'hidden',
selectors: {
[`${CallOverlayDocked} &`]: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
},
[`${CallOverlayFullscreen} &`]: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
},
},
});
export const ParticipantsHeader = style({
@@ -214,3 +275,39 @@ export const ParticipantAvatar = style({
export const ParticipantSpeaking = style({
boxShadow: `0 0 0 2px ${color.Success.Main}`,
});
export const RemoteCursorOverlay = style({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'hidden',
zIndex: 10,
});
export const RemoteCursorDot = style({
position: 'absolute',
width: '12px',
height: '12px',
borderRadius: '50%',
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.4)',
transform: 'translate(-50%, -50%)',
transition: 'left 0.05s linear, top 0.05s linear, opacity 0.2s ease',
pointerEvents: 'none',
});
export const RemoteCursorLabel = style({
position: 'absolute',
transform: 'translate(-50%, 8px)',
fontSize: '10px',
fontWeight: 600,
color: 'white',
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: '4px',
padding: '1px 5px',
whiteSpace: 'nowrap',
pointerEvents: 'none',
});

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds';
import { useAtom } from 'jotai';
import { useCall } from './useCall';
import { useRoomCallMembers } from './useRoomCallMembers';
import { CallState, CallType } from './types';
@@ -10,6 +11,13 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { UnreadBadge } from '../../components/unread-badge';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { pendingUndockDragAtom, PendingDragState } from './pendingDragState';
import { RemoteCursorOverlay, useScreenShareCursor } from './RemoteCursorOverlay';
import * as css from './CallOverlay.css';
/** Drag position state */
@@ -18,13 +26,67 @@ interface DragPosition {
y: number;
}
/** Custom hook for draggable functionality with viewport constraints */
function useDraggable() {
/** Threshold in pixels for docking detection */
const DOCK_THRESHOLD = 50;
/** Custom hook for draggable functionality with viewport constraints and docking detection */
function useDraggable(
onDockRequest?: () => void,
onUndockRequest?: () => void,
isDocked?: boolean,
pendingDrag?: PendingDragState | null,
clearPendingDrag?: () => void
) {
const [position, setPosition] = useState<DragPosition>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isNearDockZone, setIsNearDockZone] = useState(false);
const dragRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
// Check for pending drag state from undocking
useEffect(() => {
if (pendingDrag && Date.now() - pendingDrag.timestamp < 500) {
// Calculate where the floating panel should be positioned
// so the mouse is at the same relative position as when dragging started
const panelWidth = dragRef.current?.offsetWidth ?? 400;
const panelHeight = dragRef.current?.offsetHeight ?? 300;
// The floating panel renders centered (transform: translate(-50%, -50%))
// from right: 20px, bottom: 20px by default (CSS position)
// We need to calculate the position offset to put the panel under the mouse
// at the same offset as when the drag started
// Target: mouse should be at offsetX from left edge, offsetY from top edge
// Panel default position: right: 20px, bottom: 20px (centered via transform)
// Default center: (window.innerWidth - 20 - panelWidth/2, window.innerHeight - 20 - panelHeight/2)
const defaultCenterX = window.innerWidth - 20 - panelWidth / 2;
const defaultCenterY = window.innerHeight - 20 - panelHeight / 2;
// Where we want the panel's top-left corner to be:
const targetLeft = pendingDrag.startX - pendingDrag.offsetX;
const targetTop = pendingDrag.startY - pendingDrag.offsetY;
// Where the panel's top-left corner would be by default:
const defaultLeft = defaultCenterX - panelWidth / 2;
const defaultTop = defaultCenterY - panelHeight / 2;
// Calculate the offset needed
const offsetX = targetLeft - defaultLeft;
const offsetY = targetTop - defaultTop;
setPosition({ x: offsetX, y: offsetY });
setIsDragging(true);
dragStartRef.current = {
x: offsetX,
y: offsetY,
startX: pendingDrag.startX,
startY: pendingDrag.startY,
};
clearPendingDrag?.();
}
}, [pendingDrag, clearPendingDrag]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
@@ -43,30 +105,22 @@ function useDraggable() {
const rect = dragRef.current.getBoundingClientRect();
const padding = 16;
// The element is positioned with right: S400 (16px) and top: 60px in CSS
// Transform moves it from that base position
// We need to ensure the element stays fully visible
let newX = position.x;
let newY = position.y;
let needsUpdate = false;
// Check if left edge is off screen (rect.left < padding)
if (rect.left < padding) {
newX = position.x + (padding - rect.left);
needsUpdate = true;
}
// Check if right edge is off screen
if (rect.right > window.innerWidth - padding) {
newX = position.x - (rect.right - window.innerWidth + padding);
needsUpdate = true;
}
// Check if top edge is off screen
if (rect.top < padding) {
newY = position.y + (padding - rect.top);
needsUpdate = true;
}
// Check if bottom edge is off screen
if (rect.bottom > window.innerHeight - padding) {
newY = position.y - (rect.bottom - window.innerHeight + padding);
needsUpdate = true;
@@ -77,7 +131,6 @@ function useDraggable() {
}
}, [position]);
// Listen for window resize to keep element in bounds
useEffect(() => {
const handleResize = () => {
clampToViewport();
@@ -91,24 +144,36 @@ function useDraggable() {
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !dragRef.current) return;
// Check if near right edge for docking
const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD;
setIsNearDockZone(nearRightEdge && !isDocked);
// If docked and dragging away from edge, trigger undock
if (isDocked) {
const dragDistance = Math.abs(e.clientX - dragStartRef.current.startX);
if (dragDistance > DOCK_THRESHOLD) {
onUndockRequest?.();
setIsDragging(false);
dragStartRef.current = null;
return;
}
}
const deltaX = e.clientX - dragStartRef.current.startX;
const deltaY = e.clientY - dragStartRef.current.startY;
// Get element dimensions for bounds checking
const rect = dragRef.current.getBoundingClientRect();
const padding = 16;
// Calculate new position
let newX = dragStartRef.current.x + deltaX;
let newY = dragStartRef.current.y + deltaY;
// Calculate where the element would be with the new transform
const newLeft = rect.left - position.x + newX;
const newRight = rect.right - position.x + newX;
const newTop = rect.top - position.y + newY;
const newBottom = rect.bottom - position.y + newY;
// Clamp to keep fully on screen
if (newLeft < padding) {
newX += padding - newLeft;
}
@@ -125,8 +190,13 @@ function useDraggable() {
setPosition({ x: newX, y: newY });
};
const handleMouseUp = () => {
const handleMouseUp = (e: MouseEvent) => {
// Check if should dock on mouse release
if (!isDocked && e.clientX > window.innerWidth - DOCK_THRESHOLD) {
onDockRequest?.();
}
setIsDragging(false);
setIsNearDockZone(false);
dragStartRef.current = null;
};
@@ -137,9 +207,9 @@ function useDraggable() {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, position]);
}, [isDragging, position, isDocked, onDockRequest, onUndockRequest]);
return { position, isDragging, handleMouseDown, dragRef };
return { position, isDragging, isNearDockZone, handleMouseDown, dragRef };
}
/**
@@ -203,17 +273,26 @@ function formatDuration(seconds: number): string {
}
/** Screen share entry type */
interface ScreenShareEntry {
export interface ScreenShareEntry {
/** Unique identifier for the screen share */
id: string;
/** Whether this is the local user's screen share */
isLocal: boolean;
/** Matrix user ID of the participant sharing */
participantId: string;
/** Room ID the call is in (for cursor resolution) */
roomId: string;
/** LiveKit participant identity */
livekitId?: string;
/** The underlying track (LiveKit RemoteTrack) */
track?: unknown;
/** The MediaStream for the screen share */
stream?: MediaStream;
}
/** Props for ScreenShareItem component */
interface ScreenShareItemProps {
/** The screen share entry to display */
share: ScreenShareEntry;
}
@@ -221,8 +300,9 @@ interface ScreenShareItemProps {
* Individual screen share display component
* Handles attaching the video stream/track properly
*/
function ScreenShareItem({ share }: ScreenShareItemProps) {
export function ScreenShareItem({ share }: ScreenShareItemProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId);
useEffect(() => {
const container = containerRef.current;
@@ -281,7 +361,6 @@ function ScreenShareItem({ share }: ScreenShareItemProps) {
const container = containerRef.current;
if (!container) return;
// Find the video element inside the container
const videoEl = container.querySelector('video');
if (!videoEl) return;
@@ -297,14 +376,17 @@ function ScreenShareItem({ share }: ScreenShareItemProps) {
}, []);
return (
<div className={css.ScreenShareContainer}>
<div className={css.ScreenShareContainer} style={{ position: 'relative' }}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={containerRef}
className={css.ScreenShareVideoContainer}
onDoubleClick={handleDoubleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ cursor: 'pointer' }}
/>
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
</div>
);
}
@@ -326,9 +408,37 @@ export function CallOverlay() {
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { position, isDragging, handleMouseDown, dragRef } = useDraggable();
const { navigateRoom } = useRoomNavigate();
// Docking state
const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
// Pending drag state from undocking
const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom);
const clearPendingDrag = useCallback(() => {
setPendingDrag(null);
}, [setPendingDrag]);
const handleDockRequest = useCallback(() => {
setIsDocked(true);
}, [setIsDocked]);
const handleUndockRequest = useCallback(() => {
setIsDocked(false);
}, [setIsDocked]);
const { position, isDragging, isNearDockZone, handleMouseDown, dragRef } = useDraggable(
handleDockRequest,
handleUndockRequest,
isDocked,
pendingDrag,
clearPendingDrag
);
// Get unread notifications for the call room
const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom);
// Get call members from Matrix state events (provides real Matrix user IDs)
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
const myUserId = mx.getUserId() ?? '';
@@ -343,6 +453,7 @@ export function CallOverlay() {
id: 'local',
isLocal: true,
participantId: myUserId,
roomId: activeCall.roomId,
stream: activeCall.localScreenStream,
});
}
@@ -366,6 +477,7 @@ export function CallOverlay() {
id: livekitId,
isLocal: false,
participantId: matrixUserId,
roomId: activeCall?.roomId ?? '',
livekitId,
track: trackInfo.track,
stream: trackInfo.stream,
@@ -373,7 +485,7 @@ export function CallOverlay() {
});
return shares;
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, callMembers, myUserId]);
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]);
useEffect(() => {
if (!activeCall || activeCall.state !== CallState.Connected) {
@@ -482,6 +594,11 @@ export function CallOverlay() {
return null;
}
// If docked and not fullscreen, don't render as overlay - it's rendered in the layout instead
if (isDocked && !isFullscreen) {
return null;
}
const isConnecting = activeCall.state === CallState.Connecting;
const isVideoCall = activeCall.callType === CallType.Video;
@@ -583,15 +700,26 @@ export function CallOverlay() {
}
};
// Determine container class based on state
const containerClass = [
css.CallOverlayContainer,
isFullscreen && css.CallOverlayFullscreen,
isDocked && !isFullscreen && css.CallOverlayDocked,
isDragging && css.CallOverlayDragging,
].filter(Boolean).join(' ');
return (
<div
ref={setContainerRef}
className={`${css.CallOverlayContainer} ${isFullscreen ? css.CallOverlayFullscreen : ''} ${isDragging ? css.CallOverlayDragging : ''}`}
style={!isFullscreen ? {
transform: `translate(${position.x}px, ${position.y}px)`,
} : undefined}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<>
{/* Dock indicator shown when dragging near the right edge */}
{isNearDockZone && !isDocked && <div className={css.DockIndicator} />}
<div
ref={setContainerRef}
className={containerClass}
style={!isFullscreen && !isDocked ? {
transform: `translate(${position.x}px, ${position.y}px)`,
} : undefined}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.CallOverlayHeader}
onMouseDown={handleMouseDown}
@@ -621,6 +749,9 @@ export function CallOverlay() {
</TooltipProvider>
)}
</Text>
{unread && unread.total > 0 && (
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
)}
{selectedRoomId !== activeCall.roomId && (
<TooltipProvider
position="Top"
@@ -931,5 +1062,6 @@ export function CallOverlay() {
</TooltipProvider>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,455 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, Scroll, color } from 'folds';
import { useSetAtom } from 'jotai';
import { useCall } from './useCall';
import { ScreenShareItem, ScreenShareEntry } from './CallOverlay';
import { useRoomCallMembers } from './useRoomCallMembers';
import { CallState, CallType } from './types';
import { useMatrixClient } from '../../hooks/useMatrixClient';
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 { UnreadBadge } from '../../components/unread-badge';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { pendingUndockDragAtom } from './pendingDragState';
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;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Docked call panel content - renders call UI in the layout flow
* This is used when the call panel is docked to the right side
*/
/**
* Custom Screen share icon SVG (Phosphor-style)
*/
function ScreenShareIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
<path d="M12 9l-4 4h3v3h2v-3h3l-4-4z" />
</svg>
);
}
/**
* Custom Screen share off icon SVG (Phosphor-style)
*/
function ScreenShareOffIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
<path d="M2 4l20 16" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function DockedCallContent() {
const { activeCall, endCall, toggleMute, toggleDeafen, toggleScreenShare } = useCall();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selectedRoomId = useSelectedRoom();
const [duration, setDuration] = useState(0);
const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false);
const [showParticipants, setShowParticipants] = useState(true);
const { navigateRoom } = useRoomNavigate();
const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
const setPendingDrag = useSetAtom(pendingUndockDragAtom);
const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom);
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
const myUserId = mx.getUserId() ?? '';
/** Memoized list of active screen shares (local + remote) */
const allScreenShares = useMemo((): ScreenShareEntry[] => {
const shares: ScreenShareEntry[] = [];
if (activeCall?.isScreenSharing && activeCall?.localScreenStream) {
shares.push({
id: 'local',
isLocal: true,
participantId: myUserId,
roomId: activeCall.roomId,
stream: activeCall.localScreenStream,
});
}
const remoteScreenTracks = activeCall?.remoteScreenTracks
? Array.from(activeCall.remoteScreenTracks.values())
: [];
remoteScreenTracks.forEach((trackInfo) => {
const livekitId = trackInfo.participantId;
const underscoreIndex = livekitId.lastIndexOf('_');
const possibleDeviceId = underscoreIndex > 0 ? livekitId.substring(0, underscoreIndex) : livekitId;
const matchingMember = callMembers.find((m) => m.deviceId === possibleDeviceId);
const matrixUserId = matchingMember?.userId ?? livekitId;
shares.push({
id: livekitId,
isLocal: false,
participantId: matrixUserId,
roomId: activeCall?.roomId ?? '',
livekitId,
track: trackInfo.track,
stream: trackInfo.stream,
});
});
return shares;
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]);
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;
});
};
const handleEndCall = () => {
endCall();
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleDeafen = () => {
toggleDeafen();
};
const handleToggleScreenShare = async () => {
if (isTogglingScreenShare) return;
setIsTogglingScreenShare(true);
try {
await toggleScreenShare();
} finally {
setIsTogglingScreenShare(false);
}
};
/**
* Handle mousedown on header to start drag-to-undock
* Sets pending drag state so the floating overlay can continue the drag
*/
const handleHeaderMouseDown = async (e: React.MouseEvent) => {
// Don't start drag if clicking a button
if ((e.target as HTMLElement).closest('button')) return;
// Exit browser fullscreen if active before undocking
if (document.fullscreenElement) {
try {
await document.exitFullscreen();
} catch {
// Fullscreen exit may fail
}
}
// Get the header element to calculate offset
const header = e.currentTarget as HTMLElement;
const rect = header.getBoundingClientRect();
// Store the drag start position with mouse offset from panel edges
setPendingDrag({
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
timestamp: Date.now(),
});
// Undock immediately - the floating overlay will pick up the drag
setIsDocked(false);
};
if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
return null;
}
const isConnecting = activeCall.state === CallState.Connecting;
const isVideoCall = activeCall.callType === CallType.Video;
const room = mx.getRoom(activeCall.roomId);
const allParticipants = callMembers.map((m) => m.userId);
const participantCount = allParticipants.length;
return (
<div className={css.DockedCallPanelContent}>
{/* Header - drag to undock */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.CallOverlayHeader}
onMouseDown={handleHeaderMouseDown}
style={{ cursor: 'grab' }}
>
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
<Icon
size="200"
src={isVideoCall ? Icons.VideoCamera : Icons.Phone}
/>
<Text size="T300" truncate>
{isConnecting ? 'Connecting...' : room?.name ?? 'Unknown Room'}
{selectedRoomId === activeCall.roomId && (
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Current Room</Text></Tooltip>}
>
{(triggerRef) => (
<Icon
ref={triggerRef}
size="100"
src={Icons.Check}
style={{ marginLeft: '4px', opacity: 0.7, verticalAlign: 'middle' }}
/>
)}
</TooltipProvider>
)}
</Text>
{unread && unread.total > 0 && (
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
)}
</Box>
<Box alignItems="Center" gap="100">
<Text size="T200" className={css.CallDuration}>
{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 */}
<Box direction="Column" grow="Yes" style={{ overflow: 'hidden' }}>
<div className={css.ParticipantsSection} style={{ flex: 1 }}>
<button
type="button"
className={css.ParticipantsHeader}
onClick={() => setShowParticipants(!showParticipants)}
>
<Text size="T200" className={css.ParticipantsHeaderText}>
Participants ({participantCount})
</Text>
<Icon
size="100"
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
style={{ opacity: 0.7 }}
/>
</button>
{showParticipants && room && (
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '100%' }}>
<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="T300" className={css.ParticipantName} truncate>
{displayName}
{isMe && <span className={css.YouBadge}> (You)</span>}
</Text>
</Box>
{isMuted && (
<Icon
size="100"
src={Icons.MicMute}
className={css.ParticipantMutedIcon}
/>
)}
</div>
);
})}
</div>
</Scroll>
)}
</div>
</Box>
{/* Screen shares */}
{allScreenShares.length > 0 && (
<Box direction="Column" gap="200" style={{ padding: '0 8px 8px' }}>
{allScreenShares.map((share) => (
<ScreenShareItem key={share.id} share={share} />
))}
</Box>
)}
{/* Controls */}
<div className={css.CallOverlayControls}>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleMute}
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
>
<Icon size="300" 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={handleToggleDeafen}
variant={activeCall.isDeafened ? 'Critical' : 'Secondary'}
>
<Icon size="300" src={activeCall.isDeafened ? Icons.VolumeMute : Icons.VolumeHigh} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleScreenShare}
variant={activeCall.isScreenSharing ? 'Primary' : 'Secondary'}
disabled={isTogglingScreenShare}
>
<Box
as="span"
style={{
width: '1.5rem',
height: '1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{activeCall.isScreenSharing ? <ScreenShareOffIcon /> : <ScreenShareIcon />}
</Box>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>End Call</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleEndCall}
variant="Critical"
>
<Icon size="300" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Box, Line } from 'folds';
import { useIsCallDocked } from './useDockedCall';
import { DockedCallContent } from './DockedCallContent';
import * as css from './CallOverlay.css';
/**
* Component that renders the docked call panel in the layout flow
* Only renders when there's an active call AND docking is enabled
*/
export function DockedCallPanel() {
const isCallDocked = useIsCallDocked();
if (!isCallDocked) {
return null;
}
return (
<>
<Line variant="Background" direction="Vertical" size="300" />
<Box shrink="No" className={css.DockedCallPanelLayout}>
<DockedCallContent />
</Box>
</>
);
}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { useRoomCallMembers } from './useRoomCallMembers';
import { useCall } from './useCall';
import { useSendCursor, useReceiveCursors, RemoteCursorState } from './useRemoteCursor';
import colorMXID from '../../../util/colorMXID';
import * as css from './CallOverlay.css';
/** Props for RemoteCursorOverlay */
interface RemoteCursorOverlayProps {
/** The screen share ID to track cursors for */
shareId: string;
/** The room ID of the active call */
roomId: string;
}
/**
* Resolves a LiveKit participant identity to a Matrix user ID
* LiveKit identities are typically in the format "deviceId_timestamp"
*/
function resolveParticipantUserId(
livekitIdentity: string,
callMembers: { userId: string; deviceId: string }[]
): string {
const underscoreIndex = livekitIdentity.lastIndexOf('_');
const possibleDeviceId =
underscoreIndex > 0 ? livekitIdentity.substring(0, underscoreIndex) : livekitIdentity;
const match = callMembers.find((m) => m.deviceId === possibleDeviceId);
return match?.userId ?? livekitIdentity;
}
/**
* Single remote cursor dot with label
*/
function CursorDot({
cursor,
displayName,
cursorColor,
}: {
cursor: RemoteCursorState;
displayName: string;
cursorColor: string;
}) {
if (!cursor.visible) return null;
const left = `${cursor.x * 100}%`;
const top = `${cursor.y * 100}%`;
return (
<>
<div
className={css.RemoteCursorDot}
style={{
left,
top,
backgroundColor: cursorColor,
opacity: cursor.visible ? 1 : 0,
}}
/>
<div
className={css.RemoteCursorLabel}
style={{ left, top, color: 'white' }}
>
{displayName}
</div>
</>
);
}
/**
* Overlay that renders remote cursor positions on top of a screen share
* Also provides mouse event handlers for sending local cursor position
*/
export function RemoteCursorOverlay({ shareId, roomId }: RemoteCursorOverlayProps) {
const mx = useMatrixClient();
const { callMembers } = useRoomCallMembers(roomId);
const { activeCall } = useCall();
const cursors = useReceiveCursors(shareId);
const room = mx.getRoom(roomId);
const myUserId = mx.getUserId() ?? '';
/**
* Gets a display name for a LiveKit participant
*/
const getDisplayName = (livekitIdentity: string): string => {
const userId = resolveParticipantUserId(livekitIdentity, callMembers);
if (!room) return getMxIdLocalPart(userId) ?? userId;
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
};
// Filter out our own cursor and only show visible cursors
const myIdentity = activeCall?.participants.find((p) => {
const underscoreIndex = p.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? p.substring(0, underscoreIndex) : p;
const member = callMembers.find((m) => m.deviceId === deviceId);
return member?.userId === myUserId;
});
const remoteCursors = Array.from(cursors.entries()).filter(
([participantId]) => participantId !== myIdentity
);
if (remoteCursors.length === 0) return null;
return (
<div className={css.RemoteCursorOverlay}>
{remoteCursors.map(([participantId, cursor]) => {
const userId = resolveParticipantUserId(participantId, callMembers);
return (
<CursorDot
key={participantId}
cursor={cursor}
displayName={getDisplayName(participantId)}
cursorColor={colorMXID(userId)}
/>
);
})}
</div>
);
}
/**
* Wrapper that provides mouse event handlers for cursor sending on a screenshare
* @param shareId - The screen share ID
* @returns Mouse event handlers to attach to the container
*/
export function useScreenShareCursor(shareId: string) {
return useSendCursor(shareId);
}

View File

@@ -7,3 +7,6 @@ export * from './CallProviderWrapper';
export * from './CallOverlay';
export * from './IncomingCallNotification';
export * from './useRoomCallMembers';
export * from './useDockedCall';
export * from './DockedCallPanel';
export * from './DockedCallContent';

View File

@@ -0,0 +1,21 @@
import { atom } from 'jotai';
/**
* Atom to store pending drag state when transitioning from docked to floating
* When the user starts dragging the docked panel header, we store the mouse position
* so the floating overlay can continue the drag seamlessly
*/
export interface PendingDragState {
/** Mouse X position when drag started */
startX: number;
/** Mouse Y position when drag started */
startY: number;
/** Offset from panel left edge to mouse X */
offsetX: number;
/** Offset from panel top edge to mouse Y */
offsetY: number;
/** Timestamp to prevent stale drag states */
timestamp: number;
}
export const pendingUndockDragAtom = atom<PendingDragState | null>(null);

View File

@@ -0,0 +1,18 @@
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useCall } from './useCall';
import { CallState } from './types';
/**
* Hook to determine if the call panel should be rendered docked
* Returns true if there's an active call AND the panel is set to docked mode
*/
export function useIsCallDocked(): boolean {
const { activeCall } = useCall();
const [isDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
const hasActiveCall = activeCall &&
(activeCall.state === CallState.Connecting || activeCall.state === CallState.Connected);
return Boolean(hasActiveCall && isDocked);
}

View File

@@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RoomEvent } from 'livekit-client';
import { useCall } from './useCall';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
/** Data topic for cursor position messages */
const CURSOR_TOPIC = 'remote-cursor';
/** How often to send cursor updates (ms) */
const CURSOR_SEND_INTERVAL = 50;
/** How long before a cursor is considered stale and hidden (ms) */
const CURSOR_STALE_TIMEOUT = 3000;
/** Cursor position data sent over the data channel */
export interface CursorData {
/** Message type identifier */
type: 'cursor';
/** Normalized X position (01) */
x: number;
/** Normalized Y position (01) */
y: number;
/** Whether the cursor is visible (false = user moved mouse away) */
visible: boolean;
/** Screen share participant ID (Matrix user ID) this cursor relates to */
shareId: string;
}
/** Remote cursor state for rendering */
export interface RemoteCursorState {
/** LiveKit participant identity */
participantId: string;
/** Normalized X position (01) */
x: number;
/** Normalized Y position (01) */
y: number;
/** Whether the cursor is currently visible */
visible: boolean;
/** Timestamp of last update */
lastUpdate: number;
}
/**
* Hook to send local cursor position over the LiveKit data channel
* Tracks mouse movement over a screenshare container and broadcasts position
* @param shareId - The screen share ID to associate cursor data with
* @returns Object with onMouseMove/onMouseLeave handlers to attach to the container
*/
export function useSendCursor(shareId: string) {
const { callService } = useCall();
const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
const lastSendRef = useRef(0);
const encoder = useRef(new TextEncoder());
const sendCursorData = useCallback(
(data: Omit<CursorData, 'type'>) => {
const room = callService?.getLiveKitRoom();
if (!room?.localParticipant) return;
const now = Date.now();
if (!data.visible || now - lastSendRef.current >= CURSOR_SEND_INTERVAL) {
lastSendRef.current = now;
const message: CursorData = { ...data, type: 'cursor' };
const payload = encoder.current.encode(JSON.stringify(message));
room.localParticipant
.publishData(payload, { reliable: false, topic: CURSOR_TOPIC })
.catch(() => {
// Data publish can fail silently
});
}
},
[callService]
);
/**
* Handle mouse movement over a screenshare container
* Calculates normalized position and sends to remote participants
*/
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!showRemoteCursor) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
sendCursorData({ x, y, visible: true, shareId });
},
[showRemoteCursor, sendCursorData, shareId]
);
/**
* Handle mouse leaving the screenshare container
* Sends a hidden cursor update so remote stops showing cursor
*/
const handleMouseLeave = useCallback(() => {
if (!showRemoteCursor) return;
sendCursorData({ x: 0, y: 0, visible: false, shareId });
}, [showRemoteCursor, sendCursorData, shareId]);
return { handleMouseMove, handleMouseLeave };
}
/**
* Hook to receive remote cursor positions from the LiveKit data channel
* Listens for cursor data from other participants and provides state for rendering
* @param shareId - The screen share ID to filter cursor data for
* @returns Map of participant IDs to their cursor states
*/
export function useReceiveCursors(shareId: string): Map<string, RemoteCursorState> {
const { callService } = useCall();
const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
const [cursors, setCursors] = useState<Map<string, RemoteCursorState>>(new Map());
const decoder = useRef(new TextDecoder());
useEffect(() => {
if (!showRemoteCursor) {
setCursors(new Map());
return undefined;
}
const room = callService?.getLiveKitRoom();
if (!room) return undefined;
const handleDataReceived = (
payload: Uint8Array,
participant?: { identity: string },
_kind?: unknown,
topic?: string
) => {
if (!participant) return;
// Only process cursor messages by topic
if (topic !== undefined && topic !== CURSOR_TOPIC) return;
try {
const text = decoder.current.decode(payload);
const data = JSON.parse(text) as CursorData;
// Validate this is actually a cursor message
if (data.type !== 'cursor') return;
if (data.shareId !== shareId) return;
setCursors((prev) => {
const next = new Map(prev);
next.set(participant.identity, {
participantId: participant.identity,
x: data.x,
y: data.y,
visible: data.visible,
lastUpdate: Date.now(),
});
return next;
});
} catch {
// Ignore malformed data
}
};
room.on(RoomEvent.DataReceived, handleDataReceived);
// Periodically clean up stale cursors
const cleanupInterval = setInterval(() => {
setCursors((prev) => {
const now = Date.now();
let changed = false;
const next = new Map(prev);
next.forEach((cursor, key) => {
if (now - cursor.lastUpdate > CURSOR_STALE_TIMEOUT) {
next.delete(key);
changed = true;
}
});
return changed ? next : prev;
});
}, 1000);
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived);
clearInterval(cleanupInterval);
};
}, [callService, showRemoteCursor, shareId]);
return cursors;
}

View File

@@ -0,0 +1,315 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Box,
Button,
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import {
useRoomEmbedFilters,
useRoomWideEmbedFilters,
useSetRoomWideEmbedFilters,
EmbedFiltersContent,
} from '../../../hooks/useRoomEmbedFilters';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
/**
* Validates that a string is a valid regex pattern.
* @param pattern - The pattern to validate
* @returns True if the pattern is valid
*/
const isValidPattern = (pattern: string): boolean => {
try {
// eslint-disable-next-line no-new
new RegExp(pattern);
return true;
} catch {
return false;
}
};
/**
* Props for the pattern list editor component.
*/
interface PatternListEditorProps {
patterns: string[];
onAdd: (pattern: string) => Promise<void>;
onRemove: (pattern: string) => Promise<void>;
disabled?: boolean;
saving: boolean;
error?: string;
}
/**
* Reusable component for adding/removing patterns.
*/
function PatternListEditor({
patterns,
onAdd,
onRemove,
disabled,
saving,
error,
}: PatternListEditorProps) {
const [newPattern, setNewPattern] = useState('');
const [patternError, setPatternError] = useState<string>();
const handleAddPattern: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
const pattern = newPattern.trim();
if (!pattern) {
setPatternError('Pattern cannot be empty');
return;
}
if (!isValidPattern(pattern)) {
setPatternError('Invalid regex pattern');
return;
}
if (patterns.includes(pattern)) {
setPatternError('Pattern already exists');
return;
}
setPatternError(undefined);
await onAdd(pattern);
setNewPattern('');
};
return (
<Box direction="Column" gap="300">
<Box as="form" onSubmit={handleAddPattern} gap="200" alignItems="Start">
<Box grow="Yes" direction="Column" gap="100">
<Input
value={newPattern}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNewPattern(e.target.value);
setPatternError(undefined);
}}
placeholder="e.g. ^https?://example\.com"
size="300"
variant="Secondary"
radii="300"
disabled={disabled || saving}
style={{ width: '100%' }}
/>
{patternError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{patternError}
</Text>
)}
</Box>
<Button
type="submit"
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={disabled || saving || !newPattern.trim()}
before={saving && <Spinner size="100" variant="Primary" fill="Solid" />}
>
<Text size="B300">Add</Text>
</Button>
</Box>
{patterns.length > 0 && (
<Box direction="Column" gap="200">
<Text size="T200" priority="300">
Active patterns ({patterns.length}):
</Text>
<Box gap="200" wrap="Wrap">
{patterns.map((pattern) => (
<TooltipProvider
key={pattern}
tooltip={
<Tooltip>
<Text size="T300">{pattern}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
onClick={() => onRemove(pattern)}
after={!disabled && <Icon size="50" src={Icons.Cross} />}
disabled={disabled || saving}
>
<Text
size="T200"
truncate
style={{ maxWidth: config.space.S500, fontFamily: 'monospace' }}
>
{pattern}
</Text>
</Chip>
)}
</TooltipProvider>
))}
</Box>
</Box>
)}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save: {error}
</Text>
)}
</Box>
);
}
/**
* Component for managing personal URL embed filter patterns for a room.
* These patterns are stored per-user per-room and filter out URL previews
* that match the patterns.
*/
export function PersonalEmbedFilters() {
const room = useRoom();
const [filters, setFilters] = useRoomEmbedFilters(room);
const [saveState, saveFilters] = useAsyncCallback(
useCallback(
async (newFilters: EmbedFiltersContent) => {
await setFilters(newFilters);
},
[setFilters]
)
);
const saving = saveState.status === AsyncStatus.Loading;
const handleAdd = async (pattern: string) => {
const newFilters: EmbedFiltersContent = {
disabledPatterns: [...filters.disabledPatterns, pattern],
};
await saveFilters(newFilters);
};
const handleRemove = async (pattern: string) => {
const newFilters: EmbedFiltersContent = {
disabledPatterns: filters.disabledPatterns.filter((p) => p !== pattern),
};
await saveFilters(newFilters);
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Personal Embed Filters"
description="Add regex patterns for URLs that should not show embeds for you only. Patterns are case-insensitive."
/>
<PatternListEditor
patterns={filters.disabledPatterns}
onAdd={handleAdd}
onRemove={handleRemove}
saving={saving}
error={saveState.status === AsyncStatus.Error ? String(saveState.error) : undefined}
/>
</SequenceCard>
);
}
/**
* Component for managing room-wide URL embed filter patterns.
* These patterns are set by room admins and apply to all users.
*/
export function RoomWideEmbedFilters() {
const room = useRoom();
const mx = useMatrixClient();
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canEdit = permissions.stateEvent(StateEvent.RoomEmbedFilters, mx.getSafeUserId());
const filters = useRoomWideEmbedFilters(room);
const setRoomWideFilters = useSetRoomWideEmbedFilters(room);
const [saveState, saveFilters] = useAsyncCallback(
useCallback(
async (newFilters: EmbedFiltersContent) => {
await setRoomWideFilters(newFilters);
},
[setRoomWideFilters]
)
);
const saving = saveState.status === AsyncStatus.Loading;
const handleAdd = async (pattern: string) => {
const newFilters: EmbedFiltersContent = {
disabledPatterns: [...filters.disabledPatterns, pattern],
};
await saveFilters(newFilters);
};
const handleRemove = async (pattern: string) => {
const newFilters: EmbedFiltersContent = {
disabledPatterns: filters.disabledPatterns.filter((p) => p !== pattern),
};
await saveFilters(newFilters);
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Room-Wide Embed Filters"
description={
canEdit
? 'Add regex patterns that apply to all users in this room. Patterns are case-insensitive.'
: 'Embed filter patterns set by room admins. You need moderator permissions to edit.'
}
/>
<PatternListEditor
patterns={filters.disabledPatterns}
onAdd={handleAdd}
onRemove={handleRemove}
disabled={!canEdit}
saving={saving}
error={saveState.status === AsyncStatus.Error ? String(saveState.error) : undefined}
/>
</SequenceCard>
);
}
/**
* Combined component showing both personal and room-wide embed filters.
*/
export function EmbedFilters() {
return (
<Box direction="Column" gap="400">
<RoomWideEmbedFilters />
<PersonalEmbedFilters />
</Box>
);
}

View File

@@ -1,3 +1,4 @@
export * from './EmbedFilters';
export * from './RoomAddress';
export * from './RoomEncryption';
export * from './RoomHistoryVisibility';

View File

@@ -56,6 +56,7 @@ import { useGetRoom } from '../../hooks/useGetRoom';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { joiningProgressAtom } from '../../state/joiningProgress';
const useCanDropLobbyItem = (
space: Room,
@@ -158,6 +159,8 @@ export function Lobby() {
const spacePowerLevels = usePowerLevels(space);
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
const members = useRoomMembers(mx, space.roomId);
const joiningProgress = useAtomValue(joiningProgressAtom);
const currentJoiningProgress = joiningProgress.get(space.roomId);
const scrollRef = useRef<HTMLDivElement>(null);
const heroSectionRef = useRef<HTMLDivElement>(null);
@@ -530,6 +533,33 @@ export function Lobby() {
</Chip>
</Box>
)}
{currentJoiningProgress && (
<Box
style={{
position: 'absolute',
bottom: reordering
? `calc(${config.space.S400} + 40px)`
: config.space.S400,
left: 0,
right: 0,
zIndex: 2,
pointerEvents: 'none',
}}
justifyContent="Center"
>
<Chip
variant="Primary"
outlined
radii="Pill"
before={<Spinner variant="Primary" fill="Soft" size="100" />}
>
<Text size="L400">
Joining Channels: {currentJoiningProgress.current}/
{currentJoiningProgress.total}
</Text>
</Chip>
</Box>
)}
</PageContentCenter>
</PageContent>
</Scroll>

View File

@@ -181,7 +181,14 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
onClick={(evt) => {
if (evt.shiftKey) {
mx.leave(room.roomId);
requestClose();
} else {
setPromptLeave(true);
}
}}
variant="Critical"
fill="None"
size="300"

View File

@@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
import {
EmbedFilters,
RoomProfile,
RoomEncryption,
RoomHistoryVisibility,
@@ -53,6 +54,10 @@ export function General({ requestClose }: GeneralProps) {
<RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Link Previews</Text>
<EmbedFilters />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
<RoomPublishedAddresses permissions={permissions} />

View File

@@ -174,6 +174,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
],
};
const callsGroup: PermissionGroup = {
name: 'Calls',
items: [
{
location: {
state: true,
key: StateEvent.CallMember,
},
name: 'Join/Start Calls',
},
],
};
const otherSettingsGroup: PermissionGroup = {
name: 'Other',
items: [
@@ -199,6 +212,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
moderationGroup,
roomOverviewGroup,
roomSettingsGroup,
callsGroup,
otherSettingsGroup,
];
}, []);

View File

@@ -126,6 +126,11 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import {
useRoomEmbedFilters,
useRoomWideEmbedFilters,
combineEmbedFilters,
} from '../../hooks/useRoomEmbedFilters';
/** Information about a call member event for grouping */
interface CallEventInfo {
@@ -455,6 +460,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [personalEmbedFilters] = useRoomEmbedFilters(room);
const roomWideEmbedFilters = useRoomWideEmbedFilters(room);
const combinedEmbedFilters = useMemo(
() => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
[personalEmbedFilters, roomWideEmbedFilters]
);
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
@@ -1119,6 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
/>
)}
</Message>
@@ -1225,6 +1237,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
/>
);
}

View File

@@ -509,7 +509,17 @@ export const MessageDeleteItem = as<
size="300"
after={<Icon size="100" src={Icons.Delete} />}
radii="300"
onClick={() => setOpen(true)}
onClick={(evt) => {
if (evt.shiftKey) {
const eventId = mEvent.getId();
if (eventId) {
mx.redactEvent(room.roomId, eventId);
onClose?.();
}
} else {
setOpen(true);
}
}}
aria-pressed={open}
{...props}
ref={ref}

View File

@@ -23,7 +23,10 @@ import {
Header,
config,
Spinner,
color,
toRem,
} from 'folds';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
@@ -43,6 +46,8 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { useUserColor } from '../../../hooks/useUserColor';
type ProfileProps = {
profile: UserProfile;
@@ -303,6 +308,126 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
);
}
/**
* Color picker component for user's profile color
* Stored in avatar image metadata, visible to other Paarrot users
*/
function ProfileColor() {
const [userColor, setUserColor, loading] = useUserColor();
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
if (userColor) {
setLocalColor(userColor);
}
}, [userColor]);
const handleColorChange = (newColor: string) => {
setLocalColor(newColor);
setError(undefined);
};
const handleSaveColor = async () => {
setSaving(true);
setError(undefined);
try {
await setUserColor(localColor);
} catch (e) {
setError('Failed to save. Make sure you have an avatar set.');
}
setSaving(false);
};
const handleRemoveColor = async () => {
setSaving(true);
setError(undefined);
try {
await setUserColor(undefined);
setLocalColor('#3b82f6');
} catch (e) {
setError('Failed to remove color.');
}
setSaving(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Profile Color
</Text>
}
description="Embedded in your avatar. Other Paarrot users will see this color."
after={
<Box alignItems="Center" gap="200">
{loading ? (
<Text size="T300" style={{ opacity: 0.7 }}>Loading...</Text>
) : (
<HexColorPickerPopOut
picker={
<Box direction="Column" gap="200">
<HexColorPicker color={localColor} onChange={handleColorChange} />
<Box gap="100" alignItems="Center">
<Input
size="300"
variant="Secondary"
style={{ width: toRem(100) }}
value={localColor}
onChange={(e) => {
setLocalColor(e.target.value);
setError(undefined);
}}
/>
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleSaveColor}
disabled={saving}
>
<Text size="B300">{saving ? 'Saving...' : 'Save'}</Text>
</Button>
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>
)}
</Box>
}
onRemove={userColor ? handleRemoveColor : undefined}
>
{(onOpen) => (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={onOpen}
disabled={saving}
before={
<Box
style={{
width: toRem(16),
height: toRem(16),
borderRadius: toRem(4),
backgroundColor: userColor ?? localColor,
}}
/>
}
>
<Text size="T300">{userColor ? 'Change' : 'Choose'}</Text>
</Button>
)}
</HexColorPickerPopOut>
)}
</Box>
}
/>
);
}
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
@@ -318,6 +443,7 @@ export function Profile() {
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileColor />
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>

View File

@@ -23,6 +23,8 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
/** Represents an audio device (microphone or speaker) */
interface AudioDevice {
@@ -447,6 +449,7 @@ export function Audio({ requestClose }: AudioProps) {
const [speakers, setSpeakers] = useState<AudioDevice[]>([]);
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
useEffect(() => {
const loadDevices = async () => {
@@ -797,6 +800,17 @@ export function Audio({ requestClose }: AudioProps) {
/>
}
/>
<SettingTile
title="Show Remote Cursor"
description="Display other participants' cursors on shared screens. Both users must have this enabled."
after={
<Switch
variant="Primary"
value={showRemoteCursor}
onChange={setShowRemoteCursor}
/>
}
/>
</SequenceCard>
<SequenceCard

View File

@@ -52,8 +52,6 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { useUserColor } from '../../../hooks/useUserColor';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -431,131 +429,10 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
<UserColorPicker />
</Box>
);
}
/**
* Color picker component for user's profile color
* Stored in avatar PNG metadata, visible to other Paarrot users
*/
function UserColorPicker() {
const [userColor, setUserColor, loading] = useUserColor();
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
// Update local color when avatar color changes
useEffect(() => {
if (userColor) {
setLocalColor(userColor);
}
}, [userColor]);
const handleColorChange = (newColor: string) => {
setLocalColor(newColor);
setError(undefined);
};
const handleSaveColor = async () => {
setSaving(true);
setError(undefined);
try {
await setUserColor(localColor);
} catch (e) {
setError('Failed to save. Make sure you have an avatar set.');
}
setSaving(false);
};
const handleRemoveColor = async () => {
setSaving(true);
setError(undefined);
try {
await setUserColor(undefined);
setLocalColor('#3b82f6');
} catch (e) {
setError('Failed to remove color.');
}
setSaving(false);
};
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Profile Color"
description="Embedded in your avatar. Other Paarrot users will see this color."
after={
<Box alignItems="Center" gap="200">
{loading ? (
<Text size="T300" style={{ opacity: 0.7 }}>Loading...</Text>
) : (
<HexColorPickerPopOut
picker={
<Box direction="Column" gap="200">
<HexColorPicker color={localColor} onChange={handleColorChange} />
<Box gap="100" alignItems="Center">
<Input
size="300"
variant="Secondary"
style={{ width: toRem(100) }}
value={localColor}
onChange={(e) => {
setLocalColor(e.target.value);
setError(undefined);
}}
/>
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleSaveColor}
disabled={saving}
>
<Text size="B300">{saving ? 'Saving...' : 'Save'}</Text>
</Button>
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>
)}
</Box>
}
onRemove={userColor ? handleRemoveColor : undefined}
>
{(onOpen) => (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={onOpen}
disabled={saving}
before={
<Box
style={{
width: toRem(16),
height: toRem(16),
borderRadius: toRem(4),
backgroundColor: userColor ?? localColor,
}}
/>
}
>
<Text size="T300">{userColor ? 'Change' : 'Choose'}</Text>
</Button>
)}
</HexColorPickerPopOut>
)}
</Box>
}
/>
</SequenceCard>
);
}
type DateHintProps = {
hasChanges: boolean;
handleReset: () => void;
@@ -943,6 +820,32 @@ function Editor() {
);
}
function Spaces() {
const [autoJoinSpaceRooms, setAutoJoinSpaceRooms] = useSetting(
settingsAtom,
'autoJoinSpaceRooms'
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Spaces</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Auto-Join Space Rooms"
description="Automatically join all accessible rooms when joining a space, with notifications set to Mentions & Keywords."
after={
<Switch
variant="Primary"
value={autoJoinSpaceRooms}
onChange={setAutoJoinSpaceRooms}
/>
}
/>
</SequenceCard>
</Box>
);
}
function SelectMessageLayout() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
@@ -1207,6 +1110,7 @@ export function General({ requestClose }: GeneralProps) {
<Appearance />
<DateAndTime />
<Editor />
<Spaces />
<Messages />
</Box>
</PageContent>