Compare commits
4 Commits
c0f9867218
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 203568c967 | |||
| 0394fce929 | |||
| d2946c00ce | |||
| b7e1f89c1d |
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user