CI / Build & Quality Checks (pull_request) Successful in 2m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m16s
The media gallery and activity log register filtered timeline sets on the room (getOrCreateFilteredTimelineSet); the room re-emits their events as live RoomEvent.Timeline, so every matching event reached "new event in the room" listeners twice for the rest of the session. The timeline window shifted by an extra message per event (loaded messages vanished), and notifications, unread counts, widget feeds and ring handling saw duplicates. isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered set; the room timeline, roomToUnread, message notifications, clock-skew sampling, call ring/decline handling and widget event feeds ignore them. Verified headless (40 messages, every 5th a file; gallery opened; Bob sends 3 files + 1 text): before, the first rendered message drifted from the room start to "file15"; now it stays put. Fixes #259 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import React, { useEffect, useRef, useState } 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 { 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;
|
|
};
|
|
|
|
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.
|
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|
const mx = useMatrixClient();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const widgetRef = useRef(widget);
|
|
widgetRef.current = widget;
|
|
const [blocked, setBlocked] = useState(false);
|
|
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return undefined;
|
|
const current = widgetRef.current;
|
|
|
|
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) {
|
|
return (
|
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
|
<Text size="T300" align="Center">
|
|
This widget can't be loaded because its URL is on this app's own origin.
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const addedBy = widget.creatorUserId
|
|
? (room.getMember(widget.creatorUserId)?.rawDisplayName ?? widget.creatorUserId)
|
|
: undefined;
|
|
|
|
return (
|
|
<>
|
|
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
|
{pendingAsk && (
|
|
<WidgetPermissionPrompt
|
|
widgetName={widget.name || 'Widget'}
|
|
origin={pendingAsk.origin}
|
|
addedBy={addedBy}
|
|
requests={pendingAsk.requests}
|
|
onDone={pendingAsk.resolve}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|