Compare commits
10
Commits
c96c47dd0d
...
c0e8334f03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e8334f03 | ||
|
|
3e69c715e9 | ||
|
|
a0d1c565d0 | ||
|
|
a932b1999a | ||
|
|
64af736c6e | ||
|
|
c088b4c0e6 | ||
|
|
ea07fb5087 | ||
|
|
e39714a6e0 | ||
|
|
39589b40f3 | ||
|
|
c1661e48fa |
@@ -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":
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user