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 |
|
| 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-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-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.
|
**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
|
## 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
|
### Message Length Counter
|
||||||
|
|
||||||
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
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.
|
- 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.
|
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).
|
- **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.
|
- **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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
|
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { RoomAvatar } from '../../components/room-avatar';
|
import { RoomAvatar } from '../../components/room-avatar';
|
||||||
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
|
|||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
onJump: (roomId: string, eventId: string) => void;
|
onJump: (roomId: string, eventId: string) => void;
|
||||||
onRemove: (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 mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
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 }}
|
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
||||||
>
|
>
|
||||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||||
{bookmark.previewText || '(no preview)'}
|
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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 = {
|
type BookmarksPanelProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
const { bookmarks, removeBookmark } = useBookmarks();
|
const { bookmarks, removeBookmark } = useBookmarks();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||||
{filtered.map((bk) => (
|
{filtered.map((bk) => {
|
||||||
<BookmarkItem
|
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||||
key={bk.eventId}
|
// otherwise fall back to the stored snapshot for rooms we've left.
|
||||||
bookmark={bk}
|
const room = mx.getRoom(bk.roomId);
|
||||||
onJump={handleJump}
|
return room ? (
|
||||||
onRemove={removeBookmark}
|
<LiveBookmarkItem
|
||||||
/>
|
key={bk.eventId}
|
||||||
))}
|
room={room}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
Header,
|
Header,
|
||||||
@@ -29,16 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
import { buildForwardContent } from './forwardContent';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
dm: boolean;
|
dm: boolean;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
onClick: () => void;
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
};
|
};
|
||||||
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
|
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const avatarMxc = room.getMxcAvatarUrl();
|
const avatarMxc = room.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -49,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={onClick}
|
onClick={onToggle}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
|
after={
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
readOnly
|
||||||
|
variant="Primary"
|
||||||
|
disabled={sending}
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200" radii="300">
|
<Avatar size="200" radii="300">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
@@ -93,6 +108,21 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
const [error, setError] = 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(
|
const allRooms = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -109,63 +139,52 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
}, [allRooms, query]);
|
}, [allRooms, query]);
|
||||||
|
|
||||||
/**
|
const sendToSelected = useCallback(async () => {
|
||||||
* Build the content to forward:
|
if (sending || selectedRoomIds.size === 0) return;
|
||||||
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
const fwdContent = buildForwardContent(mx, mEvent);
|
||||||
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
if (!fwdContent) {
|
||||||
* original pre-edit body
|
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||||
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
return;
|
||||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
}
|
||||||
* message stands alone in the target room
|
setSending(true);
|
||||||
*/
|
setError(null);
|
||||||
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
|
||||||
if (mEvent.isDecryptionFailure()) return undefined;
|
|
||||||
|
|
||||||
let content = { ...mEvent.getContent() };
|
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
|
||||||
|
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||||
|
);
|
||||||
|
|
||||||
const eventId = mEvent.getId();
|
const failedIds: string[] = [];
|
||||||
const room = mx.getRoom(mEvent.getRoomId());
|
const failedNames: string[] = [];
|
||||||
if (eventId && room) {
|
results.forEach((result, i) => {
|
||||||
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
if (result.status === 'rejected') {
|
||||||
const newContent = editedEvent?.getContent()['m.new_content'];
|
failedIds.push(ids[i]);
|
||||||
if (newContent && typeof newContent === 'object') {
|
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
|
||||||
content = { ...(newContent as Record<string, unknown>) };
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete content['m.relates_to'];
|
setSending(false);
|
||||||
if (typeof content.body === 'string') {
|
// Prune to only the failures so a retry doesn't re-send to rooms that
|
||||||
content.body = trimReplyFromBody(content.body);
|
// already succeeded (duplicate messages).
|
||||||
|
setSelectedRoomIds(new Set(failedIds));
|
||||||
|
if (succeeded === 0) {
|
||||||
|
setError('Failed to forward. Try again.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (typeof content.formatted_body === 'string') {
|
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}, [mx, mEvent]);
|
|
||||||
|
|
||||||
const forward = useCallback(
|
|
||||||
async (room: Room) => {
|
|
||||||
if (sending) return;
|
|
||||||
const fwdContent = buildForwardContent();
|
|
||||||
if (!fwdContent) {
|
|
||||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSending(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
// 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
@@ -237,50 +256,72 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
gap="300"
|
gap="300"
|
||||||
style={{ padding: config.space.S400 }}
|
style={{ padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
<Text size="T300">✓ Forwarded to {sentTo}</Text>
|
<Text size="T300">✓ {sentTo}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
<>
|
||||||
<Scroll size="300" hideTrack visibility="Hover">
|
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
{filtered.slice(0, 60).map((room) => (
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<RoomRow
|
{filtered.slice(0, 60).map((room) => (
|
||||||
key={room.roomId}
|
<RoomRow
|
||||||
room={room}
|
key={room.roomId}
|
||||||
dm={directs.has(room.roomId)}
|
room={room}
|
||||||
useAuthentication={useAuthentication}
|
dm={directs.has(room.roomId)}
|
||||||
onClick={() => forward(room)}
|
useAuthentication={useAuthentication}
|
||||||
sending={sending}
|
selected={selectedRoomIds.has(room.roomId)}
|
||||||
/>
|
onToggle={() => toggleRoom(room.roomId)}
|
||||||
))}
|
sending={sending}
|
||||||
{filtered.length === 0 && (
|
/>
|
||||||
<Box
|
))}
|
||||||
alignItems="Center"
|
{filtered.length === 0 && (
|
||||||
justifyContent="Center"
|
<Box
|
||||||
style={{ padding: config.space.S400 }}
|
alignItems="Center"
|
||||||
>
|
justifyContent="Center"
|
||||||
<Text size="T300" priority="300">
|
style={{ padding: config.space.S400 }}
|
||||||
No rooms found
|
>
|
||||||
</Text>
|
<Text size="T300" priority="300">
|
||||||
</Box>
|
No rooms found
|
||||||
)}
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
)}
|
||||||
{sending && (
|
</Box>
|
||||||
<Box
|
</Scroll>
|
||||||
alignItems="Center"
|
{sending && (
|
||||||
justifyContent="Center"
|
<Box
|
||||||
style={{
|
alignItems="Center"
|
||||||
position: 'absolute',
|
justifyContent="Center"
|
||||||
inset: 0,
|
style={{
|
||||||
background: 'rgba(0,0,0,0.35)',
|
position: 'absolute',
|
||||||
borderRadius: config.radii.R500,
|
inset: 0,
|
||||||
}}
|
background: 'rgba(0,0,0,0.35)',
|
||||||
|
borderRadius: config.radii.R500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner variant="Secondary" size="400" />
|
||||||
|
</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}
|
||||||
>
|
>
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Text size="B400">
|
||||||
</Box>
|
Send to {selectedRoomIds.size} {selectedRoomIds.size === 1 ? 'room' : 'rooms'}
|
||||||
)}
|
</Text>
|
||||||
</Box>
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</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