Compare commits

...
10 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 c0e8334f03 chore(deps): element-call 0.25.0-lotus.14 (#43)
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m5s
Layout / settings / reactions over the widget API plus the
io.lotus.controls_state report the call bar now uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:24:06 -04:00
Lotus CIandClaude Opus 5.5 3e69c715e9 test(e2e): thread-panel aria snapshot includes "started by …" (#165)
The thread header subtitle now says who started the thread and when
(64af736c); the snapshot's room-name line is a pattern for that.
CI run 2104's Playwright job failed on this; the chromium suite passes
locally (19 passed, 3 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:22:18 -04:00
Lotus CIandClaude Opus 5.5 a0d1c565d0 feat(call): drive layout / settings / reactions over the widget API (#43)
Once the fork reports io.lotus.controls_state (element-call
v0.25.0-lotus.14), the call bar's Spotlight/Grid View, Reactions and Settings
send io.lotus.set_layout / toggle_reactions / open_settings instead of
clicking Element Call's hidden buttons, and the call bar's screenshare and
layout state come from that report instead of a MutationObserver on EC's DOM.
Older forks never send the report and keep the DOM path.

Screensharing itself is still started/stopped via EC's button: it needs the
user's click to reach the frame (Capability Delegation, step 3 of #43).

Verified in a two-person local call (Synapse + LiveKit) against a locally
built fork: controls_state arrives on join; Spotlight View / Grid View flip
EC's layout and the reported layout; Reactions opens EC's reactions menu;
Settings opens EC's settings modal; the four actions go over postMessage;
starting and stopping a screenshare flips the host's button via the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:50 -04:00
Lotus CIandClaude Opus 5.5 a932b1999a feat(threads): "Go to message" from the Threads list (#165)
A row in the Threads list only opened the thread panel; there was no way to
get to the root message in the room's timeline. Each row now has a small
"Go to message" button (a sibling of the row button, laid over its corner —
no nested buttons) that navigates the room to the root event. On a phone,
where the list covers the timeline, it also closes the list, and the button
is the bigger touch size.

Verified in Chromium: desktop jumps to the root with the list still open;
Pixel 7 jumps and closes the list (34 px target).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:58:14 -04:00
Lotus CIandClaude Opus 5.5 64af736c6e feat(threads): thread panel header says who started it and when (#165)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Failing after 10m28s
The subtitle was only the room name. It now reads e.g. "Thread Lab · started
by alice, Fri 09:18 PM" (the user's clock/date preferences via the #139
formatter), which is what you want to know when arriving from the Threads
list. Verified in the 360 px desktop panel: fits without clipping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:53:13 -04:00
Lotus CIandClaude Opus 5.5 c088b4c0e6 feat(media): show the full caption under the media in the viewer (#164)
A captioned image/video (MSC2530: `filename` set and `body` differs) put the
caption in the header, truncated to one line, so a long caption was
unreadable. The header now shows the file name; the caption is shown in full
under the media (wrapped, scrolls past 25vh). The image alt text uses the
caption, and Download now saves under the real file name instead of the
caption.

Verified in Chromium: header "lake-sunset.png", the 150-character caption
fully visible below the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:51:33 -04:00
Lotus CIandClaude Opus 5.5 ea07fb5087 fix(media): bigger prev/next arrows in the media viewer on touch screens (#164)
The viewer's arrows were 32 px at the screen edges on a phone, under the
44 px target the rest of the mobile work uses. On a coarse pointer they use
the next IconButton size (48 px measured on a Pixel 7); desktop is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:49:36 -04:00
Lotus CIandClaude Opus 5.5 e39714a6e0 refactor(types): typed send helpers replace 16 as any casts (#210)
- sendRoomMessage (composer's fire-and-forget sends: text, location, voice,
  files, GIFs) and sendRoomEvent (polls, poll responses/ends, forwards,
  reactions, edits) in utils/room.ts carry the one cast each needs
  (`keyof TimelineEvents` / `RoomMessageEventContent`, no `any`).
- sendRoomMessage also swallows the rejected promise: a failed send already
  shows on the local echo (Failed to send + Retry, or the consent prompt), so
  it no longer surfaces as an unhandled error in the console.
- getAccountData narrows to `keyof AccountDataEvents`; ForwardMessageDialog's
  guard now narrows `contentToSend` itself (same behaviour).
- `as any` 39 → 23; eslint warnings 46 → 36, ratchet tightened to 36.

Verified in Chromium on a local Synapse: a text message, a quick reaction and
an edit all reach the server with the right content; a consent-blocked send
no longer logs an unhandled MatrixError. 1219 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:47:44 -04:00
Lotus CIandClaude Opus 5.5 39589b40f3 feat(media): two-finger pinch zoom in the media viewer (#164)
Zoom on a phone was only the +/- buttons; the pinch people try first did
nothing. usePinchZoom tracks a two-finger gesture on the viewer's media area
and scales from the zoom it started at (clamped 1×–5×, snapping back to
exactly 1× when released near it, so one-finger swipe navigation re-arms).
The area gets touch-action: none so the browser doesn't zoom the page
instead. One-finger swipe and pan already ignore multi-touch.

Verified on an emulated Pixel 7 with CDP two-point touch: spread 80→200 px
gives 250 %, closing to 120 px gives 150 %, closing fully returns to 100 %,
and a one-finger swipe afterwards still moves 2/2 → 1/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:33:13 -04:00
Lotus CIandClaude Opus 5.5 c1661e48fa feat(widgets): ask before a room widget may read or send in the room (#205)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 9m15s
Room widgets were limited to display-only capabilities because the driver
couldn't serve anything else. Now:

- classifyWidgetCapabilities: display caps are still granted silently; reading
  or sending events/state in the widget's OWN room is offered to the user;
  everything else (other rooms' timelines, to-device, account data, uploads,
  user directory, delayed events) stays denied. Writing protected state
  (power levels, join rules, encryption, membership, ACLs, widgets, …) is
  never offered, and the driver refuses it again at send time.
- WidgetPermissionPrompt names the widget, the host that runs it and who added
  it; each request in plain words ("Send messages of type m.text in this
  room · as you"); reading is pre-ticked, sending is not; Deny / Escape grant
  nothing extra. "Remember my choice" stores it per viewer (localStorage),
  tied to the widget URL, so a changed URL asks again.
- GeneralWidgetDriver implements sendEvent / readRoomTimeline / readRoomState
  / readEventRelations, each refusing any room but the widget's own;
  RoomWidgetView feeds the room's live (decrypted) events and state updates,
  which ClientWidgetApi forwards only if the widget holds the capability.

Verified in Chromium with a cross-origin test widget against a local Synapse:
power-levels and timeline:* requests are never shown; after allowing
send+read the widget's message lands on the server, its power-levels write
is rejected, it receives live messages, and after a reload the remembered
choice skips the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:17:18 -04:00
23 changed files with 1034 additions and 123 deletions
@@ -1,6 +1,6 @@
- complementary "Thread":
- paragraph: Thread
- paragraph: A11y Room
- paragraph: /A11y Room · started by e2e_a11y_\w+, .+/
- button "Thread notifications":
- img
- button "Close thread":
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.13",
"@lotusguild/element-call-embedded": "0.25.0-lotus.14",
"@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.13",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.13/element-call-embedded-0.25.0-lotus.13.tgz",
"integrity": "sha512-E+icN4zWyjHmZyGFi1Tf+99fIQXhYMjCRVvMflOJHP3h0Frph4AU0LpKSLsIEWQ3I+DEnYwpkMG/u7ZVEX+kMw==",
"version": "0.25.0-lotus.14",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.14/element-call-embedded-0.25.0-lotus.14.tgz",
"integrity": "sha512-+zPDQYoG/u12V5f59x0oJFNSBJpZ2KXX6/psMboHB5G2Op7OEdc0Ej/h8OSGDNyM2UwXr98Bnr7TNi0b9TfqVg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+2 -2
View File
@@ -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 46",
"check:eslint": "eslint src/* --max-warnings 36",
"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.13",
"@lotusguild/element-call-embedded": "0.25.0-lotus.14",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName } from '../../../utils/room';
import { getMemberName, sendRoomEvent } from '../../../utils/room';
import {
ParsedPoll,
PollResponse,
@@ -220,7 +219,7 @@ export function PollContent({
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
mx.sendEvent(roomId, 'm.poll.response' as any, {
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
@@ -229,7 +228,7 @@ export function PollContent({
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
mx.sendEvent(roomId, 'm.poll.end' as any, {
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
+59 -5
View File
@@ -24,6 +24,8 @@ import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { useSwipeNav } from '../../hooks/useSwipeNav';
import { usePinchZoom } from '../../hooks/usePinchZoom';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -104,6 +106,10 @@ export type LightboxItem = {
mimeType?: string;
msgtype: MsgType.Image | MsgType.Video;
body: string;
/** The file's name when the message also carries a caption (MSC2530). */
filename?: string;
/** Caption text: `body` when it differs from `filename`. */
caption?: string;
sender: string;
ts: number;
eventId: string;
@@ -125,12 +131,19 @@ export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
const filename = typeof c.filename === 'string' && c.filename ? c.filename : undefined;
const caption =
filename && typeof c.body === 'string' && c.body && c.body !== filename
? c.body
: undefined;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
filename,
caption,
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
@@ -207,7 +220,7 @@ function LightboxMedia({
) : (
<img
src={media.url}
alt={item.body}
alt={item.caption ?? item.body}
draggable={false}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
@@ -266,6 +279,17 @@ export function Lightbox({
useCallback(() => setIndex((i) => Math.max(0, i - 1)), []),
);
const dialogRef = useRef<HTMLDivElement>(null);
const onPinchStart = usePinchZoom(isImage, zoom, setZoom);
// [Gitea #164] 44 px prev/next targets on touch screens (32 px otherwise).
const coarsePointer = useMediaQuery('(pointer: coarse)');
const navSize = coarsePointer ? '500' : '400';
const onMediaTouchStart = useCallback<React.TouchEventHandler<HTMLElement>>(
(e) => {
swipe.onTouchStart(e);
onPinchStart(e);
},
[swipe, onPinchStart],
);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
@@ -343,7 +367,7 @@ export function Lightbox({
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
{item.filename || item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
@@ -399,7 +423,7 @@ export function Lightbox({
{(ref) => (
<span ref={ref}>
<FileDownloadButton
filename={mediaFilename(item.body, item.mimeType)}
filename={mediaFilename(item.filename ?? item.body, item.mimeType)}
url={item.mxcUrl}
mimeType={item.mimeType ?? 'application/octet-stream'}
encInfo={item.encInfo}
@@ -455,13 +479,16 @@ export function Lightbox({
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
onTouchStart={swipe.onTouchStart}
onTouchStart={onMediaTouchStart}
onTouchEnd={swipe.onTouchEnd}
style={{ overflow: 'hidden', padding: config.space.S400 }}
// Our own swipe/pinch/pan handle touch here; stop the browser from
// zooming or scrolling the page underneath instead.
style={{ overflow: 'hidden', padding: config.space.S400, touchAction: 'none' }}
>
{index > 0 && (
<IconButton
variant="Surface"
size={navSize}
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
@@ -490,6 +517,7 @@ export function Lightbox({
{index < items.length - 1 && (
<IconButton
variant="Surface"
size={navSize}
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
@@ -498,6 +526,32 @@ export function Lightbox({
</IconButton>
)}
</Box>
{item.caption && (
// [Gitea #164] The full caption, readable — the header only has room
// for one truncated line.
<Box
shrink="No"
justifyContent="Center"
style={{
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
maxHeight: '25vh',
overflowY: 'auto',
}}
>
<Text
size="T300"
style={{
color: 'rgba(255,255,255,0.88)',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxWidth: '70ch',
textAlign: 'center',
}}
>
{item.caption}
</Text>
</Box>
)}
</div>
</FocusTrap>
</Overlay>
+2 -1
View File
@@ -21,6 +21,7 @@ import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { sendRoomEvent } from '../../utils/room';
interface PollCreatorProps {
roomId: string;
@@ -89,7 +90,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
);
// Pass the thread id explicitly (like the sticker path in RoomInput); the
// legacy 3-arg form always resolves to the main timeline.
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
await sendRoomEvent(mx, roomId, threadRootId ?? null, 'm.poll.start', {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+18 -13
View File
@@ -112,7 +112,12 @@ import {
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import {
getMemberName,
getMentionContent,
sendRoomMessage,
trimReplyFromBody,
} from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import {
Command,
@@ -324,7 +329,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
@@ -332,7 +337,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
} as any);
});
},
(err) => {
setLocating(false);
@@ -363,19 +368,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(blob);
const uploadResult = await mx.uploadContent(encBlob);
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: uploadResult.content_uri },
} as any);
});
} else {
const uploadResult = await mx.uploadContent(blob, {
name: 'voice-message.ogg',
type: mimeType,
});
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
url: uploadResult.content_uri,
} as any);
});
}
},
[mx, room, roomId, threadRootId],
@@ -625,7 +630,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
contents.forEach((content) => sendRoomMessage(mx, roomId, threadRootId ?? null, content));
},
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
);
@@ -732,7 +737,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, threadRootId ?? null, content as any);
sendRoomMessage(mx, roomId, threadRootId ?? null, content);
resetEditor(editor);
resetEditorHistory(editor);
setCharCount(0);
@@ -955,10 +960,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const uploadRes = await mx.uploadContent(encBlob);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
});
} else {
const uploadRes = await mx.uploadContent(gifFile, {
type: 'image/gif',
@@ -967,10 +972,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
});
}
} catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
+5 -2
View File
@@ -89,6 +89,7 @@ import {
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
sendRoomEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
@@ -1160,9 +1161,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
sendRoomEvent(
mx,
room.roomId,
MessageEvent.Reaction as any,
null,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
);
},
@@ -36,7 +36,12 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UserAvatar } from '../../../components/user-avatar';
import { ThumbnailContent } from '../../../components/message/content/ThumbnailContent';
import { getMemberAvatarMxc, getMemberName, trimReplyFromBody } from '../../../utils/room';
import {
getMemberAvatarMxc,
getMemberName,
sendRoomEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import {
recentForwardTargetsAtom,
@@ -349,12 +354,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
// content; unencrypted ones get the plaintext version, or fail outright if
// that couldn't be built — never fall back to sending the encrypted `file`.
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
if (fwdContent.file && !destEncrypted && !contentToSend) {
// Only undefined when a plaintext copy was needed and couldn't be built.
if (!contentToSend) {
return Promise.reject(new Error(plaintextContentError));
}
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
// Explicit null thread = send to the main timeline.
const sendForward = () => sendRoomEvent(mx, id, null, mEvent.getType(), contentToSend);
// Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects.
// Track rooms whose comment already landed so a retry (after the FORWARD
@@ -21,7 +21,15 @@ import {
} from 'folds';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import {
EventType,
IContent,
IMentions,
MatrixEvent,
MsgType,
RelationType,
Room,
} from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import {
AUTOCOMPLETE_PREFIXES,
@@ -55,6 +63,7 @@ import {
getEditedEvent,
getMemberName,
getMentionContent,
sendRoomEvent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
@@ -192,8 +201,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
rel_type: RelationType.Replace,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
return sendRoomEvent(mx, roomId, null, EventType.RoomMessage, content);
}
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
@@ -241,8 +249,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
return sendRoomEvent(mx, roomId, null, EventType.RoomMessage, content);
}, [
stripTracking,
mx,
@@ -28,6 +28,8 @@ import {
ThreadNotificationModeSwitcher,
} from '../../../components/ThreadNotificationModeSwitcher';
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { getMemberName } from '../../../utils/room';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadPanelHeaderProps = {
@@ -37,6 +39,12 @@ type ThreadPanelHeaderProps = {
};
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const mode = useThreadNotificationMode(room.roomId, threadId);
const { format } = useTimestampFormatter();
// [Gitea #165] Who started the thread and when — the first thing you want
// when arriving from the Threads list.
const root = room.findEventById(threadId) ?? room.getThread(threadId)?.rootEvent;
const rootSender = root?.getSender();
const startedBy = rootSender ? getMemberName(room, rootSender) : undefined;
return (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
@@ -47,6 +55,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="100">
@@ -67,6 +67,7 @@ import {
getMemberName,
getReactionContent,
reactionOrEditEvent,
sendRoomEvent,
} from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
@@ -576,12 +577,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
sendRoomEvent(
mx,
room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply.
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
);
},
@@ -14,6 +14,7 @@ import {
Scroll,
Text,
config,
toRem,
} from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -45,6 +46,8 @@ import {
import { useRoomThreads } from '../../../hooks/useRoomThreads';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
@@ -118,8 +121,20 @@ type ThreadRowProps = {
highlight: number;
participants: string[];
onOpen: (threadId: string) => void;
onJump: (threadId: string) => void;
/** Phone layout: a bigger touch target for the jump button. */
touch?: boolean;
};
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
function ThreadRow({
room,
thread,
unread,
highlight,
participants,
onOpen,
onJump,
touch,
}: ThreadRowProps) {
const { prefs } = useTimestampFormatter();
const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? '';
@@ -136,64 +151,80 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
}`;
return (
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<UserAvatar
userId={rootSender}
src={avatarUrl}
alt={rootName}
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
/>
</Avatar>
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
{rootName}
</Text>
{unread > 0 && (
<UnreadBadgeCenter>
<UnreadBadge highlight={highlight > 0} count={unread} />
</UnreadBadgeCenter>
)}
</Box>
<Text
size="T200"
priority="400"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
<Box direction="Column" style={{ position: 'relative' }}>
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
{snippet}
</Text>
<Box alignItems="Center" gap="200">
<Icon size="50" src={Icons.Thread} />
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
{count} {count === 1 ? 'reply' : 'replies'}
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</Text>
<Box shrink="No" alignItems="Center">
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
<ParticipantAvatar key={userId} room={room} userId={userId} />
))}
{extra > 0 && (
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
+{extra}
</Text>
{/* Leaves room for the "Go to message" button laid over this corner. */}
<Box alignItems="Center" gap="200" style={{ paddingRight: toRem(touch ? 36 : 28) }}>
<Avatar size="200" radii="300">
<UserAvatar
userId={rootSender}
src={avatarUrl}
alt={rootName}
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
/>
</Avatar>
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
{rootName}
</Text>
{unread > 0 && (
<UnreadBadgeCenter>
<UnreadBadge highlight={highlight > 0} count={unread} />
</UnreadBadgeCenter>
)}
</Box>
<Text
size="T200"
priority="400"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
>
{snippet}
</Text>
<Box alignItems="Center" gap="200">
<Icon size="50" src={Icons.Thread} />
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
{count} {count === 1 ? 'reply' : 'replies'}
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</Text>
<Box shrink="No" alignItems="Center">
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
<ParticipantAvatar key={userId} room={room} userId={userId} />
))}
{extra > 0 && (
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
+{extra}
</Text>
)}
</Box>
</Box>
</Box>
{/* [Gitea #165] Jump to the thread's first message in the room. A sibling
of the row button, not nested in it (no buttons inside buttons). */}
<IconButton
size={touch ? '400' : '300'}
radii="300"
variant="SurfaceVariant"
aria-label={`Go to the message that started ${rootName}'s thread`}
title="Go to message"
onClick={() => onJump(thread.id)}
style={{ position: 'absolute', top: toRem(6), right: toRem(6) }}
>
<Icon size="100" src={Icons.ArrowGoRight} />
</IconButton>
</Box>
);
}
@@ -204,6 +235,16 @@ export type ThreadsListPanelProps = {
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const handleJump = useCallback(
(threadId: string) => {
navigateRoom(room.roomId, threadId);
// On a phone the list covers the timeline; get out of the way.
if (screenSize === ScreenSize.Mobile) onClose();
},
[navigateRoom, room.roomId, screenSize, onClose],
);
const threads = useRoomThreads(room);
const threadNotifications = useAtomValue(threadNotificationsAtom);
const [storedFilter, setFilter] = useAtom(threadFilterAtom);
@@ -403,6 +444,8 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread}
onJump={handleJump}
touch={screenSize === ScreenSize.Mobile}
/>
</VirtualTile>
);
@@ -1,15 +1,190 @@
import { type Capability, WidgetDriver } from 'matrix-widget-api';
import { filterWidgetCapabilities } from './widgetUtils';
import {
type Capability,
type IReadEventRelationsResult,
type IRoomEvent,
type ISendEventDetails,
WidgetDriver,
} from 'matrix-widget-api';
import {
Direction,
EventType,
type IContent,
type MatrixClient,
type MatrixEvent,
type StateEvents,
type TimelineEvents,
} from 'matrix-js-sdk';
import {
PROTECTED_STATE_TYPES,
type WidgetPermissionRequest,
classifyWidgetCapabilities,
loadWidgetConsent,
saveWidgetConsent,
} from './widgetUtils';
// A minimal, conservative WidgetDriver for general room widgets. It only narrows
// the capabilities a widget may hold (to a benign display-only subset — see
// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice
// / uploads …) are inherited from the base WidgetDriver and are never reached,
// because the capabilities that would gate them are denied here. A richer,
// consent-prompt-driven driver is a follow-up.
/** Shows the consent prompt; resolves with the capabilities the user allowed. */
export type AskWidgetPermissions = (
requests: WidgetPermissionRequest[],
) => Promise<{ allowed: Set<Capability>; remember: boolean }>;
type WidgetIdentity = { id: string; url: string };
/**
* WidgetDriver for general room widgets. Display-only capabilities are granted
* silently. Reading or sending events/state in the widget's own room needs the
* user's OK through `ask` (remembered per viewer when they choose). Everything
* else is denied — see classifyWidgetCapabilities. The data methods below are
* only reached for capabilities the user granted (ClientWidgetApi checks), and
* additionally refuse any room other than the widget's own.
*/
export class GeneralWidgetDriver extends WidgetDriver {
// eslint-disable-next-line class-methods-use-this
public constructor(
private readonly mx: MatrixClient,
private readonly roomId: string,
private readonly widget: WidgetIdentity,
private readonly ask: AskWidgetPermissions,
) {
super();
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
return filterWidgetCapabilities(requested);
const { auto, ask } = classifyWidgetCapabilities(requested);
const granted = new Set(auto);
if (ask.length === 0) return granted;
const stored = loadWidgetConsent(this.roomId, this.widget.id, this.widget.url);
const remembered = new Set([...(stored?.allowed ?? []), ...(stored?.denied ?? [])]);
ask.forEach((r) => {
if (stored?.allowed.includes(r.capability)) granted.add(r.capability);
});
const unanswered = ask.filter((r) => !remembered.has(r.capability));
if (unanswered.length === 0) return granted;
const { allowed, remember } = await this.ask(unanswered);
unanswered.forEach((r) => {
if (allowed.has(r.capability)) granted.add(r.capability);
});
if (remember) {
saveWidgetConsent(this.roomId, this.widget.id, {
url: this.widget.url,
allowed: [
...(stored?.allowed ?? []),
...unanswered.filter((r) => allowed.has(r.capability)).map((r) => r.capability),
],
denied: [
...(stored?.denied ?? []),
...unanswered.filter((r) => !allowed.has(r.capability)).map((r) => r.capability),
],
});
}
return granted;
}
private assertOwnRoom(roomId: string | null | undefined): string {
const target = roomId || this.roomId;
if (target !== this.roomId) throw new Error('Widgets can only access their own room');
return target;
}
public async sendEvent(
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null,
): Promise<ISendEventDetails> {
const roomId = this.assertOwnRoom(targetRoomId);
let r: { event_id: string };
if (typeof stateKey === 'string') {
if (PROTECTED_STATE_TYPES.has(eventType)) {
throw new Error(`Widgets may not change ${eventType}`);
}
r = await this.mx.sendStateEvent(
roomId,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey,
);
} else if (eventType === EventType.RoomRedaction) {
r = await this.mx.redactEvent(roomId, content.redacts);
} else {
r = await this.mx.sendEvent(
roomId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
}
return { roomId, eventId: r.event_id };
}
public async readRoomTimeline(
roomId: string,
eventType: string,
msgtype: string | undefined,
stateKey: string | undefined,
limit: number,
since: string | undefined,
): Promise<IRoomEvent[]> {
const room = this.mx.getRoom(this.assertOwnRoom(roomId));
if (!room) return [];
const max = limit > 0 ? limit : Number.MAX_SAFE_INTEGER;
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents();
for (let i = events.length - 1; i >= 0 && results.length < max; i -= 1) {
const ev = events[i];
if (since !== undefined && ev.getId() === since) break;
if (
ev.getType() === eventType &&
!ev.isState() &&
(eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) &&
(ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey)
) {
results.push(ev);
}
}
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
}
public async readRoomState(
roomId: string,
eventType: string,
stateKey: string | undefined,
): Promise<IRoomEvent[]> {
const room = this.mx.getRoom(this.assertOwnRoom(roomId));
const state = room?.getLiveTimeline().getState(Direction.Forward);
if (!state) return [];
if (stateKey === undefined) {
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
}
const ev = state.getStateEvents(eventType, stateKey);
return ev ? [ev.getEffectiveEvent() as IRoomEvent] : [];
}
public async readEventRelations(
eventId: string,
roomId?: string,
relationType?: string,
eventType?: string,
from?: string,
to?: string,
limit?: number,
direction?: 'f' | 'b',
): Promise<IReadEventRelationsResult> {
const target = this.assertOwnRoom(roomId);
const { events, nextBatch, prevBatch } = await this.mx.relations(
target,
eventId,
relationType ?? null,
eventType ?? null,
{ from, to, limit, dir: direction as Direction },
);
return {
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
nextBatch: nextBatch ?? undefined,
prevBatch: prevBatch ?? undefined,
};
}
public getKnownRooms(): string[] {
return [this.roomId];
}
}
@@ -1,10 +1,24 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
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 { isWidgetUrlSafe } from './widgetUtils';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
type RoomWidgetViewProps = {
room: Room;
@@ -12,7 +26,9 @@ type RoomWidgetViewProps = {
};
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
// that wait on the client handshake load), with a conservative capability driver.
// 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) {
@@ -21,6 +37,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const widgetRef = useRef(widget);
widgetRef.current = widget;
const [blocked, setBlocked] = useState(false);
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
useEffect(() => {
const container = containerRef.current;
@@ -53,10 +70,57 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
iframe.style.border = 'none';
container.append(iframe);
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
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);
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 (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();
};
@@ -73,5 +137,22 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
);
}
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
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}
/>
)}
</>
);
}
@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Checkbox,
Dialog,
Header,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
color,
config,
} from 'folds';
import type { Capability } from 'matrix-widget-api';
import type { WidgetPermissionRequest } from './widgetUtils';
import { useModalStyle } from '../../../hooks/useModalStyle';
type WidgetPermissionPromptProps = {
widgetName: string;
/** Hostname the widget is served from — who actually gets the access. */
origin: string;
/** Who added the widget to the room, if known. */
addedBy?: string;
requests: WidgetPermissionRequest[];
onDone: (allowed: Set<Capability>, remember: boolean) => void;
};
/**
* Asks before a room widget may read or send events in this room as you.
* Reading is pre-ticked, sending is not; "Deny" (or Escape / clicking away)
* grants nothing beyond display-only access.
*/
export function WidgetPermissionPrompt({
widgetName,
origin,
addedBy,
requests,
onDone,
}: WidgetPermissionPromptProps) {
const modalStyle = useModalStyle(460);
const [checked, setChecked] = useState<Set<Capability>>(
() => new Set(requests.filter((r) => !r.sends).map((r) => r.capability)),
);
const [remember, setRemember] = useState(true);
const toggle = (cap: Capability, on: boolean) =>
setChecked((prev) => {
const next = new Set(prev);
if (on) next.add(cap);
else next.delete(cap);
return next;
});
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#widget-perm-deny',
onDeactivate: () => onDone(new Set(), false),
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="widget-perm-title"
aria-describedby="widget-perm-body"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Text as="h2" size="H4" id="widget-perm-title" truncate>
Allow “{widgetName}” to access this room?
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="widget-perm-body">
This widget is run by <b>{origin}</b>
{addedBy ? `, added to the room by ${addedBy}` : ''}. It is asking to act for you in
this room. Only allow what you trust {origin} with.
</Text>
<Box as="ul" direction="Column" gap="200" style={{ margin: 0, padding: 0 }}>
{requests.map((r) => (
<Box
as="li"
key={r.capability}
alignItems="Center"
gap="200"
style={{ listStyle: 'none' }}
>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
size="300"
variant={r.sends ? 'Warning' : 'Primary'}
checked={checked.has(r.capability)}
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
toggle(r.capability, e.currentTarget.checked)
}
/>
<Text size="T300">
{r.label}
{r.sends && (
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
{' '}
· as you
</Text>
)}
</Text>
</Box>
</Box>
))}
</Box>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
size="300"
variant="Secondary"
checked={remember}
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
setRemember(e.currentTarget.checked)
}
/>
<Text size="T300">Remember my choice for this widget on this device</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
id="widget-perm-deny"
variant="Secondary"
fill="Soft"
onClick={() => onDone(new Set(), remember)}
>
<Text size="B400">Deny</Text>
</Button>
<Button variant="Primary" onClick={() => onDone(new Set(checked), remember)}>
<Text size="B400">
{checked.size === 0 ? 'Continue without access' : 'Allow selected'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -6,6 +6,8 @@ import {
isWidgetUrlSafe,
filterWidgetCapabilities,
generateWidgetId,
classifyWidgetCapabilities,
loadWidgetConsent,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
@@ -47,3 +49,60 @@ test('generateWidgetId is prefixed and unique across calls', () => {
assert.match(a, /^lotus_/);
assert.notEqual(a, b);
});
test('classifyWidgetCapabilities: display caps are automatic, event caps are asked', () => {
const { auto, ask } = classifyWidgetCapabilities([
MatrixCapabilities.AlwaysOnScreen,
'org.matrix.msc2762.receive.event:m.room.message#m.text',
'org.matrix.msc2762.send.event:m.reaction',
'org.matrix.msc2762.receive.state_event:m.room.topic',
]);
assert.deepEqual([...auto], [MatrixCapabilities.AlwaysOnScreen]);
assert.deepEqual(
ask.map((r) => [r.label, r.sends]),
[
['See messages of type “m.text” in this room', false],
['Send reactions in this room', true],
['See the room topic in this room', false],
],
);
});
test('classifyWidgetCapabilities never offers protected state, other rooms, to-device or account data', () => {
const { auto, ask } = classifyWidgetCapabilities([
'org.matrix.msc2762.send.state_event:m.room.power_levels',
'org.matrix.msc2762.send.state_event:m.room.join_rules',
'org.matrix.msc2762.send.state_event:m.room.encryption',
'org.matrix.msc2762.send.state_event:m.room.member#@a:x',
'org.matrix.msc2762.timeline:*',
'org.matrix.msc2762.timeline:!other:x',
'org.matrix.msc3819.send.to_device:m.custom',
'org.matrix.msc3819.receive.to_device:m.custom',
'org.matrix.msc4157.send.delayed_event',
'org.matrix.msc4039.upload_file',
'org.matrix.msc3973.user_directory_search',
'org.matrix.msc2762.receive.room_account_data:m.fully_read',
]);
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
test('classifyWidgetCapabilities: reading protected state is fine, changing unprotected state is asked', () => {
const { ask } = classifyWidgetCapabilities([
'org.matrix.msc2762.receive.state_event:m.room.power_levels',
'org.matrix.msc2762.send.state_event:m.room.topic',
'org.matrix.msc2762.send.state_event:com.example.board#main',
]);
assert.deepEqual(
ask.map((r) => r.label),
[
'See room permissions in this room',
'Change the room topic',
'Send “com.example.board” state (key “main”) in this room',
],
);
});
test('loadWidgetConsent tolerates missing storage', () => {
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
});
+131 -5
View File
@@ -1,9 +1,16 @@
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
import {
Capability,
EventDirection,
EventKind,
MatrixCapabilities,
WidgetEventCapability,
} from 'matrix-widget-api';
// Conservative v1 capability policy: approve only benign display capabilities.
// Everything else (room-event send/receive, to-device, uploads, user-directory,
// delayed events, TURN servers) is denied — a random widget must not be able to
// act as the user or read room data without an explicit consent flow (follow-up).
// Capability policy. Benign display capabilities are granted silently. Reading
// or sending events/state in the widget's OWN room can be granted by the user
// through the consent prompt (see classifyWidgetCapabilities). Everything else
// (other rooms' timelines, to-device, account data, uploads, user directory,
// delayed events, TURN servers) is always denied.
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
MatrixCapabilities.AlwaysOnScreen,
MatrixCapabilities.RequiresClient,
@@ -43,3 +50,122 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
export const generateWidgetId = (): string =>
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
// State a widget may never write with the user's authority, even if approved:
// these change who can do what in the room, or its encryption/identity.
export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
'm.room.create',
'm.room.power_levels',
'm.room.join_rules',
'm.room.history_visibility',
'm.room.guest_access',
'm.room.encryption',
'm.room.member',
'm.room.server_acl',
'm.room.tombstone',
'm.room.canonical_alias',
'm.room.third_party_invite',
'im.vector.modular.widgets',
'm.widget',
]);
export type WidgetPermissionRequest = {
capability: Capability;
/** Plain-language description, e.g. "Send messages". */
label: string;
/** Sending as you (vs only reading). Shown with a warning tone. */
sends: boolean;
};
export type ClassifiedCapabilities = {
/** Granted without asking. */
auto: Set<Capability>;
/** Needs the user's OK. */
ask: WidgetPermissionRequest[];
};
const FRIENDLY_EVENT_NAMES: Record<string, string> = {
'm.room.message': 'messages',
'm.reaction': 'reactions',
'm.sticker': 'stickers',
'm.room.redaction': 'message deletions',
'm.room.name': 'the room name',
'm.room.topic': 'the room topic',
'm.room.avatar': 'the room avatar',
'm.room.pinned_events': 'pinned messages',
'm.room.member': 'the member list',
'm.room.power_levels': 'room permissions',
};
const describeEventCapability = (cap: WidgetEventCapability): string => {
const verb = cap.direction === EventDirection.Send ? 'Send' : 'See';
const friendly = FRIENDLY_EVENT_NAMES[cap.eventType];
const what =
friendly ?? `“${cap.eventType}” ${cap.kind === EventKind.State ? 'state' : 'events'}`;
let detail = '';
if (cap.keyStr) {
detail = cap.kind === EventKind.State ? ` (key “${cap.keyStr}”)` : ` of type “${cap.keyStr}”`;
}
if (cap.kind === EventKind.State && cap.direction === EventDirection.Send && friendly) {
return `Change ${friendly}${detail}`;
}
return `${verb} ${what}${detail} in this room`;
};
/**
* Split a widget's requested capabilities into those granted silently and
* those the user may approve. Anything in neither list is denied outright.
*/
export const classifyWidgetCapabilities = (
requested: Iterable<Capability>,
): ClassifiedCapabilities => {
const list = [...requested];
const auto = new Set(list.filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
const ask: WidgetPermissionRequest[] = [];
WidgetEventCapability.findEventCapabilities(list).forEach((cap) => {
if (cap.kind !== EventKind.Event && cap.kind !== EventKind.State) return;
const sends = cap.direction === EventDirection.Send;
if (sends && cap.kind === EventKind.State && PROTECTED_STATE_TYPES.has(cap.eventType)) return;
ask.push({ capability: cap.raw, label: describeEventCapability(cap), sends });
});
return { auto, ask };
};
/**
* Remembered approvals, per viewer (localStorage), keyed by room + widget id and
* tied to the widget's URL: if the URL changes, the widget is asked again.
*/
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`lotus.widgetConsent.${roomId}.${widgetId}`;
export const loadWidgetConsent = (
roomId: string,
widgetId: string,
url: string,
): StoredWidgetConsent | undefined => {
try {
const raw = localStorage.getItem(widgetConsentKey(roomId, widgetId));
if (!raw) return undefined;
const parsed = JSON.parse(raw) as StoredWidgetConsent;
if (parsed.url !== url || !Array.isArray(parsed.allowed) || !Array.isArray(parsed.denied)) {
return undefined;
}
return parsed;
} catch {
return undefined;
}
};
export const saveWidgetConsent = (
roomId: string,
widgetId: string,
consent: StoredWidgetConsent,
) => {
try {
localStorage.setItem(widgetConsentKey(roomId, widgetId), JSON.stringify(consent));
} catch {
// Storage unavailable: the widget just asks again next time.
}
};
+20
View File
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pinchZoom } from './usePinchZoom';
test('pinchZoom scales with finger distance', () => {
assert.equal(pinchZoom(100, 1, 200), 2);
assert.equal(pinchZoom(100, 2, 150), 3);
assert.equal(pinchZoom(200, 3, 100), 1.5);
});
test('pinchZoom clamps to 1×–5×', () => {
assert.equal(pinchZoom(100, 1, 1000), 5);
assert.equal(pinchZoom(100, 1, 20), 1);
});
test('pinchZoom snaps near 1× and ignores a zero start', () => {
assert.equal(pinchZoom(100, 1, 105), 1);
assert.equal(pinchZoom(100, 2, 52), 1);
assert.equal(pinchZoom(0, 1.7, 120), 1.7);
});
+82
View File
@@ -0,0 +1,82 @@
import {
type TouchList as ReactTouchList,
TouchEventHandler,
useCallback,
useEffect,
useRef,
} from 'react';
export const PINCH_MIN = 1;
export const PINCH_MAX = 5;
/** Within this of 1× the pinch snaps back to exactly 1× (so pan/swipe re-arm). */
const SNAP = 0.08;
/**
* Zoom for a pinch that started at `startDistance` with the image at
* `startZoom` and is now `distance` apart. Clamped to [min, max]; snaps to 1×
* when close. Exported for tests.
*/
export function pinchZoom(
startDistance: number,
startZoom: number,
distance: number,
min = PINCH_MIN,
max = PINCH_MAX,
): number {
if (startDistance <= 0) return startZoom;
const z = Math.min(max, Math.max(min, startZoom * (distance / startDistance)));
return Math.abs(z - 1) < SNAP ? 1 : Math.round(z * 100) / 100;
}
const distanceOf = (touches: TouchList | ReactTouchList): number => {
const a = touches[0];
const b = touches[1];
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
};
/**
* [Gitea #164] Two-finger pinch zoom for the media viewer. Attach
* `onTouchStart` to the media area (with `touch-action: none` so the browser
* doesn't zoom the page instead). One-finger gestures are left to the swipe
* and pan handlers, which ignore multi-touch.
*/
export function usePinchZoom(
enabled: boolean,
zoom: number,
setZoom: (z: number) => void,
): TouchEventHandler<HTMLElement> {
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => () => cleanupRef.current?.(), []);
return useCallback<TouchEventHandler<HTMLElement>>(
(evt) => {
if (!enabled || evt.touches.length !== 2) return;
cleanupRef.current?.();
const startDistance = distanceOf(evt.touches);
const startZoom = zoomRef.current;
const onMove = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
setZoom(pinchZoom(startDistance, startZoom, distanceOf(e.touches)));
};
const onEnd = (e: TouchEvent) => {
if (e.touches.length >= 2) return;
cleanupRef.current?.();
};
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd);
document.addEventListener('touchcancel', onEnd);
cleanupRef.current = () => {
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
document.removeEventListener('touchcancel', onEnd);
cleanupRef.current = null;
};
},
[enabled, setZoom],
);
}
+42 -1
View File
@@ -330,10 +330,32 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
// [Gitea #43] Set once the fork reports io.lotus.controls_state: from then on
// layout / settings / reactions go over the widget API and screenshare +
// layout state come from that report, not from EC's DOM. Older forks never
// send it and keep the DOM path below.
private forkControls = false;
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return;
const { screensharing, layout } = data as { screensharing?: unknown; layout?: unknown };
this.forkControls = true;
this.applyControls(
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
layout === 'spotlight' || layout === 'grid' ? layout === 'spotlight' : this.spotlight,
);
}
private onControlMutation() {
const wasScreensharing = this.screenshare;
if (this.forkControls) return;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false;
this.applyControls(screenshare, spotlight);
}
private applyControls(screenshare: boolean, spotlight: boolean) {
const wasScreensharing = this.screenshare;
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
// later screenshare doesn't start pre-muted.
@@ -436,6 +458,12 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public toggleSpotlight() {
if (this.forkControls) {
this.sendForkAction('io.lotus.set_layout', {
layout: this.spotlight ? 'grid' : 'spotlight',
});
return;
}
if (this.spotlight) {
this.gridButton?.click();
return;
@@ -444,13 +472,26 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public toggleReactions() {
if (this.forkControls) {
this.sendForkAction('io.lotus.toggle_reactions', {});
return;
}
this.reactionsButton?.click();
}
public toggleSettings() {
if (this.forkControls) {
// Same as clicking EC's settings button: opens the modal.
this.sendForkAction('io.lotus.open_settings', { open: true });
return;
}
this.settingsButton?.click();
}
private sendForkAction(action: string, data: Record<string, unknown>): void {
this.call.transport.send(action, data).catch(() => undefined);
}
/**
* Focus a specific participant's camera tile in Element Call.
*
+7
View File
@@ -417,6 +417,13 @@ export class CallEmbed {
}
}),
);
// [Gitea #43] Screenshare + layout state from the fork; also switches the
// call bar's layout / settings / reactions buttons to widget actions.
this.disposables.push(
this.listenAction('io.lotus.controls_state', (evt) => {
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
this.disposables.push(
this.listenAction('io.lotus.call_state', (evt) => {
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
+38 -1
View File
@@ -1,9 +1,11 @@
import { IconName, IconSrc } from 'folds';
import {
AccountDataEvents,
EventTimeline,
EventTimelineSet,
EventType,
IContent,
IMentions,
IPowerLevelsContent,
IPushRule,
@@ -17,8 +19,10 @@ import {
RelationType,
Room,
RoomMember,
TimelineEvents,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
IRoomCreateContent,
@@ -62,10 +66,43 @@ export function sendStateEvent<T extends object>(
return mx.sendStateEvent(roomId, eventType as any, content, stateKey);
}
// Typed message send for the composer's fire-and-forget sends. The SDK's
// `sendMessage` wants its `RoomMessageEventContent` union, which the composer's
// extensible content (MSC3488 location, MSC3245 voice, `m.mentions`, encrypted
// `file`) doesn't narrow to; the single cast lives here. A failed send already
// shows on the local echo ("Failed to send" with Retry, or the consent prompt),
// so the rejection is swallowed rather than surfacing as an unhandled error.
export function sendRoomMessage(
mx: MatrixClient,
roomId: string,
threadId: string | null,
content: IContent,
): void {
mx.sendMessage(roomId, threadId, content as RoomMessageEventContent).catch(() => undefined);
}
// Typed send for events whose type isn't one of the SDK's typed timeline events
// (MSC3381 polls, forwarded arbitrary types, the fork's reaction enum) or whose
// content is extensible. Keeps the one cast out of every call site.
export function sendRoomEvent(
mx: MatrixClient,
roomId: string,
threadId: string | null,
eventType: string,
content: IContent,
): Promise<ISendEventResponse> {
return mx.sendEvent(
roomId,
threadId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
}
export const getAccountData = (
mx: MatrixClient,
eventType: AccountDataEvent | string,
): MatrixEvent | undefined => mx.getAccountData(eventType as any);
): MatrixEvent | undefined => mx.getAccountData(eventType as keyof AccountDataEvents);
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
const roomIds = new Set<string>();