Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 203568c967 | |||
| 0394fce929 | |||
| d2946c00ce | |||
| b7e1f89c1d |
@@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
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) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
@@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||||
const usage = ImageUsage.Sticker;
|
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) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
|||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" gap="300" alignItems="Center">
|
<Box grow="Yes" gap="300" alignItems="Center">
|
||||||
|
<AvatarDecoration userId={userId}>
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={userId}
|
userId={userId}
|
||||||
@@ -59,6 +61,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
|||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
</AvatarDecoration>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="L400" truncate>
|
<Text size="L400" truncate>
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { RefObject, useRef } from 'react';
|
import React, { RefObject, useRef } from 'react';
|
||||||
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
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 { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { PrescreenControls } from './PrescreenControls';
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
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 = {
|
type CallJoinedProps = {
|
||||||
containerRef: RefObject<HTMLDivElement>;
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
@@ -175,8 +213,13 @@ export function CallView() {
|
|||||||
|
|
||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -184,8 +227,9 @@ export function CallView() {
|
|||||||
style={{ minWidth: toRem(280) }}
|
style={{ minWidth: toRem(280) }}
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
>
|
>
|
||||||
{!currentJoined && <CallPrescreen />}
|
{showLoadError && <CallLoadErrorMessage />}
|
||||||
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
|
{!currentJoined && !showLoadError && <CallPrescreen />}
|
||||||
|
{!showLoadError && <CallJoined joined={currentJoined} containerRef={callContainerRef} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = () => {
|
const handleShareLocation = useCallback(() => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setLocationError('Geolocation not supported.');
|
setLocationError('Geolocation not supported.');
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
@@ -252,7 +252,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
};
|
}, [mx, roomId]);
|
||||||
|
|
||||||
const handleVoiceSend = useCallback(
|
const handleVoiceSend = useCallback(
|
||||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||||
@@ -405,16 +405,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[setSelectedFiles, selectedFiles],
|
[setSelectedFiles, selectedFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancelUpload = (uploads: Upload[]) => {
|
const handleCancelUpload = useCallback(
|
||||||
|
(uploads: Upload[]) => {
|
||||||
uploads.forEach((upload) => {
|
uploads.forEach((upload) => {
|
||||||
if (upload.status === UploadStatus.Loading) {
|
if (upload.status === UploadStatus.Loading) {
|
||||||
mx.cancelUpload(upload.promise);
|
mx.cancelUpload(upload.promise);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||||||
};
|
},
|
||||||
|
[mx, handleRemoveUpload],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
const handleSendUpload = useCallback(
|
||||||
|
async (uploads: UploadSuccess[]) => {
|
||||||
const contentsPromises = uploads.map(async (upload) => {
|
const contentsPromises = uploads.map(async (upload) => {
|
||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
@@ -469,7 +473,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
||||||
};
|
},
|
||||||
|
[mx, roomId, selectedFiles, handleCancelUpload],
|
||||||
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
@@ -675,10 +681,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = useCallback(
|
||||||
|
(key: string, shortcode: string) => {
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
moveCursor(editor);
|
moveCursor(editor);
|
||||||
};
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGifSelect = useCallback(
|
const handleGifSelect = useCallback(
|
||||||
async (gifUrl: string, w: number, h: number) => {
|
async (gifUrl: string, w: number, h: number) => {
|
||||||
@@ -736,7 +745,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[mx, roomId, alive],
|
[mx, roomId, alive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
const handleStickerSelect = useCallback(
|
||||||
|
async (mxc: string, shortcode: string, label: string) => {
|
||||||
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||||||
if (!stickerUrl) return;
|
if (!stickerUrl) return;
|
||||||
|
|
||||||
@@ -750,7 +760,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
url: mxc,
|
url: mxc,
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[mx, roomId, useAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
if (room.getType() === 'm.server_notice') {
|
if (room.getType() === 'm.server_notice') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -906,25 +906,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [scrollToElement, editId]);
|
}, [scrollToElement, editId]);
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = useCallback(() => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
};
|
}, [eventId, navigateRoom, room]);
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = useCallback(() => {
|
||||||
if (unreadInfo?.readUptoEventId) {
|
if (unreadInfo?.readUptoEventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
loadEventTimeline(unreadInfo.readUptoEventId);
|
loadEventTimeline(unreadInfo.readUptoEventId);
|
||||||
}
|
}
|
||||||
};
|
}, [unreadInfo, loadEventTimeline]);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = useCallback(() => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
};
|
}, [mx, room, hideActivity]);
|
||||||
|
|
||||||
const handleOpenReply: MouseEventHandler = useCallback(
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
async (evt) => {
|
async (evt) => {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const getImageMsgContent = async (
|
|||||||
): Promise<IContent> => {
|
): Promise<IContent> => {
|
||||||
const { file, originalFile, encInfo, metadata } = item;
|
const { file, originalFile, encInfo, metadata } = item;
|
||||||
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
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 = {
|
const content: IContent = {
|
||||||
msgtype: MsgType.Image,
|
msgtype: MsgType.Image,
|
||||||
@@ -85,7 +85,8 @@ export const getVideoMsgContent = async (
|
|||||||
const { file, originalFile, encInfo, metadata } = item;
|
const { file, originalFile, encInfo, metadata } = item;
|
||||||
|
|
||||||
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
|
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 = {
|
const content: IContent = {
|
||||||
msgtype: MsgType.Video,
|
msgtype: MsgType.Video,
|
||||||
@@ -109,7 +110,8 @@ export const getVideoMsgContent = async (
|
|||||||
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
|
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 = {
|
content.info = {
|
||||||
...getVideoInfo(videoEl, file),
|
...getVideoInfo(videoEl, file),
|
||||||
...thumbContent,
|
...thumbContent,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
|
|||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
CallEmbed,
|
CallEmbed,
|
||||||
|
CallLoadErrorReason,
|
||||||
ElementCallThemeKind,
|
ElementCallThemeKind,
|
||||||
ElementWidgetActions,
|
ElementWidgetActions,
|
||||||
useClientWidgetApiEvent,
|
useClientWidgetApiEvent,
|
||||||
@@ -156,6 +157,26 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
|||||||
return joined;
|
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) => {
|
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export function usePresenceUpdater() {
|
|||||||
const readStatus = () =>
|
const readStatus = () =>
|
||||||
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
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 setOnline = () => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
return mx
|
return mx
|
||||||
@@ -30,7 +37,7 @@ export function usePresenceUpdater() {
|
|||||||
presence: 'online',
|
presence: 'online',
|
||||||
...(status ? { status_msg: status } : {}),
|
...(status ? { status_msg: status } : {}),
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((err) => warnPresenceFailure('online', err));
|
||||||
};
|
};
|
||||||
const setUnavailable = (statusMsg?: string) => {
|
const setUnavailable = (statusMsg?: string) => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
@@ -39,10 +46,12 @@ export function usePresenceUpdater() {
|
|||||||
presence: 'unavailable',
|
presence: 'unavailable',
|
||||||
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((err) => warnPresenceFailure('unavailable', err));
|
||||||
};
|
};
|
||||||
const setOffline = () =>
|
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.
|
// Manual presence overrides — no activity tracking needed.
|
||||||
if (hidePresence || presenceStatus === 'invisible') {
|
if (hidePresence || presenceStatus === 'invisible') {
|
||||||
@@ -100,6 +109,11 @@ export function usePresenceUpdater() {
|
|||||||
const baseUrl = mx.getHomeserverUrl();
|
const baseUrl = mx.getHomeserverUrl();
|
||||||
if (!userId || !token || !baseUrl) return;
|
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`, {
|
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -108,7 +122,7 @@ export function usePresenceUpdater() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ presence: 'offline' }),
|
body: JSON.stringify({ presence: 'offline' }),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => undefined);
|
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
|
||||||
};
|
};
|
||||||
|
|
||||||
setOnline();
|
setOnline();
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import {
|
|||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
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 {
|
export class CallEmbed {
|
||||||
private mx: MatrixClient;
|
private mx: MatrixClient;
|
||||||
|
|
||||||
@@ -55,6 +63,15 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private themeKind: ElementCallThemeKind = 'dark';
|
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()
|
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
||||||
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
||||||
|
|
||||||
@@ -218,6 +235,19 @@ export class CallEmbed {
|
|||||||
iframe.onload = () => {
|
iframe.onload = () => {
|
||||||
this.control.startObserving();
|
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;
|
let initialMediaEvent = true;
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
@@ -314,6 +344,8 @@ export class CallEmbed {
|
|||||||
this.disposables.forEach((disposable) => {
|
this.disposables.forEach((disposable) => {
|
||||||
disposable();
|
disposable();
|
||||||
});
|
});
|
||||||
|
this.clearLoadWatchdog();
|
||||||
|
this.loadErrorListeners.clear();
|
||||||
this.styleRetryObserver?.disconnect();
|
this.styleRetryObserver?.disconnect();
|
||||||
this.call.stop();
|
this.call.stop();
|
||||||
this.container.removeChild(this.iframe);
|
this.container.removeChild(this.iframe);
|
||||||
@@ -329,7 +361,57 @@ export class CallEmbed {
|
|||||||
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
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 {
|
private onCallJoined(): void {
|
||||||
|
this.settleLoad();
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.applyStyles();
|
this.applyStyles();
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
|
|||||||
+82
-15
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'browser-encrypt-attachment';
|
} from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
|
EventType,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixError,
|
MatrixError,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
|
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 { getStateEvent } from './room';
|
||||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
@@ -145,6 +146,42 @@ export type ContentUploadOptions = {
|
|||||||
onError: (error: MatrixError) => void;
|
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 (
|
export const uploadContent = async (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
file: TUploadContent,
|
file: TUploadContent,
|
||||||
@@ -152,6 +189,14 @@ export const uploadContent = async (
|
|||||||
) => {
|
) => {
|
||||||
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
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, {
|
const uploadPromise = mx.uploadContent(file, {
|
||||||
name,
|
name,
|
||||||
type: fileType,
|
type: fileType,
|
||||||
@@ -159,16 +204,38 @@ export const uploadContent = async (
|
|||||||
progressHandler: onProgress,
|
progressHandler: onProgress,
|
||||||
});
|
});
|
||||||
onPromise?.(uploadPromise);
|
onPromise?.(uploadPromise);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const data = await uploadPromise;
|
const data = await uploadPromise;
|
||||||
const mxc = data.content_uri;
|
const mxc = data.content_uri;
|
||||||
if (mxc) onSuccess(mxc);
|
if (mxc) {
|
||||||
else onError(new MatrixError(data));
|
onSuccess(mxc);
|
||||||
} catch (e: any) {
|
return;
|
||||||
const error = typeof e?.message === 'string' ? e.message : undefined;
|
|
||||||
const errcode = typeof e?.name === 'string' ? e.message : undefined;
|
|
||||||
onError(new MatrixError({ error, errcode }));
|
|
||||||
}
|
}
|
||||||
|
// 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();
|
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
|
||||||
@@ -230,11 +297,11 @@ export const addRoomIdToMDirect = async (
|
|||||||
roomId: string,
|
roomId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
const mDirectsEvent = mx.getAccountData(EventType.Direct);
|
||||||
let userIdToRoomIds: Record<string, string[]> = {};
|
let userIdToRoomIds: MDirectContent = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined')
|
if (typeof mDirectsEvent !== 'undefined')
|
||||||
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
|
||||||
|
|
||||||
// remove it from the lists of any others users
|
// remove it from the lists of any others users
|
||||||
// (it can only be a DM room for one person)
|
// (it can only be a DM room for one person)
|
||||||
@@ -255,15 +322,15 @@ export const addRoomIdToMDirect = async (
|
|||||||
}
|
}
|
||||||
userIdToRoomIds[userId] = roomIds;
|
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> => {
|
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
|
||||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
const mDirectsEvent = mx.getAccountData(EventType.Direct);
|
||||||
let userIdToRoomIds: Record<string, string[]> = {};
|
let userIdToRoomIds: MDirectContent = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined')
|
if (typeof mDirectsEvent !== 'undefined')
|
||||||
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
|
||||||
|
|
||||||
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
||||||
const roomIds = userIdToRoomIds[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 = (
|
export const mxcUrlToHttp = (
|
||||||
|
|||||||
Reference in New Issue
Block a user