182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
import React from 'react';
|
|
import { Avatar, Box, Text, Icon, Icons, config } from 'folds';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useRoomCallMembers } from '../call/useRoomCallMembers';
|
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
|
import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { nameInitials } from '../../utils/common';
|
|
import { useCall } from '../call/useCall';
|
|
import * as css from './CallParticipantsIndicator.css';
|
|
|
|
const MAX_VISIBLE_PARTICIPANTS = 8;
|
|
|
|
type CallParticipantsIndicatorProps = {
|
|
roomId: string;
|
|
};
|
|
|
|
export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const room = mx.getRoom(roomId);
|
|
const { callMembers, isCallActive } = useRoomCallMembers(roomId);
|
|
const { activeCall } = useCall();
|
|
const myUserId = mx.getUserId();
|
|
|
|
if (!isCallActive || callMembers.length === 0 || !room) {
|
|
return null;
|
|
}
|
|
|
|
const visibleMembers = callMembers.slice(0, MAX_VISIBLE_PARTICIPANTS);
|
|
const remainingCount = callMembers.length - MAX_VISIBLE_PARTICIPANTS;
|
|
|
|
const getParticipantAvatar = (userId: string): string | undefined => {
|
|
const mxcUrl = getMemberAvatarMxc(room, userId);
|
|
if (!mxcUrl) return undefined;
|
|
return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 48, 48, 'crop') ?? undefined;
|
|
};
|
|
|
|
const getParticipantName = (userId: string): string => {
|
|
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
|
};
|
|
|
|
/**
|
|
* Check if a participant is currently speaking
|
|
*/
|
|
const isParticipantSpeaking = (userId: string): boolean => {
|
|
if (!activeCall) return false;
|
|
|
|
// Direct check for Matrix user ID (works for local user)
|
|
if (activeCall.activeSpeakers.has(userId)) return true;
|
|
|
|
// For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp)
|
|
const member = callMembers.find((m) => m.userId === userId);
|
|
if (!member) return false;
|
|
|
|
// Check if any active speaker has this user's deviceId
|
|
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
|
|
const underscoreIndex = speakerId.lastIndexOf('_');
|
|
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
|
|
return deviceId === member.deviceId;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Check if a participant is currently muted
|
|
*/
|
|
const isParticipantMuted = (userId: string): boolean => {
|
|
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)
|
|
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;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Check if the local user is deafened
|
|
*/
|
|
const isLocalUserDeafened = (userId: string): boolean => {
|
|
if (!activeCall || userId !== myUserId) return false;
|
|
return activeCall.isDeafened;
|
|
};
|
|
|
|
/**
|
|
* Get the appropriate audio icon for a participant
|
|
*/
|
|
const getAudioIcon = (userId: string): string => {
|
|
if (isLocalUserDeafened(userId)) {
|
|
return Icons.VolumeMute;
|
|
}
|
|
return isParticipantMuted(userId) ? Icons.MicMute : Icons.Mic;
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
direction="Column"
|
|
gap="200"
|
|
style={{
|
|
paddingLeft: config.space.S500,
|
|
paddingTop: config.space.S200,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{visibleMembers.map((member) => {
|
|
const isSpeaking = isParticipantSpeaking(member.userId);
|
|
const isMuted = isParticipantMuted(member.userId);
|
|
const isDeafened = isLocalUserDeafened(member.userId);
|
|
|
|
return (
|
|
<Box
|
|
key={member.userId}
|
|
alignItems="Center"
|
|
gap="100"
|
|
style={{
|
|
opacity: config.opacity.P300,
|
|
}}
|
|
>
|
|
<Avatar
|
|
size="200"
|
|
radii="Pill"
|
|
className={isSpeaking ? css.ParticipantSpeaking : undefined}
|
|
>
|
|
{getParticipantAvatar(member.userId) ? (
|
|
<img
|
|
src={getParticipantAvatar(member.userId)}
|
|
alt={getParticipantName(member.userId)}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
borderRadius: 'inherit',
|
|
}}
|
|
/>
|
|
) : (
|
|
<Text as="span" size="H6">
|
|
{nameInitials(getParticipantName(member.userId))}
|
|
</Text>
|
|
)}
|
|
</Avatar>
|
|
<Box alignItems="Center" gap="100" grow="Yes">
|
|
<Icon
|
|
src={getAudioIcon(member.userId)}
|
|
size="50"
|
|
style={{
|
|
opacity: config.opacity.P500,
|
|
color: isDeafened ? 'var(--color-Critical-Main)' : isMuted ? 'var(--color-Critical-Main)' : undefined
|
|
}}
|
|
/>
|
|
<Text as="span" size="T200" truncate>
|
|
{getParticipantName(member.userId)}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
{remainingCount > 0 && (
|
|
<Box
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
opacity: config.opacity.P300,
|
|
paddingLeft: config.space.S300,
|
|
}}
|
|
>
|
|
<Text as="span" size="T200">
|
|
+{remainingCount} more
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|