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 { isRetryableSendError } from '../../../utils/outbox'; import { sendOfflineAtom } from '../../../state/sendOffline'; 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>, 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 sendOffline = useAtomValue(sendOfflineAtom); 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(); const [editHistoryEvent, setEditHistoryEvent] = useState(); // [Gitea #219] Thread images open the shared media lightbox too. const [lightboxEventId, setLightboxEventId] = useState(); const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, render: factoryRenderLinkifyWithMention((href) => renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)), ), }), [mx, room, mentionClickHandler], ); const htmlReactParserOptions = useMemo( () => 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(() => 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(null); const atBottomAnchorRef = useRef(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 = 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 = 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 = 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(); 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 ; } const type = mEvent.getType(); if (type === MessageEvent.Sticker) { return ( ( } renderViewer={(p) => } /> )} /> ); } if (type === MessageEvent.RoomMessageEncrypted) { return ( ); } if (type !== MessageEvent.RoomMessage) { return ( ); } 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 ( 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 {renderByType}; } 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 ( {forwardedMeta && ( navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)} /> )} {genuineReplyId && ( )} } reactions={ reactionRelations && ( ) } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} dateFormatString={dateFormatString} lotusTerminal={!!lotusTerminal} > {renderMessageContent(mEvent, mEventId, timelineSet)} ); }, [ 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 ? ( {formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })} ) : 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 (
{eventJSX}
{replyCount > 0 && ( {replyCount === 1 ? '1 reply' : `${replyCount} replies`} )}
); } if (eventJSX && dayDividerJSX) { return ( {dayDividerJSX} {eventJSX} ); } return eventJSX; }; const items = getItems(); const showEmptyReplies = ready && thread.length === 0; const renderPendingEvent = (mEvent: MatrixEvent) => { // [Gitea #112] Network failures while offline are queued, not failed. const queued = mEvent.status === EventStatus.NOT_SENT && sendOffline && isRetryableSendError(mEvent.error); const failed = !queued && (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED); return (
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })} {failed && ( Failed to send )} {queued && ( Queued. Will send when you're back online )}
); }; if (!ready) { return ( ); } return ( {(canPaginateBack || !rangeAtStart) && ( <> )} {items.map(eventRenderer)} {showEmptyReplies && ( No replies yet — say something )} {pendingEvents.map(renderPendingEvent)} {!atBottom && ( } onClick={handleJumpToBottom} > Jump to Latest )} {editHistoryEvent && ( setEditHistoryEvent(undefined)} /> )} {lightboxEventId && lightboxIndex !== -1 && ( setLightboxEventId(undefined)} onJump={handleLightboxJump} /> )} ); }