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:
@@ -1179,6 +1179,10 @@ Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `
|
|||||||
|
|
||||||
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
||||||
|
|
||||||
|
### Forwarded messages show their provenance
|
||||||
|
|
||||||
|
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
|
||||||
|
|
||||||
### Copy Lotus Link — direct permalinks (Gitea #130)
|
### Copy Lotus Link — direct permalinks (Gitea #130)
|
||||||
|
|
||||||
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
|
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { MouseEventHandler } from 'react';
|
||||||
|
import { Box, Icon, Icons, Text, as, toRem } from 'folds';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './Reply.css';
|
||||||
|
import { ForwardedMeta } from '../../features/room/message/forwardContent';
|
||||||
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||||
|
|
||||||
|
type ForwardedHeaderProps = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
meta: ForwardedMeta;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
/** Present when the viewer can open the original (they are in the source room). */
|
||||||
|
onJump?: MouseEventHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "↪ Forwarded · from <sender> in <room> · <time>" — the provenance line above
|
||||||
|
* a forwarded message (Gitea: forwards used to look like the forwarder's own
|
||||||
|
* words). Renders in the same visual slot and style as a reply quote so it
|
||||||
|
* reads as message context, not as content.
|
||||||
|
*/
|
||||||
|
export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
|
||||||
|
({ mx, meta, hour24Clock, dateFormatString, onJump, className, ...props }, ref) => {
|
||||||
|
const sourceRoom = meta.room_id ? mx.getRoom(meta.room_id) : null;
|
||||||
|
const senderName =
|
||||||
|
(sourceRoom && getMemberDisplayName(sourceRoom, meta.sender)) ??
|
||||||
|
mx.getUser(meta.sender)?.displayName ??
|
||||||
|
getMxIdLocalPart(meta.sender) ??
|
||||||
|
meta.sender;
|
||||||
|
const ts = meta.origin_server_ts;
|
||||||
|
const when = today(ts)
|
||||||
|
? timeHourMinute(ts, hour24Clock)
|
||||||
|
: yesterday(ts)
|
||||||
|
? `Yesterday ${timeHourMinute(ts, hour24Clock)}`
|
||||||
|
: `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||||
|
const canJump = !!sourceRoom && !!onJump;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as={canJump ? 'button' : 'div'}
|
||||||
|
className={classNames(css.Reply, className)}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
onClick={canJump ? onJump : undefined}
|
||||||
|
title={canJump ? 'Jump to the original message' : undefined}
|
||||||
|
aria-label={`Forwarded from ${senderName}${sourceRoom ? ` in ${sourceRoom.name}` : ''}, ${when}`}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ maxWidth: toRem(420) }}>
|
||||||
|
<Icon size="100" src={Icons.ArrowGoRight} />
|
||||||
|
<Text size="T300" priority="300" truncate>
|
||||||
|
Forwarded from <b>{senderName}</b>
|
||||||
|
{sourceRoom ? (
|
||||||
|
<>
|
||||||
|
{' in '}
|
||||||
|
<b>{sourceRoom.name}</b>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{' · '}
|
||||||
|
{when}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -8,3 +8,4 @@ export * from './Time';
|
|||||||
export * from './MsgTypeRenderers';
|
export * from './MsgTypeRenderers';
|
||||||
export * from './FileHeader';
|
export * from './FileHeader';
|
||||||
export * from './RenderBody';
|
export * from './RenderBody';
|
||||||
|
export * from './ForwardedHeader';
|
||||||
|
|||||||
@@ -53,10 +53,12 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||||
|
import { getForwardedMeta } from './message/forwardContent';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
CompactPlaceholder,
|
||||||
Reply,
|
Reply,
|
||||||
|
ForwardedHeader,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
MessageUnsupportedContent,
|
MessageUnsupportedContent,
|
||||||
Time,
|
Time,
|
||||||
@@ -1099,6 +1101,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
const hasReactions = reactions && reactions.length > 0;
|
const hasReactions = reactions && reactions.length > 0;
|
||||||
const { replyEventId, threadRootId } = mEvent;
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
|
|
||||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||||
@@ -1131,19 +1134,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
onEditId={handleEdit}
|
onEditId={handleEdit}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
<>
|
||||||
<Reply
|
{forwardedMeta && (
|
||||||
room={room}
|
<ForwardedHeader
|
||||||
timelineSet={timelineSet}
|
mx={mx}
|
||||||
replyEventId={replyEventId}
|
meta={forwardedMeta}
|
||||||
threadRootId={threadRootId}
|
hour24Clock={hour24Clock}
|
||||||
onClick={handleOpenReply}
|
dateFormatString={dateFormatString}
|
||||||
onThreadClick={setActiveThreadId}
|
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
/>
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
)}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
{replyEventId && (
|
||||||
/>
|
<Reply
|
||||||
)
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
<>
|
<>
|
||||||
@@ -1199,6 +1213,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
const hasReactions = reactions && reactions.length > 0;
|
const hasReactions = reactions && reactions.length > 0;
|
||||||
const { replyEventId, threadRootId } = mEvent;
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1224,19 +1239,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
onEditId={handleEdit}
|
onEditId={handleEdit}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
<>
|
||||||
<Reply
|
{forwardedMeta && (
|
||||||
room={room}
|
<ForwardedHeader
|
||||||
timelineSet={timelineSet}
|
mx={mx}
|
||||||
replyEventId={replyEventId}
|
meta={forwardedMeta}
|
||||||
threadRootId={threadRootId}
|
hour24Clock={hour24Clock}
|
||||||
onClick={handleOpenReply}
|
dateFormatString={dateFormatString}
|
||||||
onThreadClick={setActiveThreadId}
|
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
/>
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
)}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
{replyEventId && (
|
||||||
/>
|
<Reply
|
||||||
)
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { buildForwardContent } from './forwardContent';
|
import { buildForwardContent, getForwardedMeta } from './forwardContent';
|
||||||
|
|
||||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
// 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.new_content'], undefined);
|
||||||
assert.equal(content['m.relates_to'], 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
|
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||||
* message stands alone in the target room
|
* 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(
|
export function buildForwardContent(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
mEvent: MatrixEvent,
|
mEvent: MatrixEvent,
|
||||||
@@ -41,6 +69,16 @@ export function buildForwardContent(
|
|||||||
if (typeof content.formatted_body === 'string') {
|
if (typeof content.formatted_body === 'string') {
|
||||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
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;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
|
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
|
||||||
|
import { getForwardedMeta } from '../message/forwardContent';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
Reply,
|
Reply,
|
||||||
|
ForwardedHeader,
|
||||||
RedactedContent,
|
RedactedContent,
|
||||||
MSticker,
|
MSticker,
|
||||||
MessageUnsupportedContent,
|
MessageUnsupportedContent,
|
||||||
@@ -267,6 +270,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
@@ -731,6 +735,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||||
const hasReactions = !!reactions && reactions.length > 0;
|
const hasReactions = !!reactions && reactions.length > 0;
|
||||||
const { replyEventId, threadRootId } = mEvent;
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
@@ -755,18 +760,29 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
onEditId={opts.editable ? handleEdit : undefined}
|
onEditId={opts.editable ? handleEdit : undefined}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
<>
|
||||||
<Reply
|
{forwardedMeta && (
|
||||||
room={room}
|
<ForwardedHeader
|
||||||
timelineSet={timelineSet}
|
mx={mx}
|
||||||
replyEventId={replyEventId}
|
meta={forwardedMeta}
|
||||||
threadRootId={threadRootId}
|
hour24Clock={hour24Clock}
|
||||||
onClick={handleOpenReply}
|
dateFormatString={dateFormatString}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
/>
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
)}
|
||||||
/>
|
{replyEventId && (
|
||||||
)
|
<Reply
|
||||||
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
reactionRelations && (
|
||||||
@@ -794,6 +810,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
navigateRoom,
|
||||||
room,
|
room,
|
||||||
messageSpacing,
|
messageSpacing,
|
||||||
messageLayout,
|
messageLayout,
|
||||||
|
|||||||
Reference in New Issue
Block a user