import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Editor } from 'slate'; import { ReactEditor } from 'slate-react'; import { EventType, IContent, Room } from 'matrix-js-sdk'; import type { ReactionEventContent } from 'matrix-js-sdk/lib/types'; 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 { 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 myUserId = mx.getUserId(); const myReaction = myUserId ? reactions.find(factoryEventSentBy(myUserId)) : undefined; if (myReaction && !!myReaction?.isRelation()) { const reactionId = myReaction.getId(); if (reactionId) mx.redactEvent(room.roomId, reactionId); return; } const rShortcode = shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); mx.sendEvent( room.roomId, EventType.Reaction, getReactionContent(targetEventId, key, rShortcode) as ReactionEventContent ); }, [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, }; }