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
+52 -26
View File
@@ -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={
<>