Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f897b6d1 |
+1
-1
@@ -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 29",
|
||||
"check:eslint": "eslint src/* --max-warnings 36",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -40,10 +39,7 @@ export function SecretStorageRecoveryPassphrase({
|
||||
bits,
|
||||
);
|
||||
|
||||
const match = await mx.secretStorage.checkKey(
|
||||
decodedRecoveryKey,
|
||||
keyContent as SecretStorageKeyDescription,
|
||||
);
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid recovery passphrase.');
|
||||
@@ -136,10 +132,7 @@ export function SecretStorageRecoveryKey({
|
||||
async (recoveryKey) => {
|
||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||
|
||||
const match = await mx.secretStorage.checkKey(
|
||||
decodedRecoveryKey,
|
||||
keyContent as SecretStorageKeyDescription,
|
||||
);
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid recovery key.');
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
AccountDataSubmitCallback,
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { setRoomAccountData } from '../../../utils/accountData';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
@@ -59,7 +58,8 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
|
||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||
async (type, content) => {
|
||||
await setRoomAccountData(mx, room.roomId, type, content);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.setRoomAccountData(room.roomId, type as any, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||
import { IContent, MatrixError } from 'matrix-js-sdk';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
@@ -22,7 +22,6 @@ 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;
|
||||
|
||||
@@ -52,9 +51,9 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
||||
useCallback(
|
||||
(evtType, evtStateKey, evtContent) => {
|
||||
if (typeof evtStateKey === 'string') {
|
||||
return sendStateEvent(mx, room.roomId, evtType, evtContent, evtStateKey);
|
||||
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
|
||||
}
|
||||
return sendRoomEvent(mx, room.roomId, null, evtType, evtContent as IContent);
|
||||
return mx.sendEvent(room.roomId, evtType as any, evtContent);
|
||||
},
|
||||
[mx, room],
|
||||
),
|
||||
|
||||
@@ -31,7 +31,6 @@ 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;
|
||||
|
||||
@@ -60,7 +59,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
||||
|
||||
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
||||
useCallback(
|
||||
(c) => sendStateEvent(mx, room.roomId, type, c, stateKey),
|
||||
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
|
||||
[mx, room, type, stateKey],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
@@ -46,12 +46,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
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,
|
||||
});
|
||||
const completeUrl = widgetCompleteUrl(current, mx, room.roomId);
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -27,7 +27,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) {
|
||||
@@ -60,6 +66,14 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
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 +158,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 +177,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 +215,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"
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
|
||||
@@ -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 as Record<string, unknown>)[key] = DEFAULT_POWER_LEVELS[key];
|
||||
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
|
||||
}
|
||||
});
|
||||
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
WidgetApiFromWidgetAction,
|
||||
WidgetApiToWidgetAction,
|
||||
WidgetDriver,
|
||||
IWidgetApiRequest,
|
||||
} from 'matrix-widget-api';
|
||||
import { CallWidgetDriver } from './CallWidgetDriver';
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
@@ -797,7 +796,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 unknown as IWidgetApiRequest, {});
|
||||
this.call.transport.reply(ev.detail as any, {});
|
||||
callback(ev);
|
||||
};
|
||||
return this.listenEvent(`action:${type}`, wrapped);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -26,9 +25,11 @@ export const setMarkedUnread = (
|
||||
unread: boolean,
|
||||
): Promise<unknown> =>
|
||||
Promise.all([
|
||||
setRoomAccountData(mx, roomId, AccountDataEvent.MarkedUnread, { unread }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
|
||||
// Best-effort mirror for older servers; never fail the primary write on it.
|
||||
setRoomAccountData(mx, roomId, UNSTABLE_MARKED_UNREAD, { unread }).catch(() => undefined),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
|
||||
]);
|
||||
|
||||
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
||||
|
||||
@@ -18,17 +18,6 @@ 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,
|
||||
|
||||
@@ -60,7 +60,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
|
||||
export function sendStateEvent<T extends object>(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
eventType: StateEvent | string,
|
||||
eventType: StateEvent,
|
||||
content: T,
|
||||
stateKey = '',
|
||||
): Promise<ISendEventResponse> {
|
||||
|
||||
Reference in New Issue
Block a user