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

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:
2026-09-18 21:07:33 -04:00
co-authored by Claude Opus 5
parent 6460d0569c
commit 5fc90feef9
7 changed files with 231 additions and 39 deletions
@@ -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;
}