Compare commits

..

4 Commits

Author SHA1 Message Date
jared 203568c967 fix(logging): redact PII from media-error console warnings
CI / Build & Quality Checks (push) Successful in 11m5s
CI / Trigger Desktop Build (push) Successful in 17s
msgContent media load/thumbnail failures now log only the error name+message,
not the full error/event object that may carry content data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:22:01 -04:00
jared 0394fce929 feat(calls): EC iframe load watchdog + recovery UI; avatar decorations on call tiles
- CallEmbed: 25s load watchdog that fails fast on iframe error / preparing-error /
  timeout instead of hanging on a permanent spinner; additive onLoadError API,
  cleared on ready/capabilities/joined.
- CallView: user-visible "call failed to load" overlay with Retry/Leave (folds +
  tokens) via a new useCallLoadError hook.
- CallMemberCard: wrap the participant avatar in AvatarDecoration so decorations
  render in the call roster (the tile rendered UserAvatar bare while member lists
  already wrapped it).

Addresses LOTUS_BUGS item 3 (avatar decorations in calls) and EC iframe failure monitoring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:22:01 -04:00
jared d2946c00ce fix(matrix): upload retry/backoff, robust MatrixError, typed m.direct, reliable presence on unload
- uploadContent: bounded retry (max 3) reusing rateLimitedActions' capped
  exponential backoff; retries only transient failures (network/429/5xx), never 4xx.
- Robust MatrixError construction from UploadResponse / unknown error shapes.
- addRoomIdToMDirect/removeRoomIdFromMDirect: drop `as any`, use typed
  EventType.Direct + MDirectContent.
- usePresenceUpdater: keep fetch({keepalive}) for the unload offline-presence
  update (sendBeacon can't set the auth header) and log redacted warnings instead
  of silently swallowing presence errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:22:00 -04:00
jared b7e1f89c1d perf(room): memoize timeline/composer handlers and emoji-pack room lookups
- RoomTimeline: wrap jump-to-latest/unread + mark-as-read handlers in useCallback
  (the handlers passed to memoized message children were already memoized).
- RoomInput: wrap file/upload/emoji/sticker/location callbacks in useCallback so
  the editor and toolbar don't re-render needlessly.
- EmojiBoard: hoist repeated mx.getRoom() pack-label lookups into a useMemo'd map
  in the emoji and sticker sidebars (previously called per-render in map loops).

Behavior unchanged. (RoomTimeline/RoomInput already have ErrorBoundary wrappers
in RoomView, so no boundary added.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:22:00 -04:00
10 changed files with 397 additions and 134 deletions
+22 -4
View File
@@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
const labels = useEmojiGroupLabels();
const icons = useEmojiGroupIcons();
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
packs.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
map.set(pack.id, label);
});
return map;
}, [mx, packs]);
const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId);
onScrollToGroup(groupId);
@@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
<SidebarStack>
<SidebarDivider />
{packs.map((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const label = packLabels.get(pack.id);
const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
@@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
const usage = ImageUsage.Sticker;
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
packs.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
map.set(pack.id, label);
});
return map;
}, [mx, packs]);
const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId);
onScrollToGroup(groupId);
@@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
<Sidebar>
<SidebarStack>
{packs.map((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const label = packLabels.get(pack.id);
const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
+11 -8
View File
@@ -9,6 +9,7 @@ import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useRoom } from '../../hooks/useRoom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
@@ -51,14 +52,16 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
}
>
<Box grow="Yes" gap="300" alignItems="Center">
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<AvatarDecoration userId={userId}>
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</AvatarDecoration>
<Box grow="Yes">
<Text size="L400" truncate>
{name}
+49 -5
View File
@@ -1,6 +1,13 @@
import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { useSetAtom } from 'jotai';
import { Badge, Box, Button, color, Header, Icon, Icons, Scroll, Text, toRem } from 'folds';
import {
useCallEmbed,
useCallJoined,
useCallEmbedPlacementSync,
useCallLoadError,
} from '../../hooks/useCallEmbed';
import { callEmbedAtom } from '../../state/callEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@@ -153,6 +160,37 @@ function CallPrescreen() {
);
}
function CallLoadErrorMessage() {
const setCallEmbed = useSetAtom(callEmbedAtom);
// Disposing the embed tears down the hung iframe and returns the user to the
// prescreen, from which they can join again ("Retry") or simply walk away.
const dismiss = () => setCallEmbed(undefined);
return (
<Box
className={css.CallViewContent}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Warning} size="400" style={{ color: color.Critical.Main }} />
<Text style={{ color: color.Critical.Main }} size="L400" align="Center">
The call failed to load. Check your connection and try again.
</Text>
<Box gap="200" alignItems="Center">
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Retry</Text>
</Button>
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Leave</Text>
</Button>
</Box>
</Box>
);
}
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
@@ -175,8 +213,13 @@ export function CallView() {
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const loadError = useCallLoadError(callEmbed);
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
const isCurrentRoom = callEmbed?.roomId === room.roomId;
const currentJoined = isCurrentRoom && callJoined;
// Show the recovery UI when this room's embed failed to load and we never
// made it into the call (a hung iframe / blank spinner otherwise).
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
return (
<Box
@@ -184,8 +227,9 @@ export function CallView() {
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && <CallPrescreen />}
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
{showLoadError && <CallLoadErrorMessage />}
{!currentJoined && !showLoadError && <CallPrescreen />}
{!showLoadError && <CallJoined joined={currentJoined} containerRef={callContainerRef} />}
</Box>
);
}
+91 -79
View File
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = () => {
const handleShareLocation = useCallback(() => {
if (!navigator.geolocation) {
setLocationError('Geolocation not supported.');
setTimeout(() => setLocationError(null), 4000);
@@ -252,7 +252,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ timeout: 10000 },
);
};
}, [mx, roomId]);
const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -405,71 +405,77 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[setSelectedFiles, selectedFiles],
);
const handleCancelUpload = (uploads: Upload[]) => {
uploads.forEach((upload) => {
if (upload.status === UploadStatus.Loading) {
mx.cancelUpload(upload.promise);
}
});
handleRemoveUpload(uploads.map((upload) => upload.file));
};
const handleSendUpload = async (uploads: UploadSuccess[]) => {
const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
// Resolve the MXC URL to use — may be overridden if compression is enabled
let mxc = upload.mxc;
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
// Use the cached compression result if available, otherwise compute it now
let compressionResult = fileItem.metadata.compressionResult;
if (compressionResult === undefined) {
compressionResult = await compressImage(fileItem.originalFile);
const handleCancelUpload = useCallback(
(uploads: Upload[]) => {
uploads.forEach((upload) => {
if (upload.status === UploadStatus.Loading) {
mx.cancelUpload(upload.promise);
}
});
handleRemoveUpload(uploads.map((upload) => upload.file));
},
[mx, handleRemoveUpload],
);
if (compressionResult) {
const originalFile = fileItem.originalFile as File;
const compressedFile = new File([compressionResult.blob], originalFile.name, {
type: 'image/jpeg',
});
const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name,
type: 'image/jpeg',
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
// Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc;
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
return getImageMsgContent(mx, compressedItem, mxc);
const handleSendUpload = useCallback(
async (uploads: UploadSuccess[]) => {
const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
// Resolve the MXC URL to use — may be overridden if compression is enabled
let mxc = upload.mxc;
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
// Use the cached compression result if available, otherwise compute it now
let compressionResult = fileItem.metadata.compressionResult;
if (compressionResult === undefined) {
compressionResult = await compressImage(fileItem.originalFile);
}
if (compressionResult) {
const originalFile = fileItem.originalFile as File;
const compressedFile = new File([compressionResult.blob], originalFile.name, {
type: 'image/jpeg',
});
const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name,
type: 'image/jpeg',
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
// Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc;
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
return getImageMsgContent(mx, compressedItem, mxc);
}
}
}
}
if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, mxc);
}
if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, mxc);
}
if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, mxc);
}
return getFileMsgContent(fileItem, mxc);
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any));
};
if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, mxc);
}
if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, mxc);
}
if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, mxc);
}
return getFileMsgContent(fileItem, mxc);
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any));
},
[mx, roomId, selectedFiles, handleCancelUpload],
);
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
@@ -675,10 +681,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
ReactEditor.focus(editor);
}, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
moveCursor(editor);
};
const handleEmoticonSelect = useCallback(
(key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
moveCursor(editor);
},
[editor],
);
const handleGifSelect = useCallback(
async (gifUrl: string, w: number, h: number) => {
@@ -736,21 +745,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[mx, roomId, alive],
);
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
if (!stickerUrl) return;
const handleStickerSelect = useCallback(
async (mxc: string, shortcode: string, label: string) => {
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
if (!stickerUrl) return;
const info = await getImageInfo(
await loadImageElement(stickerUrl),
await getImageUrlBlob(stickerUrl),
);
const info = await getImageInfo(
await loadImageElement(stickerUrl),
await getImageUrlBlob(stickerUrl),
);
mx.sendEvent(roomId, EventType.Sticker, {
body: label,
url: mxc,
info,
});
};
mx.sendEvent(roomId, EventType.Sticker, {
body: label,
url: mxc,
info,
});
},
[mx, roomId, useAuthentication],
);
if (room.getType() === 'm.server_notice') {
return (
+6 -6
View File
@@ -906,25 +906,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [scrollToElement, editId]);
const handleJumpToLatest = () => {
const handleJumpToLatest = useCallback(() => {
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
};
}, [eventId, navigateRoom, room]);
const handleJumpToUnread = () => {
const handleJumpToUnread = useCallback(() => {
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
loadEventTimeline(unreadInfo.readUptoEventId);
}
};
}, [unreadInfo, loadEventTimeline]);
const handleMarkAsRead = () => {
const handleMarkAsRead = useCallback(() => {
markAsRead(mx, room.roomId, hideActivity);
};
}, [mx, room, hideActivity]);
const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => {
+5 -3
View File
@@ -50,7 +50,7 @@ export const getImageMsgContent = async (
): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn('Failed to load image element:', imgError.message);
if (imgError) console.warn('Failed to load image element:', imgError.name, imgError.message);
const content: IContent = {
msgtype: MsgType.Image,
@@ -85,7 +85,8 @@ export const getVideoMsgContent = async (
const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn('Failed to load video element:', videoError.message);
if (videoError)
console.warn('Failed to load video element:', videoError.name, videoError.message);
const content: IContent = {
msgtype: MsgType.Video,
@@ -109,7 +110,8 @@ export const getVideoMsgContent = async (
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
);
}
if (thumbError) console.warn('Failed to generate video thumbnail:', thumbError.message);
if (thumbError)
console.warn('Failed to generate video thumbnail:', thumbError.name, thumbError.message);
content.info = {
...getVideoInfo(videoEl, file),
...thumbContent,
+21
View File
@@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import {
CallEmbed,
CallLoadErrorReason,
ElementCallThemeKind,
ElementWidgetActions,
useClientWidgetApiEvent,
@@ -156,6 +157,26 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
return joined;
};
/**
* Surfaces a load failure (watchdog timeout or iframe error) from the embedded
* Element Call iframe so the UI can show a recovery affordance instead of an
* indefinite "Loading..." spinner.
*/
export const useCallLoadError = (embed?: CallEmbed): CallLoadErrorReason | undefined => {
const [error, setError] = useState<CallLoadErrorReason | undefined>(() => embed?.loadFailed);
useEffect(() => {
if (!embed) {
setError(undefined);
return undefined;
}
setError(embed.loadFailed);
return embed.onLoadError((reason) => setError(reason));
}, [embed]);
return error;
};
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
+18 -4
View File
@@ -23,6 +23,13 @@ export function usePresenceUpdater() {
const readStatus = () =>
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
// Log presence failures without leaking PII (user id, token, status message).
const warnPresenceFailure = (presence: string, err: unknown) => {
const reason =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'unknown error';
console.warn(`Failed to set presence to "${presence}":`, reason);
};
const setOnline = () => {
const status = readStatus();
return mx
@@ -30,7 +37,7 @@ export function usePresenceUpdater() {
presence: 'online',
...(status ? { status_msg: status } : {}),
})
.catch(() => undefined);
.catch((err) => warnPresenceFailure('online', err));
};
const setUnavailable = (statusMsg?: string) => {
const status = readStatus();
@@ -39,10 +46,12 @@ export function usePresenceUpdater() {
presence: 'unavailable',
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
})
.catch(() => undefined);
.catch((err) => warnPresenceFailure('unavailable', err));
};
const setOffline = () =>
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
mx
.setPresence({ presence: 'offline', status_msg: '' })
.catch((err) => warnPresenceFailure('offline', err));
// Manual presence overrides — no activity tracking needed.
if (hidePresence || presenceStatus === 'invisible') {
@@ -100,6 +109,11 @@ export function usePresenceUpdater() {
const baseUrl = mx.getHomeserverUrl();
if (!userId || !token || !baseUrl) return;
// Reliable delivery during page teardown: navigator.sendBeacon cannot set the
// Authorization header required by the authenticated Matrix presence endpoint, so
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.)
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: {
@@ -108,7 +122,7 @@ export function usePresenceUpdater() {
},
body: JSON.stringify({ presence: 'offline' }),
keepalive: true,
}).catch(() => undefined);
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
};
setOnline();
+82
View File
@@ -28,6 +28,14 @@ import {
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
// iframe has hung (e.g. blocked network, crashed widget, blank "Loading...")
// and surface a recoverable error instead of an indefinite spinner.
const CALL_LOAD_WATCHDOG_MS = 25_000;
export type CallLoadErrorReason = 'timeout' | 'iframe';
export class CallEmbed {
private mx: MatrixClient;
@@ -55,6 +63,15 @@ export class CallEmbed {
private themeKind: ElementCallThemeKind = 'dark';
// Watchdog: detects an iframe that never reaches a usable state.
private loadWatchdog?: ReturnType<typeof setTimeout>;
private loadSettled = false;
private loadError?: CallLoadErrorReason;
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => void>();
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
@@ -218,6 +235,19 @@ export class CallEmbed {
iframe.onload = () => {
this.control.startObserving();
};
// If the iframe document itself fails to load, fail fast.
iframe.onerror = () => {
this.settleLoad('iframe');
};
// Clear the watchdog as soon as the call reaches any usable state. The
// happy path (onCallJoined) clears it too; these cover earlier signals so a
// user sitting in the lobby/prescreen isn't flagged as an error.
this.disposables.push(this.onReady(() => this.settleLoad()));
this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
this.startLoadWatchdog();
let initialMediaEvent = true;
this.disposables.push(
@@ -314,6 +344,8 @@ export class CallEmbed {
this.disposables.forEach((disposable) => {
disposable();
});
this.clearLoadWatchdog();
this.loadErrorListeners.clear();
this.styleRetryObserver?.disconnect();
this.call.stop();
this.container.removeChild(this.iframe);
@@ -329,7 +361,57 @@ export class CallEmbed {
this.eventsToFeed = new WeakSet<MatrixEvent>();
}
private startLoadWatchdog(): void {
if (this.loadWatchdog !== undefined) return;
this.loadWatchdog = setTimeout(() => {
this.settleLoad('timeout');
}, CALL_LOAD_WATCHDOG_MS);
}
private clearLoadWatchdog(): void {
if (this.loadWatchdog !== undefined) {
clearTimeout(this.loadWatchdog);
this.loadWatchdog = undefined;
}
}
/**
* Marks the load lifecycle as settled. Called on success (no reason) or on
* failure (reason set). Idempotent so the first signal wins.
*/
private settleLoad(reason?: CallLoadErrorReason): void {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
if (reason) {
this.loadError = reason;
this.loadErrorListeners.forEach((cb) => cb(reason));
}
}
/**
* Whether the call failed to load within the watchdog window or errored.
*/
public get loadFailed(): CallLoadErrorReason | undefined {
return this.loadError;
}
/**
* Subscribe to load-failure events (watchdog timeout or iframe error). If the
* load has already failed by the time of subscription, the callback fires
* immediately so late subscribers still see the error.
* @returns an unsubscribe function.
*/
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void {
this.loadErrorListeners.add(callback);
if (this.loadError) callback(this.loadError);
return () => {
this.loadErrorListeners.delete(callback);
};
}
private onCallJoined(): void {
this.settleLoad();
this.joined = true;
this.applyStyles();
this.control.startObserving();
+92 -25
View File
@@ -5,6 +5,7 @@ import {
} from 'browser-encrypt-attachment';
import {
EventTimeline,
EventType,
MatrixClient,
MatrixError,
MatrixEvent,
@@ -15,7 +16,7 @@ import {
} from 'matrix-js-sdk';
import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { MDirectContent } from '../../types/matrix/accountData';
import { getStateEvent } from './room';
import { Membership, StateEvent } from '../../types/matrix/room';
@@ -145,6 +146,42 @@ export type ContentUploadOptions = {
onError: (error: MatrixError) => void;
};
// Build a MatrixError defensively from an unexpected upload response.
// MatrixError's constructor expects an IErrorJson ({ errcode?, error?, ... }), not a
// raw UploadResponse, so we guard the shape and only forward string fields it understands.
const matrixErrorFromUploadResponse = (data: UploadResponse): MatrixError => {
const errorJson: { errcode?: string; error?: string } = {};
const maybe = data as Partial<{ errcode: unknown; error: unknown }>;
if (typeof maybe.errcode === 'string') errorJson.errcode = maybe.errcode;
if (typeof maybe.error === 'string') errorJson.error = maybe.error;
if (!errorJson.error) errorJson.error = 'Upload failed: missing content_uri in response';
return new MatrixError(errorJson);
};
const matrixErrorFromUnknown = (e: unknown): MatrixError => {
if (e instanceof MatrixError) return e;
const err = e as Partial<{ message: unknown; errcode: unknown }> | null | undefined;
const error = typeof err?.message === 'string' ? err.message : 'Upload failed';
const errcode = typeof err?.errcode === 'string' ? err.errcode : undefined;
return new MatrixError({ error, errcode });
};
// HTTP statuses that should not be retried — client errors are deterministic
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
const isRetryableUploadError = (e: unknown): boolean => {
if (e instanceof MatrixError) {
const status = e.httpStatus;
// No status => network/transport failure (transient): retry.
if (typeof status !== 'number') return true;
// Retry on rate-limiting and server-side (5xx) errors only.
return status === 429 || status >= 500;
}
// Non-Matrix errors are typically network/transport failures: retry.
return true;
};
const UPLOAD_MAX_RETRY_COUNT = 3;
export const uploadContent = async (
mx: MatrixClient,
file: TUploadContent,
@@ -152,23 +189,53 @@ export const uploadContent = async (
) => {
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
const uploadPromise = mx.uploadContent(file, {
name,
type: fileType,
includeFilename: !hideFilename,
progressHandler: onProgress,
});
onPromise?.(uploadPromise);
try {
const data = await uploadPromise;
const mxc = data.content_uri;
if (mxc) onSuccess(mxc);
else onError(new MatrixError(data));
} catch (e: any) {
const error = typeof e?.message === 'string' ? e.message : undefined;
const errcode = typeof e?.name === 'string' ? e.message : undefined;
onError(new MatrixError({ error, errcode }));
const sleepForMs = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
let lastError: MatrixError | undefined;
for (let retryCount = 0; retryCount <= UPLOAD_MAX_RETRY_COUNT; retryCount += 1) {
const uploadPromise = mx.uploadContent(file, {
name,
type: fileType,
includeFilename: !hideFilename,
progressHandler: onProgress,
});
onPromise?.(uploadPromise);
try {
// eslint-disable-next-line no-await-in-loop
const data = await uploadPromise;
const mxc = data.content_uri;
if (mxc) {
onSuccess(mxc);
return;
}
// Missing content_uri is not a transient failure — fail immediately.
onError(matrixErrorFromUploadResponse(data));
return;
} catch (e: unknown) {
lastError = matrixErrorFromUnknown(e);
if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e)) {
onError(lastError);
return;
}
// Respect server Retry-After header; fall back to capped exponential backoff,
// mirroring rateLimitedActions (min(1000 * 2^retryCount, 30_000)ms).
const waitMS =
(e instanceof MatrixError ? e.getRetryAfterMs() : null) ??
Math.min(1000 * 2 ** retryCount, 30_000);
// eslint-disable-next-line no-await-in-loop
await sleepForMs(waitMS);
}
}
// Unreachable in practice, but keeps onError guaranteed if the loop exits.
if (lastError) onError(lastError);
};
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
@@ -230,11 +297,11 @@ export const addRoomIdToMDirect = async (
roomId: string,
userId: string,
): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
const mDirectsEvent = mx.getAccountData(EventType.Direct);
let userIdToRoomIds: MDirectContent = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
// remove it from the lists of any others users
// (it can only be a DM room for one person)
@@ -255,15 +322,15 @@ export const addRoomIdToMDirect = async (
}
userIdToRoomIds[userId] = roomIds;
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
await mx.setAccountData(EventType.Direct, userIdToRoomIds);
};
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
const mDirectsEvent = mx.getAccountData(EventType.Direct);
let userIdToRoomIds: MDirectContent = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
@@ -273,7 +340,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
}
});
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
await mx.setAccountData(EventType.Direct, userIdToRoomIds);
};
export const mxcUrlToHttp = (