Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f3c5d370a | ||
|
|
4120ccdbcc | ||
|
|
ed5bf997b4 | ||
|
|
878a34712a | ||
|
|
62672787a0 | ||
|
|
0746e80941 | ||
|
|
6300e4170e | ||
|
|
7250322977 | ||
|
|
ac1a1c9a48 | ||
|
|
8645a55769 | ||
|
|
c3f897b6d1 | ||
|
|
8784a8158c | ||
|
|
f920fd82fb | ||
|
|
42040b6412 | ||
|
|
ccbafdb147 | ||
|
|
60c0b9ad30 | ||
|
|
1873285371 |
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.27",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.27/element-call-embedded-0.25.0-lotus.27.tgz",
|
||||
"integrity": "sha512-24ZAvGR/t0C6LWFygZkvtk7HalPUvjERDWWkxpJJf4AZCDOJGuhoOuBrjmO+tUGwIrgLOTN6EFAWSaPpTU9rlQ==",
|
||||
"version": "0.25.0-lotus.29",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.29/element-call-embedded-0.25.0-lotus.29.tgz",
|
||||
"integrity": "sha512-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/* --max-warnings 36",
|
||||
"check:eslint": "eslint src/* --max-warnings 29",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FormEventHandler, useCallback } from 'react';
|
||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage';
|
||||
import { PasswordInput } from './password-input';
|
||||
import {
|
||||
SecretStorageKeyContent,
|
||||
@@ -39,7 +40,10 @@ export function SecretStorageRecoveryPassphrase({
|
||||
bits,
|
||||
);
|
||||
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
const match = await mx.secretStorage.checkKey(
|
||||
decodedRecoveryKey,
|
||||
keyContent as SecretStorageKeyDescription,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid recovery passphrase.');
|
||||
@@ -132,7 +136,10 @@ export function SecretStorageRecoveryKey({
|
||||
async (recoveryKey) => {
|
||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
const match = await mx.secretStorage.checkKey(
|
||||
decodedRecoveryKey,
|
||||
keyContent as SecretStorageKeyDescription,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid recovery key.');
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { callSummaryViewAtom } from '../../state/callSummary';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
/**
|
||||
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
|
||||
* shows who was in the call, connection quality and whose side it was on,
|
||||
* network numbers and your own activity (technical wording).
|
||||
*/
|
||||
export function CallSummaryDialog() {
|
||||
const [view, setView] = useAtom(callSummaryViewAtom);
|
||||
const modalStyle = useModalStyle(400);
|
||||
if (!view) return null;
|
||||
const close = () => setView(undefined);
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="call-summary-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
size="500"
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Phone} size="100" />
|
||||
<Text id="call-summary-title" size="H4" truncate>
|
||||
Call summary · {view.roomName}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{view.sections.map((section) => (
|
||||
<Box key={section.title} direction="Column" gap="100">
|
||||
<Text size="L400">{section.title}</Text>
|
||||
{section.lines.map((line) => (
|
||||
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AccountDataSubmitCallback,
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { setRoomAccountData } from '../../../utils/accountData';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
@@ -58,8 +59,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
|
||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||
async (type, content) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.setRoomAccountData(room.roomId, type as any, content);
|
||||
await setRoomAccountData(mx, room.roomId, type, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { IContent, MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
@@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||
import { Cursor } from '../../../plugins/text-area';
|
||||
import { sendRoomEvent, sendStateEvent } from '../../../utils/room';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
@@ -51,9 +52,9 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
||||
useCallback(
|
||||
(evtType, evtStateKey, evtContent) => {
|
||||
if (typeof evtStateKey === 'string') {
|
||||
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
|
||||
return sendStateEvent(mx, room.roomId, evtType, evtContent, evtStateKey);
|
||||
}
|
||||
return mx.sendEvent(room.roomId, evtType as any, evtContent);
|
||||
return sendRoomEvent(mx, room.roomId, null, evtType, evtContent as IContent);
|
||||
},
|
||||
[mx, room],
|
||||
),
|
||||
|
||||
@@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
@@ -59,7 +60,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
||||
|
||||
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
||||
useCallback(
|
||||
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
|
||||
(c) => sendStateEvent(mx, room.roomId, type, c, stateKey),
|
||||
[mx, room, type, stateKey],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||
|
||||
export const MessageBase = style({
|
||||
@@ -77,3 +77,14 @@ export const ReactionsMore = style([
|
||||
export const ReactionsTooltipText = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
// [Gitea #199] Long-press opens the message action sheet on touch screens. On
|
||||
// iOS a long-press otherwise belongs to the system: a link/image callout, or
|
||||
// the text-selection loupe, both of which race (and win against) the 450 ms
|
||||
// press. No callout on message rows, and no selection while a finger is
|
||||
// down; copying text is in the sheet.
|
||||
globalStyle('[data-touch-actions]', { WebkitTouchCallout: 'none' });
|
||||
globalStyle('[data-touch-actions][data-pressing], [data-touch-actions][data-pressing] *', {
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { Box, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
destroyAllWidgetSessions,
|
||||
destroyWidgetSession,
|
||||
FLOATING_WIDGET,
|
||||
floatingWidgetSessions,
|
||||
subscribeWidgetSessions,
|
||||
widgetSessionsVersion,
|
||||
} from './widgetSessions';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { widgetViewingAtom, widgetsPanelAtom } from '../../../state/widgetsPanel';
|
||||
import { zIndices } from '../../../styles/zIndex';
|
||||
|
||||
/**
|
||||
* [Gitea #205] Title bar for a widget kept on screen (MSC2931) after its room
|
||||
* or the Widgets panel was left. The widget's own frame is positioned by
|
||||
* widgetSessions.ts right below this bar. Mounted once for the signed-in
|
||||
* client; unmounting (logout) stops every widget.
|
||||
*/
|
||||
export function FloatingWidget() {
|
||||
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const setPanel = useSetAtom(widgetsPanelAtom);
|
||||
const setViewing = useSetAtom(widgetViewingAtom);
|
||||
useEffect(() => () => destroyAllWidgetSessions(), []);
|
||||
|
||||
const session = floatingWidgetSessions()[0];
|
||||
if (!session) return null;
|
||||
const f = FLOATING_WIDGET;
|
||||
const back = () => {
|
||||
setViewing((prev) => ({ ...prev, [session.roomId]: session.widgetId }));
|
||||
setPanel(true);
|
||||
navigateRoom(session.roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label={`${session.name} (kept on screen)`}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: f.right,
|
||||
bottom: f.bottom + f.height,
|
||||
width: f.width,
|
||||
height: f.bar,
|
||||
zIndex: zIndices.floatingWidget,
|
||||
padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
|
||||
background: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
borderRadius: '8px 8px 0 0',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<Icon size="100" src={Icons.Category} />
|
||||
<Text size="T300" truncate style={{ flexGrow: 1 }}>
|
||||
{session.name}
|
||||
</Text>
|
||||
<IconButton size="300" radii="300" variant="Surface" aria-label="Back to room" onClick={back}>
|
||||
<Icon size="100" src={Icons.ArrowGoLeft} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Surface"
|
||||
aria-label={`Close ${session.name}`}
|
||||
onClick={() => destroyWidgetSession(session.key)}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +1,48 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useSyncExternalStore } from 'react';
|
||||
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||
import {
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
RoomStateEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Widget } from 'matrix-widget-api';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
|
||||
type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
origin: string;
|
||||
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
||||
};
|
||||
import {
|
||||
attachWidgetSession,
|
||||
detachWidgetSession,
|
||||
getWidgetSession,
|
||||
subscribeWidgetSessions,
|
||||
widgetSessionKey,
|
||||
widgetSessionsVersion,
|
||||
} from './widgetSessions';
|
||||
|
||||
type RoomWidgetViewProps = {
|
||||
room: Room;
|
||||
widget: Widget;
|
||||
};
|
||||
|
||||
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
||||
// that wait on the client handshake load). Display-only access is granted
|
||||
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
|
||||
// and, once granted, the room's live events are fed to the widget.
|
||||
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
||||
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
||||
// Shows one room widget in the Widgets panel. The widget itself runs in a
|
||||
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
|
||||
// layer and is positioned over this placeholder, so a widget that asked to
|
||||
// stay on screen can keep running in a floating window after the panel closes.
|
||||
// Display-only access is granted silently; reading/sending in this room is
|
||||
// asked for (WidgetPermissionPrompt) and, once granted, the room's live events
|
||||
// are fed to the widget. Re-attaches only when the widget id or its (template)
|
||||
// URL changes, so viewing a widget doesn't reload it on unrelated state updates.
|
||||
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
const mx = useMatrixClient();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const slotRef = useRef<HTMLDivElement>(null);
|
||||
const widgetRef = useRef(widget);
|
||||
widgetRef.current = widget;
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
|
||||
const key = widgetSessionKey(room.roomId, widget.id);
|
||||
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||
const session = getWidgetSession(key);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
const current = widgetRef.current;
|
||||
const slot = slotRef.current;
|
||||
if (!slot) return undefined;
|
||||
attachWidgetSession(mx, room, widgetRef.current, slot);
|
||||
return () => detachWidgetSession(key, slot);
|
||||
}, [mx, room, key, widget.templateUrl]);
|
||||
|
||||
const completeUrl = current.getCompleteUrl({
|
||||
currentUserId: mx.getSafeUserId(),
|
||||
widgetRoomId: room.roomId,
|
||||
deviceId: mx.getDeviceId() ?? undefined,
|
||||
baseUrl: mx.baseUrl,
|
||||
});
|
||||
|
||||
// Security: never render a same-origin widget with allow-same-origin (a
|
||||
// same-origin frame could break out of the sandbox against our own origin).
|
||||
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
||||
setBlocked(true);
|
||||
return undefined;
|
||||
}
|
||||
setBlocked(false);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.title = current.name || 'Widget';
|
||||
iframe.sandbox.value =
|
||||
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||
iframe.allow = 'autoplay; clipboard-write;';
|
||||
iframe.src = completeUrl;
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.border = 'none';
|
||||
container.append(iframe);
|
||||
|
||||
let origin = completeUrl;
|
||||
try {
|
||||
origin = new URL(completeUrl).host;
|
||||
} catch {
|
||||
// isWidgetUrlSafe already rejected unparsable URLs
|
||||
}
|
||||
let cancelAsk: (() => void) | undefined;
|
||||
const driver = new GeneralWidgetDriver(
|
||||
mx,
|
||||
room.roomId,
|
||||
{ id: current.id, url: current.templateUrl },
|
||||
(requests) =>
|
||||
new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (allowed: Set<Capability>, remember: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cancelAsk = undefined;
|
||||
setPendingAsk(undefined);
|
||||
resolve({ allowed, remember });
|
||||
};
|
||||
cancelAsk = () => done(new Set(), false);
|
||||
setPendingAsk({ requests, origin, resolve: done });
|
||||
}),
|
||||
);
|
||||
const clientApi = new ClientWidgetApi(current, iframe, driver);
|
||||
// Only messages from this widget's own frame and origin are handled, so a
|
||||
// widget can't impersonate another one (or the call).
|
||||
restrictWidgetMessages(clientApi, iframe, current.origin);
|
||||
clientApi.setViewedRoomId(room.roomId);
|
||||
|
||||
// Live room events for widgets that were allowed to see them
|
||||
// (ClientWidgetApi drops anything the widget has no capability for).
|
||||
const feed = (ev: MatrixEvent) => {
|
||||
if (ev.getRoomId() !== room.roomId) return;
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||
};
|
||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||
if (toStart || removed || !data?.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||
else feed(ev);
|
||||
};
|
||||
const onState = (ev: MatrixEvent) => {
|
||||
if (ev.getRoomId() !== room.roomId) return;
|
||||
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, onTimeline);
|
||||
mx.on(RoomStateEvent.Events, onState);
|
||||
|
||||
return () => {
|
||||
mx.off(RoomEvent.Timeline, onTimeline);
|
||||
mx.off(RoomStateEvent.Events, onState);
|
||||
cancelAsk?.();
|
||||
clientApi.stop();
|
||||
iframe.remove();
|
||||
};
|
||||
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
||||
|
||||
if (blocked) {
|
||||
if (session?.blocked) {
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||
@@ -149,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
||||
{pendingAsk && (
|
||||
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
||||
{session?.pendingAsk && (
|
||||
<WidgetPermissionPrompt
|
||||
widgetName={widget.name || 'Widget'}
|
||||
origin={pendingAsk.origin}
|
||||
origin={session.pendingAsk.origin}
|
||||
addedBy={addedBy}
|
||||
requests={pendingAsk.requests}
|
||||
onDone={pendingAsk.resolve}
|
||||
requests={session.pendingAsk.requests}
|
||||
onDone={session.pendingAsk.resolve}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtom } from 'jotai';
|
||||
import classNames from 'classnames';
|
||||
import { widgetViewingAtom } from '../../../state/widgetsPanel';
|
||||
import * as css from './WidgetsPanel.css';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
import { RoomWidgetView } from './RoomWidgetView';
|
||||
@@ -27,7 +29,13 @@ import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
||||
import {
|
||||
generateWidgetId,
|
||||
validateWidgetUrl,
|
||||
widgetCompleteUrl,
|
||||
widgetPopoutUrl,
|
||||
WidgetUrlError,
|
||||
} from './widgetUtils';
|
||||
|
||||
const urlErrorMessage = (err: WidgetUrlError): string => {
|
||||
switch (err) {
|
||||
@@ -54,12 +62,23 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
||||
|
||||
const [viewingId, setViewingId] = useState<string | null>(null);
|
||||
const [viewingByRoom, setViewingByRoom] = useAtom(widgetViewingAtom);
|
||||
const viewingId = viewingByRoom[room.roomId] ?? null;
|
||||
const setViewingId = (id: string | null) =>
|
||||
setViewingByRoom((prev) => ({ ...prev, [room.roomId]: id }));
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
||||
// [Gitea #205] Open a widget in its own window, beside the chat. It runs
|
||||
// standalone there (no widget API), as in Element's popout.
|
||||
const popoutUrl = (widget: (typeof widgets)[number]) =>
|
||||
widgetPopoutUrl(widgetCompleteUrl(widget, mx, room.roomId), window.location.origin);
|
||||
const popout = (widget: (typeof widgets)[number]) => {
|
||||
const url = popoutUrl(widget);
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||
evt.preventDefault();
|
||||
@@ -144,7 +163,13 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
||||
{viewing ? (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S200 }}
|
||||
>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
@@ -157,6 +182,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
{viewing.name || 'Widget'}
|
||||
</Text>
|
||||
</Button>
|
||||
{popoutUrl(viewing) && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label={`Open ${viewing.name || 'widget'} in a new window`}
|
||||
onClick={() => popout(viewing)}
|
||||
>
|
||||
<Icon size="100" src={Icons.External} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<RoomWidgetView room={room} widget={viewing} />
|
||||
</Box>
|
||||
@@ -184,6 +220,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
{widget.name || widget.templateUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
{popoutUrl(widget) && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label={`Open ${widget.name || 'widget'} in a new window`}
|
||||
onClick={() => popout(widget)}
|
||||
>
|
||||
<Icon size="100" src={Icons.External} />
|
||||
</IconButton>
|
||||
)}
|
||||
{canModify && (
|
||||
<IconButton
|
||||
size="300"
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
RoomStateEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import {
|
||||
Capability,
|
||||
ClientWidgetApi,
|
||||
IRoomEvent,
|
||||
IWidgetApiRequest,
|
||||
MatrixCapabilities,
|
||||
Widget,
|
||||
WidgetApiFromWidgetAction,
|
||||
} from 'matrix-widget-api';
|
||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
import { zIndices } from '../../../styles/zIndex';
|
||||
|
||||
/**
|
||||
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
|
||||
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
|
||||
* Widgets panel or switching rooms.
|
||||
*
|
||||
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
|
||||
* start in one app-level fixed layer. While the panel shows the widget, the
|
||||
* frame is positioned over the panel's placeholder (`attach`), so it looks and
|
||||
* behaves as before. When the placeholder goes away (`detach`) the widget is
|
||||
* torn down, unless it is always-on-screen: then the same live frame moves to
|
||||
* a small floating window (see FloatingWidget) until it is closed, the widget
|
||||
* says it no longer needs to be on screen, or it is removed from the room.
|
||||
*/
|
||||
|
||||
export type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
origin: string;
|
||||
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
||||
};
|
||||
|
||||
export type WidgetSession = {
|
||||
readonly key: string;
|
||||
readonly roomId: string;
|
||||
readonly widgetId: string;
|
||||
readonly name: string;
|
||||
readonly templateUrl: string;
|
||||
/** The URL is on this app's own origin: never rendered. */
|
||||
readonly blocked: boolean;
|
||||
alwaysOnScreen: boolean;
|
||||
floating: boolean;
|
||||
pendingAsk?: PendingAsk;
|
||||
};
|
||||
|
||||
type Internal = WidgetSession & {
|
||||
frame: HTMLDivElement;
|
||||
slot: HTMLElement | null;
|
||||
raf: number | undefined;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
/** The floating window's geometry (FloatingWidget draws its title bar above). */
|
||||
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
|
||||
|
||||
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
|
||||
|
||||
const sessions = new Map<string, Internal>();
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
const notify = () => {
|
||||
version += 1;
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
};
|
||||
export const widgetSessionsVersion = (): number => version;
|
||||
|
||||
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
|
||||
`${roomId}|${widgetId}`;
|
||||
|
||||
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
|
||||
|
||||
export const floatingWidgetSessions = (): WidgetSession[] =>
|
||||
[...sessions.values()].filter((s) => s.floating);
|
||||
|
||||
let layerEl: HTMLDivElement | undefined;
|
||||
const layer = (): HTMLDivElement => {
|
||||
if (!layerEl || !layerEl.isConnected) {
|
||||
layerEl = document.createElement('div');
|
||||
layerEl.id = 'lotus-widget-layer';
|
||||
// The layer itself is click-through; only the frames inside take input.
|
||||
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
|
||||
document.body.append(layerEl);
|
||||
}
|
||||
return layerEl;
|
||||
};
|
||||
|
||||
const hideFrame = (s: Internal) => {
|
||||
s.frame.style.visibility = 'hidden';
|
||||
};
|
||||
|
||||
const placeDocked = (s: Internal) => {
|
||||
const slot = s.slot;
|
||||
if (!slot) return;
|
||||
const r = slot.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) {
|
||||
hideFrame(s);
|
||||
} else {
|
||||
Object.assign(s.frame.style, {
|
||||
visibility: 'visible',
|
||||
left: `${r.left}px`,
|
||||
top: `${r.top}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
right: '',
|
||||
bottom: '',
|
||||
borderRadius: '0',
|
||||
boxShadow: 'none',
|
||||
zIndex: String(DOCKED_Z),
|
||||
});
|
||||
}
|
||||
s.raf = requestAnimationFrame(() => placeDocked(s));
|
||||
};
|
||||
|
||||
const placeFloating = (s: Internal) => {
|
||||
const f = FLOATING_WIDGET;
|
||||
Object.assign(s.frame.style, {
|
||||
visibility: 'visible',
|
||||
left: '',
|
||||
top: '',
|
||||
right: `${f.right}px`,
|
||||
bottom: `${f.bottom}px`,
|
||||
width: `${f.width}px`,
|
||||
height: `${f.height}px`,
|
||||
borderRadius: '0 0 8px 8px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||
zIndex: String(zIndices.floatingWidget),
|
||||
});
|
||||
};
|
||||
|
||||
const stopPlacing = (s: Internal) => {
|
||||
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
|
||||
s.raf = undefined;
|
||||
};
|
||||
|
||||
export const destroyWidgetSession = (key: string): void => {
|
||||
const s = sessions.get(key);
|
||||
if (!s) return;
|
||||
sessions.delete(key);
|
||||
stopPlacing(s);
|
||||
s.stop();
|
||||
s.frame.remove();
|
||||
notify();
|
||||
};
|
||||
|
||||
export const destroyAllWidgetSessions = (): void => {
|
||||
[...sessions.keys()].forEach(destroyWidgetSession);
|
||||
};
|
||||
|
||||
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
|
||||
export function attachWidgetSession(
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
widget: Widget,
|
||||
slot: HTMLElement,
|
||||
): WidgetSession {
|
||||
const key = widgetSessionKey(room.roomId, widget.id);
|
||||
let s = sessions.get(key);
|
||||
if (s && s.templateUrl !== widget.templateUrl) {
|
||||
destroyWidgetSession(key);
|
||||
s = undefined;
|
||||
}
|
||||
if (!s) s = startSession(mx, room, widget, key);
|
||||
stopPlacing(s);
|
||||
s.slot = slot;
|
||||
s.floating = false;
|
||||
if (!s.blocked) placeDocked(s);
|
||||
notify();
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The panel stopped showing the widget: float it, or tear it down. */
|
||||
export function detachWidgetSession(key: string, slot: HTMLElement): void {
|
||||
const s = sessions.get(key);
|
||||
if (!s || s.slot !== slot) return;
|
||||
s.slot = null;
|
||||
stopPlacing(s);
|
||||
if (!s.alwaysOnScreen || s.blocked) {
|
||||
destroyWidgetSession(key);
|
||||
return;
|
||||
}
|
||||
// One floating widget at a time: a newer one replaces an older one.
|
||||
floatingWidgetSessions()
|
||||
.filter((o) => o.key !== key)
|
||||
.forEach((o) => destroyWidgetSession(o.key));
|
||||
// Nobody is there to answer a permission prompt any more.
|
||||
s.pendingAsk?.resolve(new Set(), false);
|
||||
s.floating = true;
|
||||
placeFloating(s);
|
||||
notify();
|
||||
}
|
||||
|
||||
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
|
||||
const frame = document.createElement('div');
|
||||
Object.assign(frame.style, {
|
||||
position: 'fixed',
|
||||
pointerEvents: 'auto',
|
||||
overflow: 'hidden',
|
||||
visibility: 'hidden',
|
||||
});
|
||||
|
||||
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
|
||||
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
|
||||
const s: Internal = {
|
||||
key,
|
||||
roomId: room.roomId,
|
||||
widgetId: widget.id,
|
||||
name: widget.name || 'Widget',
|
||||
templateUrl: widget.templateUrl,
|
||||
blocked,
|
||||
alwaysOnScreen: false,
|
||||
floating: false,
|
||||
frame,
|
||||
slot: null,
|
||||
raf: undefined,
|
||||
stop: () => undefined,
|
||||
};
|
||||
sessions.set(key, s);
|
||||
if (blocked) return s;
|
||||
|
||||
// Security: never render a same-origin widget with allow-same-origin (a
|
||||
// same-origin frame could break out of the sandbox against our own origin).
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.title = widget.name || 'Widget';
|
||||
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||
iframe.allow = 'autoplay; clipboard-write;';
|
||||
iframe.src = completeUrl;
|
||||
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
|
||||
frame.append(iframe);
|
||||
layer().append(frame);
|
||||
|
||||
let origin = completeUrl;
|
||||
try {
|
||||
origin = new URL(completeUrl).host;
|
||||
} catch {
|
||||
// isWidgetUrlSafe already rejected unparsable URLs
|
||||
}
|
||||
let cancelAsk: (() => void) | undefined;
|
||||
const driver = new GeneralWidgetDriver(
|
||||
mx,
|
||||
room.roomId,
|
||||
{ id: widget.id, url: widget.templateUrl },
|
||||
(requests) =>
|
||||
new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (allowed: Set<Capability>, remember: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cancelAsk = undefined;
|
||||
s.pendingAsk = undefined;
|
||||
notify();
|
||||
resolve({ allowed, remember });
|
||||
};
|
||||
cancelAsk = () => done(new Set(), false);
|
||||
// A floating widget has nobody to ask: grant nothing extra.
|
||||
if (s.floating) {
|
||||
done(new Set(), false);
|
||||
return;
|
||||
}
|
||||
s.pendingAsk = { requests, origin, resolve: done };
|
||||
notify();
|
||||
}),
|
||||
);
|
||||
const clientApi = new ClientWidgetApi(widget, iframe, driver);
|
||||
// Only messages from this widget's own frame and origin are handled, so a
|
||||
// widget can't impersonate another one (or the call).
|
||||
restrictWidgetMessages(clientApi, iframe, widget.origin);
|
||||
clientApi.setViewedRoomId(room.roomId);
|
||||
|
||||
// MSC2931: the widget asks to stay visible when the user navigates away.
|
||||
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
ev.preventDefault();
|
||||
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||
clientApi.transport.reply(ev.detail, {
|
||||
error: { message: 'Missing capability' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
|
||||
s.alwaysOnScreen = value;
|
||||
clientApi.transport.reply(ev.detail, {});
|
||||
// Floating only because it asked: once it no longer needs to be, go.
|
||||
if (!value && s.floating) destroyWidgetSession(key);
|
||||
else notify();
|
||||
};
|
||||
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
||||
|
||||
// Live room events for widgets that were allowed to see them
|
||||
// (ClientWidgetApi drops anything the widget has no capability for).
|
||||
const feed = (ev: MatrixEvent) => {
|
||||
if (ev.getRoomId() !== room.roomId) return;
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||
};
|
||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||
if (toStart || removed || !data?.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||
else feed(ev);
|
||||
};
|
||||
const onState = (ev: MatrixEvent) => {
|
||||
if (ev.getRoomId() !== room.roomId) return;
|
||||
// The widget was removed from the room while it was floating.
|
||||
if (
|
||||
s.floating &&
|
||||
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
|
||||
ev.getStateKey() === widget.id &&
|
||||
!ev.getContent()?.url
|
||||
) {
|
||||
destroyWidgetSession(key);
|
||||
return;
|
||||
}
|
||||
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, onTimeline);
|
||||
mx.on(RoomStateEvent.Events, onState);
|
||||
|
||||
s.stop = () => {
|
||||
mx.off(RoomEvent.Timeline, onTimeline);
|
||||
mx.off(RoomStateEvent.Events, onState);
|
||||
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
||||
cancelAsk?.();
|
||||
clientApi.stop();
|
||||
};
|
||||
return s;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
generateWidgetId,
|
||||
classifyWidgetCapabilities,
|
||||
loadWidgetConsent,
|
||||
widgetPopoutUrl,
|
||||
} from './widgetUtils';
|
||||
|
||||
const APP = 'https://chat.lotusguild.org';
|
||||
@@ -122,3 +123,17 @@ test('[#266] space structure, call membership and Lotus room policy can never be
|
||||
assert.equal(auto.size, 0);
|
||||
assert.deepEqual(ask, []);
|
||||
});
|
||||
|
||||
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
|
||||
// Built at runtime so the no-script-url lint rule doesn't flag the test data.
|
||||
const scriptUrl = ['javascript', 'alert(1)'].join(':');
|
||||
assert.equal(
|
||||
widgetPopoutUrl('https://pad.example.org/p/room', APP),
|
||||
'https://pad.example.org/p/room',
|
||||
);
|
||||
assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin');
|
||||
assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https');
|
||||
assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL');
|
||||
assert.equal(widgetPopoutUrl('data:text/html,<b>x</b>', APP), null, 'data:');
|
||||
assert.equal(widgetPopoutUrl('not a url', APP), null);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import {
|
||||
Capability,
|
||||
EventDirection,
|
||||
EventKind,
|
||||
MatrixCapabilities,
|
||||
type Widget,
|
||||
WidgetEventCapability,
|
||||
} from 'matrix-widget-api';
|
||||
|
||||
@@ -48,6 +50,29 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
|
||||
}
|
||||
};
|
||||
|
||||
/** The widget's URL with its template variables filled in for this viewer. */
|
||||
export const widgetCompleteUrl = (widget: Widget, mx: MatrixClient, roomId: string): string =>
|
||||
widget.getCompleteUrl({
|
||||
currentUserId: mx.getSafeUserId(),
|
||||
widgetRoomId: roomId,
|
||||
deviceId: mx.getDeviceId() ?? undefined,
|
||||
baseUrl: mx.baseUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* [Gitea #205] The URL to open a widget in its own window, or null when it
|
||||
* mustn't be opened: only cross-origin https (room state can carry anything,
|
||||
* e.g. a `javascript:` URL from another client, whose origin is "null").
|
||||
*/
|
||||
export const widgetPopoutUrl = (completeUrl: string, appOrigin: string): string | null => {
|
||||
try {
|
||||
const u = new URL(completeUrl);
|
||||
return u.protocol === 'https:' && u.origin !== appOrigin ? u.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateWidgetId = (): string =>
|
||||
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
|
||||
@@ -1749,6 +1749,7 @@ function Calls() {
|
||||
);
|
||||
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [callSummaryToast, setCallSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
@@ -2062,6 +2063,13 @@ function Calls() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Summary After Calls"
|
||||
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
|
||||
after={
|
||||
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
|
||||
}
|
||||
/>
|
||||
{hapticsSupported() && (
|
||||
<SettingTile
|
||||
title="Haptic Feedback"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.sticky) return;
|
||||
timerRef.current = setTimeout(() => {
|
||||
dismiss(toast.id);
|
||||
}, 4000);
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
dismiss(toast.id);
|
||||
},
|
||||
// [Gitea #253] A toast with a button stays long enough to use it.
|
||||
toast.actionLabel ? 10_000 : 4000,
|
||||
);
|
||||
return () => {
|
||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [dismiss, toast.id, toast.sticky]);
|
||||
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (toast.onClick) {
|
||||
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
</div>
|
||||
<div style={bodyStyle}>{toast.body}</div>
|
||||
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||
{toast.actionLabel && toast.onAction && (
|
||||
<div style={{ marginTop: config.space.S200 }}>
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toast.onAction?.();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">{toast.actionLabel}</Text>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useCallHangupEvent } from './useCallEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { describeCallEnd } from '../utils/callSummary';
|
||||
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
|
||||
import { callSummaryViewAtom } from '../state/callSummary';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
||||
// before Element Call's hangup echo; give a late one this long before toasting.
|
||||
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
|
||||
*/
|
||||
export function useCallEndedToast(embed: CallEmbed): void {
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const setSummaryView = useSetAtom(callSummaryViewAtom);
|
||||
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||
const fired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
|
||||
useCallHangupEvent(embed, () => {
|
||||
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
||||
fired.current = true;
|
||||
if (!callSummaryToast) return;
|
||||
const durationMs = Date.now() - embed.joinedAt;
|
||||
const deafenedMs = embed.deafenedMs();
|
||||
const roomName = embed.room.name ?? 'Voice call';
|
||||
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
|
||||
const toast = () =>
|
||||
setToast({
|
||||
id: `call-ended-${Date.now()}`,
|
||||
displayName: 'Lotus Chat',
|
||||
body: describeCallEnd(durationMs, embed.lastSummary),
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomName,
|
||||
roomId: embed.roomId,
|
||||
// [Gitea #253] The full breakdown, snapshotted at hangup.
|
||||
actionLabel: 'Details',
|
||||
onAction: () =>
|
||||
setSummaryView({
|
||||
roomName,
|
||||
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
|
||||
}),
|
||||
});
|
||||
if (embed.lastSummary) toast();
|
||||
else setTimeout(toast, SUMMARY_GRACE_MS);
|
||||
|
||||
@@ -17,22 +17,37 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const start = useRef<{ x: number; y: number } | null>(null);
|
||||
const fired = useRef(false);
|
||||
// [Gitea #199] The element being pressed carries `data-pressing` (set on the
|
||||
// DOM directly, no re-render) so iOS can't start a text selection under
|
||||
// the finger; see `[data-touch-actions]` in room/message/styles.css.ts.
|
||||
const pressed = useRef<HTMLElement | null>(null);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
const stopTimer = useCallback(() => {
|
||||
if (timer.current !== undefined) window.clearTimeout(timer.current);
|
||||
timer.current = undefined;
|
||||
start.current = null;
|
||||
}, []);
|
||||
|
||||
// The finger is up (or the gesture was cancelled): only now drop
|
||||
// `data-pressing`, so the system selection can't start under a finger that
|
||||
// is still down after the sheet opened.
|
||||
const clear = useCallback(() => {
|
||||
stopTimer();
|
||||
pressed.current?.removeAttribute('data-pressing');
|
||||
pressed.current = null;
|
||||
}, [stopTimer]);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(evt: React.TouchEvent) => {
|
||||
if (!coarse || evt.touches.length !== 1) return;
|
||||
const t = evt.touches[0];
|
||||
start.current = { x: t.clientX, y: t.clientY };
|
||||
fired.current = false;
|
||||
pressed.current = evt.currentTarget as HTMLElement;
|
||||
pressed.current.setAttribute('data-pressing', '');
|
||||
timer.current = window.setTimeout(() => {
|
||||
const s = start.current;
|
||||
clear();
|
||||
stopTimer();
|
||||
if (!s) return;
|
||||
fired.current = true;
|
||||
window.setTimeout(() => {
|
||||
@@ -41,7 +56,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
||||
onLongPress(s.x, s.y);
|
||||
}, LONG_PRESS_MS);
|
||||
},
|
||||
[coarse, clear, onLongPress],
|
||||
[coarse, stopTimer, onLongPress],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
@@ -72,7 +87,16 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
||||
|
||||
return {
|
||||
coarse,
|
||||
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
|
||||
handlers: coarse
|
||||
? {
|
||||
// [Gitea #199] Marks the element for the iOS hardening CSS.
|
||||
'data-touch-actions': '',
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel: clear,
|
||||
}
|
||||
: {},
|
||||
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
|
||||
suppressContextMenu: fired,
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
|
||||
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
|
||||
keys.forEach((key) => {
|
||||
if (draftPl[key] === undefined) {
|
||||
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
|
||||
(draftPl as Record<string, unknown>)[key] = DEFAULT_POWER_LEVELS[key];
|
||||
}
|
||||
});
|
||||
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
|
||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||
@@ -248,6 +249,7 @@ function App() {
|
||||
<SeasonalEffect />
|
||||
<NightLightOverlay />
|
||||
<LotusToastContainer />
|
||||
<CallSummaryDialog />
|
||||
</JotaiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Text, config } from 'folds';
|
||||
import { removeFallbackSession } from '../state/sessions';
|
||||
import type { KeychainBootResult } from '../state/keychainSession';
|
||||
import type { KeychainInvoke } from '../state/keychainMirror';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Shown instead of the app when the desktop app's sign-in is kept
|
||||
* in the OS keychain but couldn't be read this time. Never a silent logout:
|
||||
* Retry boots again; signing in again is an explicit choice.
|
||||
*/
|
||||
export function KeychainUnavailable({
|
||||
boot,
|
||||
invoke,
|
||||
}: {
|
||||
boot: KeychainBootResult;
|
||||
invoke: KeychainInvoke | undefined;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const missing = boot.mode === 'missing';
|
||||
const signInAgain = async () => {
|
||||
setBusy(true);
|
||||
removeFallbackSession();
|
||||
await invoke?.('secure_session_clear').catch(() => undefined);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="400"
|
||||
style={{ minHeight: '100vh', padding: config.space.S700, textAlign: 'center' }}
|
||||
>
|
||||
<Text size="H3">Couldn't read your sign-in</Text>
|
||||
<Text size="T300" style={{ maxWidth: 460 }}>
|
||||
{missing
|
||||
? 'Lotus Chat keeps your sign-in in Windows Credential Manager, but the entry is missing. If you removed it, sign in again.'
|
||||
: 'Lotus Chat keeps your sign-in in Windows Credential Manager and couldn’t read it just now. This is usually temporary.'}
|
||||
</Text>
|
||||
{boot.mode === 'error' && (
|
||||
<Text size="T200" priority="300">
|
||||
{boot.error}
|
||||
</Text>
|
||||
)}
|
||||
<Box gap="200">
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Text size="B400">Retry</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={signInAgain}
|
||||
>
|
||||
<Text size="B400">Sign in again</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { SHARE_PAGE_PATH } from '../../swShare';
|
||||
import { RouteError } from './RouteError';
|
||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
|
||||
|
||||
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
|
||||
|
||||
@@ -197,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
<CallStatusRenderer />
|
||||
<FloatingWidget />
|
||||
</CallEmbedProvider>
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchModalRenderer />
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
WidgetApiFromWidgetAction,
|
||||
WidgetApiToWidgetAction,
|
||||
WidgetDriver,
|
||||
IWidgetApiRequest,
|
||||
} from 'matrix-widget-api';
|
||||
import { CallWidgetDriver } from './CallWidgetDriver';
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
@@ -25,13 +26,14 @@ import {
|
||||
ElementMediaStateDetail,
|
||||
ElementWidgetActions,
|
||||
} from './types';
|
||||
import { CallControl } from './CallControl';
|
||||
import { CallControl, CallControlEvent } from './CallControl';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { getCallPageUrl } from './callPageUrl';
|
||||
import { restrictWidgetMessages } from '../widgetTransport';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
|
||||
|
||||
// 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
|
||||
@@ -58,6 +60,8 @@ export interface LotusCallSummary {
|
||||
reconnects: number;
|
||||
poorMs: number;
|
||||
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
||||
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
|
||||
details?: LotusCallSummaryDetails;
|
||||
}
|
||||
|
||||
export class CallEmbed {
|
||||
@@ -77,6 +81,25 @@ export class CallEmbed {
|
||||
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
||||
public lastSummary: LotusCallSummary | undefined;
|
||||
|
||||
// [Gitea #253] Time spent deafened (sound off), for the post-call Details.
|
||||
private deafenedSince: number | undefined;
|
||||
|
||||
private deafenedTotal = 0;
|
||||
|
||||
public deafenedMs(now = Date.now()): number {
|
||||
return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
|
||||
}
|
||||
|
||||
private trackDeafened = (): void => {
|
||||
if (!this.joined) return;
|
||||
const deaf = !this.control.sound;
|
||||
if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
|
||||
if (!deaf && this.deafenedSince !== undefined) {
|
||||
this.deafenedTotal += Date.now() - this.deafenedSince;
|
||||
this.deafenedSince = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||||
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||
public disposed = false;
|
||||
@@ -338,6 +361,8 @@ export class CallEmbed {
|
||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||
this.control = new CallControl(controlState, call, iframe);
|
||||
this.initialState = controlState;
|
||||
this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
|
||||
this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
|
||||
// If the iframe document itself fails to load, fail fast.
|
||||
iframe.onerror = () => {
|
||||
this.settleLoad('iframe');
|
||||
@@ -433,6 +458,7 @@ export class CallEmbed {
|
||||
reconnects: data.reconnects ?? 0,
|
||||
poorMs: data.poorMs ?? 0,
|
||||
verdict: data.verdict ?? 'unknown',
|
||||
details: parseCallSummaryDetails((data as { details?: unknown }).details),
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -622,6 +648,7 @@ export class CallEmbed {
|
||||
}
|
||||
this.joined = true;
|
||||
this.joinedAt = Date.now();
|
||||
this.trackDeafened();
|
||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||
this.control.forceState(this.initialState);
|
||||
}
|
||||
@@ -796,7 +823,7 @@ export class CallEmbed {
|
||||
const wrapped = (ev: CustomEvent<T>) => {
|
||||
ev.preventDefault();
|
||||
// Reply with success so the widget transport doesn't time out waiting for a response
|
||||
this.call.transport.reply(ev.detail as any, {});
|
||||
this.call.transport.reply(ev.detail as unknown as IWidgetApiRequest, {});
|
||||
callback(ev);
|
||||
};
|
||||
return this.listenEvent(`action:${type}`, wrapped);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'jotai';
|
||||
import type { CallSummarySection } from '../utils/callSummary';
|
||||
|
||||
/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
|
||||
export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
|
||||
export const callSummaryViewAtom = atom<CallSummaryView | undefined>(undefined);
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
|
||||
import {
|
||||
getFallbackSession,
|
||||
onSessionPersisted,
|
||||
Session,
|
||||
stripTokensFromStorage,
|
||||
} from './sessions';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
|
||||
* [Gitea #105] Desktop: keep the login tokens in the OS keychain.
|
||||
*
|
||||
* The session is still read from localStorage exactly as before; this only
|
||||
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
|
||||
* reading it back, so real installs prove the keychain works before step 2
|
||||
* switches reads over to it. Nothing here can log anyone out: every failure
|
||||
* just records a status for Settings.
|
||||
* Step 1 mirrored them here and verified by reading back. Step 2 (sessions.ts,
|
||||
* keychainSession.ts) reads them from here at boot; once a write is verified,
|
||||
* the tokens are dropped from localStorage. Nothing here can log anyone out:
|
||||
* a failure keeps the tokens in localStorage and records a status for
|
||||
* Settings.
|
||||
*
|
||||
* Writes are serialized (a token rotation right after login must not race
|
||||
* the login's write) and time out, so a hung credential store can't pile up.
|
||||
@@ -122,7 +127,13 @@ export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void =>
|
||||
started = true;
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
const enqueue = (session: Session | null) => {
|
||||
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
|
||||
chain = chain.then(async () => {
|
||||
const result = await syncKeychain(invoke, session);
|
||||
setStatus(result);
|
||||
// [Gitea #105] step 2: the keychain verifiably holds these tokens, so
|
||||
// localStorage can drop them (only if they're still the current ones).
|
||||
if (result.state === 'ok' && session) stripTokensFromStorage(tokensOf(session));
|
||||
});
|
||||
};
|
||||
enqueue(getFallbackSession() ?? null);
|
||||
onSessionPersisted((session) => enqueue(session));
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// localStorage stand-in (these modules read it at call time).
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
|
||||
const sessions = await import('./sessions');
|
||||
const { bootKeychainSession } = await import('./keychainSession');
|
||||
const { syncKeychain, tokensOf } = await import('./keychainMirror');
|
||||
|
||||
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
const keychain = (opts: { getThrows?: boolean; setThrows?: boolean } = {}) => {
|
||||
let stored: unknown = null;
|
||||
const invoke: Invoke = async (cmd, args) => {
|
||||
if (cmd === 'secure_session_supported') return true;
|
||||
if (cmd === 'secure_session_get') {
|
||||
if (opts.getThrows) throw new Error('Element not found.');
|
||||
return stored;
|
||||
}
|
||||
if (cmd === 'secure_session_set') {
|
||||
if (opts.setThrows) throw new Error('Access is denied.');
|
||||
stored = args?.tokens;
|
||||
return null;
|
||||
}
|
||||
if (cmd === 'secure_session_clear') {
|
||||
stored = null;
|
||||
return null;
|
||||
}
|
||||
throw new Error(cmd);
|
||||
};
|
||||
return {
|
||||
invoke,
|
||||
get: () => stored,
|
||||
set: (v: unknown) => {
|
||||
stored = v;
|
||||
},
|
||||
};
|
||||
};
|
||||
const blob = () => JSON.parse(store.get('cinny_session_v1') ?? 'null');
|
||||
const tokensOnDisk = () =>
|
||||
[...store.entries()].some(([, v]) => v.includes('syt_') || v.includes('mar_'));
|
||||
const login = (access: string, refresh?: string) =>
|
||||
sessions.setFallbackSession(access, 'DEV1', '@alice:example.org', 'https://hs.example', {
|
||||
refreshToken: refresh,
|
||||
expiresInMs: 300_000,
|
||||
});
|
||||
/** A fresh page load: drop the in-memory state, keep storage and keychain. */
|
||||
const reload = () => sessions.leaveKeychainMode();
|
||||
/** What the mirror does after a write: verify in the keychain, then strip. */
|
||||
const mirror = async (invoke: Invoke) => {
|
||||
const s = sessions.getFallbackSession() ?? null;
|
||||
const r = await syncKeychain(invoke, s);
|
||||
if (r.state === 'ok' && s) sessions.stripTokensFromStorage(tokensOf(s));
|
||||
return r;
|
||||
};
|
||||
|
||||
test('[#105] web / no keychain: localStorage stays authoritative', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_web', 'mar_web');
|
||||
assert.deepEqual(await bootKeychainSession(undefined), { mode: 'off' });
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_web');
|
||||
assert.equal(blob().accessToken, 'syt_web');
|
||||
});
|
||||
|
||||
test('[#105] migration: existing install moves its tokens to the keychain, then off disk', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_old', 'mar_old'); // a pre-step-2 install
|
||||
const kc = keychain();
|
||||
const boot = await bootKeychainSession(kc.invoke);
|
||||
assert.equal(boot.mode, 'keychain');
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old');
|
||||
assert.equal(tokensOnDisk(), true, 'not stripped before the keychain is verified');
|
||||
assert.equal((await mirror(kc.invoke)).state, 'ok');
|
||||
assert.equal(tokensOnDisk(), false, 'tokens gone from localStorage');
|
||||
assert.equal(blob().tokensIn, 'keychain');
|
||||
assert.equal(store.get('cinny_access_token'), undefined);
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old', 'still signed in');
|
||||
|
||||
// Next launch: shell + keychain -> the same session.
|
||||
reload();
|
||||
const again = await bootKeychainSession(kc.invoke);
|
||||
assert.equal(again.mode, 'keychain');
|
||||
const s = sessions.getFallbackSession();
|
||||
assert.equal(s?.accessToken, 'syt_old');
|
||||
assert.equal(s?.refreshToken, 'mar_old');
|
||||
assert.equal(s?.baseUrl, 'https://hs.example');
|
||||
assert.ok((s?.expiresInMs ?? 0) > 0, 'expiry kept in the shell');
|
||||
});
|
||||
|
||||
test('[#105] token refresh: written in full first, stripped once the keychain has it', async () => {
|
||||
// continues from the previous state: keychain mode, shell on disk
|
||||
const kc = keychain();
|
||||
kc.set({
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEV1',
|
||||
accessToken: 'syt_old',
|
||||
refreshToken: 'mar_old',
|
||||
});
|
||||
login('syt_new', 'mar_new');
|
||||
assert.equal(blob().accessToken, 'syt_new', 'durable on disk until the keychain confirms');
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_new');
|
||||
await mirror(kc.invoke);
|
||||
assert.equal(tokensOnDisk(), false);
|
||||
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
|
||||
});
|
||||
|
||||
test('[#105] a failing keychain write keeps the tokens in localStorage', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_keep', 'mar_keep');
|
||||
const kc = keychain({ setThrows: true });
|
||||
await bootKeychainSession(kc.invoke);
|
||||
assert.equal((await mirror(kc.invoke)).state, 'error');
|
||||
assert.equal(blob().accessToken, 'syt_keep', 'nothing stripped');
|
||||
reload();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
assert.equal(
|
||||
sessions.getFallbackSession()?.accessToken,
|
||||
'syt_keep',
|
||||
'still signed in after a restart',
|
||||
);
|
||||
});
|
||||
|
||||
test('[#105] shell but no keychain entry: "missing", never a silent logout', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_x');
|
||||
const kc = keychain();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
await mirror(kc.invoke);
|
||||
kc.set(null); // someone deleted the Credential Manager entry
|
||||
reload();
|
||||
const boot = await bootKeychainSession(kc.invoke);
|
||||
assert.deepEqual(boot, { mode: 'missing', userId: '@alice:example.org' });
|
||||
assert.equal(blob().tokensIn, 'keychain', 'shell kept for Retry');
|
||||
});
|
||||
|
||||
test('[#105] shell but the keychain read fails: "error", shell kept', async () => {
|
||||
// continues: shell on disk
|
||||
reload();
|
||||
const boot = await bootKeychainSession(keychain({ getThrows: true }).invoke);
|
||||
assert.equal(boot.mode, 'error');
|
||||
assert.equal(blob().tokensIn, 'keychain');
|
||||
});
|
||||
|
||||
test('[#105] a shell for another device never pairs with the keychain', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_a');
|
||||
const kc = keychain();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
await mirror(kc.invoke);
|
||||
kc.set({ userId: '@alice:example.org', deviceId: 'OTHER', accessToken: 'syt_b' });
|
||||
reload();
|
||||
assert.equal((await bootKeychainSession(kc.invoke)).mode, 'missing');
|
||||
});
|
||||
|
||||
test('[#105] logout clears memory, disk and (via the mirror) the keychain', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_bye', 'mar_bye');
|
||||
const kc = keychain();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
await mirror(kc.invoke);
|
||||
sessions.removeFallbackSession();
|
||||
assert.equal(sessions.getFallbackSession(), undefined);
|
||||
assert.equal(store.size, 0);
|
||||
await mirror(kc.invoke);
|
||||
assert.equal(kc.get(), null);
|
||||
reload();
|
||||
assert.deepEqual(await bootKeychainSession(kc.invoke), { mode: 'keychain', session: undefined });
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
enterKeychainMode,
|
||||
hasKeychainShell,
|
||||
KeychainBoot,
|
||||
KeychainSessionTokens,
|
||||
} from './sessions';
|
||||
import { KEYCHAIN_TIMEOUT_MS, KeychainInvoke } from './keychainMirror';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Desktop, step 2: at boot, before the app renders, read the login
|
||||
* tokens from the OS keychain into memory (see sessions.ts). Returns:
|
||||
* - `off`: not the desktop app, or no keychain here (Linux, older builds) —
|
||||
* localStorage stays authoritative, exactly as before;
|
||||
* - a KeychainBoot from enterKeychainMode: `keychain` (go on), or `missing`
|
||||
* (localStorage says the tokens are in the keychain but they can't be read;
|
||||
* the caller shows Retry / Sign in again and must NOT start the mirror,
|
||||
* which would clear the keychain entry it couldn't read).
|
||||
*/
|
||||
export type KeychainBootResult = { mode: 'off' } | KeychainBoot | { mode: 'error'; error: string };
|
||||
|
||||
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
|
||||
p.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const asTokens = (v: unknown): KeychainSessionTokens | null => {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const t = v as Partial<KeychainSessionTokens>;
|
||||
if (typeof t.userId !== 'string' || typeof t.deviceId !== 'string') return null;
|
||||
if (typeof t.accessToken !== 'string') return null;
|
||||
return {
|
||||
userId: t.userId,
|
||||
deviceId: t.deviceId,
|
||||
accessToken: t.accessToken,
|
||||
...(typeof t.refreshToken === 'string' ? { refreshToken: t.refreshToken } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export async function bootKeychainSession(
|
||||
invoke: KeychainInvoke | undefined,
|
||||
timeoutMs = KEYCHAIN_TIMEOUT_MS,
|
||||
): Promise<KeychainBootResult> {
|
||||
if (!invoke) return { mode: 'off' };
|
||||
let supported: unknown;
|
||||
try {
|
||||
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
if (supported !== true) {
|
||||
// A shell without a keychain to read from can only mean the store went
|
||||
// away (e.g. a downgrade of the desktop build): say so, don't log out.
|
||||
return hasKeychainShell()
|
||||
? { mode: 'error', error: 'keychain not available' }
|
||||
: { mode: 'off' };
|
||||
}
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
|
||||
} catch (e) {
|
||||
if (hasKeychainShell())
|
||||
return { mode: 'error', error: (e instanceof Error ? e.message : String(e)).slice(0, 200) };
|
||||
return { mode: 'off' };
|
||||
}
|
||||
return enterKeychainMode(asTokens(stored));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { atom, useSetAtom } from 'jotai';
|
||||
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { useEffect } from 'react';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
import { setRoomAccountData } from '../../utils/accountData';
|
||||
|
||||
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
|
||||
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
|
||||
@@ -25,11 +26,9 @@ export const setMarkedUnread = (
|
||||
unread: boolean,
|
||||
): Promise<unknown> =>
|
||||
Promise.all([
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
|
||||
setRoomAccountData(mx, roomId, AccountDataEvent.MarkedUnread, { unread }),
|
||||
// Best-effort mirror for older servers; never fail the primary write on it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
|
||||
setRoomAccountData(mx, roomId, UNSTABLE_MARKED_UNREAD, { unread }).catch(() => undefined),
|
||||
]);
|
||||
|
||||
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
||||
|
||||
@@ -51,6 +51,21 @@ const OIDC_KEYS = {
|
||||
// `_v1` suffix.
|
||||
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
||||
|
||||
// [Gitea #105] Desktop, step 2: the login tokens live in the OS keychain.
|
||||
// localStorage then holds a SHELL blob (everything but the tokens, marked
|
||||
// `tokensIn: 'keychain'`), and the full session is kept in memory, filled at
|
||||
// boot (bootKeychainSession) before the app renders, so every reader of
|
||||
// getFallbackSession stays synchronous. A new session is written to
|
||||
// localStorage in full first; the tokens are stripped only once the keychain
|
||||
// write was read back (stripTokensFromStorage), so at every instant one durable
|
||||
// store holds the newest tokens (a token refresh invalidates the old ones).
|
||||
let keychainMode = false;
|
||||
let memorySession: PersistedSession | undefined;
|
||||
|
||||
type ShellSession = Omit<PersistedSession, 'accessToken' | 'refreshToken'> & {
|
||||
tokensIn: 'keychain';
|
||||
};
|
||||
|
||||
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
|
||||
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
|
||||
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
|
||||
@@ -266,6 +281,7 @@ export function setFallbackSession(
|
||||
extra?: FallbackSessionExtra,
|
||||
) {
|
||||
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
|
||||
if (keychainMode) memorySession = persisted;
|
||||
// ONE setItem — the blob can never be observed half-written.
|
||||
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
|
||||
// Dual-write the legacy keys (removal of this half is a future release).
|
||||
@@ -276,6 +292,7 @@ export function setFallbackSession(
|
||||
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
|
||||
// or legacy-fallback) can resurrect a logged-out session.
|
||||
export const removeFallbackSession = () => {
|
||||
memorySession = undefined;
|
||||
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
@@ -286,7 +303,23 @@ export const removeFallbackSession = () => {
|
||||
// corrupt/partial we transparently assemble from the legacy keys (migration);
|
||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||
// wins by construction.
|
||||
// The stored session with its tokens, choosing blob vs legacy as below.
|
||||
const readStoredPersisted = (): PersistedSession | undefined => {
|
||||
const blob = readSessionBlob();
|
||||
const legacy = readLegacyKeys();
|
||||
if (
|
||||
blob &&
|
||||
legacy &&
|
||||
typeof legacy.expiresAt === 'number' &&
|
||||
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
|
||||
) {
|
||||
return legacy;
|
||||
}
|
||||
return blob ?? legacy;
|
||||
};
|
||||
|
||||
export const getFallbackSession = (): Session | undefined => {
|
||||
if (keychainMode) return memorySession ? sessionFromPersisted(memorySession) : undefined;
|
||||
const blob = readSessionBlob();
|
||||
const legacy = readLegacyKeys();
|
||||
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
||||
@@ -305,6 +338,112 @@ export const getFallbackSession = (): Session | undefined => {
|
||||
if (!persisted) return undefined;
|
||||
return sessionFromPersisted(persisted);
|
||||
};
|
||||
// --- [Gitea #105] desktop keychain session -----------------------------------
|
||||
|
||||
export type KeychainSessionTokens = {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
/** The shell blob written while the tokens are in the keychain, if any. */
|
||||
const readShell = (): Omit<ShellSession, 'tokensIn'> | undefined => {
|
||||
const raw = localStorage.getItem(SESSION_BLOB_KEY);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(raw) as Partial<ShellSession>;
|
||||
if (
|
||||
p?.tokensIn !== 'keychain' ||
|
||||
typeof p.deviceId !== 'string' ||
|
||||
typeof p.userId !== 'string' ||
|
||||
typeof p.baseUrl !== 'string'
|
||||
)
|
||||
return undefined;
|
||||
const { tokensIn: _drop, ...rest } = p as ShellSession;
|
||||
return rest;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** A session in localStorage that still carries its tokens (blob or legacy). */
|
||||
export const readStoredSessionWithTokens = (): Session | undefined => {
|
||||
const persisted = readStoredPersisted();
|
||||
return persisted ? sessionFromPersisted(persisted) : undefined;
|
||||
};
|
||||
|
||||
/** Whether localStorage holds a token-less shell (tokens are in the keychain). */
|
||||
export const hasKeychainShell = (): boolean => readShell() !== undefined;
|
||||
|
||||
export type KeychainBoot =
|
||||
/** Use the keychain: memory holds `session` (or none = signed out). */
|
||||
| { mode: 'keychain'; session: Session | undefined }
|
||||
/** A shell says the tokens are in the keychain, but they can't be read. */
|
||||
| { mode: 'missing'; userId: string };
|
||||
|
||||
/**
|
||||
* Decide the boot session from the keychain's tokens and localStorage, and
|
||||
* switch this tab to keychain mode. `tokens` is what the keychain returned
|
||||
* (null = no entry). Pure apart from reading localStorage and setting the
|
||||
* in-memory state.
|
||||
*/
|
||||
export const enterKeychainMode = (tokens: KeychainSessionTokens | null): KeychainBoot => {
|
||||
const withTokens = readStoredPersisted();
|
||||
const shell = readShell();
|
||||
keychainMode = true;
|
||||
if (withTokens) {
|
||||
// Pre-step-2 install, or a write not yet confirmed: localStorage has the
|
||||
// full session and stays authoritative until the mirror verifies it.
|
||||
memorySession = withTokens;
|
||||
return { mode: 'keychain', session: sessionFromPersisted(withTokens) };
|
||||
}
|
||||
if (shell) {
|
||||
if (tokens && tokens.userId === shell.userId && tokens.deviceId === shell.deviceId) {
|
||||
memorySession = {
|
||||
...shell,
|
||||
accessToken: tokens.accessToken,
|
||||
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
|
||||
};
|
||||
return { mode: 'keychain', session: sessionFromPersisted(memorySession) };
|
||||
}
|
||||
// Never treat this as a logout: the shell stays, the caller offers Retry.
|
||||
memorySession = undefined;
|
||||
return { mode: 'missing', userId: shell.userId };
|
||||
}
|
||||
memorySession = undefined;
|
||||
return { mode: 'keychain', session: undefined };
|
||||
};
|
||||
|
||||
/** Leave keychain mode (the keychain failed): localStorage is authoritative again. */
|
||||
export const leaveKeychainMode = (): void => {
|
||||
keychainMode = false;
|
||||
memorySession = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The keychain now verifiably holds `tokens`: if they're still the current
|
||||
* session's, drop the tokens from localStorage (shell blob, no legacy keys).
|
||||
*/
|
||||
export const stripTokensFromStorage = (tokens: KeychainSessionTokens): boolean => {
|
||||
const m = memorySession;
|
||||
if (
|
||||
!keychainMode ||
|
||||
!m ||
|
||||
m.userId !== tokens.userId ||
|
||||
m.deviceId !== tokens.deviceId ||
|
||||
m.accessToken !== tokens.accessToken ||
|
||||
(m.refreshToken ?? undefined) !== (tokens.refreshToken ?? undefined)
|
||||
)
|
||||
return false;
|
||||
const { accessToken: _a, refreshToken: _r, ...rest } = m;
|
||||
const shell: ShellSession = { ...rest, tokensIn: 'keychain' };
|
||||
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(shell));
|
||||
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* End of migration code for old session
|
||||
*/
|
||||
|
||||
@@ -310,6 +310,8 @@ export interface Settings {
|
||||
screenshareFramerate: ScreenshareFramerate;
|
||||
// [P5-15] In-call soundboard
|
||||
soundboardEnabled: boolean;
|
||||
// [Gitea #253] The call-ended toast (with its Details card).
|
||||
callSummaryToast: boolean;
|
||||
soundboardVolume: number; // 0–100
|
||||
|
||||
seasonalThemeOverride: SeasonalThemeOverride;
|
||||
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
|
||||
screenshareBitrate: 'auto',
|
||||
screenshareFramerate: 'auto',
|
||||
soundboardEnabled: true,
|
||||
callSummaryToast: true,
|
||||
soundboardVolume: 80,
|
||||
|
||||
seasonalThemeOverride: 'auto',
|
||||
|
||||
@@ -13,6 +13,8 @@ export type ToastNotif = {
|
||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
||||
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
|
||||
onAction?: () => void;
|
||||
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
||||
};
|
||||
|
||||
|
||||
@@ -2,3 +2,7 @@ import { atom } from 'jotai';
|
||||
|
||||
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
||||
export const widgetsPanelAtom = atom<boolean>(false);
|
||||
|
||||
// [Gitea #205] Which widget the panel is showing, per room, so a floating
|
||||
// widget's "Back to room" can open the panel on it.
|
||||
export const widgetViewingAtom = atom<Record<string, string | null>>({});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* z-index values and is intentionally not listed here.
|
||||
*/
|
||||
export const zIndices = {
|
||||
/** [Gitea #205] A room widget kept on screen after leaving its room. */
|
||||
floatingWidget: 9980,
|
||||
/** In-call incoming-call banner — below seasonal/night-light/modals. */
|
||||
inCallBanner: 9990,
|
||||
/** Seasonal particle effect — below the night-light tint so particles tint. */
|
||||
|
||||
@@ -18,6 +18,17 @@ export function getAccountData<T>(
|
||||
return event?.getContent() as T | undefined;
|
||||
}
|
||||
|
||||
/** Room account data under a custom or developer-chosen type (same cast). */
|
||||
export function setRoomAccountData<T>(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
content: T,
|
||||
): Promise<unknown> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (mx as any).setRoomAccountData(roomId, eventType, content);
|
||||
}
|
||||
|
||||
export function setAccountData<T>(
|
||||
mx: MatrixClient,
|
||||
eventType: AccountDataEvent | string,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeCallDuration, describeCallEnd } from './callSummary';
|
||||
import {
|
||||
describeCallDuration,
|
||||
describeCallEnd,
|
||||
describeCallDetails,
|
||||
parseCallSummaryDetails,
|
||||
} from './callSummary';
|
||||
|
||||
describe('describeCallDuration', () => {
|
||||
it('picks the unit', () => {
|
||||
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
|
||||
const d = parseCallSummaryDetails({
|
||||
participants: ['@bob:x', 7, '@carol:x'],
|
||||
peakParticipants: 3,
|
||||
quality: {
|
||||
youPoorMs: 1000,
|
||||
othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
|
||||
everyonePoorMs: 0,
|
||||
},
|
||||
network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
|
||||
activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
|
||||
});
|
||||
assert.deepEqual(d, {
|
||||
participants: ['@bob:x', '@carol:x'],
|
||||
peakParticipants: 3,
|
||||
quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
|
||||
network: { rttMedianMs: 38, inboundLossPct: 0.5 },
|
||||
activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
|
||||
});
|
||||
assert.equal(parseCallSummaryDetails(undefined), undefined);
|
||||
assert.equal(parseCallSummaryDetails('details'), undefined);
|
||||
});
|
||||
|
||||
it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
|
||||
const names: Record<string, string> = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
|
||||
const sections = describeCallDetails(
|
||||
{
|
||||
durationMs: 2_460_000,
|
||||
reconnects: 2,
|
||||
poorMs: 120_000,
|
||||
verdict: 'fair',
|
||||
details: {
|
||||
participants: ['@bob:x', '@carol:x'],
|
||||
peakParticipants: 3,
|
||||
quality: {
|
||||
youPoorMs: 120_000,
|
||||
othersPoorMs: { '@carol:x': 30_000 },
|
||||
everyonePoorMs: 10_000,
|
||||
},
|
||||
network: {
|
||||
rttMedianMs: 38,
|
||||
rttP95Ms: 310,
|
||||
outboundLossPct: 4.1,
|
||||
inboundLossPct: 0.5,
|
||||
jitterMs: 6,
|
||||
},
|
||||
activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
|
||||
},
|
||||
},
|
||||
2_460_000,
|
||||
90_000,
|
||||
(u) => names[u] ?? u,
|
||||
);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
|
||||
{
|
||||
title: 'Connection',
|
||||
lines: [
|
||||
'Verdict: fair · 2 reconnects',
|
||||
'Your connection poor/lost: 2 min',
|
||||
'Carol poor/lost: 30 s',
|
||||
'Everyone poor at once: 10 s (likely server or SFU side)',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
lines: [
|
||||
'RTT median 38 ms · p95 310 ms',
|
||||
'Outbound packet loss 4.1%',
|
||||
'Inbound packet loss 0.5%',
|
||||
'Jitter 6 ms',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'You',
|
||||
lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
|
||||
const sections = describeCallDetails(
|
||||
{ durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
|
||||
60_000,
|
||||
0,
|
||||
(u) => u,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sections.map((s) => s.title),
|
||||
['Call', 'Connection'],
|
||||
);
|
||||
assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
|
||||
});
|
||||
|
||||
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** [Gitea #253] The fork's `details` in io.lotus.call_summary (lotus.29+). */
|
||||
export type LotusCallSummaryDetails = {
|
||||
participants: string[];
|
||||
peakParticipants: number;
|
||||
quality: { youPoorMs: number; othersPoorMs: Record<string, number>; everyonePoorMs: number };
|
||||
network: {
|
||||
rttMedianMs?: number;
|
||||
rttP95Ms?: number;
|
||||
outboundLossPct?: number;
|
||||
inboundLossPct?: number;
|
||||
jitterMs?: number;
|
||||
};
|
||||
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
|
||||
};
|
||||
|
||||
const num = (v: unknown): number | undefined =>
|
||||
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
|
||||
const obj = (v: unknown): Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
|
||||
/** Defensive: the frame is another origin; take only well-formed fields. */
|
||||
export function parseCallSummaryDetails(raw: unknown): LotusCallSummaryDetails | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||
const r = obj(raw);
|
||||
const quality = obj(r.quality);
|
||||
const network = obj(r.network);
|
||||
const activity = obj(r.activity);
|
||||
const othersPoorMs: Record<string, number> = {};
|
||||
Object.entries(obj(quality.othersPoorMs)).forEach(([user, ms]) => {
|
||||
const n = num(ms);
|
||||
if (n !== undefined && user.startsWith('@')) othersPoorMs[user] = n;
|
||||
});
|
||||
const net: LotusCallSummaryDetails['network'] = {};
|
||||
(['rttMedianMs', 'rttP95Ms', 'outboundLossPct', 'inboundLossPct', 'jitterMs'] as const).forEach(
|
||||
(k) => {
|
||||
const n = num(network[k]);
|
||||
if (n !== undefined) net[k] = n;
|
||||
},
|
||||
);
|
||||
return {
|
||||
participants: Array.isArray(r.participants)
|
||||
? r.participants.filter((p): p is string => typeof p === 'string').slice(0, 200)
|
||||
: [],
|
||||
peakParticipants: num(r.peakParticipants) ?? 1,
|
||||
quality: {
|
||||
youPoorMs: num(quality.youPoorMs) ?? 0,
|
||||
othersPoorMs,
|
||||
everyonePoorMs: num(quality.everyonePoorMs) ?? 0,
|
||||
},
|
||||
network: net,
|
||||
activity: {
|
||||
speakingMs: num(activity.speakingMs) ?? 0,
|
||||
mutedMs: num(activity.mutedMs) ?? 0,
|
||||
screenshareMs: num(activity.screenshareMs) ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CallSummarySection = { title: string; lines: string[] };
|
||||
|
||||
/**
|
||||
* [Gitea #253] The Details card, in technical wording (the owner's choice):
|
||||
* who was there, connection quality and whose side it was on, network
|
||||
* numbers, and your own activity. `nameOf` turns a user id into a display name.
|
||||
*/
|
||||
export function describeCallDetails(
|
||||
summary: LotusCallSummary | undefined,
|
||||
durationMs: number,
|
||||
deafenedMs: number,
|
||||
nameOf: (userId: string) => string,
|
||||
): CallSummarySection[] {
|
||||
const sections: CallSummarySection[] = [];
|
||||
const d = summary?.details;
|
||||
const dur = describeCallDuration;
|
||||
const people = d?.participants ?? [];
|
||||
sections.push({
|
||||
title: 'Call',
|
||||
lines: [
|
||||
`Duration ${dur(durationMs)}${d ? ` · peak ${d.peakParticipants} in the call` : ''}`,
|
||||
people.length
|
||||
? `With ${people.map(nameOf).join(', ')}`
|
||||
: d
|
||||
? 'Nobody else joined'
|
||||
: 'Participants unknown',
|
||||
],
|
||||
});
|
||||
if (summary) {
|
||||
const quality: string[] = [
|
||||
`Verdict: ${summary.verdict} · ${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`,
|
||||
];
|
||||
if (d) {
|
||||
quality.push(`Your connection poor/lost: ${dur(d.quality.youPoorMs)}`);
|
||||
Object.entries(d.quality.othersPoorMs)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.forEach(([user, ms]) => quality.push(`${nameOf(user)} poor/lost: ${dur(ms)}`));
|
||||
if (d.quality.everyonePoorMs > 0)
|
||||
quality.push(
|
||||
`Everyone poor at once: ${dur(d.quality.everyonePoorMs)} (likely server or SFU side)`,
|
||||
);
|
||||
} else quality.push(`Your connection poor/lost: ${dur(summary.poorMs)}`);
|
||||
sections.push({ title: 'Connection', lines: quality });
|
||||
}
|
||||
if (d) {
|
||||
const n = d.network;
|
||||
const net: string[] = [];
|
||||
if (n.rttMedianMs !== undefined)
|
||||
net.push(`RTT median ${n.rttMedianMs} ms · p95 ${n.rttP95Ms ?? n.rttMedianMs} ms`);
|
||||
if (n.outboundLossPct !== undefined) net.push(`Outbound packet loss ${n.outboundLossPct}%`);
|
||||
if (n.inboundLossPct !== undefined) net.push(`Inbound packet loss ${n.inboundLossPct}%`);
|
||||
if (n.jitterMs !== undefined) net.push(`Jitter ${n.jitterMs} ms`);
|
||||
if (net.length) sections.push({ title: 'Network', lines: net });
|
||||
const act = [
|
||||
`Talking ${dur(d.activity.speakingMs)} · muted ${dur(d.activity.mutedMs)} · deafened ${dur(deafenedMs)}`,
|
||||
];
|
||||
if (d.activity.screenshareMs > 0) act.push(`Screensharing ${dur(d.activity.screenshareMs)}`);
|
||||
sections.push({ title: 'You', lines: act });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
|
||||
export function sendStateEvent<T extends object>(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
eventType: StateEvent,
|
||||
eventType: StateEvent | string,
|
||||
content: T,
|
||||
stateKey = '',
|
||||
): Promise<ISendEventResponse> {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
spotifyEmbedHeight,
|
||||
parseMediaEmbed,
|
||||
isTwitterNoResults,
|
||||
getVimeoEventParts,
|
||||
} from './videoEmbed';
|
||||
|
||||
const HOST = 'chat.lotusguild.org';
|
||||
@@ -429,3 +430,34 @@ test('isTwitterNoResults: ignores normal embed traffic and junk', () => {
|
||||
assert.equal(isTwitterNoResults(null), false);
|
||||
assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false);
|
||||
});
|
||||
|
||||
test('[#200] Vimeo live events embed from vimeo.com/event/{id}/embed', () => {
|
||||
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567'), {
|
||||
id: '4321567',
|
||||
hash: undefined,
|
||||
});
|
||||
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567/a1b2c3d4e5'), {
|
||||
id: '4321567',
|
||||
hash: 'a1b2c3d4e5',
|
||||
});
|
||||
assert.equal(
|
||||
getVimeoEventParts('https://vimeo.com/event/4321567/embed/a1b2c3d4e5')?.hash,
|
||||
'a1b2c3d4e5',
|
||||
);
|
||||
assert.equal(getVimeoEventParts('https://vimeo.com/event/4321567/chat'), null);
|
||||
assert.equal(getVimeoEventParts('https://vimeo.com/123456789'), null);
|
||||
assert.equal(getVimeoEventParts('https://evil.example/event/4321567'), null);
|
||||
assert.equal(
|
||||
parseMediaEmbed('https://vimeo.com/event/4321567', 'h')?.embedUrl,
|
||||
'https://vimeo.com/event/4321567/embed?autoplay=1&dnt=1',
|
||||
);
|
||||
assert.equal(
|
||||
parseMediaEmbed('https://vimeo.com/event/4321567/a1b2c3d4e5', 'h')?.embedUrl,
|
||||
'https://vimeo.com/event/4321567/embed/a1b2c3d4e5?autoplay=1&dnt=1',
|
||||
);
|
||||
// An ordinary video still goes to the player.
|
||||
assert.match(
|
||||
parseMediaEmbed('https://vimeo.com/123456789', 'h')?.embedUrl ?? '',
|
||||
/^https:\/\/player\.vimeo\.com\/video\/123456789/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,25 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #200] Vimeo live events: vimeo.com/event/{id}, or /event/{id}/{hash}
|
||||
* when unlisted. Embedded from vimeo.com itself (not player.vimeo.com); the
|
||||
* web and desktop CSP `frame-src https:` already allow it.
|
||||
*/
|
||||
export function getVimeoEventParts(url: string): { id: string; hash?: string } | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||
const m = pathname.match(/^\/event\/(\d+)(?:\/(?:embed\/)?([0-9a-f]{6,}))?\/?$/);
|
||||
return m ? { id: m[1], hash: m[2] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const buildVimeoEventEmbedUrl = (id: string, hash?: string): string =>
|
||||
`https://vimeo.com/event/${enc(id)}/embed${hash ? `/${enc(hash)}` : ''}?autoplay=1&dnt=1`;
|
||||
|
||||
export function getVimeoVideoId(url: string): string | null {
|
||||
return getVimeoParts(url)?.id ?? null;
|
||||
}
|
||||
@@ -713,6 +732,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
embedUrl: buildVideoEmbedUrl('youtube', ytId),
|
||||
};
|
||||
|
||||
const vimeoEvent = getVimeoEventParts(url);
|
||||
if (vimeoEvent)
|
||||
return {
|
||||
provider: 'vimeo',
|
||||
kind: 'landscape',
|
||||
embedUrl: buildVimeoEventEmbedUrl(vimeoEvent.id, vimeoEvent.hash),
|
||||
};
|
||||
|
||||
const vimeo = getVimeoParts(url);
|
||||
if (vimeo)
|
||||
return {
|
||||
|
||||
+85
-67
@@ -18,75 +18,18 @@ import './app/i18n';
|
||||
import { pushSessionToSW } from './sw-session';
|
||||
import { getFallbackSession } from './app/state/sessions';
|
||||
import { startKeychainMirror } from './app/state/keychainMirror';
|
||||
import { bootKeychainSession, KeychainBootResult } from './app/state/keychainSession';
|
||||
import { KeychainUnavailable } from './app/pages/KeychainUnavailable';
|
||||
import { tauriInvoke } from './app/hooks/useTauri';
|
||||
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
|
||||
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
|
||||
// [Gitea #105] Desktop: keep a copy of the login tokens in the OS keychain
|
||||
// (step 1: mirror only; the session is still read from localStorage).
|
||||
startKeychainMirror(tauriInvoke());
|
||||
|
||||
// Register Service Worker
|
||||
// Service workers only register on http(s) pages. The desktop app loads from
|
||||
// `tauri://localhost` in debug builds (and on any platform where the localhost
|
||||
// plugin isn't used), where register() rejects: that surfaced in Sentry as an
|
||||
// unhandled "must be called with a script URL whose protocol is either HTTP or
|
||||
// HTTPS". Skip it there; the app works without a SW, only authenticated media
|
||||
// falls back to the client's own fetch.
|
||||
const swProtocolOk = window.location.protocol === 'https:' || window.location.protocol === 'http:';
|
||||
if ('serviceWorker' in navigator && swProtocolOk) {
|
||||
const swUrl =
|
||||
import.meta.env.MODE === 'production'
|
||||
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
||||
: `/dev-sw.js?dev-sw`;
|
||||
|
||||
const sendSessionToSW = () => {
|
||||
const session = getFallbackSession();
|
||||
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
|
||||
};
|
||||
|
||||
// The dev worker is an ES module (it imports workbox), so it must be
|
||||
// registered as one — otherwise "script evaluation failed" and the dev
|
||||
// client has no SW: authenticated media 401s and notification clicks
|
||||
// never route. Production sw.js is a classic bundled script.
|
||||
navigator.serviceWorker
|
||||
.register(swUrl, import.meta.env.MODE === 'production' ? undefined : { type: 'module' })
|
||||
.then(sendSessionToSW)
|
||||
.catch((err) => {
|
||||
// e.g. a private window with SWs disabled; the app still works.
|
||||
console.warn('Service worker registration failed', err);
|
||||
});
|
||||
navigator.serviceWorker.ready.then(sendSessionToSW);
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (ev) => {
|
||||
const { type } = ev.data ?? {};
|
||||
|
||||
if (type === 'requestSession') {
|
||||
sendSessionToSW();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Request persistent storage so the browser can't evict the IndexedDB
|
||||
// rust-crypto store under storage pressure. Eviction (while the localStorage
|
||||
// session/device-id survives) resurrects the device with a blank crypto store,
|
||||
// which then re-uploads OTKs the server already holds → the "one time key
|
||||
// already exists" upload storm and E2EE breakage. Only ask for sessions worth
|
||||
// protecting (skip anonymous/landing visitors to avoid a needless Firefox
|
||||
// prompt); check persisted() first so we don't re-prompt. Best-effort.
|
||||
// [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another
|
||||
// tab still held the DB), finish it now that no session exists.
|
||||
if (!getFallbackSession()) {
|
||||
cleanupSearchCacheIfSignedOut().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (navigator.storage?.persist && getFallbackSession()) {
|
||||
navigator.storage
|
||||
.persisted()
|
||||
.then((already) => (already ? undefined : navigator.storage.persist()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
// [Gitea #105] Desktop: the login tokens live in the OS keychain. Read them
|
||||
// into memory BEFORE anything reads the session (step 2), then keep the
|
||||
// keychain in line with every login / refresh / logout. If the keychain should
|
||||
// hold them but can't be read, show Retry instead of the app, and don't start
|
||||
// the mirror: it would clear the entry it couldn't read.
|
||||
|
||||
// Reload once if a lazy-loaded chunk is missing (stale deployment)
|
||||
window.addEventListener('vite:preloadError', () => {
|
||||
@@ -121,7 +64,72 @@ window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload-at
|
||||
};
|
||||
}
|
||||
|
||||
const mountApp = () => {
|
||||
// Everything that reads the session runs once the keychain step decided it.
|
||||
const startSessionDependent = () => {
|
||||
// Register Service Worker
|
||||
// Service workers only register on http(s) pages. The desktop app loads from
|
||||
// `tauri://localhost` in debug builds (and on any platform where the localhost
|
||||
// plugin isn't used), where register() rejects: that surfaced in Sentry as an
|
||||
// unhandled "must be called with a script URL whose protocol is either HTTP or
|
||||
// HTTPS". Skip it there; the app works without a SW, only authenticated media
|
||||
// falls back to the client's own fetch.
|
||||
const swProtocolOk =
|
||||
window.location.protocol === 'https:' || window.location.protocol === 'http:';
|
||||
if ('serviceWorker' in navigator && swProtocolOk) {
|
||||
const swUrl =
|
||||
import.meta.env.MODE === 'production'
|
||||
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
||||
: `/dev-sw.js?dev-sw`;
|
||||
|
||||
const sendSessionToSW = () => {
|
||||
const session = getFallbackSession();
|
||||
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
|
||||
};
|
||||
|
||||
// The dev worker is an ES module (it imports workbox), so it must be
|
||||
// registered as one — otherwise "script evaluation failed" and the dev
|
||||
// client has no SW: authenticated media 401s and notification clicks
|
||||
// never route. Production sw.js is a classic bundled script.
|
||||
navigator.serviceWorker
|
||||
.register(swUrl, import.meta.env.MODE === 'production' ? undefined : { type: 'module' })
|
||||
.then(sendSessionToSW)
|
||||
.catch((err) => {
|
||||
// e.g. a private window with SWs disabled; the app still works.
|
||||
console.warn('Service worker registration failed', err);
|
||||
});
|
||||
navigator.serviceWorker.ready.then(sendSessionToSW);
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (ev) => {
|
||||
const { type } = ev.data ?? {};
|
||||
|
||||
if (type === 'requestSession') {
|
||||
sendSessionToSW();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Request persistent storage so the browser can't evict the IndexedDB
|
||||
// rust-crypto store under storage pressure. Eviction (while the localStorage
|
||||
// session/device-id survives) resurrects the device with a blank crypto store,
|
||||
// which then re-uploads OTKs the server already holds → the "one time key
|
||||
// already exists" upload storm and E2EE breakage. Only ask for sessions worth
|
||||
// protecting (skip anonymous/landing visitors to avoid a needless Firefox
|
||||
// prompt); check persisted() first so we don't re-prompt. Best-effort.
|
||||
// [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another
|
||||
// tab still held the DB), finish it now that no session exists.
|
||||
if (!getFallbackSession()) {
|
||||
cleanupSearchCacheIfSignedOut().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (navigator.storage?.persist && getFallbackSession()) {
|
||||
navigator.storage
|
||||
.persisted()
|
||||
.then((already) => (already ? undefined : navigator.storage.persist()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const mountApp = (keychainBoot: KeychainBootResult, keychainBlocked: boolean) => {
|
||||
const rootContainer = document.getElementById('root');
|
||||
|
||||
if (rootContainer === null) {
|
||||
@@ -130,7 +138,17 @@ const mountApp = () => {
|
||||
}
|
||||
|
||||
const root = createRoot(rootContainer);
|
||||
root.render(<App />);
|
||||
root.render(
|
||||
keychainBlocked ? <KeychainUnavailable boot={keychainBoot} invoke={tauriInvoke()} /> : <App />,
|
||||
);
|
||||
};
|
||||
|
||||
mountApp();
|
||||
bootKeychainSession(tauriInvoke())
|
||||
// Whatever happens, the app mounts: an unexpected failure means "no keychain".
|
||||
.catch((): KeychainBootResult => ({ mode: 'off' }))
|
||||
.then((keychainBoot) => {
|
||||
const keychainBlocked = keychainBoot.mode === 'missing' || keychainBoot.mode === 'error';
|
||||
if (!keychainBlocked) startKeychainMirror(tauriInvoke());
|
||||
startSessionDependent();
|
||||
mountApp(keychainBoot, keychainBlocked);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user