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) ? ( + {getParticipantName(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 ( {invitePrompt && room && ( @@ -140,6 +157,19 @@ const RoomNavItemMenu = forwardRef( + } + radii="300" + disabled={!callSupported} + > + + Join Voice + + - - - - - {showAvatar ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - + + + + + + + {showAvatar ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + {room.name} + + + {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( + + + + )} + {!optionsVisible && unread && ( + + 0} count={unread.total} /> + + )} + {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( + )} - - - - {room.name} - - {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( - - - - )} - {!optionsVisible && unread && ( - - 0} count={unread.total} /> - - )} - {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - - )} - - - + + {optionsVisible && ( )} - + + + ); } diff --git a/src/app/features/room-nav/index.ts b/src/app/features/room-nav/index.ts index d1ea3ec..4c76496 100644 --- a/src/app/features/room-nav/index.ts +++ b/src/app/features/room-nav/index.ts @@ -1,2 +1,3 @@ export * from './RoomNavItem'; export * from './RoomNavCategoryButton'; +export * from './CallParticipantsIndicator'; diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts index 8be9c77..6a4a848 100644 --- a/src/app/utils/tauri.ts +++ b/src/app/utils/tauri.ts @@ -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); + } } };