feat(channels): M4a drawer rich chrome with edit menu reactions and reply affordances
This commit is contained in:
parent
e80453785e
commit
896a2e2083
6 changed files with 670 additions and 321 deletions
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './Reactions';
|
||||
export * from './Message';
|
||||
export * from './EncryptedContent';
|
||||
export * from './useMessageInteractionHandlers';
|
||||
|
|
|
|||
266
src/app/features/room/message/useMessageInteractionHandlers.ts
Normal file
266
src/app/features/room/message/useMessageInteractionHandlers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue