make edited and redacted message work in replies

This commit is contained in:
Ajay Bura 2026-08-18 11:03:01 +05:30
commit ab9a4089bb
8 changed files with 182 additions and 113 deletions

View file

@ -0,0 +1,40 @@
import React from 'react';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { DecryptionManager } from '../../client/decryption';
import { MessageEvent } from '../../types/matrix/room';
import { EncryptedMessageEvent } from './encrypted-event';
type MessageContentProps = {
event: MessageEventWithoutRoomId;
editedEvent?: MessageEventWithoutRoomId;
roomId: string;
decryptionManager: DecryptionManager;
reply?: boolean;
children: (event: MessageEventWithoutRoomId) => React.ReactNode;
};
export function MessageContent({
event,
editedEvent,
roomId,
decryptionManager,
reply,
children,
}: MessageContentProps) {
const effectiveEvent = editedEvent ?? event;
if (effectiveEvent.type === MessageEvent.RoomMessageEncrypted) {
return (
<EncryptedMessageEvent
roomId={roomId}
decryptionManager={decryptionManager}
event={effectiveEvent}
reply={reply}
>
{(decryptedEvent) => children(decryptedEvent)}
</EncryptedMessageEvent>
);
}
return children(effectiveEvent);
}

View file

@ -43,12 +43,14 @@ type EncryptedMessageEventProps = {
decryptionManager: DecryptionManager;
roomId: string;
event: MessageEventWithoutRoomId;
reply?: boolean;
children: (decryptedEvent: MessageEventWithoutRoomId) => ReactNode;
};
export function EncryptedMessageEvent({
decryptionManager,
roomId,
event,
reply,
children,
}: EncryptedMessageEventProps) {
const encryptedEvent = decryptionManager.decryptableMessageEvent(roomId, event);
@ -59,7 +61,7 @@ export function EncryptedMessageEvent({
if (decryption instanceof EventDecryptionIdle) {
return (
<Text>
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionIdle />
</Text>
);
@ -67,7 +69,7 @@ export function EncryptedMessageEvent({
if (decryption instanceof EventDecryptionPending) {
return (
<Text>
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionPending />
</Text>
);
@ -81,7 +83,7 @@ export function EncryptedMessageEvent({
if (cause) {
return (
<Text>
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionError
message={
cause instanceof Error

View file

@ -1,16 +1,17 @@
import { Box, Text, color, config, toRem } from 'folds';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import { ClientEventWithoutRoomId } from 'neurone/lib/api/types';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import { MessageBadEncryptedContent } from './content';
import { MessageFailedContent } from './content';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { GetRoomMemberNameCallback } from '../../hooks/useRoomMembers';
import { rUnsigned } from '../../../client/reader';
import { rContent, rUnsigned } from '../../../client/reader';
import { TimelineView } from '../../../client/room';
import { RenderReplyContent } from '../RenderReplyContent';
import { Time } from './Time';
@ -18,6 +19,11 @@ import { BubbleLayout, CompactLayout, MessageBase, ModernLayout } from './layout
import { MessageLayout, MessageSpacing } from '../../state/settings';
import { RedactedContent } from './MsgTypeRenderers';
import { HoverUnderline } from '../../styles/Text.css';
import { useObservableValueOptionally } from '../../hooks/useObservableValue';
import { getLatestEditEvent } from '../../utils/room';
import { replaced } from '../../../client/room/relations';
import { MessageContent } from '../MessageContent';
import { useClient } from '../../hooks/useClient';
type ReplyMessageProps = {
username?: ReactNode;
@ -125,7 +131,6 @@ type ReplyProps = {
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
getMemberName: GetRoomMemberNameCallback;
redacted: boolean;
timeline?: TimelineView;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
@ -145,7 +150,6 @@ export function Reply({
accessibleTagColors,
legacyUsernameColor,
getMemberName,
redacted,
timeline,
htmlReactParserOptions,
linkifyOpts,
@ -154,7 +158,12 @@ export function Reply({
messageLayout,
messageSpacing,
}: ReplyProps) {
const ct = useClient();
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const redactedEvents = useObservableValueOptionally(timeline?.relations.redacted);
const edits = useObservableValueOptionally(timeline?.relations.edits.eventToRelations);
const replyEvent = useRoomEvent(
roomId,
eventId,
@ -167,9 +176,50 @@ export function Reply({
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? eventId) : tagColor;
const redactedEvent = redacted || !!rUnsigned.redactedBecause(replyEvent?.unsigned);
const redacted =
redactedEvents?.has(eventId) || !!rUnsigned.redactedBecause(replyEvent?.unsigned);
const renderEventContent = (event: ClientEventWithoutRoomId) => {
const aggregatedEdit = replaced(event);
const editEvents = edits?.get(eventId);
const editedEvent =
editEvents && redactedEvents
? getLatestEditEvent(
event.sender,
aggregatedEdit ? [aggregatedEdit, ...editEvents] : editEvents,
redactedEvents
)
: undefined;
return (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
{redacted ? (
<RedactedContent size="T300" />
) : (
<MessageContent
event={event}
editedEvent={editedEvent}
roomId={roomId}
decryptionManager={ct.decryption}
reply
>
{(contentEvent) => (
<RenderReplyContent
displayName={getMemberName(event.sender)}
eventType={event.type}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
</MessageContent>
)}
</Box>
);
};
// TODO: make edit message appear in reply
return (
<MessageBase space={messageSpacing}>
<ReplyMessage
@ -205,26 +255,13 @@ export function Reply({
}
data-event-id={eventId}
>
{replyEvent !== undefined ? (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
{redactedEvent && <RedactedContent size="T300" />}
{!redactedEvent &&
(replyEvent === null ? (
<Text size="B300">
<MessageBadEncryptedContent />
</Text>
) : (
<RenderReplyContent
displayName={getMemberName(replyEvent.sender)}
eventType={replyEvent.type}
edited={false} // TODO:
content={replyEvent.content}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
))}
</Box>
) : (
{replyEvent && renderEventContent(replyEvent)}
{replyEvent === null && (
<Text size="T300">
<MessageFailedContent />
</Text>
)}
{replyEvent === undefined && (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,

View file

@ -212,7 +212,6 @@ function MessageSending({
export function OutboxView({
messageBatch,
senders,
redacted,
messageLayout,
messageSpacing,
membersAPI,
@ -231,7 +230,6 @@ export function OutboxView({
}: {
messageBatch: MessageBatch;
senders: readonly LocalEventSender[];
redacted: Set<string>;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
membersAPI: RoomMembersAPI;
@ -244,7 +242,7 @@ export function OutboxView({
urlPreview?: boolean;
hour24Clock: boolean;
dateFormatString: string;
onReplyClick?: MouseEventHandler | undefined;
onReplyClick?: () => void;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
}) {
@ -290,7 +288,6 @@ export function OutboxView({
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
redacted={redacted.has(replyEventId)}
getMemberName={membersAPI.getName}
timeline={timeline}
htmlReactParserOptions={htmlReactParserOptions}
@ -299,7 +296,6 @@ export function OutboxView({
dateFormatString={dateFormatString}
messageLayout={messageLayout}
messageSpacing={messageSpacing}
mediaAutoLoad={mediaAutoLoad}
/>
);

View file

@ -71,9 +71,9 @@ import {
UnreadControls,
} from './components';
import { OutboxView } from './OutboxView';
import { EncryptedMessageEvent } from '../../components/encrypted-event';
import { useUserReadReceipt } from '../../hooks/useReadReceipt';
import { useMessageParserOptions } from '../../hooks/useMessageParserOptions';
import { MessageContent } from '../../components/MessageContent';
type RoomTimelineProps = {
eventId?: string;
@ -413,47 +413,31 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{redacted.has(event.event_id) && <RedactedContent />}
{!redacted.has(event.event_id) &&
((editedEvent ?? event).type === MessageEvent.RoomMessageEncrypted ? (
<EncryptedMessageEvent
roomId={roomId}
decryptionManager={ct.decryption}
event={editedEvent ?? event}
>
{(decryptedEvent) => (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={decryptedEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(decryptedEvent.content) ?? decryptedEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
</EncryptedMessageEvent>
) : (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={event.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={
editedEvent
? rContent.newContent(editedEvent.content) ?? editedEvent.content
: event.content
}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
))}
{redacted.has(event.event_id) ? (
<RedactedContent />
) : (
<MessageContent
event={event}
editedEvent={editedEvent}
roomId={roomId}
decryptionManager={ct.decryption}
>
{(contentEvent) => (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={contentEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
</MessageContent>
)}
{reactionJSX}
{threadTimeline && (
<ThreadSelector
@ -524,7 +508,6 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
redacted={redacted.has(replyEventId)}
getMemberName={membersAPI.getName}
timeline={timeline}
htmlReactParserOptions={htmlReactParserInlineOptions}
@ -596,7 +579,6 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
senders={localEventSenders}
messageBatch={messageBatch}
membersAPI={membersAPI}
redacted={redacted}
messageLayout={messageLayout}
messageSpacing={messageSpacing}
onUserClick={handleUserClick}

View file

@ -1,12 +1,4 @@
import React, {
MouseEventHandler,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ReactEditor } from 'slate-react';
import { Box, Scroll, Text, config } from 'folds';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
@ -18,6 +10,7 @@ import {
CompactPlaceholder,
Reply,
DividerTime,
RedactedContent,
} from '../../../components/message';
import { fallbackReply, getLatestEditEvent, getReplyId } from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
@ -70,6 +63,7 @@ import {
import { OutboxView } from '../OutboxView';
import { useUserReadReceipt } from '../../../hooks/useReadReceipt';
import { useMessageParserOptions } from '../../../hooks/useMessageParserOptions';
import { MessageContent } from '../../../components/MessageContent';
type TimelineViewProps = {
threadTimeline: ThreadTimeline;
@ -93,7 +87,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = encryptedRooms.has(roomId) ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
@ -294,11 +287,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
/>
);
const content = editedEvent
? rContent.newContent(editedEvent.content) ?? event.content
: event.content;
// FIXME: Redacted message
return (
<Message
key={event.event_id}
@ -332,18 +320,31 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={event.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
{redacted.has(event.event_id) ? (
<RedactedContent />
) : (
<MessageContent
event={event}
editedEvent={editedEvent}
roomId={roomId}
decryptionManager={ct.decryption}
>
{(contentEvent) => (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={contentEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
</MessageContent>
)}
{reactionJSX}
</Message>
);
@ -363,10 +364,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
if (event.sender && ignoredUsersSet.has(event.sender)) {
return null;
}
// FIXME: dont hide redacted event for threads
if (redacted.has(event.event_id) && !showHiddenEvents) {
return null;
}
const newDivider = getNewDivider(event) && event.sender !== ct.session.userId;
const dayDivider = getDayDivider(event);
@ -403,7 +400,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
redacted={redacted.has(replyEventId)}
getMemberName={membersAPI.getName}
timeline={threadTimeline}
htmlReactParserOptions={htmlReactParserInlineOptions}
@ -495,7 +491,6 @@ export function TimelineView({ threadTimeline }: TimelineViewProps) {
senders={localEventSenders}
messageBatch={messageBatch}
membersAPI={membersAPI}
redacted={redacted}
messageLayout={messageLayout}
messageSpacing={messageSpacing}
onUserClick={handleUserClick}

View file

@ -4,7 +4,7 @@ import { Disposable, Disposer } from 'neurone/lib/utils/disposable';
import { MessageEvent } from '../../../types/matrix/room';
import { Relations } from './Relations';
import { RelationType, ReactionContent } from '../../../types/matrix/common';
import { getRedactedEventId, redacted, reference, relation, replacement } from './utils';
import { getRedactedEventId, redacted, reference, relation, replaced, replacement } from './utils';
export class RelationsProcessor implements Disposable {
private readonly publishKey = crypto.randomUUID();
@ -64,6 +64,12 @@ export class RelationsProcessor implements Disposable {
redactedEvents.push(event.event_id);
}
// Check if event itself has replacement
const replacedWith = replaced(event);
if (replacedWith) {
editEvents.push(event);
}
// Handle relation events
if (relation(event)) {
if (event.type === MessageEvent.Reaction) {

View file

@ -1,5 +1,5 @@
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { rContent, rUnsigned, rEvent, rRelatesTo } from '../../reader';
import { rContent, rUnsigned, rEvent, rRelatesTo, rRelations } from '../../reader';
import { RelationType } from '../../../types/matrix/common';
export const getRedactedEventId = (event: MessageEventWithoutRoomId): string | undefined => {
@ -14,6 +14,17 @@ export const redacted = (event: MessageEventWithoutRoomId): boolean => {
return rUnsigned.redactedBecause(unsigned) !== undefined;
};
export const replaced = (
event: MessageEventWithoutRoomId
): MessageEventWithoutRoomId | undefined => {
const { unsigned } = event;
const relations = rUnsigned.relations(unsigned);
const replacementEvent = rRelations.replace(relations);
return replacementEvent as MessageEventWithoutRoomId | undefined;
};
export const relation = (event: MessageEventWithoutRoomId): boolean =>
rRelatesTo.relType(rContent.relatesTo(event.content)) !== undefined;