CI / Build & Quality Checks (push) Successful in 4m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 11m58s
CI / Trigger Desktop Build (push) Successful in 8s
Thread images opened the room media lightbox, which looks the event up in the room's detached media timeline. Thread replies never reach that timeline, so a thread image always showed "1 / 1" with no prev/next, after paging the room's media up to six times. The thread panel now builds the viewer's items from its own root + loaded replies, and "Go to message" scrolls the thread panel instead of the room. The viewer gains a "Copy image" button: fetches the displayed media (blob URL for E2EE, authenticated URL otherwise), re-encodes to PNG when needed, and writes it via ClipboardItem with a promise so Safari keeps the click's user activation. Hidden where ClipboardItem is missing. No "open in new tab": an E2EE blob URL is revoked when the viewer closes and authenticated media 401s in a bare tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
1097 lines
38 KiB
TypeScript
1097 lines
38 KiB
TypeScript
import React, {
|
|
Dispatch,
|
|
MouseEventHandler,
|
|
ReactNode,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Direction,
|
|
EventStatus,
|
|
EventTimeline,
|
|
EventTimelineSet,
|
|
EventTimelineSetHandlerMap,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
RelationType,
|
|
Room,
|
|
RoomEvent,
|
|
Thread,
|
|
ThreadEvent,
|
|
} from 'matrix-js-sdk';
|
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
|
import { Editor } from 'slate';
|
|
import { ReactEditor } from 'slate-react';
|
|
import to from 'await-to-js';
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
|
import classNames from 'classnames';
|
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
|
import { useAlive } from '../../../hooks/useAlive';
|
|
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
|
|
import { getForwardedMeta } from '../message/forwardContent';
|
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
|
import {
|
|
DefaultPlaceholder,
|
|
MessageBase,
|
|
Reply,
|
|
ForwardedHeader,
|
|
RedactedContent,
|
|
MSticker,
|
|
MessageUnsupportedContent,
|
|
MessageNotDecryptedContent,
|
|
ImageContent,
|
|
} from '../../../components/message';
|
|
import {
|
|
factoryRenderLinkifyWithMention,
|
|
getReactCustomHtmlParser,
|
|
LINKIFY_OPTS,
|
|
makeMentionCustomProps,
|
|
renderMatrixMention,
|
|
} from '../../../plugins/react-custom-html-parser';
|
|
import {
|
|
canEditEvent,
|
|
decryptAllTimelineEvent,
|
|
getEditedEvent,
|
|
getEventReactions,
|
|
getLatestEditableEvt,
|
|
getMemberName,
|
|
getReactionContent,
|
|
reactionOrEditEvent,
|
|
sendRoomEvent,
|
|
} from '../../../utils/room';
|
|
import { useSetting } from '../../../state/hooks/settings';
|
|
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
|
import { Message, Reactions, EncryptedContent } from '../message';
|
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
|
import { Lightbox, toLightboxItems } from '../MediaGallery';
|
|
import { Image } from '../../../components/media';
|
|
import { ImageViewer } from '../../../components/image-viewer';
|
|
import * as css from './ThreadTimeline.css';
|
|
import { inSameDay, minuteDifference } from '../../../utils/time';
|
|
import { formatDayDivider } from '../../../utils/formatTimestamp';
|
|
import { tick } from '../../../utils/haptics';
|
|
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
|
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
|
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
|
import {
|
|
getIntersectionObserverEntry,
|
|
useIntersectionObserver,
|
|
} from '../../../hooks/useIntersectionObserver';
|
|
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
|
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
|
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
|
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';
|
|
import { useTheme } from '../../../hooks/useTheme';
|
|
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
|
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
|
import { EditHistoryModal } from '../message/EditHistoryModal';
|
|
import {
|
|
getLinkedTimelines,
|
|
getTimelineAndBaseIndex,
|
|
getTimelineEvent,
|
|
getTimelineRelativeIndex,
|
|
getTimelinesEventsCount,
|
|
timelineToEventsCount,
|
|
} from '../RoomTimeline';
|
|
import { getThreadDraftKey } from '../../../state/room/thread';
|
|
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
|
|
|
|
// Virtual window size (how many items render around the viewport).
|
|
const PAGINATION_LIMIT = 50;
|
|
// Network page size for backward /relations pagination of the thread timeline.
|
|
const THREAD_PAGE_LIMIT = 30;
|
|
|
|
type Timeline = {
|
|
linkedTimelines: EventTimeline[];
|
|
range: ItemRange;
|
|
};
|
|
|
|
const getEmptyTimeline = (): Timeline => ({
|
|
linkedTimelines: [],
|
|
range: { start: 0, end: 0 },
|
|
});
|
|
|
|
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
|
|
const linkedTimelines =
|
|
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
|
|
const evLength = getTimelinesEventsCount(linkedTimelines);
|
|
return {
|
|
linkedTimelines,
|
|
range: {
|
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
|
end: evLength,
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
|
|
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
|
|
* the thread timeline's /relations pagination.
|
|
*/
|
|
const useThreadTimelinePagination = (
|
|
mx: MatrixClient,
|
|
timeline: Timeline,
|
|
setTimeline: Dispatch<SetStateAction<Timeline>>,
|
|
limit: number,
|
|
) => {
|
|
const timelineRef = useRef(timeline);
|
|
timelineRef.current = timeline;
|
|
const alive = useAlive();
|
|
|
|
const handleTimelinePagination = useMemo(() => {
|
|
let fetching = false;
|
|
|
|
const recalibratePagination = (
|
|
linkedTimelines: EventTimeline[],
|
|
timelinesEventsCount: number[],
|
|
backwards: boolean,
|
|
) => {
|
|
const topTimeline = linkedTimelines[0];
|
|
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
|
|
|
|
const newLTimelines = getLinkedTimelines(topTimeline);
|
|
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
|
|
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
|
|
|
|
const topTmAddedEvt =
|
|
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
|
|
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
|
|
|
|
setTimeline((currentTimeline) => ({
|
|
linkedTimelines: newLTimelines,
|
|
range:
|
|
offsetRange > 0
|
|
? {
|
|
start: currentTimeline.range.start + offsetRange,
|
|
end: currentTimeline.range.end + offsetRange,
|
|
}
|
|
: { ...currentTimeline.range },
|
|
}));
|
|
};
|
|
|
|
return async (backwards: boolean) => {
|
|
if (fetching) return;
|
|
const { linkedTimelines: lTimelines } = timelineRef.current;
|
|
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
|
|
|
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
|
|
if (!timelineToPaginate) return;
|
|
|
|
const paginationToken = timelineToPaginate.getPaginationToken(
|
|
backwards ? Direction.Backward : Direction.Forward,
|
|
);
|
|
if (
|
|
!paginationToken &&
|
|
getTimelinesEventsCount(lTimelines) !==
|
|
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
|
|
) {
|
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
|
return;
|
|
}
|
|
|
|
fetching = true;
|
|
const [err] = await to(
|
|
mx.paginateEventTimeline(timelineToPaginate, {
|
|
backwards,
|
|
limit,
|
|
}),
|
|
);
|
|
if (err) {
|
|
fetching = false;
|
|
return;
|
|
}
|
|
const fetchedTimeline =
|
|
timelineToPaginate.getNeighbouringTimeline(
|
|
backwards ? Direction.Backward : Direction.Forward,
|
|
) ?? timelineToPaginate;
|
|
// Decrypt all event ahead of render cycle
|
|
const roomId = fetchedTimeline.getRoomId();
|
|
const room = roomId ? mx.getRoom(roomId) : null;
|
|
|
|
if (room?.hasEncryptionStateEvent()) {
|
|
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
|
}
|
|
|
|
fetching = false;
|
|
if (alive()) {
|
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
|
}
|
|
};
|
|
}, [mx, alive, setTimeline, limit]);
|
|
return handleTimelinePagination;
|
|
};
|
|
|
|
export type ThreadTimelineProps = {
|
|
room: Room;
|
|
thread: Thread;
|
|
editor: Editor;
|
|
};
|
|
|
|
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|
const mx = useMatrixClient();
|
|
const alive = useAlive();
|
|
const useAuthentication = useMediaAuthentication();
|
|
|
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
|
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
|
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
|
|
|
const direct = useIsDirectRoom();
|
|
const ignoredUsersList = useIgnoredUsers();
|
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
|
|
|
const setReplyDraft = useSetAtom(
|
|
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
|
|
);
|
|
|
|
const powerLevels = usePowerLevelsContext();
|
|
const creators = useRoomCreators(room);
|
|
const creatorsTag = useRoomCreatorsTag();
|
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
|
|
|
const theme = useTheme();
|
|
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
|
theme.kind,
|
|
creatorsTag,
|
|
powerLevelTags,
|
|
);
|
|
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|
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());
|
|
|
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
|
const spoilerClickHandler = useSpoilerClickHandler();
|
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
|
const space = useSpaceOptionally();
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
|
|
|
const [editId, setEditId] = useState<string>();
|
|
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
|
// [Gitea #219] Thread images open the shared media lightbox too.
|
|
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
|
|
|
|
const linkifyOpts = useMemo<LinkifyOpts>(
|
|
() => ({
|
|
...LINKIFY_OPTS,
|
|
render: factoryRenderLinkifyWithMention((href) =>
|
|
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
|
|
),
|
|
}),
|
|
[mx, room, mentionClickHandler],
|
|
);
|
|
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
|
() =>
|
|
getReactCustomHtmlParser(mx, room.roomId, {
|
|
linkifyOpts,
|
|
useAuthentication,
|
|
handleSpoilerClick: spoilerClickHandler,
|
|
handleMentionClick: mentionClickHandler,
|
|
}),
|
|
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
|
|
);
|
|
|
|
const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
|
|
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
|
|
|
|
const [timeline, setTimeline] = useState<Timeline>(() =>
|
|
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
|
|
);
|
|
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
|
|
|
const canPaginateBack =
|
|
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
|
|
const rangeAtStart = timeline.range.start === 0;
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
|
const [atBottom, setAtBottom] = useState(true);
|
|
const atBottomRef = useRef(atBottom);
|
|
atBottomRef.current = atBottom;
|
|
const scrollToBottomRef = useRef({ count: 0, smooth: true });
|
|
|
|
const handleTimelinePagination = useThreadTimelinePagination(
|
|
mx,
|
|
timeline,
|
|
setTimeline,
|
|
THREAD_PAGE_LIMIT,
|
|
);
|
|
|
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
|
|
|
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
|
|
count: eventsLength,
|
|
limit: PAGINATION_LIMIT,
|
|
range: timeline.range,
|
|
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
|
getScrollElement,
|
|
getItemElement: useCallback(
|
|
(index: number) =>
|
|
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
|
undefined,
|
|
[],
|
|
),
|
|
onEnd: handleTimelinePagination,
|
|
});
|
|
|
|
// Seed local timeline once the thread has fetched its initial events.
|
|
const seededRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!ready || seededRef.current) return;
|
|
seededRef.current = true;
|
|
setTimeline(getInitialThreadTimeline(thread, timelines));
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = false;
|
|
if (room.hasEncryptionStateEvent()) {
|
|
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
|
|
if (alive()) setTimeline((ct) => ({ ...ct }));
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
|
|
}, [ready, thread]);
|
|
|
|
// Re-render / stick-to-bottom on live thread activity.
|
|
useEffect(() => {
|
|
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
|
mEvent,
|
|
eventRoom,
|
|
toStartOfTimeline,
|
|
removed,
|
|
data,
|
|
) => {
|
|
if (!data?.liveEvent) return;
|
|
if (atBottomRef.current) {
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = true;
|
|
setTimeline((ct) => ({
|
|
...ct,
|
|
range: {
|
|
start: ct.range.start + 1,
|
|
end: ct.range.end + 1,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
setTimeline((ct) => ({ ...ct }));
|
|
};
|
|
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
|
|
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
|
|
// the stored linkedTimelines would then point at a detached timeline, so
|
|
// reseed the window from the fresh liveTimeline.
|
|
const handleReset = () => {
|
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = false;
|
|
};
|
|
|
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
|
thread.on(ThreadEvent.Update, handleUpdate);
|
|
thread.on(RoomEvent.TimelineReset, handleReset);
|
|
return () => {
|
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
|
thread.removeListener(ThreadEvent.Update, handleUpdate);
|
|
thread.removeListener(RoomEvent.TimelineReset, handleReset);
|
|
};
|
|
}, [thread]);
|
|
|
|
// atBottom detection
|
|
useIntersectionObserver(
|
|
useCallback((entries) => {
|
|
const target = atBottomAnchorRef.current;
|
|
if (!target) return;
|
|
const entry = getIntersectionObserverEntry(target, entries);
|
|
if (entry) setAtBottom(entry.isIntersecting);
|
|
}, []),
|
|
useCallback(
|
|
() => ({
|
|
root: getScrollElement(),
|
|
rootMargin: '100px',
|
|
}),
|
|
[getScrollElement],
|
|
),
|
|
useCallback(() => atBottomAnchorRef.current, []),
|
|
);
|
|
|
|
// Initial scroll to bottom on mount.
|
|
useLayoutEffect(() => {
|
|
const scrollEl = scrollRef.current;
|
|
if (scrollEl) scrollToBottom(scrollEl);
|
|
}, []);
|
|
|
|
// Scroll to bottom when requested.
|
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
|
useLayoutEffect(() => {
|
|
if (scrollToBottomCount > 0) {
|
|
const scrollEl = scrollRef.current;
|
|
if (scrollEl)
|
|
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
|
}
|
|
}, [scrollToBottomCount]);
|
|
|
|
const handleJumpToBottom = useCallback(() => {
|
|
// Re-anchor the virtual window at the thread tail first. While scrolled up,
|
|
// live replies deliberately don't extend the window, so without this the chip
|
|
// would scroll to the bottom of the STALE window (a mid/old event) instead of
|
|
// the newest reply. Mirrors the main timeline's handleJumpToLatest.
|
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = true;
|
|
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
|
// events resume sticking to the bottom.
|
|
setAtBottom(true);
|
|
}, [thread]);
|
|
|
|
// Scroll in-place editor into view.
|
|
useEffect(() => {
|
|
if (editId) {
|
|
const editMsgElement =
|
|
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
|
undefined;
|
|
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
}
|
|
}, [editId]);
|
|
|
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
(evt) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
|
if (!userId) return;
|
|
openUserRoomProfile(
|
|
room.roomId,
|
|
space?.roomId,
|
|
userId,
|
|
evt.currentTarget.getBoundingClientRect(),
|
|
);
|
|
},
|
|
[room, space, openUserRoomProfile],
|
|
);
|
|
|
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
(evt) => {
|
|
evt.preventDefault();
|
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
|
if (!userId) return;
|
|
const name = getMemberName(room, userId);
|
|
editor.insertNode(
|
|
createMentionElement(
|
|
userId,
|
|
name.startsWith('@') ? name : `@${name}`,
|
|
userId === mx.getUserId(),
|
|
),
|
|
);
|
|
ReactEditor.focus(editor);
|
|
moveCursor(editor);
|
|
},
|
|
[mx, room, editor],
|
|
);
|
|
|
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
(evt) => {
|
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
|
if (!replyId) return;
|
|
const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
|
|
if (!replyEvt) return;
|
|
const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
|
|
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
|
const { body, formatted_body: formattedBody } = content;
|
|
const senderId = replyEvt.getSender();
|
|
if (senderId && typeof body === 'string') {
|
|
setReplyDraft({
|
|
userId: senderId,
|
|
eventId: replyId,
|
|
body,
|
|
formattedBody,
|
|
relation: { rel_type: RelationType.Thread, event_id: thread.id },
|
|
});
|
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
|
}
|
|
},
|
|
[room, thread, setReplyDraft, editor],
|
|
);
|
|
|
|
// Non-thread relations (reactions, edits) that target the thread root live only in
|
|
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
|
|
// for the root must use the room set instead of the thread set.
|
|
const getRelationTimelineSet = useCallback(
|
|
(eventId: string) =>
|
|
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
|
|
[room, thread],
|
|
);
|
|
|
|
const handleReactionToggle = useCallback(
|
|
(targetEventId: string, key: string, shortcode?: string) => {
|
|
const isRoot = targetEventId === thread.id;
|
|
const timelineSet = getRelationTimelineSet(targetEventId);
|
|
const relations = getEventReactions(timelineSet, 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);
|
|
tick('reaction', hapticFeedback);
|
|
sendRoomEvent(
|
|
mx,
|
|
room.roomId,
|
|
// A reaction on the root is a main-timeline event, not a thread reply.
|
|
isRoot ? null : thread.id,
|
|
MessageEvent.Reaction,
|
|
getReactionContent(targetEventId, key, rShortcode),
|
|
);
|
|
},
|
|
[mx, room, thread, getRelationTimelineSet, hapticFeedback],
|
|
);
|
|
|
|
const handleEdit = useCallback(
|
|
(editEvtId?: string) => {
|
|
if (editEvtId) {
|
|
setEditId(editEvtId);
|
|
return;
|
|
}
|
|
setEditId(undefined);
|
|
ReactEditor.focus(editor);
|
|
},
|
|
[editor],
|
|
);
|
|
|
|
// Up-arrow in the (empty) thread composer edits your last editable reply in
|
|
// this thread — parity with the main timeline. Gated on the thread composer's
|
|
// distinct editableName so it and the main handler never cross-fire.
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(evt) => {
|
|
if (
|
|
isKeyHotkey('arrowup', evt) &&
|
|
editableActiveElement() &&
|
|
document.activeElement?.getAttribute('data-editable-name') === 'ThreadInput' &&
|
|
isEmptyEditor(editor)
|
|
) {
|
|
const editableEvt = getLatestEditableEvt(thread.liveTimeline, (mEvt) =>
|
|
canEditEvent(mx, mEvt),
|
|
);
|
|
const editableEvtId = editableEvt?.getId();
|
|
if (!editableEvtId) return;
|
|
setEditId(editableEvtId);
|
|
evt.preventDefault();
|
|
}
|
|
},
|
|
[mx, thread, editor],
|
|
),
|
|
);
|
|
|
|
// best-effort: scroll to an event if it is inside the loaded thread window
|
|
const scrollToThreadEvent = useCallback(
|
|
(targetId: string) => {
|
|
let absIndex = -1;
|
|
let acc = 0;
|
|
timeline.linkedTimelines.some((tl) => {
|
|
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
|
|
if (idx !== -1) {
|
|
absIndex = acc + idx;
|
|
return true;
|
|
}
|
|
acc += tl.getEvents().length;
|
|
return false;
|
|
});
|
|
if (absIndex >= 0) {
|
|
scrollToItem(absIndex, {
|
|
behavior: 'smooth',
|
|
align: 'center',
|
|
stopInView: true,
|
|
});
|
|
}
|
|
},
|
|
[timeline.linkedTimelines, scrollToItem],
|
|
);
|
|
|
|
const handleOpenReply: MouseEventHandler = useCallback(
|
|
(evt) => {
|
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
|
if (targetId) scrollToThreadEvent(targetId);
|
|
},
|
|
[scrollToThreadEvent],
|
|
);
|
|
|
|
// [Gitea #164] The viewer steps through this thread's media (root + loaded
|
|
// replies). Thread replies never reach the room's media timeline, so the
|
|
// room lightbox could only ever show a thread image as "1 / 1".
|
|
const lightboxItems = useMemo(() => {
|
|
if (!lightboxEventId) return [];
|
|
const seen = new Set<string>();
|
|
const events = [thread.rootEvent, ...timeline.linkedTimelines.flatMap((tl) => tl.getEvents())]
|
|
.filter((ev): ev is MatrixEvent => !!ev)
|
|
.filter((ev) => {
|
|
const id = ev.getId();
|
|
if (!id || seen.has(id) || ev.isRedacted()) return false;
|
|
seen.add(id);
|
|
return true;
|
|
});
|
|
return toLightboxItems(room, events);
|
|
}, [lightboxEventId, thread.rootEvent, timeline.linkedTimelines, room]);
|
|
const lightboxIndex = lightboxItems.findIndex((it) => it.eventId === lightboxEventId);
|
|
const handleLightboxJump = useCallback(
|
|
(id: string) => {
|
|
setLightboxEventId(undefined);
|
|
scrollToThreadEvent(id);
|
|
},
|
|
[scrollToThreadEvent],
|
|
);
|
|
|
|
const renderMessageContent = useCallback(
|
|
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
|
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
|
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
|
|
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
|
|
// live-arriving encrypted reply would show "Unable to decrypt" forever.
|
|
const renderByType = (): ReactNode => {
|
|
if (mEvent.isRedacted()) {
|
|
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
|
|
}
|
|
const type = mEvent.getType();
|
|
if (type === MessageEvent.Sticker) {
|
|
return (
|
|
<MSticker
|
|
content={mEvent.getContent()}
|
|
renderImageContent={(props) => (
|
|
<ImageContent
|
|
{...props}
|
|
autoPlay={mediaAutoLoad}
|
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
|
renderViewer={(p) => <ImageViewer {...p} />}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
if (type === MessageEvent.RoomMessageEncrypted) {
|
|
return (
|
|
<Text>
|
|
<MessageNotDecryptedContent />
|
|
</Text>
|
|
);
|
|
}
|
|
if (type !== MessageEvent.RoomMessage) {
|
|
return (
|
|
<Text>
|
|
<MessageUnsupportedContent />
|
|
</Text>
|
|
);
|
|
}
|
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
|
const getContent = (() =>
|
|
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderDisplayName = getMemberName(room, senderId);
|
|
return (
|
|
<RenderMessageContent
|
|
displayName={senderDisplayName}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
ts={mEvent.getTs()}
|
|
edited={!!editedEvent}
|
|
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
|
getContent={getContent}
|
|
mediaAutoLoad={mediaAutoLoad}
|
|
urlPreview={showUrlPreview}
|
|
htmlReactParserOptions={htmlReactParserOptions}
|
|
linkifyOpts={linkifyOpts}
|
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
|
eventId={mEventId}
|
|
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
|
mEvent={mEvent}
|
|
/>
|
|
);
|
|
};
|
|
|
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
|
|
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
|
|
}
|
|
return renderByType();
|
|
},
|
|
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
|
|
);
|
|
|
|
const renderMessage = useCallback(
|
|
(
|
|
mEvent: MatrixEvent,
|
|
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
|
|
): ReactNode => {
|
|
const mEventId = mEvent.getId();
|
|
if (!mEventId) return null;
|
|
const timelineSet = getRelationTimelineSet(mEventId);
|
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
|
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
|
const hasReactions = !!reactions && reactions.length > 0;
|
|
const { replyEventId } = mEvent;
|
|
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
|
// Inside the panel every reply carries the spec's *fallback* reply
|
|
// relation to the thread root (`is_falling_back: true`) so non-threaded
|
|
// clients can render it as a reply. Here the root is already at the top,
|
|
// so that quote — and the "Thread" chip — is pure noise on every row
|
|
// (Gitea #165). Only a genuine reply-to-a-reply keeps its quote.
|
|
const relation = mEvent.getWireContent()?.['m.relates_to'] as
|
|
| { is_falling_back?: boolean }
|
|
| undefined;
|
|
const genuineReplyId =
|
|
replyEventId && !(relation?.is_falling_back && replyEventId === thread.id)
|
|
? replyEventId
|
|
: undefined;
|
|
|
|
return (
|
|
<Message
|
|
key={mEventId}
|
|
data-message-item={opts.item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={opts.collapse}
|
|
highlight={opts.highlight}
|
|
edit={opts.editable && editId === mEventId}
|
|
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={opts.editable ? handleEdit : undefined}
|
|
reply={
|
|
<>
|
|
{forwardedMeta && (
|
|
<ForwardedHeader
|
|
mx={mx}
|
|
meta={forwardedMeta}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
|
/>
|
|
)}
|
|
{genuineReplyId && (
|
|
<Reply
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
replyEventId={genuineReplyId}
|
|
onClick={handleOpenReply}
|
|
getMemberPowerTag={getMemberPowerTag}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
lotusTerminal={!!lotusTerminal}
|
|
>
|
|
{renderMessageContent(mEvent, mEventId, timelineSet)}
|
|
</Message>
|
|
);
|
|
},
|
|
[
|
|
navigateRoom,
|
|
thread.id,
|
|
room,
|
|
messageSpacing,
|
|
messageLayout,
|
|
editId,
|
|
canRedact,
|
|
canDeleteOwn,
|
|
canSendReaction,
|
|
canPinEvent,
|
|
imagePackRooms,
|
|
handleUserClick,
|
|
handleUsernameClick,
|
|
handleReplyClick,
|
|
handleReactionToggle,
|
|
handleEdit,
|
|
handleOpenReply,
|
|
getMemberPowerTag,
|
|
accessiblePowerTagColors,
|
|
legacyUsernameColor,
|
|
direct,
|
|
hideActivity,
|
|
showDeveloperTools,
|
|
hour24Clock,
|
|
dateFormatString,
|
|
lotusTerminal,
|
|
mx,
|
|
renderMessageContent,
|
|
getRelationTimelineSet,
|
|
],
|
|
);
|
|
|
|
let prevEvent: MatrixEvent | undefined;
|
|
let isPrevRendered = false;
|
|
let dayDivider = false;
|
|
const eventRenderer = (item: number) => {
|
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
|
if (!eventTimeline) return null;
|
|
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
|
const mEventId = mEvent?.getId();
|
|
if (!mEvent || !mEventId) return null;
|
|
|
|
// Skip annotations, edits, and any state/membership events (they can't be threaded).
|
|
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
|
|
prevEvent = mEvent;
|
|
return null;
|
|
}
|
|
const eventSender = mEvent.getSender();
|
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
|
prevEvent = mEvent;
|
|
return null;
|
|
}
|
|
|
|
const isRoot = mEventId === thread.id;
|
|
|
|
if (!dayDivider) {
|
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
|
}
|
|
|
|
const collapsed =
|
|
!isRoot &&
|
|
!perMessageProfiles &&
|
|
isPrevRendered &&
|
|
!dayDivider &&
|
|
prevEvent !== undefined &&
|
|
prevEvent.getId() !== thread.id &&
|
|
prevEvent.getSender() === eventSender &&
|
|
prevEvent.getType() === mEvent.getType() &&
|
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
|
|
|
|
const eventJSX = renderMessage(mEvent, {
|
|
item,
|
|
collapse: collapsed,
|
|
highlight: false,
|
|
editable: true,
|
|
});
|
|
|
|
const dayDividerJSX =
|
|
dayDivider && eventJSX && !isRoot ? (
|
|
<MessageBase space={messageSpacing}>
|
|
<Box gap="100" justifyContent="Center" alignItems="Center">
|
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
|
<Text size="L400">
|
|
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
|
|
</Text>
|
|
</Badge>
|
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
|
</Box>
|
|
</MessageBase>
|
|
) : null;
|
|
|
|
prevEvent = mEvent;
|
|
isPrevRendered = !!eventJSX;
|
|
if (dayDividerJSX) dayDivider = false;
|
|
|
|
// Root gets an emphasized container + a "N replies" divider under it.
|
|
if (isRoot && eventJSX) {
|
|
const replyCount = thread.length;
|
|
return (
|
|
<React.Fragment key={mEventId}>
|
|
<div className={css.RootMessage}>{eventJSX}</div>
|
|
{replyCount > 0 && (
|
|
<Box
|
|
className={css.RepliesDivider}
|
|
gap="100"
|
|
justifyContent="Center"
|
|
alignItems="Center"
|
|
>
|
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
|
<Text size="L400" priority="300">
|
|
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
|
</Text>
|
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
|
</Box>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
if (eventJSX && dayDividerJSX) {
|
|
return (
|
|
<React.Fragment key={mEventId}>
|
|
{dayDividerJSX}
|
|
{eventJSX}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
return eventJSX;
|
|
};
|
|
|
|
const items = getItems();
|
|
const showEmptyReplies = ready && thread.length === 0;
|
|
|
|
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
|
const failed =
|
|
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
|
return (
|
|
<div
|
|
key={mEvent.getId() ?? mEvent.getTxnId()}
|
|
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
|
|
>
|
|
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
|
|
{failed && (
|
|
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
Failed to send
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (!ready) {
|
|
return (
|
|
<Box
|
|
className={css.ThreadCentered}
|
|
grow="Yes"
|
|
direction="Column"
|
|
justifyContent="Center"
|
|
alignItems="Center"
|
|
>
|
|
<Spinner variant="Secondary" size="600" />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box className={css.ThreadTimeline} grow="Yes">
|
|
<Scroll ref={scrollRef} visibility="Hover">
|
|
<Box
|
|
className={css.ThreadTimelineContent}
|
|
direction="Column"
|
|
justifyContent="End"
|
|
role="log"
|
|
aria-label="Thread timeline"
|
|
aria-live="polite"
|
|
>
|
|
{(canPaginateBack || !rangeAtStart) && (
|
|
<>
|
|
<MessageBase>
|
|
<DefaultPlaceholder />
|
|
</MessageBase>
|
|
<MessageBase ref={observeBackAnchor}>
|
|
<DefaultPlaceholder />
|
|
</MessageBase>
|
|
</>
|
|
)}
|
|
|
|
{items.map(eventRenderer)}
|
|
|
|
{showEmptyReplies && (
|
|
<Box className={css.NoReplies} justifyContent="Center">
|
|
<Text size="T300" priority="300">
|
|
No replies yet — say something
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{pendingEvents.map(renderPendingEvent)}
|
|
|
|
<span ref={atBottomAnchorRef} />
|
|
</Box>
|
|
</Scroll>
|
|
{!atBottom && (
|
|
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
|
onClick={handleJumpToBottom}
|
|
>
|
|
<Text size="L400">Jump to Latest</Text>
|
|
</Chip>
|
|
</Box>
|
|
)}
|
|
{editHistoryEvent && (
|
|
<EditHistoryModal
|
|
room={room}
|
|
mEvent={editHistoryEvent}
|
|
onClose={() => setEditHistoryEvent(undefined)}
|
|
/>
|
|
)}
|
|
{lightboxEventId && lightboxIndex !== -1 && (
|
|
<Lightbox
|
|
items={lightboxItems}
|
|
initialIndex={lightboxIndex}
|
|
useAuthentication={useAuthentication}
|
|
onClose={() => setLightboxEventId(undefined)}
|
|
onJump={handleLightboxJump}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|