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:
@@ -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), []);
|
||||||
|
});
|
||||||
@@ -1,21 +1,67 @@
|
|||||||
import { Room } from 'matrix-js-sdk';
|
import { Room, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { useMemo } from 'react';
|
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 { 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.
|
* 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[] => {
|
export const useRoomWidgets = (room: Room): Widget[] => {
|
||||||
const state = useRoomState(room);
|
const state = useRoomState(room);
|
||||||
return useMemo(() => {
|
return useMemo(
|
||||||
const widgetEvents = state.get(StateEvent.Widget);
|
() => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin),
|
||||||
if (!widgetEvents) return [];
|
[state],
|
||||||
const stateEvents = Array.from(widgetEvents.values()).map(
|
);
|
||||||
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
|
|
||||||
);
|
|
||||||
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
|
|
||||||
}, [state]);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user