diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index f0387a779..ade303432 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -544,11 +544,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const handleReject = useCallback( (room: Room, eventId: string) => { + // Best-effort: the local UI dismisses regardless (below), but a failed + // decline used to reject uncaught. Log it instead of surfacing — the caller + // will time the call out on their side even if our decline never lands. mx.sendEvent(room.roomId, EventType.RTCDecline, { 'm.relates_to': { rel_type: RelationType.Reference, event_id: eventId, }, + }).catch((err) => { + console.error('Failed to send call decline:', err); }); setCallInfo(undefined); }, diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 37a3f74e4..cb5e8c305 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -26,7 +26,7 @@ import { } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import dayjs from 'dayjs'; import isToday from 'dayjs/plugin/isToday'; import isYesterday from 'dayjs/plugin/isYesterday'; @@ -68,6 +68,7 @@ import { import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { callChatAtom } from '../../state/callEmbed'; +import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; @@ -324,6 +325,7 @@ const RoomNavItemMenu = forwardRef( const canInvite = permissions.action('invite', mx.getSafeUserId()); const openRoomSettings = useOpenRoomSettings(); const space = useSpaceOptionally(); + const setToast = useSetAtom(toastQueueAtom); const [invitePrompt, setInvitePrompt] = useState(false); const [muteMenuAnchor, setMuteMenuAnchor] = useState(); @@ -332,23 +334,31 @@ const RoomNavItemMenu = forwardRef( const isFavorite = !!room.tags?.['m.favourite']; const isLowPriority = !!room.tags?.['m.lowpriority']; + // Surface a room-tag write failure instead of letting the toggle fail silently. + const notifyTagFailure = (err: unknown) => { + console.error('Failed to update room tag:', err); + setToast( + createErrorToast('Could not update this room. Please try again.', Icons.Warning, 'Failed'), + ); + }; + const handleToggleFavorite = () => { if (isFavorite) { - mx.deleteRoomTag(room.roomId, 'm.favourite'); + mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure); } else { // Favourite and low-priority are mutually exclusive. - if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority'); - mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }); + if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure); + mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }).catch(notifyTagFailure); } requestClose(); }; const handleToggleLowPriority = () => { if (isLowPriority) { - mx.deleteRoomTag(room.roomId, 'm.lowpriority'); + mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure); } else { - if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite'); - mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }); + if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure); + mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }).catch(notifyTagFailure); } requestClose(); }; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index dde70dfba..dae5489bb 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -136,6 +136,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { DraftIndicator } from './DraftIndicator'; import { scheduledMessagesAtom } from '../../state/scheduledMessages'; +import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { getThreadDraftKey } from '../../state/room/thread'; const GifPicker = React.lazy(() => @@ -184,6 +185,7 @@ export const RoomInput = forwardRef( const [scheduleOpen, setScheduleOpen] = useState(false); const [scheduleContent, setScheduleContent] = useState(null); const setScheduledMessages = useSetAtom(scheduledMessagesAtom); + const setToast = useSetAtom(toastQueueAtom); const alive = useAlive(); // Scope drafts/replies/uploads by thread so a thread composer stays fully @@ -539,7 +541,22 @@ export const RoomInput = forwardRef( } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { - commandContent.exe(plainText); + // Fire-and-forget by design (the editor resets immediately for UX), but + // surface a rejection instead of failing silently. NOTE: /kick and /ban + // route through rateLimitedActions (utils/matrix.ts), whose to() helper + // swallows non-429 errors, so those two commands can still resolve even + // when the underlying kick/ban failed — this catch only covers errors + // that actually reject out of exe(). + commandContent.exe(plainText).catch((err) => { + console.error(`Failed to run /${commandName} command:`, err); + setToast( + createErrorToast( + `The /${commandName} command failed. Please try again.`, + Icons.Warning, + 'Command failed', + ), + ); + }); } resetEditor(editor); resetEditorHistory(editor); @@ -600,6 +617,7 @@ export const RoomInput = forwardRef( setReplyDraft, isMarkdown, commands, + setToast, ]); /** diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 19aa4c0b6..6caeaaddd 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -291,7 +291,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }); } - rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason)); + // NOTE: rateLimitedActions' to() helper (utils/matrix.ts) swallows every + // non-429 error, so a failed kick (e.g. insufficient power level) resolves + // silently here — the RoomInput exe() catch cannot surface it. Propagating + // would require refactoring rateLimitedActions' shared error handling. + await rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason)); }, }, [Command.Ban]: { @@ -313,7 +317,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }); } - rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason)); + // See the /kick note: rateLimitedActions swallows non-429 errors, so a + // failed ban resolves silently and can't be surfaced by the exe() catch. + await rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason)); }, }, [Command.UnBan]: { diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts index 239d58f97..d656482ca 100644 --- a/src/app/state/toast.ts +++ b/src/app/state/toast.ts @@ -27,6 +27,23 @@ export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastN onClick: () => undefined, }); +// Build an error/system toast (e.g. a failed command or account action). Mirrors +// createDownloadToast: folds-free (icon src passed in), empty roomId + no-op +// onClick so a click only dismisses (never navigates to a room). +export const createErrorToast = ( + body: string, + iconSrc?: IconSrc, + displayName = 'Something went wrong', +): ToastNotif => ({ + id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + displayName, + body, + roomName: '', + roomId: '', + iconSrc, + onClick: () => undefined, +}); + const baseAtom = atom([]); // Write-only setter used in ClientNonUIFeatures