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, Line, Scroll, Spinner, Text, color, config } from 'folds'; import classNames from 'classnames'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator'; import { useAlive } from '../../../hooks/useAlive'; import { scrollToBottom } from '../../../utils/dom'; import { DefaultPlaceholder, MessageBase, Reply, RedactedContent, MSticker, MessageUnsupportedContent, MessageNotDecryptedContent, ImageContent, } from '../../../components/message'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, LINKIFY_OPTS, makeMentionCustomProps, renderMatrixMention, } from '../../../plugins/react-custom-html-parser'; import { decryptAllTimelineEvent, getEditedEvent, getEventReactions, getMemberDisplayName, getReactionContent, reactionOrEditEvent, } 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 { Image } from '../../../components/media'; import { ImageViewer } from '../../../components/image-viewer'; import * as css from './ThreadTimeline.css'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday, } from '../../../utils/time'; import { createMentionElement, moveCursor } from '../../../components/editor'; 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 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 [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(); 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]); // 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 = 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], ); 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], ); const handleReactionToggle = useCallback( (targetEventId: string, key: string, shortcode?: string) => { const timelineSet = thread.getUnfilteredTimelineSet(); 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); mx.sendEvent( room.roomId, thread.id, // eslint-disable-next-line @typescript-eslint/no-explicit-any MessageEvent.Reaction as any, getReactionContent(targetEventId, key, rShortcode), ); }, [mx, room, thread], ); const handleEdit = useCallback( (editEvtId?: string) => { if (editEvtId) { setEditId(editEvtId); return; } setEditId(undefined); ReactEditor.focus(editor); }, [editor], ); const handleOpenReply: MouseEventHandler = useCallback( (evt) => { const targetId = evt.currentTarget.getAttribute('data-event-id'); if (!targetId) return; // best-effort: scroll to referenced event if it is inside the loaded thread window 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 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 = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; return ( setEditHistoryEvent(mEvent) : undefined} getContent={getContent} mediaAutoLoad={mediaAutoLoad} urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} eventId={mEventId} /> ); }; 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 = thread.getUnfilteredTimelineSet(); const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations?.getSortedAnnotationsByKey(); const hasReactions = !!reactions && reactions.length > 0; const { replyEventId, threadRootId } = mEvent; return ( ) } 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)} ); }, [ thread, 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, ], ); 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 ? ( {(() => { if (today(mEvent.getTs())) return 'Today'; if (yesterday(mEvent.getTs())) return 'Yesterday'; return timeDayMonthYear(mEvent.getTs()); })()} ) : 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) => { const failed = mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED; return (
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })} {failed && ( Failed to send )}
); }; if (!ready) { return ( ); } return ( {(canPaginateBack || !rangeAtStart) && ( <> )} {items.map(eventRenderer)} {showEmptyReplies && ( No replies yet — say something )} {pendingEvents.map(renderPendingEvent)} {editHistoryEvent && ( setEditHistoryEvent(undefined)} /> )} ); }