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:
2026-02-05 03:24:18 +11:00
parent cd912025f1
commit 1a452f52ca
27 changed files with 1888 additions and 179 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/sound/Mic_Mute.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -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"

View File

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

View File

@@ -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,

View 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;
}
}

View 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%',
});

View 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>
);
}

View File

@@ -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';

View File

@@ -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',
}

View File

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

View File

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

View File

@@ -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 (

View File

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

View File

@@ -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>

View File

@@ -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 />

View File

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

View File

@@ -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;
}

View File

@@ -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 {