fix(errors): surface previously-silent async failures (DP1/DP4/DP6)
Several fire-and-forget promises failed silently: - RoomInput slash-command exe() rejections reset the editor as if they succeeded; now caught and shown via an error toast. - Favourite / low-priority room-tag toggles (setRoomTag/deleteRoomTag) swallowed rejections; now surfaced via the same error toast. - Call-decline sendEvent(RTCDecline) was uncaught; now logged best-effort while the local UI still dismisses. Adds a shared createErrorToast builder mirroring createDownloadToast. /kick and /ban route through rateLimitedActions, whose to() helper swallows non-429 errors, so those two can still resolve on failure — noted in code; the top-level exe() catch covers everything that does reject. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -544,11 +544,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
const handleReject = useCallback(
|
const handleReject = useCallback(
|
||||||
(room: Room, eventId: string) => {
|
(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, {
|
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
||||||
'm.relates_to': {
|
'm.relates_to': {
|
||||||
rel_type: RelationType.Reference,
|
rel_type: RelationType.Reference,
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
},
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to send call decline:', err);
|
||||||
});
|
});
|
||||||
setCallInfo(undefined);
|
setCallInfo(undefined);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useFocusWithin, useHover } from 'react-aria';
|
import { useFocusWithin, useHover } from 'react-aria';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import isToday from 'dayjs/plugin/isToday';
|
import isToday from 'dayjs/plugin/isToday';
|
||||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
|
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||||
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
@@ -324,6 +325,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
const openRoomSettings = useOpenRoomSettings();
|
const openRoomSettings = useOpenRoomSettings();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
||||||
@@ -332,23 +334,31 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const isFavorite = !!room.tags?.['m.favourite'];
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
const isLowPriority = !!room.tags?.['m.lowpriority'];
|
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 = () => {
|
const handleToggleFavorite = () => {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
|
||||||
} else {
|
} else {
|
||||||
// Favourite and low-priority are mutually exclusive.
|
// Favourite and low-priority are mutually exclusive.
|
||||||
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
|
||||||
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }).catch(notifyTagFailure);
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleLowPriority = () => {
|
const handleToggleLowPriority = () => {
|
||||||
if (isLowPriority) {
|
if (isLowPriority) {
|
||||||
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
|
||||||
} else {
|
} else {
|
||||||
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
|
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
|
||||||
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
|
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }).catch(notifyTagFailure);
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
|
|||||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||||
import { DraftIndicator } from './DraftIndicator';
|
import { DraftIndicator } from './DraftIndicator';
|
||||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
|
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||||
import { getThreadDraftKey } from '../../state/room/thread';
|
import { getThreadDraftKey } from '../../state/room/thread';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
@@ -184,6 +185,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||||
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
||||||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
||||||
@@ -539,7 +541,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
} else if (commandName) {
|
} else if (commandName) {
|
||||||
const commandContent = commands[commandName as Command];
|
const commandContent = commands[commandName as Command];
|
||||||
if (commandContent) {
|
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);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
@@ -600,6 +617,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setReplyDraft,
|
setReplyDraft,
|
||||||
isMarkdown,
|
isMarkdown,
|
||||||
commands,
|
commands,
|
||||||
|
setToast,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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]: {
|
[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]: {
|
[Command.UnBan]: {
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastN
|
|||||||
onClick: () => undefined,
|
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<ToastNotif[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|
||||||
// Write-only setter used in ClientNonUIFeatures
|
// Write-only setter used in ClientNonUIFeatures
|
||||||
|
|||||||
Reference in New Issue
Block a user