242 lines
9.3 KiB
TypeScript
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,
|
|
};
|
|
}
|