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:
@@ -34,7 +34,8 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
|||||||
try {
|
try {
|
||||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
// Silently ignore OIDC configuration errors when server doesn't support it
|
||||||
|
// This is expected for most Matrix servers
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPreview();
|
loadPreview().catch(() => {
|
||||||
|
// Error is already handled by AsyncStatus.Error state
|
||||||
|
});
|
||||||
}, [loadPreview]);
|
}, [loadPreview]);
|
||||||
|
|
||||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||||
|
|||||||
125
src/app/features/room-nav/CallParticipantsIndicator.tsx
Normal file
125
src/app/features/room-nav/CallParticipantsIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
|
|||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
|
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
|
||||||
|
import { useCall } from '../call/useCall';
|
||||||
|
import { CallType } from '../call/types';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -64,6 +67,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
const { startCall, endCall, activeCall, callSupported } = useCall();
|
||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
@@ -93,6 +97,19 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
requestClose();
|
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 (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||||
{invitePrompt && room && (
|
{invitePrompt && room && (
|
||||||
@@ -140,6 +157,19 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<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
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -260,65 +290,66 @@ export function RoomNavItem({
|
|||||||
const optionsVisible = hover || !!menuAnchor;
|
const optionsVisible = hover || !!menuAnchor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavItem
|
<Box direction="Column" style={{ width: '100%' }}>
|
||||||
variant="Background"
|
<NavItem
|
||||||
radii="400"
|
variant="Background"
|
||||||
highlight={unread !== undefined}
|
radii="400"
|
||||||
aria-selected={selected}
|
highlight={unread !== undefined}
|
||||||
data-hover={!!menuAnchor}
|
aria-selected={selected}
|
||||||
onContextMenu={handleContextMenu}
|
data-hover={!!menuAnchor}
|
||||||
{...hoverProps}
|
onContextMenu={handleContextMenu}
|
||||||
{...focusWithinProps}
|
{...hoverProps}
|
||||||
>
|
{...focusWithinProps}
|
||||||
<NavLink to={linkPath}>
|
>
|
||||||
<NavItemContent>
|
<NavLink to={linkPath}>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<NavItemContent>
|
||||||
<Avatar size="200" radii="400">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
{showAvatar ? (
|
<Avatar size="200" radii="400">
|
||||||
<RoomAvatar
|
{showAvatar ? (
|
||||||
roomId={room.roomId}
|
<RoomAvatar
|
||||||
src={
|
roomId={room.roomId}
|
||||||
direct
|
src={
|
||||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
direct
|
||||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
}
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
alt={room.name}
|
}
|
||||||
renderFallback={() => (
|
alt={room.name}
|
||||||
<Text as="span" size="H6">
|
renderFallback={() => (
|
||||||
{nameInitials(room.name)}
|
<Text as="span" size="H6">
|
||||||
</Text>
|
{nameInitials(room.name)}
|
||||||
)}
|
</Text>
|
||||||
/>
|
)}
|
||||||
) : (
|
/>
|
||||||
<RoomIcon
|
) : (
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
<RoomIcon
|
||||||
filled={selected}
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
size="100"
|
filled={selected}
|
||||||
joinRule={room.getJoinRule()}
|
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>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
</NavItemContent>
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
</NavLink>
|
||||||
<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>
|
|
||||||
{optionsVisible && (
|
{optionsVisible && (
|
||||||
<NavItemOptions>
|
<NavItemOptions>
|
||||||
<PopOut
|
<PopOut
|
||||||
@@ -360,6 +391,8 @@ export function RoomNavItem({
|
|||||||
</PopOut>
|
</PopOut>
|
||||||
</NavItemOptions>
|
</NavItemOptions>
|
||||||
)}
|
)}
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<CallParticipantsIndicator roomId={room.roomId} />
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './RoomNavItem';
|
export * from './RoomNavItem';
|
||||||
export * from './RoomNavCategoryButton';
|
export * from './RoomNavCategoryButton';
|
||||||
|
export * from './CallParticipantsIndicator';
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user