diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 857d13d3..51e2b224 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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(); 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 = 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 = 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 = 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< diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 16ecfd1b..e7995430 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -48,32 +48,20 @@ export const ThreadDrawerScroll = style({ position: 'relative', }); +// M4a: top padding bumped from S400 (16px) to S700 (32px). ``'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({ diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 8548cc62..7e38050f 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -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 ( -
-
- - } - /> - -
-
- - - {senderDisplayName} - - - {mEvent.isRedacted() ? ( - - ) : ( - - )} - {editedEvent && !mEvent.isRedacted() && ( - - {t('Room.edited')} - - )} -
-
- ); -} +// M4a: thread events render through the shared `` 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 ``. // 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( + `[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 `` instead of `` (see + // bottom of this component). In that branch the Slate `` + // 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(() => room.getThread(rootId)); const [rootEvent, setRootEvent] = useState( @@ -617,6 +637,43 @@ export function ThreadDrawer({ }) : []; + // M4a: mount-transition trigger for `` 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 `` 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 » 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 ( + + ) + } + reactions={ + reactionRelations && ( + + ) + } + 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 ( + + ); + } + if (eventType === MessageEvent.Sticker) { + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + } + if (eventType === MessageEvent.RoomMessage) { + const editedEvent = getEditedEvent(eventId, mEvent, timelineSet); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? + mEvent.getContent()) as GetContentCallback; + return ( + + ); + } + if (eventType === MessageEvent.RoomMessageEncrypted) { + return ( + + + + ); + } + return ( + + + + ); + })()} + + ); + }; + + if (isEncrypted) { + return ( + + {renderMessage} + + ); + } + return renderMessage(); + }; + const renderBody = () => { if (rootError) { return ( @@ -823,14 +1061,7 @@ export function ThreadDrawer({ return (
- + {renderThreadEvent(rootEvent)} {(() => { // Counter prefers the larger of materialized `thread.length` // (replyCount + pending) and loaded `replies.length` — covers @@ -899,17 +1130,7 @@ export function ThreadDrawer({ )} - {replies.map((reply) => ( - - ))} + {replies.map((reply) => renderThreadEvent(reply))}
); diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 9a7567aa..8ab6afb0 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -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]) ); diff --git a/src/app/features/room/message/index.ts b/src/app/features/room/message/index.ts index d79e2aab..2cffc670 100644 --- a/src/app/features/room/message/index.ts +++ b/src/app/features/room/message/index.ts @@ -1,3 +1,4 @@ export * from './Reactions'; export * from './Message'; export * from './EncryptedContent'; +export * from './useMessageInteractionHandlers'; diff --git a/src/app/features/room/message/useMessageInteractionHandlers.ts b/src/app/features/room/message/useMessageInteractionHandlers.ts new file mode 100644 index 00000000..977ee443 --- /dev/null +++ b/src/app/features/room/message/useMessageInteractionHandlers.ts @@ -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; + handleUsernameClick: MouseEventHandler; + handleReplyClick: ( + ev: MouseEvent, + startThread?: boolean + ) => void; + handleReactionToggle: ( + targetEventId: string, + key: string, + shortcode?: string + ) => void; +}; + +// Wiring layer for `` 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(); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + onOpenEvent(targetId); + }, + [onOpenEvent] + ); + + const handleUserClick: MouseEventHandler = 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 = 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, 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, + }; +}