fix(widgets): room widgets render again (bypass broken isValidUrl)

matrix-widget-api 1.17.0's WidgetParser rejects every URL (it compares
URL.protocol "https:" to "https"), so the widgets panel was always empty.
Build Widget objects from the raw state events with a correct scheme
check plus the existing origin check. Unit-tested against a real state
event.

Fixes #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 19:46:06 -04:00
co-authored by Claude Opus 5
parent 8210ee7046
commit 32e5fec6f3
2 changed files with 130 additions and 12 deletions
@@ -0,0 +1,72 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixEvent } from 'matrix-js-sdk';
import { widgetsFromStateEvents } from './useRoomWidgets';
const APP = 'https://chat.lotusguild.org';
// Minimal fake MatrixEvent, just enough of the surface widgetsFromStateEvents reads.
const fakeEvent = (stateKey: string, sender: string, content: Record<string, unknown>) =>
({
getStateKey: () => stateKey,
getSender: () => sender,
getContent: () => content,
}) as unknown as MatrixEvent;
test('returns a Widget for a valid im.vector.modular.widgets state event', () => {
// Regression test for matrix-widget-api 1.17.0's broken isValidUrl, which
// compares URL.protocol ("https:") to "https" and rejects every URL,
// making WidgetParser.parseWidgetsFromRoomState always return [].
const events = new Map([
[
'w1',
fakeEvent('w1', '@a:example.org', {
id: 'w1',
type: 'custom',
url: 'https://example.com/widget',
name: 'My Widget',
creatorUserId: '@a:example.org',
}),
],
]);
const widgets = widgetsFromStateEvents(events, APP);
assert.equal(widgets.length, 1);
assert.equal(widgets[0].id, 'w1');
assert.equal(widgets[0].templateUrl, 'https://example.com/widget');
assert.equal(widgets[0].creatorUserId, '@a:example.org');
assert.equal(widgets[0].name, 'My Widget');
});
test('skips removed widgets (empty content)', () => {
const events = new Map([['w1', fakeEvent('w1', '@a:example.org', {})]]);
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
});
test('skips non-https and same-origin urls', () => {
const events = new Map([
[
'w1',
fakeEvent('w1', '@a:example.org', {
id: 'w1',
type: 'custom',
url: 'http://example.com/widget',
creatorUserId: '@a:example.org',
}),
],
[
'w2',
fakeEvent('w2', '@a:example.org', {
id: 'w2',
type: 'custom',
url: `${APP}/evil`,
creatorUserId: '@a:example.org',
}),
],
]);
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
});
test('undefined state map yields no widgets', () => {
assert.deepEqual(widgetsFromStateEvents(undefined, APP), []);
});
+58 -12
View File
@@ -1,21 +1,67 @@
import { Room } from 'matrix-js-sdk';
import { Room, MatrixEvent } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
import { Widget } from 'matrix-widget-api';
import { StateEvent } from '../../../../types/matrix/room';
import { useRoomState } from '../../../hooks/useRoomState';
import { StateKeyToEvents, useRoomState } from '../../../hooks/useRoomState';
import { isWidgetUrlSafe } from './widgetUtils';
/**
* Builds the `Widget` list from raw `im.vector.modular.widgets` state events.
*
* NOTE: we do NOT use `WidgetParser.parseWidgetsFromRoomState` here. In
* matrix-widget-api 1.17.0 its `isValidUrl` compares `URL.protocol` (which is
* always colon-suffixed, e.g. "https:") against the bare strings "http"/
* "https", so it rejects every URL and the parser always returns []. We build
* the `Widget`s ourselves with a correct scheme check plus the existing
* `isWidgetUrlSafe` origin check.
*/
export const widgetsFromStateEvents = (
widgetEvents: StateKeyToEvents | undefined,
appOrigin: string,
): Widget[] => {
if (!widgetEvents) return [];
const widgets: Widget[] = [];
Array.from(widgetEvents.values()).forEach((event: MatrixEvent) => {
const content = event.getContent();
// Removed widgets are represented as an empty content state event.
if (!content || Object.keys(content).length === 0) return;
const id = event.getStateKey();
const { type, url, name, data, waitForIframeLoad } = content;
const creatorUserId = content.creatorUserId || event.getSender();
if (!id || !type || !url || !creatorUserId) return;
let scheme: string;
try {
scheme = new URL(url).protocol;
} catch {
return;
}
if (scheme !== 'https:') return;
if (!isWidgetUrlSafe(url, appOrigin)) return;
widgets.push(
new Widget({
id,
creatorUserId,
type,
url,
name,
data,
waitForIframeLoad,
}),
);
});
return widgets;
};
/**
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
*/
export const useRoomWidgets = (room: Room): Widget[] => {
const state = useRoomState(room);
return useMemo(() => {
const widgetEvents = state.get(StateEvent.Widget);
if (!widgetEvents) return [];
const stateEvents = Array.from(widgetEvents.values()).map(
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
);
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
}, [state]);
return useMemo(
() => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin),
[state],
);
};