feat(call): add sound effects and incoming call notification UI
- Implemented sound management for call events including mute, unmute, deafen, undeafen, call joined, call depart, and dialing sounds. - Created a new CallSounds class to handle sound playback and state management. - Added IncomingCallNotification component to display incoming call alerts with caller information and action buttons. - Styled the incoming call notification using vanilla-extract CSS for animations and layout. - Integrated sound playback for incoming calls with a 15-second threshold for dialing sound.
This commit is contained in:
BIN
public/sound/Call_Depart.ogg
Normal file
BIN
public/sound/Call_Depart.ogg
Normal file
Binary file not shown.
BIN
public/sound/Call_Dialing.ogg
Normal file
BIN
public/sound/Call_Dialing.ogg
Normal file
Binary file not shown.
BIN
public/sound/Call_Joined.ogg
Normal file
BIN
public/sound/Call_Joined.ogg
Normal file
Binary file not shown.
BIN
public/sound/Mic_Mute.ogg
Normal file
BIN
public/sound/Mic_Mute.ogg
Normal file
Binary file not shown.
BIN
public/sound/Mic_Unmuted.ogg
Normal file
BIN
public/sound/Mic_Unmuted.ogg
Normal file
Binary file not shown.
BIN
public/sound/Speakers_Deafen.ogg
Normal file
BIN
public/sound/Speakers_Deafen.ogg
Normal file
Binary file not shown.
BIN
public/sound/Speakers_Undeafen.ogg
Normal file
BIN
public/sound/Speakers_Undeafen.ogg
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ export const CallOverlayContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'fixed',
|
||||
bottom: config.space.S400,
|
||||
top: '60px',
|
||||
right: config.space.S400,
|
||||
zIndex: 9999,
|
||||
backgroundColor: color.Surface.Container,
|
||||
@@ -16,6 +16,7 @@ export const CallOverlayContainer = style([
|
||||
maxWidth: '400px',
|
||||
transition: 'box-shadow 0.2s ease',
|
||||
userSelect: 'none',
|
||||
color: color.Surface.OnContainer,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -44,6 +45,7 @@ export const CallOverlayHeader = style({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: config.space.S200,
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const CallOverlayControls = style({
|
||||
@@ -55,6 +57,7 @@ export const CallOverlayControls = style({
|
||||
|
||||
export const CallDuration = style({
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const VideoContainer = style({
|
||||
@@ -111,21 +114,6 @@ export const ScreenShareContainer = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const ScreenShareLabel = style({
|
||||
position: 'absolute',
|
||||
top: config.space.S100,
|
||||
left: config.space.S100,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: 'white',
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const ScreenShareVideoContainer = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -140,3 +128,89 @@ export const ScreenShareVideo = style({
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
export const ParticipantsSection = style({
|
||||
marginBottom: config.space.S200,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const ParticipantsHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: color.Surface.OnContainer,
|
||||
transition: 'background-color 0.15s ease',
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
});
|
||||
|
||||
export const ParticipantsHeaderText = style({
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const ParticipantsList = style({
|
||||
padding: `0 ${config.space.S200} ${config.space.S200}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
});
|
||||
|
||||
export const ParticipantItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Surface.Container,
|
||||
transition: 'background-color 0.15s ease',
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
});
|
||||
|
||||
export const ParticipantName = style({
|
||||
color: color.Surface.OnContainer,
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const ParticipantId = style({
|
||||
color: color.Secondary.Main,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
export const YouBadge = style({
|
||||
color: color.Primary.Main,
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const ParticipantMutedIcon = style({
|
||||
color: color.Critical.Main,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const ParticipantAvatarWrapper = style({
|
||||
position: 'relative',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
transition: 'box-shadow 0.15s ease',
|
||||
});
|
||||
|
||||
export const ParticipantAvatar = style({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
|
||||
export const ParticipantSpeaking = style({
|
||||
boxShadow: `0 0 0 2px ${color.Success.Main}`,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds';
|
||||
import { useCall } from './useCall';
|
||||
import { CallState, CallType, ScreenShareTrackInfo } from './types';
|
||||
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 * as css from './CallOverlay.css';
|
||||
|
||||
/** Drag position state */
|
||||
@@ -10,9 +18,9 @@ interface DragPosition {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Custom hook for draggable functionality */
|
||||
function useDraggable(initialPosition: DragPosition = { x: 0, y: 0 }) {
|
||||
const [position, setPosition] = useState<DragPosition>(initialPosition);
|
||||
/** Custom hook for draggable functionality with viewport constraints */
|
||||
function useDraggable() {
|
||||
const [position, setPosition] = useState<DragPosition>({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
|
||||
@@ -29,17 +37,92 @@ function useDraggable(initialPosition: DragPosition = { x: 0, y: 0 }) {
|
||||
};
|
||||
}, [position]);
|
||||
|
||||
// Clamp position to viewport bounds
|
||||
const clampToViewport = useCallback(() => {
|
||||
if (!dragRef.current) return;
|
||||
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;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
// Listen for window resize to keep element in bounds
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
clampToViewport();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [clampToViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return undefined;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
if (!dragStartRef.current || !dragRef.current) return;
|
||||
const deltaX = e.clientX - dragStartRef.current.startX;
|
||||
const deltaY = e.clientY - dragStartRef.current.startY;
|
||||
setPosition({
|
||||
x: dragStartRef.current.x + deltaX,
|
||||
y: dragStartRef.current.y + deltaY,
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (newRight > window.innerWidth - padding) {
|
||||
newX -= newRight - window.innerWidth + padding;
|
||||
}
|
||||
if (newTop < padding) {
|
||||
newY += padding - newTop;
|
||||
}
|
||||
if (newBottom > window.innerHeight - padding) {
|
||||
newY -= newBottom - window.innerHeight + padding;
|
||||
}
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -54,7 +137,7 @@ function useDraggable(initialPosition: DragPosition = { x: 0, y: 0 }) {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
}, [isDragging, position]);
|
||||
|
||||
return { position, isDragging, handleMouseDown, dragRef };
|
||||
}
|
||||
@@ -119,30 +202,178 @@ function formatDuration(seconds: number): string {
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Screen share entry type */
|
||||
interface ScreenShareEntry {
|
||||
id: string;
|
||||
isLocal: boolean;
|
||||
participantId: string;
|
||||
livekitId?: string;
|
||||
track?: unknown;
|
||||
stream?: MediaStream;
|
||||
}
|
||||
|
||||
/** Props for ScreenShareItem component */
|
||||
interface ScreenShareItemProps {
|
||||
share: ScreenShareEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual screen share display component
|
||||
* Handles attaching the video stream/track properly
|
||||
*/
|
||||
function ScreenShareItem({ share }: ScreenShareItemProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
// Handle local screenshare (MediaStream)
|
||||
if (share.isLocal && share.stream) {
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.className = css.ScreenShareVideo;
|
||||
videoEl.autoplay = true;
|
||||
videoEl.playsInline = true;
|
||||
videoEl.muted = true;
|
||||
videoEl.srcObject = share.stream;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(videoEl);
|
||||
|
||||
return () => {
|
||||
videoEl.srcObject = null;
|
||||
if (videoEl.parentNode) {
|
||||
videoEl.parentNode.removeChild(videoEl);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle remote screenshare (LiveKit track)
|
||||
if (!share.isLocal && share.track) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const track = share.track as any;
|
||||
|
||||
if (typeof track.attach === 'function') {
|
||||
const videoEl = track.attach() as HTMLVideoElement;
|
||||
videoEl.className = css.ScreenShareVideo;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(videoEl);
|
||||
|
||||
return () => {
|
||||
if (typeof track.detach === 'function') {
|
||||
track.detach(videoEl);
|
||||
}
|
||||
if (videoEl.parentNode) {
|
||||
videoEl.parentNode.removeChild(videoEl);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [share.isLocal, share.stream, share.track]);
|
||||
|
||||
/**
|
||||
* Handles double-click to toggle fullscreen on the screenshare video element
|
||||
*/
|
||||
const handleDoubleClick = useCallback(async () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Find the video element inside the container
|
||||
const videoEl = container.querySelector('video');
|
||||
if (!videoEl) return;
|
||||
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await videoEl.requestFullscreen();
|
||||
}
|
||||
} catch (err) {
|
||||
// Fullscreen may not be available
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={css.ScreenShareContainer}>
|
||||
{/* 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}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays active call controls and video streams
|
||||
*/
|
||||
export function CallOverlay() {
|
||||
const { activeCall, endCall, toggleMute, toggleVideo, toggleScreenShare } = useCall();
|
||||
const { activeCall, endCall, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare } = useCall();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false);
|
||||
const [screenShareTrack, setScreenShareTrack] = useState<ScreenShareTrackInfo | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isPip, setIsPip] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(true);
|
||||
const [collapsedScreenShares, setCollapsedScreenShares] = useState<Set<string>>(new Set());
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { position, isDragging, handleMouseDown } = useDraggable();
|
||||
const { position, isDragging, handleMouseDown, dragRef } = useDraggable();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
// Get call members from Matrix state events (provides real Matrix user IDs)
|
||||
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
|
||||
// Update screen share track state when activeCall changes
|
||||
useEffect(() => {
|
||||
if (activeCall?.remoteScreenTracks && activeCall.remoteScreenTracks.size > 0) {
|
||||
const [firstTrackInfo] = activeCall.remoteScreenTracks.values();
|
||||
setScreenShareTrack(firstTrackInfo || null);
|
||||
} else {
|
||||
setScreenShareTrack(null);
|
||||
// Memoize the screenshare list to prevent re-renders causing flickering
|
||||
const allScreenShares = useMemo((): ScreenShareEntry[] => {
|
||||
const shares: ScreenShareEntry[] = [];
|
||||
|
||||
// Add local screenshare first if active
|
||||
if (activeCall?.isScreenSharing && activeCall?.localScreenStream) {
|
||||
shares.push({
|
||||
id: 'local',
|
||||
isLocal: true,
|
||||
participantId: myUserId,
|
||||
stream: activeCall.localScreenStream,
|
||||
});
|
||||
}
|
||||
}, [activeCall]);
|
||||
|
||||
// Add remote screenshares
|
||||
const remoteScreenTracks = activeCall?.remoteScreenTracks
|
||||
? Array.from(activeCall.remoteScreenTracks.values())
|
||||
: [];
|
||||
|
||||
remoteScreenTracks.forEach((trackInfo) => {
|
||||
// Try to extract deviceId from LiveKit identity (format: "deviceId_timestamp")
|
||||
const livekitId = trackInfo.participantId;
|
||||
const underscoreIndex = livekitId.lastIndexOf('_');
|
||||
const possibleDeviceId = underscoreIndex > 0 ? livekitId.substring(0, underscoreIndex) : livekitId;
|
||||
|
||||
// Look up Matrix user ID by matching device ID from call members
|
||||
const matchingMember = callMembers.find((m) => m.deviceId === possibleDeviceId);
|
||||
const matrixUserId = matchingMember?.userId ?? livekitId;
|
||||
|
||||
shares.push({
|
||||
id: livekitId,
|
||||
isLocal: false,
|
||||
participantId: matrixUserId,
|
||||
livekitId,
|
||||
track: trackInfo.track,
|
||||
stream: trackInfo.stream,
|
||||
});
|
||||
});
|
||||
|
||||
return shares;
|
||||
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, callMembers, myUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCall || activeCall.state !== CallState.Connected) {
|
||||
@@ -177,46 +408,6 @@ export function CallOverlay() {
|
||||
}
|
||||
}, [activeCall?.remoteStreams]);
|
||||
|
||||
// Attach screen share track to container using LiveKit's recommended approach
|
||||
useEffect(() => {
|
||||
const containerEl = document.getElementById('screen-share-container');
|
||||
|
||||
if (screenShareTrack?.track && containerEl) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const track = screenShareTrack.track as any;
|
||||
|
||||
if (typeof track.attach === 'function') {
|
||||
// Let LiveKit create and manage the video element
|
||||
const videoEl = track.attach() as HTMLVideoElement;
|
||||
videoEl.className = css.ScreenShareVideo;
|
||||
|
||||
// Clear any existing content
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.appendChild(videoEl);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('📺 Attached screen share - LiveKit created video element');
|
||||
|
||||
// Cleanup: detach when effect re-runs or unmounts
|
||||
return () => {
|
||||
if (typeof track.detach === 'function') {
|
||||
track.detach(videoEl);
|
||||
}
|
||||
if (videoEl.parentNode) {
|
||||
videoEl.parentNode.removeChild(videoEl);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No track, clear container
|
||||
if (containerEl && !screenShareTrack?.track) {
|
||||
containerEl.innerHTML = '';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [screenShareTrack]);
|
||||
|
||||
// Fullscreen toggle handler
|
||||
const handleToggleFullscreen = useCallback(async () => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -254,10 +445,11 @@ export function CallOverlay() {
|
||||
}
|
||||
}, [isPip]);
|
||||
|
||||
// Listen for fullscreen change events
|
||||
// Listen for fullscreen change events (only for the call overlay itself, not screenshares)
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
// Only set fullscreen state if the call overlay container is fullscreened
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
@@ -280,15 +472,90 @@ export function CallOverlay() {
|
||||
};
|
||||
}, [activeCall]);
|
||||
|
||||
// Assign both refs to the container - must be before early return
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
(dragRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}, [dragRef]);
|
||||
|
||||
if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isConnecting = activeCall.state === CallState.Connecting;
|
||||
const isVideoCall = activeCall.callType === CallType.Video;
|
||||
|
||||
// Check if anyone is sharing their screen
|
||||
const hasRemoteScreenShare = screenShareTrack !== null;
|
||||
|
||||
// Get room for participant info
|
||||
const room = mx.getRoom(activeCall.roomId);
|
||||
|
||||
// Build participant list from Matrix state events (provides real user IDs)
|
||||
// callMembers has the actual Matrix user IDs from org.matrix.msc3401.call.member events
|
||||
const allParticipants = callMembers.map((m) => m.userId);
|
||||
const participantCount = allParticipants.length;
|
||||
|
||||
/**
|
||||
* Gets avatar URL for a participant
|
||||
*/
|
||||
const getParticipantAvatar = (participantId: string): string | undefined => {
|
||||
if (!room) return undefined;
|
||||
const mxcUrl = getMemberAvatarMxc(room, participantId);
|
||||
if (!mxcUrl) return undefined;
|
||||
return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 48, 48, 'crop') ?? undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets display name for a participant
|
||||
*/
|
||||
const getParticipantName = (participantId: string): string => {
|
||||
if (!room) return getMxIdLocalPart(participantId) ?? participantId;
|
||||
return getMemberDisplayName(room, participantId) ?? getMxIdLocalPart(participantId) ?? participantId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a participant is currently speaking
|
||||
* Handles mapping between Matrix user IDs and LiveKit identities
|
||||
*/
|
||||
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)
|
||||
// We need to find the deviceId for this user and check if any matching identity is speaking
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a participant is currently muted
|
||||
* Handles mapping between Matrix user IDs and LiveKit identities
|
||||
*/
|
||||
const isParticipantMuted = (userId: string): boolean => {
|
||||
if (!activeCall) return false;
|
||||
|
||||
// For local user, check activeCall.isMuted
|
||||
if (userId === myUserId) return activeCall.isMuted;
|
||||
|
||||
// For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp)
|
||||
// We need to find the deviceId for this user and check if any matching identity is muted
|
||||
const member = callMembers.find((m) => m.userId === userId);
|
||||
if (!member) return false;
|
||||
|
||||
// Check if any muted participant has this user's deviceId
|
||||
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();
|
||||
@@ -298,6 +565,10 @@ export function CallOverlay() {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const handleToggleDeafen = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const handleToggleVideo = () => {
|
||||
toggleVideo();
|
||||
};
|
||||
@@ -314,7 +585,7 @@ export function CallOverlay() {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
ref={setContainerRef}
|
||||
className={`${css.CallOverlayContainer} ${isFullscreen ? css.CallOverlayFullscreen : ''} ${isDragging ? css.CallOverlayDragging : ''}`}
|
||||
style={!isFullscreen ? {
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
@@ -326,14 +597,36 @@ export function CallOverlay() {
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
|
||||
<Icon
|
||||
size="200"
|
||||
src={isVideoCall ? Icons.VideoCamera : Icons.Phone}
|
||||
/>
|
||||
<Text size="T300">
|
||||
{isConnecting ? 'Connecting...' : 'In Call'}
|
||||
<Text size="T300" truncate>
|
||||
{isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`}
|
||||
{selectedRoomId === activeCall.roomId && (
|
||||
<span style={{ opacity: 0.7, fontSize: '0.85em' }}> (Current Room)</span>
|
||||
)}
|
||||
</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"
|
||||
style={{ padding: '2px', marginLeft: '4px' }}
|
||||
>
|
||||
<Icon size="100" src={Icons.Link} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Text size="T200" className={css.CallDuration}>
|
||||
@@ -372,17 +665,109 @@ export function CallOverlay() {
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Remote Screen Share Display */}
|
||||
{hasRemoteScreenShare && screenShareTrack && (
|
||||
<div className={css.ScreenShareContainer}>
|
||||
<div className={css.ScreenShareLabel}>
|
||||
<ScreenShareIcon />
|
||||
<span>{screenShareTrack.participantId}</span>
|
||||
{/* Participants Section */}
|
||||
<div className={css.ParticipantsSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ParticipantsHeader}
|
||||
onClick={() => setShowParticipants(!showParticipants)}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="100" src={Icons.User} />
|
||||
<Text size="T200" className={css.ParticipantsHeaderText}>
|
||||
{participantCount} {participantCount === 1 ? 'Participant' : 'Participants'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Icon
|
||||
size="100"
|
||||
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||
/>
|
||||
</button>
|
||||
{showParticipants && (
|
||||
<div className={css.ParticipantsList}>
|
||||
{allParticipants.map((participantId) => {
|
||||
const isMe = participantId === myUserId;
|
||||
const displayName = getParticipantName(participantId);
|
||||
const avatarUrl = getParticipantAvatar(participantId);
|
||||
const isSpeaking = isParticipantSpeaking(participantId);
|
||||
const isMuted = isParticipantMuted(participantId);
|
||||
// Get screenshares for this participant
|
||||
const participantScreenShares = allScreenShares.filter(
|
||||
(share) => share.participantId === 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" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<Text size="T200" className={css.ParticipantName} truncate>
|
||||
{displayName}
|
||||
{isMe && <span className={css.YouBadge}> (You)</span>}
|
||||
</Text>
|
||||
<Text size="T200" className={css.ParticipantId} truncate>
|
||||
{participantId}
|
||||
</Text>
|
||||
</Box>
|
||||
{isMuted && (
|
||||
<Icon size="100" src={Icons.MicMute} className={css.ParticipantMutedIcon} />
|
||||
)}
|
||||
{participantScreenShares.length > 0 && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>{collapsedScreenShares.has(participantScreenShares[0].id) ? 'Show Screen' : 'Hide Screen'}</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={() => {
|
||||
setCollapsedScreenShares((prev) => {
|
||||
const next = new Set(prev);
|
||||
// Toggle all screenshares for this participant
|
||||
participantScreenShares.forEach((share) => {
|
||||
if (next.has(share.id)) {
|
||||
next.delete(share.id);
|
||||
} else {
|
||||
next.add(share.id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
style={{ padding: '2px' }}
|
||||
>
|
||||
<ScreenShareIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* LiveKit will create and manage the video element inside this container */}
|
||||
<div id="screen-share-container" className={css.ScreenShareVideoContainer} />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screen shares rendered outside participant list */}
|
||||
{allScreenShares
|
||||
.filter((share) => !collapsedScreenShares.has(share.id))
|
||||
.map((share) => (
|
||||
<ScreenShareItem key={share.id} share={share} />
|
||||
))}
|
||||
|
||||
{isVideoCall && (
|
||||
<div className={css.VideoContainer}>
|
||||
@@ -432,6 +817,29 @@ export function CallOverlay() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{isVideoCall && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { CallProvider, useCallService } from './useCall';
|
||||
import { CallOverlay } from './CallOverlay';
|
||||
import { IncomingCallNotification } from './IncomingCallNotification';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
|
||||
interface CallProviderWrapperProps {
|
||||
@@ -9,7 +10,7 @@ interface CallProviderWrapperProps {
|
||||
|
||||
/**
|
||||
* Wrapper component that initializes the call service and provides it to children
|
||||
* Also renders the CallOverlay for active call controls
|
||||
* Also renders the CallOverlay for active call controls and monitors incoming calls
|
||||
*/
|
||||
export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
|
||||
const clientConfig = useClientConfig();
|
||||
@@ -24,6 +25,7 @@ export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
|
||||
<CallProvider value={callContextValue}>
|
||||
{children}
|
||||
<CallOverlay />
|
||||
<IncomingCallNotification />
|
||||
</CallProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './types';
|
||||
import { fetchLiveKitJWT, fetchWellKnownWithRTC, getLiveKitFocus, requestOpenIdToken } from './useRtcConfig';
|
||||
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
||||
import { getCallSounds, CallSoundType } from './CallSounds';
|
||||
|
||||
type CallEventListener = (event: CallEvent) => void;
|
||||
|
||||
@@ -190,11 +191,17 @@ export class CallService {
|
||||
remoteScreenStreams: new Map(),
|
||||
remoteScreenTracks: new Map(),
|
||||
participants: [],
|
||||
activeSpeakers: new Set(),
|
||||
mutedParticipants: new Set(),
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isVideoEnabled: callType === CallType.Video,
|
||||
isScreenSharing: false,
|
||||
};
|
||||
|
||||
// Play connecting/dialing sound
|
||||
getCallSounds().playSound(CallSoundType.CallDialing, true);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
roomId,
|
||||
@@ -262,10 +269,15 @@ export class CallService {
|
||||
}
|
||||
|
||||
// Step 5: Send Matrix state event to announce participation
|
||||
// This state event will be rendered in the timeline as a "joined the call" notification
|
||||
await this.sendCallMemberEvent(roomId, callId, true);
|
||||
|
||||
console.log('Announced call participation to Matrix room');
|
||||
|
||||
// Stop dialing sound and play join sound
|
||||
getCallSounds().stopDialingLoop();
|
||||
getCallSounds().playSound(CallSoundType.CallJoined);
|
||||
|
||||
this.setState(CallState.Connected);
|
||||
} catch (error) {
|
||||
console.error('Failed to start call:', error);
|
||||
@@ -339,6 +351,12 @@ export class CallService {
|
||||
|
||||
document.body.appendChild(audioElement);
|
||||
console.log('Audio element attached for', participant.identity);
|
||||
|
||||
// If we're currently deafened, mute this new track so we don't hear them
|
||||
if (this.activeCall?.isDeafened) {
|
||||
track.setMuted(true);
|
||||
console.log('Auto-muted new audio track from', participant.identity, 'due to deafen state');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle screen share video tracks separately
|
||||
@@ -454,6 +472,10 @@ export class CallService {
|
||||
|
||||
if (this.activeCall && !this.activeCall.participants.includes(participant.identity)) {
|
||||
this.activeCall.participants.push(participant.identity);
|
||||
|
||||
// Play join sound for remote participant
|
||||
getCallSounds().playSound(CallSoundType.CallJoined);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.ParticipantJoined,
|
||||
roomId: this.activeCall.roomId,
|
||||
@@ -471,6 +493,9 @@ export class CallService {
|
||||
);
|
||||
this.activeCall.remoteStreams.delete(participant.identity);
|
||||
|
||||
// Play leave sound for remote participant
|
||||
getCallSounds().playSound(CallSoundType.CallDepart);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.ParticipantLeft,
|
||||
roomId: this.activeCall.roomId,
|
||||
@@ -488,21 +513,125 @@ export class CallService {
|
||||
}
|
||||
});
|
||||
|
||||
// Track mute/unmute events for remote participants
|
||||
this.livekitRoom.on(RoomEvent.TrackMuted, (publication, participant) => {
|
||||
// Only emit for remote participants and audio tracks
|
||||
if (!this.activeCall) return;
|
||||
if (participant.identity === this.livekitRoom?.localParticipant.identity) return;
|
||||
if (publication.kind !== 'audio') return;
|
||||
|
||||
console.log(`🔇 ${participant.identity} muted their microphone`);
|
||||
|
||||
// Track muted state
|
||||
this.activeCall.mutedParticipants.add(participant.identity);
|
||||
|
||||
// Play mute sound for remote participant
|
||||
getCallSounds().playSound(CallSoundType.MicMute);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.RemoteMuteChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { participantId: participant.identity, isMuted: true },
|
||||
});
|
||||
});
|
||||
|
||||
this.livekitRoom.on(RoomEvent.TrackUnmuted, (publication, participant) => {
|
||||
// Only emit for remote participants and audio tracks
|
||||
if (!this.activeCall) return;
|
||||
if (participant.identity === this.livekitRoom?.localParticipant.identity) return;
|
||||
if (publication.kind !== 'audio') return;
|
||||
|
||||
console.log(`🔊 ${participant.identity} unmuted their microphone`);
|
||||
|
||||
// Track unmuted state
|
||||
this.activeCall.mutedParticipants.delete(participant.identity);
|
||||
|
||||
// Play unmute sound for remote participant
|
||||
getCallSounds().playSound(CallSoundType.MicUnmute);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.RemoteMuteChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { participantId: participant.identity, isMuted: false },
|
||||
});
|
||||
});
|
||||
|
||||
// Voice activity detection - logs when speakers change
|
||||
// Note: This event tracks remote speakers. Local speaker is tracked separately.
|
||||
// We preserve the existing activeSpeakers that may include local user
|
||||
this.livekitRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
|
||||
if (!this.activeCall) return;
|
||||
|
||||
// Get remote speaker identities
|
||||
const remoteSpeakerIds = speakers
|
||||
.filter((s) => s.identity !== this.livekitRoom?.localParticipant.identity)
|
||||
.map((s) => s.identity);
|
||||
|
||||
// Check if local participant is speaking (from LiveKit's list)
|
||||
const localSpeaking = speakers.some(
|
||||
(s) => s.identity === this.livekitRoom?.localParticipant.identity
|
||||
);
|
||||
|
||||
// Build new activeSpeakers set - use Matrix user ID for local
|
||||
const newSpeakers = new Set<string>(remoteSpeakerIds);
|
||||
if (localSpeaking) {
|
||||
newSpeakers.add(this.config.userId);
|
||||
}
|
||||
|
||||
this.activeCall.activeSpeakers = newSpeakers;
|
||||
|
||||
if (speakers.length > 0) {
|
||||
const speakerNames = speakers.map((s) => s.identity).join(', ');
|
||||
console.log('🎤 Active speakers:', speakerNames);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.SpeakersChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { speakers: newSpeakers },
|
||||
});
|
||||
});
|
||||
|
||||
// Track local participant speaking state
|
||||
// Listen for participant metadata changes (for deafen state)
|
||||
this.livekitRoom.on(RoomEvent.ParticipantMetadataChanged, (prevMetadata: string | undefined, participant: Participant) => {
|
||||
if (!this.activeCall) return;
|
||||
if (participant.identity === this.livekitRoom?.localParticipant.identity) return;
|
||||
|
||||
try {
|
||||
const prevData = prevMetadata ? JSON.parse(prevMetadata) : {};
|
||||
const newData = participant.metadata ? JSON.parse(participant.metadata) : {};
|
||||
|
||||
const wasDeafened = prevData.isDeafened === true;
|
||||
const isDeafened = newData.isDeafened === true;
|
||||
|
||||
if (wasDeafened !== isDeafened) {
|
||||
console.log(`🎧 ${participant.identity} ${isDeafened ? 'deafened' : 'undeafened'}`);
|
||||
getCallSounds().playSound(isDeafened ? CallSoundType.Deafen : CallSoundType.Undeafen);
|
||||
}
|
||||
} catch {
|
||||
// Ignore metadata parse errors
|
||||
}
|
||||
});
|
||||
|
||||
// Track local participant speaking state - use Matrix user ID for consistency
|
||||
this.livekitRoom.localParticipant.on(ParticipantEvent.IsSpeakingChanged, (speaking: boolean) => {
|
||||
if (!this.activeCall) return;
|
||||
|
||||
const myUserId = this.config.userId;
|
||||
|
||||
if (speaking) {
|
||||
console.log('🎙️ You are speaking');
|
||||
this.activeCall.activeSpeakers.add(myUserId);
|
||||
} else {
|
||||
console.log('🔇 You stopped speaking');
|
||||
this.activeCall.activeSpeakers.delete(myUserId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.SpeakersChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { speakers: this.activeCall.activeSpeakers },
|
||||
});
|
||||
});
|
||||
|
||||
// Track audio level changes for local participant (more detailed)
|
||||
@@ -522,8 +651,13 @@ export class CallService {
|
||||
const { roomId } = this.activeCall;
|
||||
this.setState(CallState.Disconnecting);
|
||||
|
||||
// Stop any dialing loop and play departure sound
|
||||
getCallSounds().stopDialingLoop();
|
||||
getCallSounds().playSound(CallSoundType.CallDepart);
|
||||
|
||||
try {
|
||||
// Clear Matrix call membership
|
||||
// Clear Matrix call membership - this state event will be rendered
|
||||
// in the timeline as a "left the call" notification
|
||||
await this.sendCallMemberEvent(roomId, '', false);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear call membership:', error);
|
||||
@@ -550,22 +684,97 @@ export class CallService {
|
||||
|
||||
/**
|
||||
* Toggles audio mute state
|
||||
* Unmuting will also undeafen if currently deafened
|
||||
*/
|
||||
toggleMute(): boolean {
|
||||
if (!this.livekitRoom?.localParticipant) {
|
||||
if (!this.livekitRoom?.localParticipant || !this.activeCall) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newMuteState = !this.activeCall?.isMuted;
|
||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
|
||||
|
||||
if (this.activeCall) {
|
||||
this.activeCall.isMuted = newMuteState;
|
||||
const newMuteState = !this.activeCall.isMuted;
|
||||
|
||||
// If unmuting while deafened, undeafen first
|
||||
if (!newMuteState && this.activeCall.isDeafened) {
|
||||
this.activeCall.isDeafened = false;
|
||||
getCallSounds().setDeafened(false);
|
||||
|
||||
// Unmute all remote audio tracks
|
||||
this.livekitRoom.remoteParticipants.forEach((participant) => {
|
||||
participant.audioTrackPublications.forEach((publication) => {
|
||||
if (publication.track) {
|
||||
publication.track.setMuted(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast undeafen state
|
||||
const metadata = JSON.stringify({ isDeafened: false });
|
||||
this.livekitRoom.localParticipant.setMetadata(metadata).catch((err) => {
|
||||
console.warn('Failed to set participant metadata:', err);
|
||||
});
|
||||
}
|
||||
|
||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
|
||||
this.activeCall.isMuted = newMuteState;
|
||||
|
||||
// Play mute/unmute sound
|
||||
getCallSounds().playSound(
|
||||
newMuteState ? CallSoundType.MicMute : CallSoundType.MicUnmute
|
||||
);
|
||||
|
||||
return newMuteState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles deafen state (mutes all incoming audio and your mic)
|
||||
* Also broadcasts deafen state to other participants via metadata
|
||||
*/
|
||||
toggleDeafen(): boolean {
|
||||
if (!this.livekitRoom || !this.activeCall) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newDeafenState = !this.activeCall.isDeafened;
|
||||
this.activeCall.isDeafened = newDeafenState;
|
||||
|
||||
// Update CallSounds deafen state
|
||||
getCallSounds().setDeafened(newDeafenState);
|
||||
|
||||
// Mute/unmute all remote audio tracks (so we don't hear them)
|
||||
this.livekitRoom.remoteParticipants.forEach((participant) => {
|
||||
participant.audioTrackPublications.forEach((publication) => {
|
||||
if (publication.track) {
|
||||
publication.track.setMuted(newDeafenState);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Also mute/unmute our mic when deafening (silently - no mute sound)
|
||||
// Only change mic state if it doesn't match deafen state
|
||||
if (newDeafenState && !this.activeCall.isMuted) {
|
||||
// Deafening - also mute mic
|
||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
|
||||
this.activeCall.isMuted = true;
|
||||
} else if (!newDeafenState && this.activeCall.isMuted) {
|
||||
// Undeafening - also unmute mic
|
||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
|
||||
this.activeCall.isMuted = false;
|
||||
}
|
||||
|
||||
// Broadcast deafen state to other participants via metadata
|
||||
const metadata = JSON.stringify({ isDeafened: newDeafenState });
|
||||
this.livekitRoom.localParticipant.setMetadata(metadata).catch((err) => {
|
||||
console.warn('Failed to set participant metadata:', err);
|
||||
});
|
||||
|
||||
// Play deafen/undeafen sound (these always play for user feedback)
|
||||
getCallSounds().playSound(
|
||||
newDeafenState ? CallSoundType.Deafen : CallSoundType.Undeafen
|
||||
);
|
||||
|
||||
return newDeafenState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles video enabled state
|
||||
*/
|
||||
@@ -658,7 +867,9 @@ export class CallService {
|
||||
|
||||
if (screenSharePub?.track) {
|
||||
console.log(`Screen share started with bitrate: ${SCREEN_SHARE_BITRATES[audioSettings.screenShareBitrate].bitrate}kbps`);
|
||||
// LiveKit handles bitrate via simulcast and adaptive streaming
|
||||
// Capture local screen stream for preview
|
||||
const mediaStream = new MediaStream([screenSharePub.track.mediaStreamTrack]);
|
||||
this.activeCall.localScreenStream = mediaStream;
|
||||
}
|
||||
|
||||
this.activeCall.isScreenSharing = true;
|
||||
@@ -693,6 +904,7 @@ export class CallService {
|
||||
try {
|
||||
await this.livekitRoom.localParticipant.setScreenShareEnabled(false);
|
||||
this.activeCall.isScreenSharing = false;
|
||||
this.activeCall.localScreenStream = undefined;
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
|
||||
157
src/app/features/call/CallSounds.ts
Normal file
157
src/app/features/call/CallSounds.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/* eslint-disable no-console */
|
||||
import MicMuteSound from '../../../../public/sound/Mic_Mute.ogg';
|
||||
import MicUnmuteSound from '../../../../public/sound/Mic_Unmuted.ogg';
|
||||
import SpeakersDeafenSound from '../../../../public/sound/Speakers_Deafen.ogg';
|
||||
import SpeakersUndeafenSound from '../../../../public/sound/Speakers_Undeafen.ogg';
|
||||
import CallJoinedSound from '../../../../public/sound/Call_Joined.ogg';
|
||||
import CallDepartSound from '../../../../public/sound/Call_Depart.ogg';
|
||||
import CallDialingSound from '../../../../public/sound/Call_Dialing.ogg';
|
||||
|
||||
/**
|
||||
* Sound types that can be played during a call
|
||||
*/
|
||||
export enum CallSoundType {
|
||||
MicMute = 'mic_mute',
|
||||
MicUnmute = 'mic_unmute',
|
||||
Deafen = 'deafen',
|
||||
Undeafen = 'undeafen',
|
||||
CallJoined = 'call_joined',
|
||||
CallDepart = 'call_depart',
|
||||
CallDialing = 'call_dialing',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps sound types to their audio sources
|
||||
*/
|
||||
const SOUND_SOURCES: Record<CallSoundType, string> = {
|
||||
[CallSoundType.MicMute]: MicMuteSound,
|
||||
[CallSoundType.MicUnmute]: MicUnmuteSound,
|
||||
[CallSoundType.Deafen]: SpeakersDeafenSound,
|
||||
[CallSoundType.Undeafen]: SpeakersUndeafenSound,
|
||||
[CallSoundType.CallJoined]: CallJoinedSound,
|
||||
[CallSoundType.CallDepart]: CallDepartSound,
|
||||
[CallSoundType.CallDialing]: CallDialingSound,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages call-related sound effects.
|
||||
* Handles playing local sounds and coordinating with remote participants.
|
||||
*/
|
||||
export class CallSounds {
|
||||
private audioElements: Map<CallSoundType, HTMLAudioElement> = new Map();
|
||||
|
||||
private dialingLoopElement: HTMLAudioElement | null = null;
|
||||
|
||||
private isDeafened = false;
|
||||
|
||||
constructor() {
|
||||
this.preloadSounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads all call sounds for instant playback
|
||||
*/
|
||||
private preloadSounds(): void {
|
||||
Object.entries(SOUND_SOURCES).forEach(([type, src]) => {
|
||||
const audio = new Audio(src);
|
||||
audio.preload = 'auto';
|
||||
this.audioElements.set(type as CallSoundType, audio);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a call sound locally
|
||||
* @param soundType - The type of sound to play
|
||||
* @param loop - Whether to loop the sound (e.g., for dialing)
|
||||
*/
|
||||
playSound(soundType: CallSoundType, loop = false): void {
|
||||
// Don't play sounds if deafened (except deafen/undeafen sounds for feedback)
|
||||
if (this.isDeafened &&
|
||||
soundType !== CallSoundType.Deafen &&
|
||||
soundType !== CallSoundType.Undeafen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = this.audioElements.get(soundType);
|
||||
if (!audio) {
|
||||
console.warn(`Sound not found: ${soundType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any currently playing instance and reset
|
||||
audio.currentTime = 0;
|
||||
audio.loop = loop;
|
||||
|
||||
// Track looping dialing sound separately
|
||||
if (soundType === CallSoundType.CallDialing && loop) {
|
||||
this.dialingLoopElement = audio;
|
||||
}
|
||||
|
||||
audio.play().catch((err) => {
|
||||
console.warn(`Failed to play sound ${soundType}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the dialing loop sound if playing
|
||||
*/
|
||||
stopDialingLoop(): void {
|
||||
if (this.dialingLoopElement) {
|
||||
this.dialingLoopElement.pause();
|
||||
this.dialingLoopElement.currentTime = 0;
|
||||
this.dialingLoopElement.loop = false;
|
||||
this.dialingLoopElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the deafened state - affects whether sounds are played
|
||||
* @param deafened - Whether the user is deafened
|
||||
*/
|
||||
setDeafened(deafened: boolean): void {
|
||||
this.isDeafened = deafened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets whether sounds are currently deafened
|
||||
*/
|
||||
getIsDeafened(): boolean {
|
||||
return this.isDeafened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up audio resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.stopDialingLoop();
|
||||
this.audioElements.forEach((audio) => {
|
||||
audio.pause();
|
||||
});
|
||||
this.audioElements.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of CallSounds for app-wide use
|
||||
*/
|
||||
let callSoundsInstance: CallSounds | null = null;
|
||||
|
||||
/**
|
||||
* Gets or creates the CallSounds singleton instance
|
||||
*/
|
||||
export function getCallSounds(): CallSounds {
|
||||
if (!callSoundsInstance) {
|
||||
callSoundsInstance = new CallSounds();
|
||||
}
|
||||
return callSoundsInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the CallSounds singleton
|
||||
*/
|
||||
export function disposeCallSounds(): void {
|
||||
if (callSoundsInstance) {
|
||||
callSoundsInstance.dispose();
|
||||
callSoundsInstance = null;
|
||||
}
|
||||
}
|
||||
119
src/app/features/call/IncomingCallNotification.css.ts
Normal file
119
src/app/features/call/IncomingCallNotification.css.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { style, keyframes } from '@vanilla-extract/css';
|
||||
import { config, DefaultReset, color } from 'folds';
|
||||
|
||||
const pulseRing = keyframes({
|
||||
'0%': {
|
||||
transform: 'scale(0.95)',
|
||||
opacity: 1,
|
||||
},
|
||||
'50%': {
|
||||
transform: 'scale(1.1)',
|
||||
opacity: 0.5,
|
||||
},
|
||||
'100%': {
|
||||
transform: 'scale(0.95)',
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const slideIn = keyframes({
|
||||
'0%': {
|
||||
transform: 'translateX(100%)',
|
||||
opacity: 0,
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(0)',
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const IncomingCallContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'fixed',
|
||||
top: config.space.S400,
|
||||
right: config.space.S400,
|
||||
zIndex: 10000,
|
||||
animation: `${slideIn} 0.3s ease-out`,
|
||||
},
|
||||
]);
|
||||
|
||||
export const IncomingCallContent = style({
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
padding: config.space.S400,
|
||||
boxShadow: `0 4px 20px ${color.Other.Shadow}`,
|
||||
minWidth: '300px',
|
||||
maxWidth: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const CallerInfo = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S300,
|
||||
});
|
||||
|
||||
export const CallerAvatarWrapper = style({
|
||||
position: 'relative',
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const CallerAvatar = style({
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const CallerAvatarRing = style({
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${color.Success.Main}`,
|
||||
animation: `${pulseRing} 1.5s ease-in-out infinite`,
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
export const CallerName = style({
|
||||
color: color.Surface.OnContainer,
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const OtherCallers = style({
|
||||
color: color.Secondary.Main,
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const RoomName = style({
|
||||
color: color.Secondary.Main,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
});
|
||||
|
||||
export const CallActions = style({
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: config.space.S100,
|
||||
});
|
||||
|
||||
export const DeclineButton = style({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
|
||||
export const AcceptButton = style({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
316
src/app/features/call/IncomingCallNotification.tsx
Normal file
316
src/app/features/call/IncomingCallNotification.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
/* eslint-disable no-console */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Text, color } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCall } from './useCall';
|
||||
import { CallType } from './types';
|
||||
import { getCallSounds, CallSoundType } from './CallSounds';
|
||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import * as css from './IncomingCallNotification.css';
|
||||
|
||||
/** The Matrix state event type for call membership (MSC3401) */
|
||||
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
|
||||
|
||||
/** Represents an incoming call */
|
||||
interface IncomingCall {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
callerIds: string[];
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a room is a DM or small group (not a server/space room)
|
||||
* @param room - The Matrix room to check
|
||||
* @returns true if the room is a DM or small group
|
||||
*/
|
||||
function isDirectOrSmallGroup(room: Room): boolean {
|
||||
if (room.isSpaceRoom()) {
|
||||
return false;
|
||||
}
|
||||
const memberCount = room.getJoinedMemberCount();
|
||||
return memberCount <= 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets active call members from a room who are NOT the current user
|
||||
* Also returns the earliest call start time
|
||||
* @param room - The Matrix room
|
||||
* @param myUserId - The current user's ID
|
||||
* @returns Object with member IDs and earliest call start time
|
||||
*/
|
||||
function getOtherCallMembersWithTime(room: Room, myUserId: string): { members: string[]; earliestTime: number } {
|
||||
const members: string[] = [];
|
||||
const now = Date.now();
|
||||
let earliestTime = now;
|
||||
|
||||
try {
|
||||
const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE);
|
||||
|
||||
stateEvents.forEach((event) => {
|
||||
const content = event.getContent();
|
||||
const userId = event.getStateKey();
|
||||
|
||||
if (!userId || userId === myUserId || !content['m.calls']) return;
|
||||
|
||||
const calls = content['m.calls'];
|
||||
if (!Array.isArray(calls)) return;
|
||||
|
||||
calls.forEach((call) => {
|
||||
const devices = call['m.devices'];
|
||||
if (!Array.isArray(devices)) return;
|
||||
|
||||
devices.forEach((device) => {
|
||||
const expiresTs = device.expires_ts || 0;
|
||||
|
||||
if (expiresTs > now && !members.includes(userId)) {
|
||||
members.push(userId);
|
||||
// Track event origin time to determine call age
|
||||
const eventTime = event.getTs() || now;
|
||||
if (eventTime < earliestTime) {
|
||||
earliestTime = eventTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error reading call member events:', e);
|
||||
}
|
||||
|
||||
return { members, earliestTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for backward compatibility
|
||||
*/
|
||||
function getOtherCallMembers(room: Room, myUserId: string): string[] {
|
||||
return getOtherCallMembersWithTime(room, myUserId).members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that monitors for incoming calls and displays a notification UI
|
||||
* with room info, caller details, and accept/decline buttons.
|
||||
*/
|
||||
export function IncomingCallNotification() {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { activeCall, startCall } = useCall();
|
||||
const [incomingCall, setIncomingCall] = useState<IncomingCall | null>(null);
|
||||
const [declinedRooms, setDeclinedRooms] = useState<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Checks all rooms for incoming calls
|
||||
*/
|
||||
const checkForIncomingCalls = useCallback(() => {
|
||||
const myUserId = mx.getUserId();
|
||||
if (!myUserId) return;
|
||||
|
||||
// If we're already in a call, don't show incoming call notification
|
||||
if (activeCall) {
|
||||
setIncomingCall(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check all joined rooms for calls
|
||||
const rooms = mx.getRooms();
|
||||
|
||||
// Find the first room with an incoming call
|
||||
const incomingRoom = rooms.find((room) => {
|
||||
// Only monitor DMs and small groups
|
||||
if (!isDirectOrSmallGroup(room)) return false;
|
||||
// Skip rooms we've declined
|
||||
if (declinedRooms.has(room.roomId)) return false;
|
||||
const { members } = getOtherCallMembersWithTime(room, myUserId);
|
||||
return members.length > 0;
|
||||
});
|
||||
|
||||
if (incomingRoom) {
|
||||
const { members, earliestTime } = getOtherCallMembersWithTime(incomingRoom, myUserId);
|
||||
setIncomingCall({
|
||||
roomId: incomingRoom.roomId,
|
||||
roomName: incomingRoom.name || 'Unknown Room',
|
||||
callerIds: members,
|
||||
startTime: earliestTime,
|
||||
});
|
||||
} else {
|
||||
setIncomingCall(null);
|
||||
}
|
||||
}, [mx, activeCall, declinedRooms]);
|
||||
|
||||
/**
|
||||
* Handles state events for call member changes
|
||||
*/
|
||||
const handleStateEvent = useCallback((event: MatrixEvent) => {
|
||||
if (event.getType() === CALL_MEMBER_EVENT_TYPE) {
|
||||
// Clear declined status if call ended in that room
|
||||
const roomId = event.getRoomId();
|
||||
if (roomId && declinedRooms.has(roomId)) {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (room) {
|
||||
const myUserId = mx.getUserId();
|
||||
if (myUserId) {
|
||||
const members = getOtherCallMembers(room, myUserId);
|
||||
if (members.length === 0) {
|
||||
// Call ended, remove from declined
|
||||
setDeclinedRooms((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(roomId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checkForIncomingCalls();
|
||||
}
|
||||
}, [checkForIncomingCalls, declinedRooms, mx]);
|
||||
|
||||
// Set up event listeners
|
||||
useEffect(() => {
|
||||
mx.on(RoomStateEvent.Events, handleStateEvent);
|
||||
checkForIncomingCalls();
|
||||
const interval = setInterval(checkForIncomingCalls, 10000);
|
||||
|
||||
return () => {
|
||||
mx.off(RoomStateEvent.Events, handleStateEvent);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [mx, handleStateEvent, checkForIncomingCalls]);
|
||||
|
||||
// Play/stop dialing sound based on incoming call state
|
||||
// Only play sound if call started less than 15 seconds ago
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
const callAge = Date.now() - incomingCall.startTime;
|
||||
const MAX_CALL_AGE_FOR_SOUND = 15000; // 15 seconds
|
||||
|
||||
if (callAge < MAX_CALL_AGE_FOR_SOUND) {
|
||||
getCallSounds().playSound(CallSoundType.CallDialing, true);
|
||||
console.log('🔔 Incoming call from room:', incomingCall.roomName, '(age:', Math.round(callAge / 1000), 's)');
|
||||
} else {
|
||||
console.log('📞 Incoming call too old for sound:', incomingCall.roomName, '(age:', Math.round(callAge / 1000), 's)');
|
||||
getCallSounds().stopDialingLoop();
|
||||
}
|
||||
} else {
|
||||
getCallSounds().stopDialingLoop();
|
||||
}
|
||||
|
||||
return () => {
|
||||
getCallSounds().stopDialingLoop();
|
||||
};
|
||||
}, [incomingCall]);
|
||||
|
||||
/**
|
||||
* Handles accepting the incoming call
|
||||
*/
|
||||
const handleAccept = useCallback(async () => {
|
||||
if (!incomingCall) return;
|
||||
|
||||
getCallSounds().stopDialingLoop();
|
||||
try {
|
||||
await startCall(incomingCall.roomId, CallType.Voice);
|
||||
} catch (error) {
|
||||
console.error('Failed to join call:', error);
|
||||
}
|
||||
}, [incomingCall, startCall]);
|
||||
|
||||
/**
|
||||
* Handles declining the incoming call
|
||||
*/
|
||||
const handleDecline = useCallback(() => {
|
||||
if (!incomingCall) return;
|
||||
|
||||
getCallSounds().stopDialingLoop();
|
||||
setDeclinedRooms((prev) => new Set(prev).add(incomingCall.roomId));
|
||||
setIncomingCall(null);
|
||||
}, [incomingCall]);
|
||||
|
||||
// Don't render if no incoming call
|
||||
if (!incomingCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const room = mx.getRoom(incomingCall.roomId);
|
||||
const firstCaller = incomingCall.callerIds[0];
|
||||
|
||||
/**
|
||||
* Gets the display name for a user
|
||||
*/
|
||||
const getDisplayName = (userId: string): string => {
|
||||
if (!room) return getMxIdLocalPart(userId) ?? userId;
|
||||
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the avatar URL for a user
|
||||
*/
|
||||
const getAvatarUrl = (userId: string): string | undefined => {
|
||||
if (!room) return undefined;
|
||||
const mxcUrl = getMemberAvatarMxc(room, userId);
|
||||
if (!mxcUrl) return undefined;
|
||||
return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 48, 48, 'crop') ?? undefined;
|
||||
};
|
||||
|
||||
const callerName = getDisplayName(firstCaller);
|
||||
const avatarUrl = getAvatarUrl(firstCaller);
|
||||
const otherCallersCount = incomingCall.callerIds.length - 1;
|
||||
|
||||
return (
|
||||
<div className={css.IncomingCallContainer}>
|
||||
<div className={css.IncomingCallContent}>
|
||||
<div className={css.CallerInfo}>
|
||||
<div className={css.CallerAvatarWrapper}>
|
||||
<UserAvatar
|
||||
className={css.CallerAvatar}
|
||||
userId={firstCaller}
|
||||
src={avatarUrl}
|
||||
alt={callerName}
|
||||
renderFallback={() => (
|
||||
<Text size="H5" style={{ color: color.Surface.Container }}>
|
||||
{callerName.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
<div className={css.CallerAvatarRing} />
|
||||
</div>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300" className={css.CallerName}>
|
||||
{callerName}
|
||||
{otherCallersCount > 0 && (
|
||||
<span className={css.OtherCallers}>
|
||||
{` +${otherCallersCount} other${otherCallersCount > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
<Text size="T200" className={css.RoomName}>
|
||||
<Icon size="100" src={Icons.Phone} />
|
||||
<span>Calling from {incomingCall.roomName}</span>
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box alignItems="Center" gap="200" className={css.CallActions}>
|
||||
<IconButton
|
||||
onClick={handleDecline}
|
||||
variant="Critical"
|
||||
className={css.DeclineButton}
|
||||
>
|
||||
<Icon size="300" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleAccept}
|
||||
variant="Success"
|
||||
className={css.AcceptButton}
|
||||
>
|
||||
<Icon size="300" src={Icons.Phone} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
export * from './types';
|
||||
export * from './useRtcConfig';
|
||||
export * from './CallService';
|
||||
export * from './CallSounds';
|
||||
export * from './useCall';
|
||||
export * from './CallProviderWrapper';
|
||||
export * from './CallOverlay';
|
||||
export * from './IncomingCallNotification';
|
||||
export * from './useRoomCallMembers';
|
||||
|
||||
@@ -113,7 +113,10 @@ export interface ActiveCall {
|
||||
remoteScreenStreams: Map<string, MediaStream>;
|
||||
remoteScreenTracks: Map<string, ScreenShareTrackInfo>;
|
||||
participants: string[];
|
||||
activeSpeakers: Set<string>;
|
||||
mutedParticipants: Set<string>;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
isScreenSharing: boolean;
|
||||
}
|
||||
@@ -129,6 +132,8 @@ export enum CallEventType {
|
||||
RemoteStreamRemoved = 'remote_stream_removed',
|
||||
ScreenShareStarted = 'screen_share_started',
|
||||
ScreenShareStopped = 'screen_share_stopped',
|
||||
SpeakersChanged = 'speakers_changed',
|
||||
RemoteMuteChanged = 'remote_mute_changed',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CallContextValue {
|
||||
startCall: (roomId: string, callType: CallType) => Promise<void>;
|
||||
endCall: () => Promise<void>;
|
||||
toggleMute: () => boolean;
|
||||
toggleDeafen: () => boolean;
|
||||
toggleVideo: () => boolean;
|
||||
toggleScreenShare: () => Promise<boolean>;
|
||||
}
|
||||
@@ -113,15 +114,19 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
|
||||
event.type === CallEventType.ScreenShareStarted ||
|
||||
event.type === CallEventType.ScreenShareStopped ||
|
||||
event.type === CallEventType.RemoteStreamAdded ||
|
||||
event.type === CallEventType.RemoteStreamRemoved) {
|
||||
event.type === CallEventType.RemoteStreamRemoved ||
|
||||
event.type === CallEventType.ParticipantJoined ||
|
||||
event.type === CallEventType.ParticipantLeft ||
|
||||
event.type === CallEventType.SpeakersChanged) {
|
||||
const newCall = service?.getActiveCall();
|
||||
if (newCall) {
|
||||
// Deep copy Maps to ensure React detects changes
|
||||
// Deep copy Maps and Sets to ensure React detects changes
|
||||
setActiveCall({
|
||||
...newCall,
|
||||
remoteStreams: new Map(newCall.remoteStreams),
|
||||
remoteScreenTracks: new Map(newCall.remoteScreenTracks),
|
||||
remoteScreenStreams: new Map(newCall.remoteScreenStreams),
|
||||
activeSpeakers: new Set(newCall.activeSpeakers),
|
||||
});
|
||||
} else {
|
||||
setActiveCall(null);
|
||||
@@ -173,6 +178,18 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
|
||||
return newState;
|
||||
}, [callService]);
|
||||
|
||||
const toggleDeafen = useCallback(() => {
|
||||
if (!callService) {
|
||||
return false;
|
||||
}
|
||||
const newState = callService.toggleDeafen();
|
||||
const call = callService.getActiveCall();
|
||||
if (call) {
|
||||
setActiveCall({ ...call });
|
||||
}
|
||||
return newState;
|
||||
}, [callService]);
|
||||
|
||||
const toggleVideo = useCallback(() => {
|
||||
if (!callService) {
|
||||
return false;
|
||||
@@ -206,10 +223,11 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
|
||||
startCall,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
toggleVideo,
|
||||
toggleScreenShare,
|
||||
}),
|
||||
[callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleVideo, toggleScreenShare]
|
||||
[callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -25,20 +26,20 @@ function getActiveCallMembers(room: Room): CallMember[] {
|
||||
try {
|
||||
const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE);
|
||||
|
||||
for (const event of stateEvents) {
|
||||
stateEvents.forEach((event) => {
|
||||
const content = event.getContent();
|
||||
const userId = event.getStateKey();
|
||||
|
||||
if (!userId || !content['m.calls']) continue;
|
||||
if (!userId || !content['m.calls']) return;
|
||||
|
||||
const calls = content['m.calls'];
|
||||
if (!Array.isArray(calls)) continue;
|
||||
if (!Array.isArray(calls)) return;
|
||||
|
||||
for (const call of calls) {
|
||||
calls.forEach((call) => {
|
||||
const devices = call['m.devices'];
|
||||
if (!Array.isArray(devices)) continue;
|
||||
if (!Array.isArray(devices)) return;
|
||||
|
||||
for (const device of devices) {
|
||||
devices.forEach((device) => {
|
||||
const expiresTs = device.expires_ts || 0;
|
||||
|
||||
// Check if the call membership is still valid (not expired)
|
||||
@@ -50,9 +51,9 @@ function getActiveCallMembers(room: Room): CallMember[] {
|
||||
expiresTs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error reading call member events:', e);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@ import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
|
||||
/** Information about a call member event for grouping */
|
||||
interface CallEventInfo {
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
isJoining: boolean;
|
||||
ts: number;
|
||||
item: number;
|
||||
mEventId: string;
|
||||
mEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||
({ position, className, ...props }, ref) => (
|
||||
<Box
|
||||
@@ -450,6 +461,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
// Track consecutive call member events for collapsing
|
||||
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
|
||||
|
||||
const ignoredUsersList = useIgnoredUsers();
|
||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||
|
||||
@@ -1468,6 +1482,46 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.CallMember]: (mEventId, mEvent, item) => {
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
const content = mEvent.getContent();
|
||||
const prevContent = mEvent.getPrevContent();
|
||||
|
||||
// Check current content for active devices
|
||||
const calls = content['m.calls'];
|
||||
const hasDevices = Array.isArray(calls) && calls.some((call: { 'm.devices'?: unknown[] }) =>
|
||||
Array.isArray(call['m.devices']) && call['m.devices'].length > 0
|
||||
);
|
||||
|
||||
// Check previous content for active devices
|
||||
const prevCalls = prevContent['m.calls'];
|
||||
const hadDevices = Array.isArray(prevCalls) && prevCalls.some((call: { 'm.devices'?: unknown[] }) =>
|
||||
Array.isArray(call['m.devices']) && call['m.devices'].length > 0
|
||||
);
|
||||
|
||||
// Determine if this is a join or leave
|
||||
const isJoining = hasDevices && !hadDevices;
|
||||
const isLeaving = !hasDevices && hadDevices;
|
||||
|
||||
// Skip if no change (refresh events or device updates within same call)
|
||||
if (!isJoining && !isLeaving) return null;
|
||||
|
||||
// Add to pending call events for grouping
|
||||
// Return a special marker that eventRenderer will recognize
|
||||
pendingCallEventsRef.current.push({
|
||||
senderId,
|
||||
senderName,
|
||||
isJoining,
|
||||
ts: mEvent.getTs(),
|
||||
item,
|
||||
mEventId,
|
||||
mEvent,
|
||||
});
|
||||
|
||||
// Return special marker for call event (will be handled by eventRenderer)
|
||||
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
||||
},
|
||||
},
|
||||
(mEventId, mEvent, item) => {
|
||||
if (!showHiddenEvents) return null;
|
||||
@@ -1571,7 +1625,128 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
let isPrevRendered = false;
|
||||
let newDivider = false;
|
||||
let dayDivider = false;
|
||||
const eventRenderer = (item: number) => {
|
||||
|
||||
// Clear pending call events at the start of each render
|
||||
pendingCallEventsRef.current = [];
|
||||
|
||||
/**
|
||||
* Renders a summary of accumulated call events
|
||||
*/
|
||||
const renderCallEventSummary = (callEvents: CallEventInfo[]): React.ReactNode => {
|
||||
if (callEvents.length === 0) return null;
|
||||
|
||||
// Group by user, tracking the sequence of actions to preserve order
|
||||
const userActions = new Map<string, {
|
||||
senderName: string;
|
||||
firstAction: 'join' | 'leave';
|
||||
lastAction: 'join' | 'leave';
|
||||
joins: number;
|
||||
leaves: number;
|
||||
}>();
|
||||
|
||||
callEvents.forEach((evt) => {
|
||||
const existing = userActions.get(evt.senderId);
|
||||
const action = evt.isJoining ? 'join' : 'leave';
|
||||
|
||||
if (existing) {
|
||||
existing.lastAction = action;
|
||||
if (evt.isJoining) {
|
||||
existing.joins += 1;
|
||||
} else {
|
||||
existing.leaves += 1;
|
||||
}
|
||||
} else {
|
||||
userActions.set(evt.senderId, {
|
||||
senderName: evt.senderName,
|
||||
firstAction: action,
|
||||
lastAction: action,
|
||||
joins: evt.isJoining ? 1 : 0,
|
||||
leaves: evt.isJoining ? 0 : 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Build summary text
|
||||
const summaryParts: string[] = [];
|
||||
userActions.forEach((actions) => {
|
||||
const { joins, leaves, senderName, firstAction, lastAction } = actions;
|
||||
const total = joins + leaves;
|
||||
|
||||
if (total === 1) {
|
||||
// Single action
|
||||
summaryParts.push(`${senderName} ${firstAction === 'join' ? 'joined' : 'left'}`);
|
||||
} else if (joins > 0 && leaves > 0) {
|
||||
// Mixed joins and leaves - describe based on first action and cycles
|
||||
const startedWith = firstAction === 'join' ? 'joined' : 'left';
|
||||
const opposite = firstAction === 'join' ? 'left' : 'joined';
|
||||
const cycles = Math.min(joins, leaves);
|
||||
|
||||
if (firstAction === lastAction) {
|
||||
// Ended on same action as started (odd number of same action)
|
||||
// e.g., join-leave-join or leave-join-leave
|
||||
if (cycles === 1) {
|
||||
summaryParts.push(`${senderName} ${startedWith}, ${opposite}, then ${startedWith} again`);
|
||||
} else {
|
||||
summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times, then ${startedWith}`);
|
||||
}
|
||||
} else {
|
||||
// Ended on opposite action (equal joins/leaves or started-opposite pattern)
|
||||
if (cycles === 1) {
|
||||
summaryParts.push(`${senderName} ${startedWith}, then ${opposite}`);
|
||||
} else {
|
||||
summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times`);
|
||||
}
|
||||
}
|
||||
} else if (joins > 1) {
|
||||
summaryParts.push(`${senderName} joined ${joins} times`);
|
||||
} else if (leaves > 1) {
|
||||
summaryParts.push(`${senderName} left ${leaves} times`);
|
||||
}
|
||||
});
|
||||
|
||||
// Use the first event for rendering metadata
|
||||
const firstEvent = callEvents[0];
|
||||
const lastEvent = callEvents[callEvents.length - 1];
|
||||
|
||||
const timeJSX = (
|
||||
<Time
|
||||
ts={lastEvent.ts}
|
||||
compact={messageLayout === MessageLayout.Compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
key={`call-summary-${firstEvent.mEventId}`}
|
||||
data-message-item={firstEvent.item}
|
||||
data-message-id={firstEvent.mEventId}
|
||||
room={room}
|
||||
mEvent={firstEvent.mEvent}
|
||||
highlight={false}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={false}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
iconSrc={Icons.Phone}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T300" priority="300">
|
||||
{summaryParts.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Event>
|
||||
);
|
||||
};
|
||||
|
||||
const eventRenderer = (item: number, index: number, allItems: number[]) => {
|
||||
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||
if (!eventTimeline) return null;
|
||||
const timelineSet = eventTimeline?.getTimelineSet();
|
||||
@@ -1604,7 +1779,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
const rawEventJSX = reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
@@ -1615,11 +1790,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
timelineSet,
|
||||
collapsed
|
||||
);
|
||||
|
||||
// Check if this is a call event that should be grouped
|
||||
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
|
||||
|
||||
// Determine if we need to flush pending call events
|
||||
// Flush when: we hit a non-call event, or this is the last item
|
||||
const isLastItem = index === allItems.length - 1;
|
||||
const pendingCallEvents = pendingCallEventsRef.current;
|
||||
const shouldFlushCallEvents = pendingCallEvents.length > 0 && (!isCallEventPending || isLastItem);
|
||||
|
||||
let callSummaryJSX: React.ReactNode = null;
|
||||
if (shouldFlushCallEvents) {
|
||||
callSummaryJSX = renderCallEventSummary([...pendingCallEvents]);
|
||||
pendingCallEventsRef.current = []; // Clear array
|
||||
}
|
||||
|
||||
// If this was a call event marker, don't render it directly
|
||||
const eventJSX = isCallEventPending ? null : rawEventJSX;
|
||||
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
isPrevRendered = !!eventJSX || !!callSummaryJSX;
|
||||
|
||||
const newDividerJSX =
|
||||
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
||||
newDivider && (eventJSX || callSummaryJSX) && eventSender !== mx.getUserId() ? (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
||||
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
||||
@@ -1630,7 +1824,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
) : null;
|
||||
|
||||
const dayDividerJSX =
|
||||
dayDivider && eventJSX ? (
|
||||
dayDivider && (eventJSX || callSummaryJSX) ? (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider variant="Surface">
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
@@ -1646,7 +1840,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</MessageBase>
|
||||
) : null;
|
||||
|
||||
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
||||
// Handle various combinations of dividers, call summary, and regular events
|
||||
const hasContent = eventJSX || callSummaryJSX;
|
||||
const hasDividers = newDividerJSX || dayDividerJSX;
|
||||
|
||||
if (hasContent && hasDividers) {
|
||||
if (newDividerJSX) newDivider = false;
|
||||
if (dayDividerJSX) dayDivider = false;
|
||||
|
||||
@@ -1654,12 +1852,22 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<React.Fragment key={mEventId}>
|
||||
{newDividerJSX}
|
||||
{dayDividerJSX}
|
||||
{callSummaryJSX}
|
||||
{eventJSX}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return eventJSX;
|
||||
if (callSummaryJSX && eventJSX) {
|
||||
return (
|
||||
<React.Fragment key={mEventId}>
|
||||
{callSummaryJSX}
|
||||
{eventJSX}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return callSummaryJSX || eventJSX;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
color,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
@@ -71,6 +72,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useRoomCall, useRoomCallMembers } from '../call';
|
||||
import { CallState, CallType } from '../call/types';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -261,53 +264,106 @@ type CallIndicatorProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows when others are in a call in this room
|
||||
* Shows all users currently in a call in this room (including self)
|
||||
*/
|
||||
function CallIndicator({ roomId }: CallIndicatorProps) {
|
||||
const { othersInCall, isCallActive } = useRoomCallMembers(roomId);
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { callMembers, isCallActive } = useRoomCallMembers(roomId);
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
if (!isCallActive || othersInCall.length === 0) {
|
||||
if (!isCallActive || callMembers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userCount = othersInCall.length;
|
||||
const tooltipText = userCount === 1
|
||||
? `${othersInCall[0].userId.split(':')[0].slice(1)} is in a call`
|
||||
: `${userCount} people are in a call`;
|
||||
const getAvatarUrl = (userId: string): string | undefined => {
|
||||
if (!room) return undefined;
|
||||
const mxcUrl = getMemberAvatarMxc(room, userId);
|
||||
if (!mxcUrl) return undefined;
|
||||
return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 24, 24, 'crop') ?? undefined;
|
||||
};
|
||||
|
||||
const getDisplayName = (userId: string): string => {
|
||||
if (!room) return userId.split(':')[0].slice(1);
|
||||
return getMemberDisplayName(room, userId) ?? userId.split(':')[0].slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{tooltipText}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(12),
|
||||
backgroundColor: 'var(--mx-positive-container)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Box
|
||||
ref={triggerRef}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(12),
|
||||
backgroundColor: 'var(--mx-positive-container)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size="100"
|
||||
src={Icons.Phone}
|
||||
style={{ color: 'var(--mx-positive)' }}
|
||||
/>
|
||||
<Box alignItems="Center" gap="100">
|
||||
{callMembers.slice(0, 5).map((member) => {
|
||||
const displayName = getDisplayName(member.userId);
|
||||
const avatarUrl = getAvatarUrl(member.userId);
|
||||
return (
|
||||
<TooltipProvider
|
||||
key={member.userId}
|
||||
position="Bottom"
|
||||
offset={8}
|
||||
tooltip={
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="50"
|
||||
style={{
|
||||
padding: `${toRem(8)} ${toRem(12)}`,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: toRem(8),
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Speech bubble arrow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: toRem(-6),
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${toRem(6)} solid transparent`,
|
||||
borderRight: `${toRem(6)} solid transparent`,
|
||||
borderBottom: `${toRem(6)} solid ${color.SurfaceVariant.Container}`,
|
||||
}}
|
||||
/>
|
||||
<Text size="T300" style={{ fontWeight: 600, color: color.SurfaceVariant.OnContainer }}>{displayName}</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7, color: color.SurfaceVariant.OnContainer }}>{member.userId}</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Avatar ref={triggerRef} size="200">
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => (
|
||||
<Text size="T200">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
{callMembers.length > 5 && (
|
||||
<Text size="T200" style={{ color: 'var(--mx-positive)' }}>
|
||||
{userCount} in call
|
||||
+{callMembers.length - 5}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { KeySymbol } from '../../../utils/key-symbol';
|
||||
import { isMacOS } from '../../../utils/user-agent';
|
||||
@@ -303,10 +303,50 @@ function PageZoomInput() {
|
||||
);
|
||||
}
|
||||
|
||||
const emojiStyleNames: Record<EmojiStyle, string> = {
|
||||
[EmojiStyle.System]: 'System',
|
||||
[EmojiStyle.Apple]: 'Apple',
|
||||
[EmojiStyle.Twemoji]: 'Twemoji',
|
||||
};
|
||||
|
||||
type EmojiStyleSelectorProps = {
|
||||
selected: EmojiStyle;
|
||||
onSelect: (style: EmojiStyle) => void;
|
||||
};
|
||||
const EmojiStyleSelector = as<'div', EmojiStyleSelectorProps>(
|
||||
({ selected, onSelect, ...props }, ref) => (
|
||||
<Menu {...props} ref={ref}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{Object.values(EmojiStyle).map((style) => (
|
||||
<MenuItem
|
||||
key={style}
|
||||
size="300"
|
||||
variant={style === selected ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => onSelect(style)}
|
||||
>
|
||||
<Text size="T300">{emojiStyleNames[style]}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
)
|
||||
);
|
||||
|
||||
function Appearance() {
|
||||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [emojiStyle, setEmojiStyle] = useSetting(settingsAtom, 'emojiStyle');
|
||||
const [emojiMenuCords, setEmojiMenuCords] = useState<RectCords>();
|
||||
|
||||
const handleEmojiMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setEmojiMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleEmojiStyleSelect = (style: EmojiStyle) => {
|
||||
setEmojiStyle(style);
|
||||
setEmojiMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -342,8 +382,45 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Twitter Emoji"
|
||||
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
|
||||
title="Emoji Style"
|
||||
description="Choose which emoji set to display."
|
||||
after={
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleEmojiMenu}
|
||||
>
|
||||
<Text size="T300">{emojiStyleNames[emojiStyle]}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={emojiMenuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setEmojiMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<EmojiStyleSelector selected={emojiStyle} onSelect={handleEmojiStyleSelect} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { EmojiStyle, settingsAtom } from '../../state/settings';
|
||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||
import { usePreviousValue } from '../../hooks/usePreviousValue';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -28,13 +28,26 @@ import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { isTauri, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
/**
|
||||
* Applies the selected emoji style font to the document.
|
||||
* - System: Uses the native OS emoji font
|
||||
* - Apple: Uses Apple Color Emoji (bundled font)
|
||||
* - Twemoji: Uses Twitter's Twemoji font
|
||||
*/
|
||||
function EmojiStyleFeature() {
|
||||
const [emojiStyle] = useSetting(settingsAtom, 'emojiStyle');
|
||||
|
||||
if (twitterEmoji) {
|
||||
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
|
||||
switch (emojiStyle) {
|
||||
case EmojiStyle.Apple:
|
||||
document.documentElement.style.setProperty('--font-emoji', 'AppleColorEmoji');
|
||||
break;
|
||||
case EmojiStyle.Twemoji:
|
||||
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
|
||||
break;
|
||||
case EmojiStyle.System:
|
||||
default:
|
||||
document.documentElement.style.setProperty('--font-emoji', 'SystemEmoji');
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -316,7 +329,7 @@ type ClientNonUIFeaturesProps = {
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
<SystemEmojiFeature />
|
||||
<EmojiStyleFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
<InviteNotifications />
|
||||
|
||||
@@ -9,6 +9,12 @@ export enum MessageLayout {
|
||||
Bubble = 2,
|
||||
}
|
||||
|
||||
export enum EmojiStyle {
|
||||
System = 'system',
|
||||
Apple = 'apple',
|
||||
Twemoji = 'twemoji',
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
themeId?: string;
|
||||
useSystemTheme: boolean;
|
||||
@@ -17,7 +23,7 @@ export interface Settings {
|
||||
monochromeMode?: boolean;
|
||||
isMarkdown: boolean;
|
||||
editorToolbar: boolean;
|
||||
twitterEmoji: boolean;
|
||||
emojiStyle: EmojiStyle;
|
||||
pageZoom: number;
|
||||
hideActivity: boolean;
|
||||
|
||||
@@ -51,7 +57,7 @@ const defaultSettings: Settings = {
|
||||
monochromeMode: false,
|
||||
isMarkdown: true,
|
||||
editorToolbar: false,
|
||||
twitterEmoji: false,
|
||||
emojiStyle: EmojiStyle.Apple,
|
||||
pageZoom: 100,
|
||||
hideActivity: false,
|
||||
|
||||
@@ -80,9 +86,18 @@ const defaultSettings: Settings = {
|
||||
export const getSettings = () => {
|
||||
const settings = localStorage.getItem(STORAGE_KEY);
|
||||
if (settings === null) return defaultSettings;
|
||||
|
||||
const parsed = JSON.parse(settings) as Settings & { twitterEmoji?: boolean };
|
||||
|
||||
// Migrate old twitterEmoji boolean to new emojiStyle enum
|
||||
if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) {
|
||||
parsed.emojiStyle = parsed.twitterEmoji ? EmojiStyle.Twemoji : EmojiStyle.Apple;
|
||||
delete parsed.twitterEmoji;
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultSettings,
|
||||
...(JSON.parse(settings) as Settings),
|
||||
...parsed,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Twemoji font (Twitter emoji) */
|
||||
@font-face {
|
||||
font-family: Twemoji;
|
||||
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
|
||||
@@ -5,6 +6,28 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
|
||||
falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
|
||||
@font-face {
|
||||
font-family: AppleColorEmoji;
|
||||
src: local('Apple Color Emoji'),
|
||||
local('Segoe UI Emoji'),
|
||||
local('Noto Color Emoji');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* System emoji - uses native OS emoji */
|
||||
@font-face {
|
||||
font-family: SystemEmoji;
|
||||
src: local('Apple Color Emoji'),
|
||||
local('Segoe UI Emoji'),
|
||||
local('Segoe UI Symbol'),
|
||||
local('Noto Color Emoji'),
|
||||
local('Android Emoji'),
|
||||
local('EmojiOne Color');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--tc-link: hsl(213deg 100% 45%);
|
||||
|
||||
@@ -18,7 +41,7 @@
|
||||
--mx-uc-7: hsl(242, 100%, 45%);
|
||||
--mx-uc-8: hsl(94, 100%, 35%);
|
||||
|
||||
--font-emoji: 'Twemoji_DISABLED';
|
||||
--font-emoji: 'AppleColorEmoji';
|
||||
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ export enum StateEvent {
|
||||
|
||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||
|
||||
/** Matrix RTC call membership (MSC3401) */
|
||||
CallMember = 'org.matrix.msc3401.call.member',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
|
||||
Reference in New Issue
Block a user