vojo/src/app/features/room/message/useMessageInteractionHandlers.ts

242 lines
9.3 KiB
TypeScript

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<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 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,
};
}