/* eslint-disable react/destructuring-assignment */ import React, { Dispatch, MouseEventHandler, RefObject, SetStateAction, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { Direction, EventTimeline, EventTimelineSet, EventTimelineSetHandlerMap, IContent, MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap, } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import classNames from 'classnames'; import { ReactEditor } from 'slate-react'; import { Editor } from 'slate'; import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; import { Badge, Box, Chip, ContainerColor, Icon, Icons, Line, Scroll, Text, as, color, config, toRem, } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } 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 { DefaultPlaceholder, CompactPlaceholder, Reply, MessageBase, MessageUnsupportedContent, Time, MessageNotDecryptedContent, RedactedContent, MSticker, ImageContent, EventContent, } from '../../components/message'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, LINKIFY_OPTS, makeMentionCustomProps, renderMatrixMention, } from '../../plugins/react-custom-html-parser'; import { canEditEvent, decryptAllTimelineEvent, getEditedEvent, getEventReactions, getLatestEditableEvt, getMemberDisplayName, getReactionContent, isMembershipChanged, reactionOrEditEvent, } from '../../utils/room'; import { useSetting } from '../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../state/settings'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { Reactions, Message, Event, EncryptedContent } from './message'; import { useMemberEventParser } from '../../hooks/useMemberEventParser'; import * as customHtmlCss from '../../styles/CustomHtml.css'; import { RoomIntro } from '../../components/room-intro'; import { getIntersectionObserverEntry, useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import { markAsRead } from '../../../client/action/notifications'; import { useDebounce } from '../../hooks/useDebounce'; import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; import * as css from './RoomTimeline.css'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; import { Image } from '../../components/media'; import { ImageViewer } from '../../components/image-viewer'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; import { useIsDirectRoom } from '../../hooks/useRoom'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( ) ); const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( ({ variant, children, ...props }, ref) => ( {children} ) ); export const getLiveTimeline = (room: Room): EventTimeline => room.getUnfilteredTimelineSet().getLiveTimeline(); export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => { const timelineSet = room.getUnfilteredTimelineSet(); return timelineSet.getTimelineForEvent(eventId) ?? undefined; }; export const getFirstLinkedTimeline = ( timeline: EventTimeline, direction: Direction ): EventTimeline => { const linkedTm = timeline.getNeighbouringTimeline(direction); if (!linkedTm) return timeline; return getFirstLinkedTimeline(linkedTm, direction); }; export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => { const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward); const timelines: EventTimeline[] = []; for ( let nextTimeline: EventTimeline | null = firstTimeline; nextTimeline; nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward) ) { timelines.push(nextTimeline); } return timelines; }; export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length; export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => { const timelineEventCountReducer = (count: number, tm: EventTimeline) => count + timelineToEventsCount(tm); return timelines.reduce(timelineEventCountReducer, 0); }; export const getTimelineAndBaseIndex = ( timelines: EventTimeline[], index: number ): [EventTimeline | undefined, number] => { let uptoTimelineLen = 0; const timeline = timelines.find((t) => { uptoTimelineLen += t.getEvents().length; if (index < uptoTimelineLen) return true; return false; }); if (!timeline) return [undefined, 0]; return [timeline, uptoTimelineLen - timeline.getEvents().length]; }; export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) => absoluteIndex - timelineBaseIndex; export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined => timeline.getEvents()[index]; export const getEventIdAbsoluteIndex = ( timelines: EventTimeline[], eventTimeline: EventTimeline, eventId: string ): number | undefined => { const timelineIndex = timelines.findIndex((t) => t === eventTimeline); if (timelineIndex === -1) return undefined; const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId); if (eventIndex === -1) return undefined; const baseIndex = timelines .slice(0, timelineIndex) .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0); return baseIndex + eventIndex; }; type RoomTimelineProps = { room: Room; eventId?: string; roomInputRef: RefObject; editor: Editor; getPowerLevelTag: GetPowerLevelTag; accessibleTagColors: Map; }; const PAGINATION_LIMIT = 80; type Timeline = { linkedTimelines: EventTimeline[]; range: ItemRange; }; const useEventTimelineLoader = ( mx: MatrixClient, room: Room, onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void, onError: (err: Error | null) => void ) => { const loadEventTimeline = useCallback( async (eventId: string) => { const [err, replyEvtTimeline] = await to( mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId) ); if (!replyEvtTimeline) { onError(err ?? null); return; } const linkedTimelines = getLinkedTimelines(replyEvtTimeline); const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); if (absIndex === undefined) { onError(err ?? null); return; } onLoad(eventId, linkedTimelines, absIndex); }, [mx, room, onLoad, onError] ); return loadEventTimeline; }; const useTimelinePagination = ( 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) { // TODO: handle pagination error. 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; }; const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => { useEffect(() => { const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( mEvent, eventRoom, toStartOfTimeline, removed, data ) => { if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; onArrive(mEvent); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => { if (eventRoom?.roomId !== room.roomId) return; onArrive(mEvent); }; room.on(RoomEvent.Timeline, handleTimelineEvent); room.on(RoomEvent.Redaction, handleRedaction); return () => { room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Redaction, handleRedaction); }; }, [room, onArrive]); }; const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { if (r.roomId !== room.roomId) return; onRefresh(); }; room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh); return () => { room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh); }; }, [room, onRefresh]); }; const getInitialTimeline = (room: Room) => { const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); const evLength = getTimelinesEventsCount(linkedTimelines); return { linkedTimelines, range: { start: Math.max(evLength - PAGINATION_LIMIT, 0), end: evLength, }, }; }; const getEmptyTimeline = () => ({ range: { start: 0, end: 0 }, linkedTimelines: [], }); const getRoomUnreadInfo = (room: Room, scrollTo = false) => { const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); if (!readUptoEventId) return undefined; const evtTimeline = getEventTimeline(room, readUptoEventId); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); return { readUptoEventId, inLiveTimeline: latestTimeline === room.getLiveTimeline(), scrollTo, }; }; export function RoomTimeline({ room, eventId, roomInputRef, editor, getPowerLevelTag, accessibleTagColors, }: RoomTimelineProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const direct = useIsDirectRoom(); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const ignoredUsersList = useIgnoredUsers(); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const powerLevels = usePowerLevelsContext(); const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); const canRedact = canDoAction('redact', myPowerLevel); const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel); const [editId, setEditId] = useState(); const roomToParents = useAtomValue(roomToParentsAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const { navigateRoom } = useRoomNavigate(); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); const openUserRoomProfile = useOpenUserRoomProfile(); const space = useSpaceOptionally(); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); const readUptoEventIdRef = useRef(); if (unreadInfo) { readUptoEventIdRef.current = unreadInfo.readUptoEventId; } const atBottomAnchorRef = useRef(null); const [atBottom, setAtBottom] = useState(true); const atBottomRef = useRef(atBottom); atBottomRef.current = atBottom; const scrollRef = useRef(null); const scrollToBottomRef = useRef({ count: 0, smooth: true, }); const [focusItem, setFocusItem] = useState< | { index: number; scrollTo: boolean; highlight: boolean; } | undefined >(); const alive = useAlive(); 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 parseMemberEvent = useMemberEventParser(); const [timeline, setTimeline] = useState(() => eventId ? getEmptyTimeline() : getInitialTimeline(room) ); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const liveTimelineLinked = timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room); const canPaginateBack = typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string'; const rangeAtStart = timeline.range.start === 0; const rangeAtEnd = timeline.range.end === eventsLength; const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd); atLiveEndRef.current = liveTimelineLinked && rangeAtEnd; const handleTimelinePagination = useTimelinePagination( mx, timeline, setTimeline, PAGINATION_LIMIT ); const getScrollElement = useCallback(() => scrollRef.current, []); const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } = 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, }); const loadEventTimeline = useEventTimelineLoader( mx, room, useCallback( (evtId, lTimelines, evtAbsIndex) => { if (!alive()) return; const evLength = getTimelinesEventsCount(lTimelines); setFocusItem({ index: evtAbsIndex, scrollTo: true, highlight: evtId !== readUptoEventIdRef.current, }); setTimeline({ linkedTimelines: lTimelines, range: { start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0), end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength), }, }); }, [alive] ), useCallback(() => { if (!alive()) return; setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; }, [alive, room]) ); useLiveEventArrive( room, useCallback( (mEvt: MatrixEvent) => { // if user is at bottom of timeline // keep paginating timeline and conditionally mark as read // otherwise we update timeline without paginating // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { // Check if the document is in focus (user is actively viewing the app), // and either there are no unread messages or the latest message is from the current user. // If either condition is met, trigger the markAsRead function to send a read receipt. requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); } if (!document.hasFocus() && !unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } 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 })); if (!unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } }, [mx, room, unreadInfo, hideActivity] ) ); const handleOpenEvent = useCallback( async ( evtId: string, highlight = true, onScroll: ((scrolled: boolean) => void) | undefined = undefined ) => { const evtTimeline = getEventTimeline(room, evtId); const absoluteIndex = evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId); if (typeof absoluteIndex === 'number') { const scrolled = scrollToItem(absoluteIndex, { behavior: 'smooth', align: 'center', stopInView: true, }); if (onScroll) onScroll(scrolled); setFocusItem({ index: absoluteIndex, scrollTo: false, highlight, }); } else { setTimeline(getEmptyTimeline()); loadEventTimeline(evtId); } }, [room, timeline, scrollToItem, loadEventTimeline] ); useLiveTimelineRefresh( room, useCallback(() => { if (liveTimelineLinked) { setTimeline(getInitialTimeline(room)); } }, [room, liveTimelineLinked]) ); // Stay at bottom when room editor resize useResizeObserver( useMemo(() => { let mounted = false; return (entries) => { if (!mounted) { // skip initial mounting call mounted = true; return; } if (!roomInputRef.current) return; const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries); const scrollElement = getScrollElement(); if (!editorBaseEntry || !scrollElement) return; if (atBottomRef.current) { scrollToBottom(scrollElement); } }; }, [getScrollElement, roomInputRef]), useCallback(() => roomInputRef.current, [roomInputRef]) ); const tryAutoMarkAsRead = useCallback(() => { const readUptoEventId = readUptoEventIdRef.current; if (!readUptoEventId) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); return; } const evtTimeline = getEventTimeline(room, readUptoEventId); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); if (latestTimeline === room.getLiveTimeline()) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); } }, [mx, room, hideActivity]); const debounceSetAtBottom = useDebounce( useCallback((entry: IntersectionObserverEntry) => { if (!entry.isIntersecting) setAtBottom(false); }, []), { wait: 1000 } ); useIntersectionObserver( useCallback( (entries) => { const target = atBottomAnchorRef.current; if (!target) return; const targetEntry = getIntersectionObserverEntry(target, entries); if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry?.isIntersecting && atLiveEndRef.current) { setAtBottom(true); if (document.hasFocus()) { tryAutoMarkAsRead(); } } }, [debounceSetAtBottom, tryAutoMarkAsRead] ), useCallback( () => ({ root: getScrollElement(), rootMargin: '100px', }), [getScrollElement] ), useCallback(() => atBottomAnchorRef.current, []) ); useDocumentFocusChange( useCallback( (inFocus) => { if (inFocus && atBottomRef.current) { if (unreadInfo?.inLiveTimeline) { handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => { // the unread event is already in view // so, try mark as read; if (!scrolled) { tryAutoMarkAsRead(); } }); return; } tryAutoMarkAsRead(); } }, [tryAutoMarkAsRead, unreadInfo, handleOpenEvent] ) ); // Handle up arrow edit useKeyDown( window, useCallback( (evt) => { if ( isKeyHotkey('arrowup', evt) && editableActiveElement() && document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' && isEmptyEditor(editor) ) { const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) => canEditEvent(mx, mEvt) ); const editableEvtId = editableEvt?.getId(); if (!editableEvtId) return; setEditId(editableEvtId); evt.preventDefault(); } }, [mx, room, editor] ) ); useEffect(() => { if (eventId) { setTimeline(getEmptyTimeline()); loadEventTimeline(eventId); } }, [eventId, loadEventTimeline]); // Scroll to bottom on initial timeline load useLayoutEffect(() => { const scrollEl = scrollRef.current; if (scrollEl) { scrollToBottom(scrollEl); } }, []); // if live timeline is linked and unreadInfo change // Scroll to last read message useLayoutEffect(() => { const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {}; if (readUptoEventId && inLiveTimeline && scrollTo) { const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); const evtTimeline = getEventTimeline(room, readUptoEventId); const absoluteIndex = evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId); if (absoluteIndex) { scrollToItem(absoluteIndex, { behavior: 'instant', align: 'start', stopInView: true, }); } } }, [room, unreadInfo, scrollToItem]); // scroll to focused message useLayoutEffect(() => { if (focusItem && focusItem.scrollTo) { scrollToItem(focusItem.index, { behavior: 'instant', align: 'center', stopInView: true, }); } setTimeout(() => { if (!alive()) return; setFocusItem((currentItem) => { if (currentItem === focusItem) return undefined; return currentItem; }); }, 2000); }, [alive, focusItem, scrollToItem]); // scroll to bottom of timeline const scrollToBottomCount = scrollToBottomRef.current.count; useLayoutEffect(() => { if (scrollToBottomCount > 0) { const scrollEl = scrollRef.current; if (scrollEl) scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant'); } }, [scrollToBottomCount]); // Remove unreadInfo on mark as read useEffect(() => { if (!unread) { setUnreadInfo(undefined); } }, [unread]); // scroll out of view msg editor in view. useEffect(() => { if (editId) { const editMsgElement = (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ?? undefined; if (editMsgElement) { scrollToElement(editMsgElement, { align: 'center', behavior: 'smooth', stopInView: true, }); } } }, [scrollToElement, editId]); const handleJumpToLatest = () => { if (eventId) { navigateRoom(room.roomId, undefined, { replace: true }); } setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; }; const handleJumpToUnread = () => { if (unreadInfo?.readUptoEventId) { setTimeline(getEmptyTimeline()); loadEventTimeline(unreadInfo.readUptoEventId); } }; const handleMarkAsRead = () => { 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 = 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 = useCallback( (evt) => { evt.preventDefault(); 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] ); const handleReplyClick: MouseEventHandler = useCallback( (evt) => { 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; 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 } = 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] ); 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, getReactionContent(targetEventId, key, rShortcode) ); }, [mx, room] ); const handleEdit = useCallback( (editEvtId?: string) => { if (editEvtId) { setEditId(editEvtId); return; } setEditId(undefined); ReactEditor.focus(editor); }, [editor] ); const { t } = useTranslation(); const renderMatrixEvent = useMatrixEventRenderer< [string, MatrixEvent, number, EventTimelineSet, boolean] >( { [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; const { replyEventId, threadRootId } = mEvent; const highlighted = focusItem?.index === item && focusItem.highlight; const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const getContent = (() => editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; const senderId = mEvent.getSender() ?? ''; const senderPowerLevel = getPowerLevel(mEvent.getSender()); const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; return ( ) } reactions={ reactionRelations && ( ) } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} powerLevelTag={getPowerLevelTag(senderPowerLevel)} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} > {mEvent.isRedacted() ? ( ) : ( )} ); }, [MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; const { replyEventId, threadRootId } = mEvent; const highlighted = focusItem?.index === item && focusItem.highlight; const senderPowerLevel = getPowerLevel(mEvent.getSender()); return ( ) } reactions={ reactionRelations && ( ) } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} powerLevelTag={getPowerLevelTag(senderPowerLevel)} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} > {() => { if (mEvent.isRedacted()) return ; if (mEvent.getType() === MessageEvent.Sticker) return ( ( } renderViewer={(p) => } /> )} /> ); if (mEvent.getType() === MessageEvent.RoomMessage) { 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 ( ); } if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) return ( ); return ( ); }} ); }, [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; const highlighted = focusItem?.index === item && focusItem.highlight; const senderPowerLevel = getPowerLevel(mEvent.getSender()); return ( ) } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} powerLevelTag={getPowerLevelTag(senderPowerLevel)} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} > {mEvent.isRedacted() ? ( ) : ( ( } renderViewer={(p) => } /> )} /> )} ); }, [StateEvent.RoomMember]: (mEventId, mEvent, item) => { const membershipChanged = isMembershipChanged(mEvent); if (membershipChanged && hideMembershipEvents) return null; if (!membershipChanged && hideNickAvatarEvents) return null; const highlighted = focusItem?.index === item && focusItem.highlight; const parsed = parseMemberEvent(mEvent); const timeJSX = ( } /> ); }, [StateEvent.RoomName]: (mEventId, mEvent, item) => { const highlighted = focusItem?.index === item && focusItem.highlight; const senderId = mEvent.getSender() ?? ''; const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const timeJSX = (