feat(ux): forward to multiple rooms + live bookmark previews (P6-3)
Forward: checkbox multi-select room picker + "Send to N rooms" batch send (Promise.allSettled). Full success auto-closes; partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary and prunes the selection to only the failures (retry won't duplicate to already-sent rooms). Content builder extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip, undecryptable-refused; 4 tests). Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent) so previews reflect edits and show a deleted indicator for redactions; the stored snapshot stays as the fallback while loading, on fetch failure, or after leaving the room. Stored bookmark shape unchanged. Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on retry + Checkbox readOnly fixed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
||||
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
||||
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
||||
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
||||
|
||||
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||
|
||||
|
||||
@@ -905,6 +905,14 @@ Hook: `src/app/hooks/useUserNotes.ts`
|
||||
|
||||
## UX & Composer
|
||||
|
||||
### Forward to Multiple Rooms (P6-3)
|
||||
|
||||
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
|
||||
|
||||
### Live Bookmark Previews (P6-3)
|
||||
|
||||
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
|
||||
|
||||
### Message Length Counter
|
||||
|
||||
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
||||
|
||||
+7
-1
@@ -541,7 +541,13 @@ Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*
|
||||
- Retire the `useCallSpeakers` DOM-scrape fallback once `io.lotus.call_state` is verified.
|
||||
Fork commits are local (coordinator); publishing needs the user's npm token.
|
||||
|
||||
### [ ] P6-3 · Web UX wins (from the audit ADD list)
|
||||
### [~] P6-3 · Web UX wins - DONE (2026-07): forward multi-select + live bookmark previews
|
||||
|
||||
**Shipped:** Forward Message multi-select (checkbox rooms + "Send to N", batch `Promise.allSettled` with partial-failure summary; content builder extracted to tested `forwardContent.ts`). Live bookmark previews (`BookmarksPanel` renders the live event via `useRoomEvent` - edits + redactions - snapshot as fallback / left-room). Both `lotus`, gate-green (665 tests).
|
||||
|
||||
_Original scope:_
|
||||
|
||||
### [ ] P6-3-orig · Web UX wins (from the audit ADD list)
|
||||
|
||||
- **Forward to multiple rooms** — multi-select (checkbox + "Send to N") in `ForwardMessageDialog` (currently one room per open, capped at 60).
|
||||
- **Live bookmark previews** — `BookmarksPanel` shows a stale snapshot captured at save time; resolve live from the event when cached (edits/redactions), fall back to the snapshot.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
import classNames from 'classnames';
|
||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
|
||||
bookmark: Bookmark;
|
||||
onJump: (roomId: string, eventId: string) => void;
|
||||
onRemove: (eventId: string) => void;
|
||||
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
|
||||
preview?: ReactNode;
|
||||
};
|
||||
|
||||
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
||||
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||
@@ -104,18 +109,50 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
||||
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
||||
>
|
||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||
{bookmark.previewText || '(no preview)'}
|
||||
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
|
||||
|
||||
// Renders the same layout as BookmarkItem, but resolves the message body live so
|
||||
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
|
||||
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
|
||||
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
||||
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
||||
room.findEventById(bookmark.eventId),
|
||||
);
|
||||
|
||||
const snapshot = bookmark.previewText || '(no preview)';
|
||||
let preview: ReactNode = snapshot;
|
||||
|
||||
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
||||
if (liveEvent) {
|
||||
if (liveEvent.isRedacted()) {
|
||||
preview = (
|
||||
<MessageDeletedContent
|
||||
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// body is already the edited text since useRoomEvent applied m.replace.
|
||||
const { body } = liveEvent.getContent();
|
||||
preview = typeof body === 'string' && body ? body : snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
||||
}
|
||||
|
||||
type BookmarksPanelProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { bookmarks, removeBookmark } = useBookmarks();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
</Box>
|
||||
) : (
|
||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||
{filtered.map((bk) => (
|
||||
{filtered.map((bk) => {
|
||||
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||
// otherwise fall back to the stored snapshot for rooms we've left.
|
||||
const room = mx.getRoom(bk.roomId);
|
||||
return room ? (
|
||||
<LiveBookmarkItem
|
||||
key={bk.eventId}
|
||||
room={room}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
) : (
|
||||
<BookmarkItem
|
||||
key={bk.eventId}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
|
||||
@@ -3,6 +3,8 @@ import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
@@ -29,16 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
dm: boolean;
|
||||
useAuthentication: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
sending: boolean;
|
||||
};
|
||||
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
|
||||
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -49,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={onClick}
|
||||
onClick={onToggle}
|
||||
disabled={sending}
|
||||
after={
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
readOnly
|
||||
variant="Primary"
|
||||
disabled={sending}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
@@ -93,6 +108,21 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Selection persists across query changes: a room selected then filtered out
|
||||
// of the rendered slice stays selected.
|
||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleRoom = useCallback((roomId: string) => {
|
||||
setSelectedRoomIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(roomId)) {
|
||||
next.delete(roomId);
|
||||
} else {
|
||||
next.add(roomId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allRooms = useMemo(
|
||||
() =>
|
||||
@@ -109,64 +139,53 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
|
||||
/**
|
||||
* Build the content to forward:
|
||||
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||
* original pre-edit body
|
||||
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||
* message stands alone in the target room
|
||||
*/
|
||||
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
||||
if (mEvent.isDecryptionFailure()) return undefined;
|
||||
|
||||
let content = { ...mEvent.getContent() };
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const room = mx.getRoom(mEvent.getRoomId());
|
||||
if (eventId && room) {
|
||||
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||
if (newContent && typeof newContent === 'object') {
|
||||
content = { ...(newContent as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
|
||||
delete content['m.relates_to'];
|
||||
if (typeof content.body === 'string') {
|
||||
content.body = trimReplyFromBody(content.body);
|
||||
}
|
||||
if (typeof content.formatted_body === 'string') {
|
||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||
}
|
||||
return content;
|
||||
}, [mx, mEvent]);
|
||||
|
||||
const forward = useCallback(
|
||||
async (room: Room) => {
|
||||
if (sending) return;
|
||||
const fwdContent = buildForwardContent();
|
||||
const sendToSelected = useCallback(async () => {
|
||||
if (sending || selectedRoomIds.size === 0) return;
|
||||
const fwdContent = buildForwardContent(mx, mEvent);
|
||||
if (!fwdContent) {
|
||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
const ids = [...selectedRoomIds];
|
||||
const results = await Promise.allSettled(
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
|
||||
setSentTo(room.name);
|
||||
setTimeout(onClose, 1400);
|
||||
} catch {
|
||||
setSending(false);
|
||||
setError(`Failed to forward to ${room.name}. Try again.`);
|
||||
}
|
||||
},
|
||||
[mx, mEvent, onClose, sending, buildForwardContent],
|
||||
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||
);
|
||||
|
||||
const failedIds: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
failedIds.push(ids[i]);
|
||||
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
|
||||
}
|
||||
});
|
||||
|
||||
const total = ids.length;
|
||||
const failed = failedNames.length;
|
||||
const succeeded = total - failed;
|
||||
|
||||
if (failed === 0) {
|
||||
setSentTo(`Forwarded to ${total} ${total === 1 ? 'room' : 'rooms'}`);
|
||||
setTimeout(onClose, 1400);
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
// Prune to only the failures so a retry doesn't re-send to rooms that
|
||||
// already succeeded (duplicate messages).
|
||||
setSelectedRoomIds(new Set(failedIds));
|
||||
if (succeeded === 0) {
|
||||
setError('Failed to forward. Try again.');
|
||||
return;
|
||||
}
|
||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -237,9 +256,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
gap="300"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Text size="T300">✓ Forwarded to {sentTo}</Text>
|
||||
<Text size="T300">✓ {sentTo}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
@@ -249,7 +269,8 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
room={room}
|
||||
dm={directs.has(room.roomId)}
|
||||
useAuthentication={useAuthentication}
|
||||
onClick={() => forward(room)}
|
||||
selected={selectedRoomIds.has(room.roomId)}
|
||||
onToggle={() => toggleRoom(room.roomId)}
|
||||
sending={sending}
|
||||
/>
|
||||
))}
|
||||
@@ -281,6 +302,26 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Line size="300" />
|
||||
<Box
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||
>
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
radii="400"
|
||||
disabled={selectedRoomIds.size === 0 || sending}
|
||||
before={sending && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||
onClick={sendToSelected}
|
||||
>
|
||||
<Text size="B400">
|
||||
Send to {selectedRoomIds.size} {selectedRoomIds.size === 1 ? 'room' : 'rooms'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
|
||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||
// MatrixClient / MatrixEvent are mocked minimally. getEditedEvent reads edits off
|
||||
// `timelineSet.relations.getChildEventsForEvent(...).getRelations()`, so the base
|
||||
// client returns no child edits and the edit test injects one.
|
||||
|
||||
const SENDER = '@me:example.org';
|
||||
|
||||
type EventOptions = {
|
||||
content?: Record<string, unknown>;
|
||||
type?: string;
|
||||
id?: string;
|
||||
roomId?: string;
|
||||
decryptionFailure?: boolean;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
const makeEvent = (options: EventOptions = {}): MatrixEvent => {
|
||||
const {
|
||||
content = {},
|
||||
type = 'm.room.message',
|
||||
id = '$evt:example.org',
|
||||
roomId = '!room:example.org',
|
||||
decryptionFailure = false,
|
||||
ts = 0,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
getContent: () => content,
|
||||
getType: () => type,
|
||||
getId: () => id,
|
||||
getRoomId: () => roomId,
|
||||
getSender: () => SENDER,
|
||||
getTs: () => ts,
|
||||
isDecryptionFailure: () => decryptionFailure,
|
||||
} as unknown as MatrixEvent;
|
||||
};
|
||||
|
||||
// Base client: the timeline reports no `m.replace` edits, so the original content
|
||||
// is forwarded unchanged.
|
||||
const makeClient = (): MatrixClient =>
|
||||
({
|
||||
getRoom: () => ({
|
||||
getUnfilteredTimelineSet: () => ({
|
||||
relations: {
|
||||
getChildEventsForEvent: () => null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}) as unknown as MatrixClient;
|
||||
|
||||
test('plain text forwards the body and strips m.relates_to', () => {
|
||||
const mx = makeClient();
|
||||
const mEvent = makeEvent({
|
||||
content: {
|
||||
msgtype: 'm.text',
|
||||
body: 'hello world',
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
||||
},
|
||||
});
|
||||
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
|
||||
assert.ok(content);
|
||||
assert.equal(content.body, 'hello world');
|
||||
assert.equal(content.msgtype, 'm.text');
|
||||
assert.equal(content['m.relates_to'], undefined);
|
||||
});
|
||||
|
||||
test('reply quote is stripped from body and formatted_body', () => {
|
||||
const mx = makeClient();
|
||||
const mEvent = makeEvent({
|
||||
content: {
|
||||
msgtype: 'm.text',
|
||||
body: '> <@alice:example.org> original\n\nmy reply',
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: '<mx-reply><blockquote>original</blockquote></mx-reply>my reply',
|
||||
'm.relates_to': { 'm.in_reply_to': { event_id: '$root:example.org' } },
|
||||
},
|
||||
});
|
||||
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
|
||||
assert.ok(content);
|
||||
assert.equal(content.body, 'my reply');
|
||||
assert.equal(content.formatted_body, 'my reply');
|
||||
assert.equal(content['m.relates_to'], undefined);
|
||||
});
|
||||
|
||||
test('decryption failure returns undefined', () => {
|
||||
const mx = makeClient();
|
||||
const mEvent = makeEvent({
|
||||
content: { msgtype: 'm.bad.encrypted' },
|
||||
decryptionFailure: true,
|
||||
});
|
||||
|
||||
assert.equal(buildForwardContent(mx, mEvent), undefined);
|
||||
});
|
||||
|
||||
test('edited message forwards m.new_content', () => {
|
||||
const mEvent = makeEvent({
|
||||
content: {
|
||||
msgtype: 'm.text',
|
||||
body: 'original body',
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
||||
},
|
||||
});
|
||||
|
||||
// The latest `m.replace` edit carries the new content under `m.new_content`.
|
||||
const editEvent = makeEvent({
|
||||
content: { 'm.new_content': { msgtype: 'm.text', body: 'edited body' } },
|
||||
ts: 100,
|
||||
});
|
||||
const mx = {
|
||||
getRoom: () => ({
|
||||
getUnfilteredTimelineSet: () => ({
|
||||
relations: {
|
||||
getChildEventsForEvent: () => ({
|
||||
getRelations: () => [editEvent],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
|
||||
assert.ok(content);
|
||||
assert.equal(content.body, 'edited body');
|
||||
assert.equal(content.msgtype, 'm.text');
|
||||
assert.equal(content['m.new_content'], undefined);
|
||||
assert.equal(content['m.relates_to'], undefined);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
|
||||
/**
|
||||
* Build the content to forward:
|
||||
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||
* original pre-edit body
|
||||
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||
* message stands alone in the target room
|
||||
*/
|
||||
export function buildForwardContent(
|
||||
mx: MatrixClient,
|
||||
mEvent: MatrixEvent,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (mEvent.isDecryptionFailure()) return undefined;
|
||||
|
||||
let content = { ...mEvent.getContent() };
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const room = mx.getRoom(mEvent.getRoomId());
|
||||
if (eventId && room) {
|
||||
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||
if (newContent && typeof newContent === 'object') {
|
||||
content = { ...(newContent as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
|
||||
delete content['m.relates_to'];
|
||||
if (typeof content.body === 'string') {
|
||||
content.body = trimReplyFromBody(content.body);
|
||||
}
|
||||
if (typeof content.formatted_body === 'string') {
|
||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
Reference in New Issue
Block a user