feat(channels): M4a drawer rich chrome with edit menu reactions and reply affordances

This commit is contained in:
heaven 2026-05-10 18:48:55 +03:00
parent e80453785e
commit 896a2e2083
6 changed files with 670 additions and 321 deletions

View file

@ -1,7 +1,6 @@
/* eslint-disable react/destructuring-assignment */
import React, {
Dispatch,
MouseEventHandler,
RefObject,
SetStateAction,
useCallback,
@ -16,7 +15,6 @@ import {
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
IContent,
MatrixClient,
MatrixEvent,
Room,
@ -25,7 +23,6 @@ import {
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js';
@ -44,8 +41,8 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useParams } from 'react-router-dom';
import { getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
@ -80,7 +77,6 @@ import {
getEventReactions,
getLatestEditableEvt,
getMemberDisplayName,
getReactionContent,
isBridgedRoom,
isMembershipChanged,
reactionOrEditEvent,
@ -88,7 +84,13 @@ import {
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import {
Reactions,
Message,
Event,
EncryptedContent,
useMessageInteractionHandlers,
} from './message';
import { ThreadSummaryCard } from './ThreadSummaryCard';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
@ -102,7 +104,7 @@ import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { isEmptyEditor } from '../../components/editor';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
@ -122,9 +124,6 @@ import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
import { getChannelsThreadPath } from '../../pages/pathUtils';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
@ -515,12 +514,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// drawer to a different thread root.
const threadDrawerOpen = useThreadDrawerOpen();
const hideMainReplyAffordance = threadDrawerOpen;
// Captured for the channels-mode «Reply in Thread» button — the
// handler turns a click on a row into a navigate(thread URL) instead
// of writing a thread reply-draft into the channel composer. Decoded
// params are pre-encoded by react-router; getChannelsThreadPath
// re-encodes via generatePath so passing the raw URL value is fine.
const navigate = useNavigate();
// Captured for the channels-mode «Reply in Thread» branch inside
// useMessageInteractionHandlers — re-encoded via generatePath so
// passing the raw (already URL-encoded) values from useParams is fine.
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
@ -558,15 +554,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@ -823,6 +816,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[room, timeline, scrollToItem, loadEventTimeline]
);
const {
editId,
handleEdit,
handleOpenReply,
handleUserClick,
handleUsernameClick,
handleReplyClick,
handleReactionToggle,
} = useMessageInteractionHandlers({
room,
editor,
composerSuspended: threadDrawerOpen,
setReplyDraft,
onOpenEvent: handleOpenEvent,
channelsMode,
isBridged,
spaceIdOrAlias,
roomIdOrAlias,
});
useLiveTimelineRefresh(
room,
useCallback(() => {
@ -970,11 +983,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const editableEvtId = editableEvt?.getId();
if (!editableEvtId) return;
setEditId(editableEvtId);
handleEdit(editableEvtId);
evt.preventDefault();
}
},
[mx, room, editor, threadDrawerOpen]
[mx, room, editor, threadDrawerOpen, handleEdit]
)
);
@ -1084,163 +1097,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
markAsRead(mx, room.roomId, hideActivity);
};
const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
handleOpenEvent(targetId);
},
[handleOpenEvent]
);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
console.warn('Button should have "data-user-id" attribute!');
return;
}
openUserRoomProfile(
room.roomId,
space?.roomId,
userId,
evt.currentTarget.getBoundingClientRect()
);
},
[room, space, openUserRoomProfile]
);
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
// Drawer-active: this handler targets the MAIN composer's editor,
// which is unmounted while the drawer is open. Inserting a mention
// would silently write into a hidden Slate instance the user can't
// see, then `ReactEditor.focus` no-ops on the unmounted editor.
// Mention-from-username inside an open thread drawer is M9 polish
// (drawer composer's own click handler); for M2 the click is a
// no-op so the user doesn't lose the input invisibly.
if (threadDrawerOpen) return;
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
console.warn('Button should have "data-user-id" attribute!');
return;
}
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId()
)
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor, threadDrawerOpen]
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt, startThread = false) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) {
console.warn('Button should have "data-event-id" attribute!');
return;
}
const replyEvt = room.findEventById(replyId);
if (!replyEvt) return;
// Channels: «Reply in Thread» opens the right-side drawer instead
// of stuffing a thread relation into the channel composer's reply
// draft. Bridged rooms (Telegram puppets etc.) have no thread
// semantic on the bridge side — fall back to the legacy
// m.in_reply_to draft path so messages still go through.
if (
startThread &&
channelsMode &&
!isBridged &&
spaceIdOrAlias &&
roomIdOrAlias
) {
// useParams returns the encoded URL value; getChannelsThreadPath
// re-encodes via generatePath so we decode once first to avoid
// double-encoding (matches `routeParent.ts` decode pattern).
let decodedSpace: string;
let decodedRoom: string;
try {
decodedSpace = decodeURIComponent(spaceIdOrAlias);
decodedRoom = decodeURIComponent(roomIdOrAlias);
} catch {
decodedSpace = spaceIdOrAlias;
decodedRoom = roomIdOrAlias;
}
navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId));
return;
}
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[
room,
setReplyDraft,
editor,
channelsMode,
isBridged,
navigate,
spaceIdOrAlias,
roomIdOrAlias,
]
);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
if (myReaction && !!myReaction?.isRelation()) {
mx.redactEvent(room.roomId, myReaction.getId()!);
return;
}
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent(
room.roomId,
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode)
);
},
[mx, room]
);
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor]
);
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer<

View file

@ -48,32 +48,20 @@ export const ThreadDrawerScroll = style({
position: 'relative',
});
// M4a: top padding bumped from S400 (16px) to S700 (32px). `<Message>`'s
// action bar (`MessageOptionsBase`, top: -30px in styles.css.ts:8-16)
// sits 30px above its row. Mid-list rows have the previous row's height
// absorbing that negative position, but the FIRST row (root event)
// only has this container's top padding above it — S400 wasn't enough,
// so the bar clipped at the scroll viewport's top edge on hover. S700
// gives the bar 32px of clearance, enough to render the ~28-32px hover
// bar fully on the root. Side / bottom padding stays S400 to preserve
// the original visual density.
export const ThreadDrawerContent = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S400,
padding: `${config.space.S400} ${config.space.S400}`,
});
export const ThreadEventCard = style({
display: 'flex',
flexDirection: 'row',
gap: config.space.S300,
});
export const ThreadEventAvatar = style({
flexShrink: 0,
});
export const ThreadEventBody = style({
flexGrow: 1,
minWidth: 0,
});
export const ThreadEventEdited = style({
marginLeft: config.space.S100,
color: color.SurfaceVariant.OnContainer,
opacity: 0.6,
padding: `${config.space.S700} ${config.space.S400} ${config.space.S400}`,
});
export const ThreadDivider = style({

View file

@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAtomValue, useSetAtom } from 'jotai';
import {
Avatar,
Box,
Button,
Header,
@ -20,7 +20,6 @@ import {
EventType,
MatrixEvent,
MatrixEventEvent,
MsgType,
RelationType,
Room,
Thread,
@ -39,21 +38,48 @@ import { markAsThreadRead } from '../../utils/notifications';
import { useEditor } from '../../components/editor';
import {
getEditedEvent,
getMemberAvatarMxc,
getEventReactions,
getMemberDisplayName,
isBridgedRoom,
reactionOrEditEvent,
} from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { RedactedContent, Time } from '../../components/message';
import { getMxIdLocalPart } from '../../utils/matrix';
import {
ImageContent,
MessageNotDecryptedContent,
MessageUnsupportedContent,
MSticker,
RedactedContent,
Reply,
} from '../../components/message';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomInput } from './RoomInput';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import {
EncryptedContent,
Message,
Reactions,
useMessageInteractionHandlers,
} from './message';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useChannelsMode } from '../../hooks/useChannelsMode';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useTheme } from '../../hooks/useTheme';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import {
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../hooks/useMemberPowerTag';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
@ -63,7 +89,7 @@ import {
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import { GetContentCallback } from '../../../types/matrix/room';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
// IntersectionObserver ratio at which the bottom sentinel still counts
// as «at the bottom of the replies list». 0.9 lets a few pixels of
@ -104,98 +130,12 @@ type ThreadDrawerProps = {
variant: 'desktop' | 'mobile';
};
type ThreadEventCardProps = {
room: Room;
mEvent: MatrixEvent;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: LinkifyOpts;
mediaAutoLoad: boolean;
showUrlPreview: boolean;
};
// Avatar + name + Time + content. Content rendering delegates to
// `RenderMessageContent` — the same pipeline RoomTimeline uses for the
// main column. That gives image / video / sticker / file / location /
// notice / emote / unable-to-decrypt rendering for free, and edit
// aggregation flows through `getEditedEvent` against the room's
// unfiltered timelineSet (which is where `m.replace` events live).
function ThreadEventCard({
room,
mEvent,
htmlReactParserOptions,
linkifyOpts,
mediaAutoLoad,
showUrlPreview,
}: ThreadEventCardProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { t } = useTranslation();
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const avatarMxc = getMemberAvatarMxc(room, senderId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 100, 100, 'crop')
: undefined;
const eventId = mEvent.getId();
const editedEvent =
eventId && !mEvent.isRedacted()
? getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet())
: undefined;
const getContent = ((): GetContentCallback =>
editedEvent
? () => editedEvent.getContent()['m.new_content']
: () => mEvent.getContent())();
const msgType = (mEvent.getContent().msgtype as string | undefined) ?? '';
return (
<div className={css.ThreadEventCard}>
<div className={css.ThreadEventAvatar}>
<Avatar size="300" style={{ width: '32px', height: '32px' }}>
<UserAvatar
userId={senderId}
src={avatarUrl ?? undefined}
alt={senderDisplayName}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
</div>
<div className={css.ThreadEventBody}>
<Box alignItems="Center" gap="200">
<Text size="T400">
<b>{senderDisplayName}</b>
</Text>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
</Box>
{mEvent.isRedacted() ? (
<RedactedContent
reason={mEvent.getUnsigned().redacted_because?.content?.reason}
/>
) : (
<RenderMessageContent
displayName={senderDisplayName}
msgType={msgType || MsgType.Text}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
)}
{editedEvent && !mEvent.isRedacted() && (
<Text className={css.ThreadEventEdited} as="span" size="T200">
{t('Room.edited')}
</Text>
)}
</div>
</div>
);
}
// M4a: thread events render through the shared `<Message>` component
// — same hover/menu/edit/reactions/reply chrome as the main timeline,
// `'channel'` layout (avatar + name + body without bubble). Root and
// replies share the render path so editing/reactions/redact work
// identically for both, matching Slack/Element-web and removing the
// pre-M4a fork between `ThreadEventCard` and `<Message>`.
// Drawer body — handles all the loading / error states the plan calls
// out (null Thread, fetchRoomEvent failure, paginate failure, post-load
@ -269,6 +209,86 @@ export function ThreadDrawer({
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
// Power-tag colour wiring — drawer mirrors RoomTimeline so coloured
// username chips (mods / room creators / power-levelled users) render
// identically in main timeline and drawer. Bundling these into a
// shared hook is M9 polish; for now duplicating the call sites keeps
// the dependency graph explicit.
const theme = useTheme();
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const accessiblePowerTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags
);
const isOneOnOne = useIsOneOnOne();
const channelsMode = useChannelsMode();
const isBridged = channelsMode && isBridgedRoom(room);
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
// Reply-draft scope: tuple key `[roomId, rootId]` so the chip writes
// into the drawer composer's draft slot, not the channel composer's.
// The `RoomInput` instance below subscribes to the same atom via
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
// the chip surfaces inside the drawer composer.
const setReplyDraft = useSetAtom(
roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId))
);
// Reply-chip click scrolls within the drawer to the matching
// `data-message-id` row. If the target lives in the main timeline
// (e.g. a chip on the root that points to a quoted main-column
// message) the scroll is a silent no-op — that's an accepted M4a
// limitation; cross-pane navigation is M9 polish.
const handleScrollToDrawerEvent = useCallback((evtId: string) => {
const host = scrollHostRef.current;
if (!host) return;
const target = host.querySelector<HTMLElement>(
`[data-message-id="${evtId}"]`
);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, []);
const {
editId,
handleEdit,
handleOpenReply,
handleUserClick,
handleUsernameClick,
handleReplyClick,
handleReactionToggle,
} = useMessageInteractionHandlers({
room,
editor,
// Drawer composer is mounted alongside the drawer, EXCEPT in the
// read-only branch where `canMessage === false` and the body
// renders `<RoomInputPlaceholder>` instead of `<RoomInput>` (see
// bottom of this component). In that branch the Slate `<Editable>`
// is never registered in slate-react's `EDITOR_TO_ELEMENT` map, so
// `ReactEditor.focus(editor)` from the username-click handler
// would throw. Treating !canMessage as suspended makes the hook
// bail before touching the unmounted editor.
composerSuspended: !canMessage,
setReplyDraft,
onOpenEvent: handleScrollToDrawerEvent,
channelsMode,
isBridged,
spaceIdOrAlias,
roomIdOrAlias,
});
const [thread, setThread] = useState<Thread | null>(() => room.getThread(rootId));
const [rootEvent, setRootEvent] = useState<MatrixEvent | null>(
@ -617,6 +637,43 @@ export function ThreadDrawer({
})
: [];
// M4a: mount-transition trigger for `<Reactions>` and edited-body
// refresh. `getEventReactions` and `getEditedEvent` return undefined
// until the SDK creates a Relations container — at which point the
// target event emits `MatrixEventEvent.RelationsCreated`
// (matrix-js-sdk relations.js:375). Without this listener, the FIRST
// reaction or edit on a thread row never surfaces in the drawer:
// ThreadEvent.Update doesn't fire for annotations/replaces (Thread
// returns early at thread.js:332-334 before updateThreadMetadata),
// and `<Reactions>` only self-subscribes via `useRelations` AFTER
// it's mounted. Once mounted, subsequent relation deltas flow
// through useRelations and `getEditedEvent` re-evaluates each
// render. This effect handles only the empty-→-first transition.
//
// Stable string signature (`repliesIds`) keeps the effect from
// re-running on every `replies` re-allocation; the array reference
// changes each render by design (see comment above) but content is
// stable across non-paginate renders.
const repliesIds = replies.map((r) => r.getId() ?? '').join('|');
useEffect(() => {
const handler = () => forceRender((n) => n + 1);
const subscribed: MatrixEvent[] = [];
if (rootEvent) {
rootEvent.on(MatrixEventEvent.RelationsCreated, handler);
subscribed.push(rootEvent);
}
replies.forEach((evt) => {
evt.on(MatrixEventEvent.RelationsCreated, handler);
subscribed.push(evt);
});
return () => {
subscribed.forEach((evt) =>
evt.off(MatrixEventEvent.RelationsCreated, handler)
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootEvent, repliesIds]);
// Read receipts (M4): fired on first reveal at-bottom, on reply
// growth while at-bottom, and on own send via the layout/effect
// pair below. The receipt handler in `state/room/roomToUnread.ts`
@ -794,6 +851,187 @@ export function ThreadDrawer({
navigate(parentRoomPath, { replace: true });
}, [navigate, parentRoomPath]);
const renderThreadEvent = (mEvent: MatrixEvent) => {
const eventId = mEvent.getId();
if (!eventId) return null;
// Reactions and edits aggregate into different timelineSets depending
// on the event's thread partition. Root reactions live on the room's
// unfiltered timelineSet (root has `shouldLiveInRoom: true`, see
// matrix-js-sdk room.js:eventShouldLiveIn). Thread-reply reactions
// live on `thread.timelineSet.relations` —
// `Thread.addRelatedThreadEvent` (thread.js:367 / 332-334) calls
// `aggregateChildEvent(event, this.timelineSet)` and returns BEFORE
// updateThreadMetadata, so they never touch the room's relation
// cache. Reading from the wrong source means `getEventReactions`
// and `getEditedEvent` return undefined for thread replies even
// when the SDK has fully ingested the relation.
const eventThread = mEvent.getThread();
const isThreadReply =
mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
const timelineSet =
isThreadReply && eventThread
? eventThread.timelineSet
: room.getUnfilteredTimelineSet();
const reactionRelations = getEventReactions(timelineSet, eventId);
const reactionList = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = reactionList && reactionList.length > 0;
const isEncrypted = mEvent.getType() === MessageEvent.RoomMessageEncrypted;
const renderMessage = () => {
const eventType = mEvent.getType();
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const { replyEventId, threadRootId } = mEvent;
// matrix-js-sdk auto-injects a fallback `m.in_reply_to` into every
// thread reply for non-thread-aware clients (client.js:1819 sets
// `is_falling_back: !isReply` — true when user just typed in the
// thread without explicitly quoting). MSC3440 says thread-aware
// clients SHOULD ignore the fallback chip. Without this gate the
// drawer renders «↩ test6 <prev body>» on every reply, making it
// look like the user quoted the previous message every time.
// Cinny's `RoomInput.tsx:393` flips the flag to `false` only for
// explicit replies (the chip the user added with the Reply menu),
// so checking `is_falling_back === false` cleanly distinguishes
// intentional quotes from auto-fallback chains.
const wireRelatesTo = mEvent.getWireContent()['m.relates_to'];
const isFallbackInReplyTo =
wireRelatesTo?.rel_type === RelationType.Thread &&
wireRelatesTo?.is_falling_back !== false;
const showReplyChip = !!replyEventId && !isFallbackInReplyTo;
return (
<Message
key={eventId}
data-message-id={eventId}
room={room}
mEvent={mEvent}
collapse={false}
highlight={false}
edit={editId === eventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
showReplyChip && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={eventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
msgType={mEvent.getContent().msgtype ?? ''}
// We're inside a thread — nested threads aren't a thing in
// Matrix, hide ThreadPlus on the row (and its menu twin).
hideThreadReplyAffordance
// Drawer composer is mounted alongside the row — regular
// m.in_reply_to reply works in-drawer; setReplyDraft routes
// to `[roomId, rootId]` so the chip surfaces there.
hideMainReplyAffordance={false}
layout="channel"
>
{(() => {
if (mEvent.isRedacted()) {
return (
<RedactedContent
reason={mEvent.getUnsigned().redacted_because?.content?.reason}
/>
);
}
if (eventType === MessageEvent.Sticker) {
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
}
if (eventType === MessageEvent.RoomMessage) {
const editedEvent = getEditedEvent(eventId, mEvent, timelineSet);
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ??
mEvent.getContent()) as GetContentCallback;
return (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
);
}
if (eventType === MessageEvent.RoomMessageEncrypted) {
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
}
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
})()}
</Message>
);
};
if (isEncrypted) {
return (
<EncryptedContent key={eventId} mEvent={mEvent}>
{renderMessage}
</EncryptedContent>
);
}
return renderMessage();
};
const renderBody = () => {
if (rootError) {
return (
@ -823,14 +1061,7 @@ export function ThreadDrawer({
return (
<div className={css.ThreadDrawerContent}>
<ThreadEventCard
room={room}
mEvent={rootEvent}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
mediaAutoLoad={mediaAutoLoad}
showUrlPreview={showUrlPreview}
/>
{renderThreadEvent(rootEvent)}
{(() => {
// Counter prefers the larger of materialized `thread.length`
// (replyCount + pending) and loaded `replies.length` — covers
@ -899,17 +1130,7 @@ export function ThreadDrawer({
<Spinner size="200" />
</Box>
)}
{replies.map((reply) => (
<ThreadEventCard
key={reply.getId()}
room={room}
mEvent={reply}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
mediaAutoLoad={mediaAutoLoad}
showUrlPreview={showUrlPreview}
/>
))}
{replies.map((reply) => renderThreadEvent(reply))}
<div ref={setBottomSentinel} />
</div>
);

View file

@ -153,7 +153,24 @@ export const MessageEditor = as<'div', MessageEditorProps>(
},
};
return mx.sendMessage(roomId, content);
// Pass threadRootId so the local-echo MatrixEvent gets its
// `.thread` pointer set via `localEvent.setThread(thread)`
// (client.js:1872-1875) and `Thread.onEcho` re-emits
// `ThreadEvent.Update` post-send, which the drawer subscribes
// to. SDK `addThreadRelationIfNeeded` (client.js:1810-1820)
// doesn't inject `m.thread` because m.replace already owns
// `m.relates_to.rel_type` — spec-correct, edit aggregation
// chains via the original event's relation. Element-web's
// EditMessageComposer.tsx:349-351 uses the same pattern.
// M4a-followup: own-send local-echo of m.replace doesn't
// aggregate into `thread.timelineSet.relations` (canContain
// rejects against unfilteredTimelineSet at
// event-timeline-set.js:786-803, and the thread-side path runs
// only on remote echo). User sees the new body only after
// /sync round-trip — same lag as M4 receipts. Optimistic
// local-replace via `mEvent.makeReplaced(localEvent)` is
// tracked for follow-up.
return mx.sendMessage(roomId, mEvent.threadRootId ?? null, content as any);
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
);

View file

@ -1,3 +1,4 @@
export * from './Reactions';
export * from './Message';
export * from './EncryptedContent';
export * from './useMessageInteractionHandlers';

View file

@ -0,0 +1,266 @@
import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { createMentionElement, moveCursor } from '../../../components/editor';
import {
getEditedEvent,
getEventReactions,
getMemberDisplayName,
getReactionContent,
} from '../../../utils/room';
import {
eventWithShortcode,
factoryEventSentBy,
getMxIdLocalPart,
} from '../../../utils/matrix';
import { IReplyDraft } from '../../../state/room/roomInputDrafts';
import { MessageEvent } from '../../../../types/matrix/room';
import { getChannelsThreadPath } from '../../../pages/pathUtils';
export type UseMessageInteractionHandlersOptions = {
room: Room;
// Slate editor that owns this hook's caller composer (main timeline →
// channel/DM/legacy editor, drawer → thread composer editor). Mention
// insertion + reply-draft focus target this instance.
editor: Editor;
// True when the composer associated with `editor` is currently
// unmounted/hidden. Main timeline passes `threadDrawerOpen` (drawer
// open hides main composer); drawer passes `false`. When suspended,
// the username-click mention insert no-ops to avoid writing into a
// hidden Slate instance the user can't see.
composerSuspended: boolean;
// Reply-draft setter targeting the right composer scope. Caller
// subscribes via `useSetAtom(roomIdToReplyDraftAtomFamily(...))` —
// main timeline scopes to `[roomId, 'main']`, drawer scopes to
// `[roomId, rootId]`.
setReplyDraft: (draft: IReplyDraft | undefined) => void;
// Caller-specific scroll-to-event for reply-chip clicks. Main timeline
// scrolls via virtual paginator + setFocusItem; drawer scrolls via DOM
// scrollIntoView on the matching `data-message-id` node.
onOpenEvent: (eventId: string) => void;
// Channels-mode flags that gate the «Reply in Thread» navigate branch
// in `handleReplyClick`. Drawer hides the ThreadPlus affordance via
// `hideThreadReplyAffordance` so the branch is dead from drawer in
// practice; the hook stays generic for symmetry.
channelsMode: boolean;
isBridged: boolean;
spaceIdOrAlias?: string;
roomIdOrAlias?: string;
};
export type MessageInteractionHandlers = {
editId: string | undefined;
handleEdit: (editEvtId?: string) => void;
handleOpenReply: MouseEventHandler;
handleUserClick: MouseEventHandler<HTMLButtonElement>;
handleUsernameClick: MouseEventHandler<HTMLButtonElement>;
handleReplyClick: (
ev: MouseEvent<HTMLButtonElement>,
startThread?: boolean
) => void;
handleReactionToggle: (
targetEventId: string,
key: string,
shortcode?: string
) => void;
};
// Wiring layer for `<Message>` event-handler props, shared between
// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Each
// hook instance owns its own `editId` — editing in the drawer doesn't
// flip a row in the main timeline into edit mode and vice versa, which
// matches the user's mental model of two independent conversations.
export function useMessageInteractionHandlers({
room,
editor,
composerSuspended,
setReplyDraft,
onOpenEvent,
channelsMode,
isBridged,
spaceIdOrAlias,
roomIdOrAlias,
}: UseMessageInteractionHandlersOptions): MessageInteractionHandlers {
const mx = useMatrixClient();
const navigate = useNavigate();
const space = useSpaceOptionally();
const openUserRoomProfile = useOpenUserRoomProfile();
const [editId, setEditId] = useState<string>();
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
onOpenEvent(targetId);
},
[onOpenEvent]
);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
// eslint-disable-next-line no-console
console.warn('Button should have "data-user-id" attribute!');
return;
}
openUserRoomProfile(
room.roomId,
space?.roomId,
userId,
evt.currentTarget.getBoundingClientRect()
);
},
[room, space, openUserRoomProfile]
);
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
// Suspended composer: editor is unmounted or hidden, so inserting
// a mention would write into a Slate instance the user can't see
// and the focus call would no-op silently. RoomTimeline passes
// `threadDrawerOpen` for this — drawer always passes `false`.
if (composerSuspended) return;
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
// eslint-disable-next-line no-console
console.warn('Button should have "data-user-id" attribute!');
return;
}
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId()
)
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor, composerSuspended]
);
const handleReplyClick = useCallback(
(evt: MouseEvent<HTMLButtonElement>, startThread = false) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) {
// eslint-disable-next-line no-console
console.warn('Button should have "data-event-id" attribute!');
return;
}
const replyEvt = room.findEventById(replyId);
if (!replyEvt) return;
// Channels: «Reply in Thread» opens the right-side drawer instead
// of stuffing a thread relation into the channel composer's reply
// draft. Bridged rooms (Telegram puppets etc.) have no thread
// semantic on the bridge side — fall back to the legacy
// m.in_reply_to draft path so messages still go through.
if (
startThread &&
channelsMode &&
!isBridged &&
spaceIdOrAlias &&
roomIdOrAlias
) {
// useParams returns the encoded URL value; getChannelsThreadPath
// re-encodes via generatePath so we decode once first to avoid
// double-encoding (matches `routeParent.ts` decode pattern).
let decodedSpace: string;
let decodedRoom: string;
try {
decodedSpace = decodeURIComponent(spaceIdOrAlias);
decodedRoom = decodeURIComponent(roomIdOrAlias);
} catch {
decodedSpace = spaceIdOrAlias;
decodedRoom = roomIdOrAlias;
}
navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId));
return;
}
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[
room,
setReplyDraft,
editor,
channelsMode,
isBridged,
navigate,
spaceIdOrAlias,
roomIdOrAlias,
]
);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
if (myReaction && !!myReaction?.isRelation()) {
mx.redactEvent(room.roomId, myReaction.getId()!);
return;
}
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent(
room.roomId,
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode)
);
},
[mx, room]
);
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor]
);
return {
editId,
handleEdit,
handleOpenReply,
handleUserClick,
handleUsernameClick,
handleReplyClick,
handleReactionToggle,
};
}