feat(channels): M4a drawer rich chrome with edit menu reactions and reply affordances
This commit is contained in:
parent
01107c0656
commit
dd55900dd6
6 changed files with 670 additions and 321 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
/* eslint-disable react/destructuring-assignment */
|
/* eslint-disable react/destructuring-assignment */
|
||||||
import React, {
|
import React, {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
MouseEventHandler,
|
|
||||||
RefObject,
|
RefObject,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -16,7 +15,6 @@ import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
EventTimelineSet,
|
EventTimelineSet,
|
||||||
EventTimelineSetHandlerMap,
|
EventTimelineSetHandlerMap,
|
||||||
IContent,
|
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
Room,
|
Room,
|
||||||
|
|
@ -25,7 +23,6 @@ import {
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ReactEditor } from 'slate-react';
|
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
|
|
@ -44,8 +41,8 @@ import {
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
|
@ -80,7 +77,6 @@ import {
|
||||||
getEventReactions,
|
getEventReactions,
|
||||||
getLatestEditableEvt,
|
getLatestEditableEvt,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
getReactionContent,
|
|
||||||
isBridgedRoom,
|
isBridgedRoom,
|
||||||
isMembershipChanged,
|
isMembershipChanged,
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
|
|
@ -88,7 +84,13 @@ import {
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
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 { ThreadSummaryCard } from './ThreadSummaryCard';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||||
|
|
@ -102,7 +104,7 @@ import { useDebounce } from '../../hooks/useDebounce';
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import * as css from './RoomTimeline.css';
|
import * as css from './RoomTimeline.css';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
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 { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
|
|
@ -122,9 +124,6 @@ import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||||
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
|
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 { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
|
|
@ -515,12 +514,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
// drawer to a different thread root.
|
// drawer to a different thread root.
|
||||||
const threadDrawerOpen = useThreadDrawerOpen();
|
const threadDrawerOpen = useThreadDrawerOpen();
|
||||||
const hideMainReplyAffordance = threadDrawerOpen;
|
const hideMainReplyAffordance = threadDrawerOpen;
|
||||||
// Captured for the channels-mode «Reply in Thread» button — the
|
// Captured for the channels-mode «Reply in Thread» branch inside
|
||||||
// handler turns a click on a row into a navigate(thread URL) instead
|
// useMessageInteractionHandlers — re-encoded via generatePath so
|
||||||
// of writing a thread reply-draft into the channel composer. Decoded
|
// passing the raw (already URL-encoded) values from useParams is fine.
|
||||||
// params are pre-encoded by react-router; getChannelsThreadPath
|
|
||||||
// re-encodes via generatePath so passing the raw URL value is fine.
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
|
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
|
||||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
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 canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
const [editId, setEditId] = useState<string>();
|
|
||||||
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
const spoilerClickHandler = useSpoilerClickHandler();
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
|
||||||
const space = useSpaceOptionally();
|
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
|
|
@ -823,6 +816,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
[room, timeline, scrollToItem, loadEventTimeline]
|
[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(
|
useLiveTimelineRefresh(
|
||||||
room,
|
room,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|
@ -970,11 +983,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
);
|
);
|
||||||
const editableEvtId = editableEvt?.getId();
|
const editableEvtId = editableEvt?.getId();
|
||||||
if (!editableEvtId) return;
|
if (!editableEvtId) return;
|
||||||
setEditId(editableEvtId);
|
handleEdit(editableEvtId);
|
||||||
evt.preventDefault();
|
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);
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const renderMatrixEvent = useMatrixEventRenderer<
|
const renderMatrixEvent = useMatrixEventRenderer<
|
||||||
|
|
|
||||||
|
|
@ -48,32 +48,20 @@ export const ThreadDrawerScroll = style({
|
||||||
position: 'relative',
|
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({
|
export const ThreadDrawerContent = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: config.space.S400,
|
gap: config.space.S400,
|
||||||
padding: `${config.space.S400} ${config.space.S400}`,
|
padding: `${config.space.S700} ${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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ThreadDivider = style({
|
export const ThreadDivider = style({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Header,
|
Header,
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
EventType,
|
EventType,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
MsgType,
|
|
||||||
RelationType,
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
Thread,
|
Thread,
|
||||||
|
|
@ -39,21 +38,48 @@ import { markAsThreadRead } from '../../utils/notifications';
|
||||||
import { useEditor } from '../../components/editor';
|
import { useEditor } from '../../components/editor';
|
||||||
import {
|
import {
|
||||||
getEditedEvent,
|
getEditedEvent,
|
||||||
getMemberAvatarMxc,
|
getEventReactions,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
isBridgedRoom,
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import {
|
||||||
import { RedactedContent, Time } from '../../components/message';
|
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 { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||||
|
import {
|
||||||
|
EncryptedContent,
|
||||||
|
Message,
|
||||||
|
Reactions,
|
||||||
|
useMessageInteractionHandlers,
|
||||||
|
} from './message';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
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 { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
|
|
@ -63,7 +89,7 @@ import {
|
||||||
makeMentionCustomProps,
|
makeMentionCustomProps,
|
||||||
renderMatrixMention,
|
renderMatrixMention,
|
||||||
} from '../../plugins/react-custom-html-parser';
|
} 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
|
// IntersectionObserver ratio at which the bottom sentinel still counts
|
||||||
// as «at the bottom of the replies list». 0.9 lets a few pixels of
|
// as «at the bottom of the replies list». 0.9 lets a few pixels of
|
||||||
|
|
@ -104,98 +130,12 @@ type ThreadDrawerProps = {
|
||||||
variant: 'desktop' | 'mobile';
|
variant: 'desktop' | 'mobile';
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThreadEventCardProps = {
|
// M4a: thread events render through the shared `<Message>` component
|
||||||
room: Room;
|
// — same hover/menu/edit/reactions/reply chrome as the main timeline,
|
||||||
mEvent: MatrixEvent;
|
// `'channel'` layout (avatar + name + body without bubble). Root and
|
||||||
htmlReactParserOptions: HTMLReactParserOptions;
|
// replies share the render path so editing/reactions/redact work
|
||||||
linkifyOpts: LinkifyOpts;
|
// identically for both, matching Slack/Element-web and removing the
|
||||||
mediaAutoLoad: boolean;
|
// pre-M4a fork between `ThreadEventCard` and `<Message>`.
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drawer body — handles all the loading / error states the plan calls
|
// Drawer body — handles all the loading / error states the plan calls
|
||||||
// out (null Thread, fetchRoomEvent failure, paginate failure, post-load
|
// out (null Thread, fetchRoomEvent failure, paginate failure, post-load
|
||||||
|
|
@ -269,6 +209,86 @@ export function ThreadDrawer({
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
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 [thread, setThread] = useState<Thread | null>(() => room.getThread(rootId));
|
||||||
const [rootEvent, setRootEvent] = useState<MatrixEvent | null>(
|
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
|
// Read receipts (M4): fired on first reveal at-bottom, on reply
|
||||||
// growth while at-bottom, and on own send via the layout/effect
|
// growth while at-bottom, and on own send via the layout/effect
|
||||||
// pair below. The receipt handler in `state/room/roomToUnread.ts`
|
// pair below. The receipt handler in `state/room/roomToUnread.ts`
|
||||||
|
|
@ -794,6 +851,187 @@ export function ThreadDrawer({
|
||||||
navigate(parentRoomPath, { replace: true });
|
navigate(parentRoomPath, { replace: true });
|
||||||
}, [navigate, parentRoomPath]);
|
}, [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 = () => {
|
const renderBody = () => {
|
||||||
if (rootError) {
|
if (rootError) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -823,14 +1061,7 @@ export function ThreadDrawer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.ThreadDrawerContent}>
|
<div className={css.ThreadDrawerContent}>
|
||||||
<ThreadEventCard
|
{renderThreadEvent(rootEvent)}
|
||||||
room={room}
|
|
||||||
mEvent={rootEvent}
|
|
||||||
htmlReactParserOptions={htmlReactParserOptions}
|
|
||||||
linkifyOpts={linkifyOpts}
|
|
||||||
mediaAutoLoad={mediaAutoLoad}
|
|
||||||
showUrlPreview={showUrlPreview}
|
|
||||||
/>
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Counter prefers the larger of materialized `thread.length`
|
// Counter prefers the larger of materialized `thread.length`
|
||||||
// (replyCount + pending) and loaded `replies.length` — covers
|
// (replyCount + pending) and loaded `replies.length` — covers
|
||||||
|
|
@ -899,17 +1130,7 @@ export function ThreadDrawer({
|
||||||
<Spinner size="200" />
|
<Spinner size="200" />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{replies.map((reply) => (
|
{replies.map((reply) => renderThreadEvent(reply))}
|
||||||
<ThreadEventCard
|
|
||||||
key={reply.getId()}
|
|
||||||
room={room}
|
|
||||||
mEvent={reply}
|
|
||||||
htmlReactParserOptions={htmlReactParserOptions}
|
|
||||||
linkifyOpts={linkifyOpts}
|
|
||||||
mediaAutoLoad={mediaAutoLoad}
|
|
||||||
showUrlPreview={showUrlPreview}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div ref={setBottomSentinel} />
|
<div ref={setBottomSentinel} />
|
||||||
</div>
|
</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])
|
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './Reactions';
|
export * from './Reactions';
|
||||||
export * from './Message';
|
export * from './Message';
|
||||||
export * from './EncryptedContent';
|
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