feat: add CallParticipantsIndicator component to display active call participants

fix: silently ignore OIDC configuration errors in ServerConfigsLoader
fix: handle loadPreview errors in UrlPreviewCard component
fix: silently ignore errors in notification tap listener setup in tauri
This commit is contained in:
2026-02-22 03:17:36 +11:00
parent a713141420
commit f5ba778e46
6 changed files with 227 additions and 61 deletions

View File

@@ -34,7 +34,8 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
// Silently ignore OIDC configuration errors when server doesn't support it
// This is expected for most Matrix servers
}
return {

View File

@@ -36,7 +36,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
);
useEffect(() => {
loadPreview();
loadPreview().catch(() => {
// Error is already handled by AsyncStatus.Error state
});
}, [loadPreview]);
if (previewStatus.status === AsyncStatus.Error) return null;

View File

@@ -0,0 +1,125 @@
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';
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();
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;
};
const isParticipantMuted = (userId: string): boolean => {
if (!activeCall) return false;
// For local user, check activeCall.isMuted
if (userId === mx.getUserId()) 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;
});
};
return (
<Box
direction="Column"
gap="200"
style={{
paddingLeft: config.space.S500,
paddingTop: config.space.S200,
width: '100%',
}}
>
{visibleMembers.map((member) => (
<Box
key={member.userId}
alignItems="Center"
gap="100"
style={{
opacity: config.opacity.P300,
}}
>
<Avatar size="200" radii="Pill">
{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={isParticipantMuted(member.userId) ? Icons.MicMute : Icons.Mic}
size="50"
style={{ opacity: config.opacity.P500 }}
/>
<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>
);
}

View File

@@ -51,6 +51,9 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
import { useCall } from '../call/useCall';
import { CallType } from '../call/types';
type RoomNavItemMenuProps = {
room: Room;
@@ -64,6 +67,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const { startCall, endCall, activeCall, callSupported } = useCall();
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
@@ -93,6 +97,19 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
requestClose();
};
const handleJoinVoice = async () => {
try {
// If already in a call, leave it first
if (activeCall) {
await endCall();
}
await startCall(room.roomId, CallType.Voice);
requestClose();
} catch (error) {
console.error('Failed to join voice call:', error);
}
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
@@ -140,6 +157,19 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleJoinVoice}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.VolumeHigh} />}
radii="300"
disabled={!callSupported}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Join Voice
</Text>
</MenuItem>
<MenuItem
onClick={handleInvite}
variant="Primary"
@@ -260,65 +290,66 @@ export function RoomNavItem({
const optionsVisible = hover || !!menuAnchor;
return (
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
{...hoverProps}
{...focusWithinProps}
>
<NavLink to={linkPath}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
<Box direction="Column" style={{ width: '100%' }}>
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
{...hoverProps}
{...focusWithinProps}
>
<NavLink to={linkPath}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
<Box as="span" grow="Yes">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation />
</Badge>
)}
{!optionsVisible && unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
)}
</Avatar>
<Box as="span" grow="Yes">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation />
</Badge>
)}
{!optionsVisible && unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
)}
</Box>
</NavItemContent>
</NavLink>
</NavItemContent>
</NavLink>
{optionsVisible && (
<NavItemOptions>
<PopOut
@@ -360,6 +391,8 @@ export function RoomNavItem({
</PopOut>
</NavItemOptions>
)}
</NavItem>
</NavItem>
<CallParticipantsIndicator roomId={room.roomId} />
</Box>
);
}

View File

@@ -1,2 +1,3 @@
export * from './RoomNavItem';
export * from './RoomNavCategoryButton';
export * from './CallParticipantsIndicator';

View File

@@ -114,7 +114,11 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
}
});
} catch (err) {
console.warn('Failed to set up notification tap listener:', err);
// Silently ignore errors in Electron compatibility layer
// Only warn if we're actually in a Tauri environment
if (typeof window !== 'undefined' && '__TAURI__' in window) {
console.warn('Failed to set up notification tap listener:', err);
}
}
};