diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx
index 3c8ce8e..ba27bd7 100644
--- a/src/app/components/ServerConfigsLoader.tsx
+++ b/src/app/components/ServerConfigsLoader.tsx
@@ -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 {
diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx
index c93fc46..dfe1bef 100644
--- a/src/app/components/url-preview/UrlPreviewCard.tsx
+++ b/src/app/components/url-preview/UrlPreviewCard.tsx
@@ -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;
diff --git a/src/app/features/room-nav/CallParticipantsIndicator.tsx b/src/app/features/room-nav/CallParticipantsIndicator.tsx
new file mode 100644
index 0000000..cb112b0
--- /dev/null
+++ b/src/app/features/room-nav/CallParticipantsIndicator.tsx
@@ -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 (
+
+ {visibleMembers.map((member) => (
+
+
+ {getParticipantAvatar(member.userId) ? (
+
+ ) : (
+
+ {nameInitials(getParticipantName(member.userId))}
+
+ )}
+
+
+
+
+ {getParticipantName(member.userId)}
+
+
+
+ ))}
+ {remainingCount > 0 && (
+
+
+ +{remainingCount} more
+
+
+ )}
+
+ );
+}
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index a30a89e..b5d1b32 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -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(
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(
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 (