feat(forward): Discord-style provenance — 'Forwarded from <sender> in <room> · <time>' with jump to the original
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
Forwarded events carried no origin at all; they read as the forwarder's own words. buildForwardContent now stamps io.lotus.forwarded (sender, ts, room_id, event_id; re-forwards keep the original stamp) and the main and thread timelines render a reply-style header above the message that jumps to the original when the viewer is in the source room (sender + time only otherwise — the source room's name is not leaked). Unit-tested; verified end to end with Playwright (header text, event content, jump, re-forward). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -53,10 +53,12 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||
import { getForwardedMeta } from './message/forwardContent';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
CompactPlaceholder,
|
||||
Reply,
|
||||
ForwardedHeader,
|
||||
MessageBase,
|
||||
MessageUnsupportedContent,
|
||||
Time,
|
||||
@@ -1099,6 +1101,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
const { replyEventId, threadRootId } = mEvent;
|
||||
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
|
||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||
@@ -1131,19 +1134,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={handleEdit}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{forwardedMeta && (
|
||||
<ForwardedHeader
|
||||
mx={mx}
|
||||
meta={forwardedMeta}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||
/>
|
||||
)}
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
reactions={
|
||||
<>
|
||||
@@ -1199,6 +1213,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
const { replyEventId, threadRootId } = mEvent;
|
||||
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
|
||||
return (
|
||||
@@ -1224,19 +1239,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={handleEdit}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{forwardedMeta && (
|
||||
<ForwardedHeader
|
||||
mx={mx}
|
||||
meta={forwardedMeta}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||
/>
|
||||
)}
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
reactions={
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
import { buildForwardContent, getForwardedMeta } from './forwardContent';
|
||||
|
||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||
@@ -136,3 +136,38 @@ test('edited message forwards m.new_content', () => {
|
||||
assert.equal(content['m.new_content'], undefined);
|
||||
assert.equal(content['m.relates_to'], undefined);
|
||||
});
|
||||
|
||||
test('forward stamps io.lotus.forwarded with the original sender / time / room / event', () => {
|
||||
const mx = makeClient();
|
||||
const mEvent = makeEvent({ content: { msgtype: 'm.text', body: 'prov' } });
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
assert.ok(content);
|
||||
const meta = getForwardedMeta(content);
|
||||
assert.ok(meta);
|
||||
assert.equal(meta.sender, mEvent.getSender());
|
||||
assert.equal(meta.origin_server_ts, mEvent.getTs());
|
||||
assert.equal(meta.room_id, mEvent.getRoomId());
|
||||
assert.equal(meta.event_id, mEvent.getId());
|
||||
});
|
||||
|
||||
test('forwarding a forward keeps the ORIGINAL provenance', () => {
|
||||
const mx = makeClient();
|
||||
const original = {
|
||||
sender: '@origin:example.org',
|
||||
origin_server_ts: 1000,
|
||||
room_id: '!origin:example.org',
|
||||
event_id: '$origin:example.org',
|
||||
};
|
||||
const mEvent = makeEvent({
|
||||
content: { msgtype: 'm.text', body: 'again', 'io.lotus.forwarded': original },
|
||||
});
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
assert.ok(content);
|
||||
assert.deepEqual(getForwardedMeta(content), original);
|
||||
});
|
||||
|
||||
test('getForwardedMeta rejects malformed metadata', () => {
|
||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': 'nope' }), undefined);
|
||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
||||
assert.equal(getForwardedMeta({}), undefined);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import { IEncryptedFile } from '../../../../types/matrix/common';
|
||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||
* message stands alone in the target room
|
||||
*/
|
||||
/**
|
||||
* Provenance stamped onto a forwarded event so the receiving client can show
|
||||
* "Forwarded from <sender> in <room> · <time>" and jump to the original
|
||||
* (Discord-style). `room_id`/`event_id` let a recipient who is also in the
|
||||
* source room jump there; a recipient who is not just sees the sender + time
|
||||
* (the source room's NAME is deliberately not included — it is not theirs to
|
||||
* see). Other Matrix clients ignore the key and show the plain content.
|
||||
*/
|
||||
export const FORWARDED_KEY = 'io.lotus.forwarded';
|
||||
export type ForwardedMeta = {
|
||||
sender: string;
|
||||
origin_server_ts: number;
|
||||
room_id: string;
|
||||
event_id: string;
|
||||
};
|
||||
export const getForwardedMeta = (content: Record<string, unknown>): ForwardedMeta | undefined => {
|
||||
const raw = content[FORWARDED_KEY];
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const m = raw as Partial<ForwardedMeta>;
|
||||
if (typeof m.sender !== 'string' || typeof m.origin_server_ts !== 'number') return undefined;
|
||||
return {
|
||||
sender: m.sender,
|
||||
origin_server_ts: m.origin_server_ts,
|
||||
room_id: typeof m.room_id === 'string' ? m.room_id : '',
|
||||
event_id: typeof m.event_id === 'string' ? m.event_id : '',
|
||||
};
|
||||
};
|
||||
|
||||
export function buildForwardContent(
|
||||
mx: MatrixClient,
|
||||
mEvent: MatrixEvent,
|
||||
@@ -41,6 +69,16 @@ export function buildForwardContent(
|
||||
if (typeof content.formatted_body === 'string') {
|
||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||
}
|
||||
|
||||
// Forwarding a forward keeps the ORIGINAL provenance rather than chaining.
|
||||
const existing = getForwardedMeta(content);
|
||||
const meta: ForwardedMeta = existing ?? {
|
||||
sender: mEvent.getSender() ?? '',
|
||||
origin_server_ts: mEvent.getTs(),
|
||||
room_id: mEvent.getRoomId() ?? '',
|
||||
event_id: eventId ?? '',
|
||||
};
|
||||
content[FORWARDED_KEY] = meta;
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,13 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
|
||||
import { getForwardedMeta } from '../message/forwardContent';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
MessageBase,
|
||||
Reply,
|
||||
ForwardedHeader,
|
||||
RedactedContent,
|
||||
MSticker,
|
||||
MessageUnsupportedContent,
|
||||
@@ -267,6 +270,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -731,6 +735,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = !!reactions && reactions.length > 0;
|
||||
const { replyEventId, threadRootId } = mEvent;
|
||||
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||
|
||||
return (
|
||||
<Message
|
||||
@@ -755,18 +760,29 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={opts.editable ? handleEdit : undefined}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{forwardedMeta && (
|
||||
<ForwardedHeader
|
||||
mx={mx}
|
||||
meta={forwardedMeta}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||
/>
|
||||
)}
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
reactions={
|
||||
reactionRelations && (
|
||||
@@ -794,6 +810,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
);
|
||||
},
|
||||
[
|
||||
navigateRoom,
|
||||
room,
|
||||
messageSpacing,
|
||||
messageLayout,
|
||||
|
||||
Reference in New Issue
Block a user