diff --git a/public/sound/Call_Depart.ogg b/public/sound/Call_Depart.ogg new file mode 100644 index 0000000..ca92682 Binary files /dev/null and b/public/sound/Call_Depart.ogg differ diff --git a/public/sound/Call_Dialing.ogg b/public/sound/Call_Dialing.ogg new file mode 100644 index 0000000..895fa3d Binary files /dev/null and b/public/sound/Call_Dialing.ogg differ diff --git a/public/sound/Call_Joined.ogg b/public/sound/Call_Joined.ogg new file mode 100644 index 0000000..c44f822 Binary files /dev/null and b/public/sound/Call_Joined.ogg differ diff --git a/public/sound/Mic_Mute.ogg b/public/sound/Mic_Mute.ogg new file mode 100644 index 0000000..99d012e Binary files /dev/null and b/public/sound/Mic_Mute.ogg differ diff --git a/public/sound/Mic_Unmuted.ogg b/public/sound/Mic_Unmuted.ogg new file mode 100644 index 0000000..58489af Binary files /dev/null and b/public/sound/Mic_Unmuted.ogg differ diff --git a/public/sound/Speakers_Deafen.ogg b/public/sound/Speakers_Deafen.ogg new file mode 100644 index 0000000..553720c Binary files /dev/null and b/public/sound/Speakers_Deafen.ogg differ diff --git a/public/sound/Speakers_Undeafen.ogg b/public/sound/Speakers_Undeafen.ogg new file mode 100644 index 0000000..57d725b Binary files /dev/null and b/public/sound/Speakers_Undeafen.ogg differ diff --git a/public/sound/invite.ogg b/public/sound/invite.ogg index 4a00af6..e513124 100755 Binary files a/public/sound/invite.ogg and b/public/sound/invite.ogg differ diff --git a/public/sound/notification.ogg b/public/sound/notification.ogg index 93b3a01..18ecd09 100755 Binary files a/public/sound/notification.ogg and b/public/sound/notification.ogg differ diff --git a/src/app/features/call/CallOverlay.css.ts b/src/app/features/call/CallOverlay.css.ts index f2d712b..559a2c5 100644 --- a/src/app/features/call/CallOverlay.css.ts +++ b/src/app/features/call/CallOverlay.css.ts @@ -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}`, +}); diff --git a/src/app/features/call/CallOverlay.tsx b/src/app/features/call/CallOverlay.tsx index 70b68c4..e05b23a 100644 --- a/src/app/features/call/CallOverlay.tsx +++ b/src/app/features/call/CallOverlay.tsx @@ -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(initialPosition); +/** Custom hook for draggable functionality with viewport constraints */ +function useDraggable() { + const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const dragRef = useRef(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(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 ( +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+
+ ); +} + /** * 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(null); const [isFullscreen, setIsFullscreen] = useState(false); const [isPip, setIsPip] = useState(false); + const [showParticipants, setShowParticipants] = useState(true); + const [collapsedScreenShares, setCollapsedScreenShares] = useState>(new Set()); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const containerRef = useRef(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).current = el; + (dragRef as React.MutableRefObject).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 (
- + - - {isConnecting ? 'Connecting...' : 'In Call'} + + {isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`} + {selectedRoomId === activeCall.roomId && ( + (Current Room) + )} + {selectedRoomId !== activeCall.roomId && ( + Go to Room} + > + {(triggerRef) => ( + navigateRoom(activeCall.roomId)} + variant="Secondary" + size="300" + style={{ padding: '2px', marginLeft: '4px' }} + > + + + )} + + )} @@ -372,17 +665,109 @@ export function CallOverlay() {
- {/* Remote Screen Share Display */} - {hasRemoteScreenShare && screenShareTrack && ( -
-
- - {screenShareTrack.participantId} + {/* Participants Section */} +
+ + {showParticipants && ( +
+ {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 ( +
+
+ ( + + {displayName.charAt(0).toUpperCase()} + + )} + /> +
+ + + {displayName} + {isMe && (You)} + + + {participantId} + + + {isMuted && ( + + )} + {participantScreenShares.length > 0 && ( + {collapsedScreenShares.has(participantScreenShares[0].id) ? 'Show Screen' : 'Hide Screen'}} + > + {(triggerRef) => ( + { + 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' }} + > + + + )} + + )} +
+ ); + })}
- {/* LiveKit will create and manage the video element inside this container */} -
-
- )} + )} +
+ + {/* Screen shares rendered outside participant list */} + {allScreenShares + .filter((share) => !collapsedScreenShares.has(share.id)) + .map((share) => ( + + ))} {isVideoCall && (
@@ -432,6 +817,29 @@ export function CallOverlay() { )} + + {activeCall.isDeafened ? 'Undeafen' : 'Deafen'} + + } + > + {(triggerRef) => ( + + + + )} + + {isVideoCall && ( {children} + ); } diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts index bbd5320..752e528 100644 --- a/src/app/features/call/CallService.ts +++ b/src/app/features/call/CallService.ts @@ -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(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, diff --git a/src/app/features/call/CallSounds.ts b/src/app/features/call/CallSounds.ts new file mode 100644 index 0000000..e233445 --- /dev/null +++ b/src/app/features/call/CallSounds.ts @@ -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.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 = 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; + } +} diff --git a/src/app/features/call/IncomingCallNotification.css.ts b/src/app/features/call/IncomingCallNotification.css.ts new file mode 100644 index 0000000..b42c994 --- /dev/null +++ b/src/app/features/call/IncomingCallNotification.css.ts @@ -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%', +}); diff --git a/src/app/features/call/IncomingCallNotification.tsx b/src/app/features/call/IncomingCallNotification.tsx new file mode 100644 index 0000000..2bc683b --- /dev/null +++ b/src/app/features/call/IncomingCallNotification.tsx @@ -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(null); + const [declinedRooms, setDeclinedRooms] = useState>(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 ( +
+
+
+
+ ( + + {callerName.charAt(0).toUpperCase()} + + )} + /> +
+
+ + + {callerName} + {otherCallersCount > 0 && ( + + {` +${otherCallersCount} other${otherCallersCount > 1 ? 's' : ''}`} + + )} + + + + Calling from {incomingCall.roomName} + + +
+ + + + + + + + + +
+
+ ); +} diff --git a/src/app/features/call/index.ts b/src/app/features/call/index.ts index f3a6fce..6b769e8 100644 --- a/src/app/features/call/index.ts +++ b/src/app/features/call/index.ts @@ -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'; diff --git a/src/app/features/call/types.ts b/src/app/features/call/types.ts index b8a4d69..f3b2e82 100644 --- a/src/app/features/call/types.ts +++ b/src/app/features/call/types.ts @@ -113,7 +113,10 @@ export interface ActiveCall { remoteScreenStreams: Map; remoteScreenTracks: Map; participants: string[]; + activeSpeakers: Set; + mutedParticipants: Set; 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', } diff --git a/src/app/features/call/useCall.ts b/src/app/features/call/useCall.ts index 7270ce5..47ddbee 100644 --- a/src/app/features/call/useCall.ts +++ b/src/app/features/call/useCall.ts @@ -12,6 +12,7 @@ interface CallContextValue { startCall: (roomId: string, callType: CallType) => Promise; endCall: () => Promise; toggleMute: () => boolean; + toggleDeafen: () => boolean; toggleVideo: () => boolean; toggleScreenShare: () => Promise; } @@ -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] ); } diff --git a/src/app/features/call/useRoomCallMembers.ts b/src/app/features/call/useRoomCallMembers.ts index e2063fe..cf79774 100644 --- a/src/app/features/call/useRoomCallMembers.ts +++ b/src/app/features/call/useRoomCallMembers.ts @@ -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); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2281b59..9e729be 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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) => ( ([]); + const ignoredUsersList = useIgnoredUsers(); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); @@ -1468,6 +1482,46 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, + [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(); + + 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 = ( + + } + /> + + ); + }; + + 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() ? ( @@ -1630,7 +1824,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) : null; const dayDividerJSX = - dayDivider && eventJSX ? ( + dayDivider && (eventJSX || callSummaryJSX) ? ( @@ -1646,7 +1840,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) : 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 {newDividerJSX} {dayDividerJSX} + {callSummaryJSX} {eventJSX} ); } - return eventJSX; + if (callSummaryJSX && eventJSX) { + return ( + + {callSummaryJSX} + {eventJSX} + + ); + } + + return callSummaryJSX || eventJSX; }; return ( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 2fd460b..2112e92 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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 ( - - {tooltipText} - - } + - {(triggerRef) => ( - - + + {callMembers.slice(0, 5).map((member) => { + const displayName = getDisplayName(member.userId); + const avatarUrl = getAvatarUrl(member.userId); + return ( + + {/* Speech bubble arrow */} +
+ {displayName} + {member.userId} + + } + > + {(triggerRef) => ( + + ( + + {displayName.charAt(0).toUpperCase()} + + )} + /> + + )} + + ); + })} + {callMembers.length > 5 && ( - {userCount} in call + +{callMembers.length - 5} - - )} - + )} + + ); } diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b861a06..a5e09ef 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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.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) => ( + + + {Object.values(EmojiStyle).map((style) => ( + onSelect(style)} + > + {emojiStyleNames[style]} + + ))} + + + ) +); + 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(); + + const handleEmojiMenu: MouseEventHandler = (evt) => { + setEmojiMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleEmojiStyleSelect = (style: EmojiStyle) => { + setEmojiStyle(style); + setEmojiMenuCords(undefined); + }; return ( @@ -342,8 +382,45 @@ function Appearance() { } + title="Emoji Style" + description="Choose which emoji set to display." + after={ + <> + + setEmojiMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + } + /> + + } /> diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ee63c77..30e1aa9 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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 ( <> - + diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 31ee6cc..864b0d5 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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, }; }; diff --git a/src/index.css b/src/index.css index 3612138..7704600 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index b866fd7..5ab3672 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -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 {