2439 lines
92 KiB
TypeScript
2439 lines
92 KiB
TypeScript
/* eslint-disable react/destructuring-assignment */
|
|
import React, {
|
|
Dispatch,
|
|
RefObject,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Direction,
|
|
EventTimeline,
|
|
EventTimelineSet,
|
|
EventTimelineSetHandlerMap,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
Room,
|
|
RoomEvent,
|
|
RoomEventHandlerMap,
|
|
} from 'matrix-js-sdk';
|
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
|
import classNames from 'classnames';
|
|
import { Editor } from 'slate';
|
|
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
|
import to from 'await-to-js';
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
import { Box, Chip, Icon, Icons, Scroll, Text, as, config, toRem } from 'folds';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useParams } from 'react-router-dom';
|
|
import { 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,
|
|
Reply,
|
|
MessageBase,
|
|
MessageUnsupportedContent,
|
|
Time,
|
|
MessageNotDecryptedContent,
|
|
RedactedContent,
|
|
MSticker,
|
|
ImageContent,
|
|
EventContent,
|
|
STREAM_MESSAGE_SPACING,
|
|
StreamDayDivider,
|
|
CHANNEL_MESSAGE_SPACING,
|
|
ChannelDayDivider,
|
|
} from '../../components/message';
|
|
import {
|
|
factoryRenderLinkifyWithMention,
|
|
getReactCustomHtmlParser,
|
|
LINKIFY_OPTS,
|
|
makeMentionCustomProps,
|
|
renderMatrixMention,
|
|
} from '../../plugins/react-custom-html-parser';
|
|
import {
|
|
canEditEvent,
|
|
decryptAllTimelineEvent,
|
|
getEditedEvent,
|
|
getEventReactions,
|
|
getLatestEditableEvt,
|
|
getMemberDisplayName,
|
|
isBridgedRoom,
|
|
isMembershipChanged,
|
|
reactionOrEditEvent,
|
|
} from '../../utils/room';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
|
import {
|
|
Reactions,
|
|
Message,
|
|
Event,
|
|
CallMessage,
|
|
CallAggregate,
|
|
SyslineMessage,
|
|
EncryptedContent,
|
|
useMessageInteractionHandlers,
|
|
} from './message';
|
|
import { ThreadSummaryCard } from './ThreadSummaryCard';
|
|
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 '../../utils/notifications';
|
|
import { useDebounce } from '../../hooks/useDebounce';
|
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
|
import * as css from './RoomTimeline.css';
|
|
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
|
|
import { isEmptyEditor } from '../../components/editor';
|
|
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
|
import { 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 { useIsOneOnOne } from '../../hooks/useRoom';
|
|
import { isNativePlatform } from '../../utils/capacitor';
|
|
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
|
|
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';
|
|
|
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
|
({ position, className, ...props }, ref) => (
|
|
<Box
|
|
className={classNames(css.TimelineFloat({ position }), className)}
|
|
justifyContent="Center"
|
|
alignItems="Center"
|
|
gap="200"
|
|
{...props}
|
|
ref={ref}
|
|
/>
|
|
)
|
|
);
|
|
|
|
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<HTMLElement>;
|
|
editor: Editor;
|
|
// Pixel height of the overlay composer painted by RoomView. The CSS var
|
|
// applied on the chat surface paints the bottom padding visually, but
|
|
// when this number transitions 0 → N at mount the scroll-content grows
|
|
// without a corresponding scrollTop bump — the latest message ends up
|
|
// hidden behind the composer. We use this prop in a layout effect to
|
|
// re-anchor the scroll to the bottom whenever it changes and the user
|
|
// was already at the bottom.
|
|
bottomOverlayHeight?: number;
|
|
// Native-only scroll-aware composer hide. Timeline owns the scroll
|
|
// element, RoomView owns the composer DOM — so direction detection
|
|
// lives here and reports up. Web ignores the callback entirely; the
|
|
// gating happens inside the listener attach effect.
|
|
onComposerHiddenChange?: (hidden: boolean) => void;
|
|
};
|
|
|
|
const PAGINATION_LIMIT = 80;
|
|
|
|
// Native scroll-aware composer thresholds. Asymmetric hysteresis: easier to
|
|
// reveal the composer than to hide it, so a flick up-then-down can't strand
|
|
// it in hidden state. NEAR_BOTTOM_PX matches roughly the visible breathing
|
|
// room below the last message (timeline paddingBottom + overlay padding) —
|
|
// while the user is within this band of the live edge, small scrolls never
|
|
// strip the composer.
|
|
const COMPOSER_HIDE_DELTA_PX = 12;
|
|
const COMPOSER_SHOW_DELTA_PX = 6;
|
|
const COMPOSER_NEAR_BOTTOM_PX = 200;
|
|
|
|
// Jump-to-latest FAB visibility thresholds. Decoupled from the auto-follow
|
|
// `atBottom` gate (which carries a 1s debounce designed for live-timeline
|
|
// tracking, not visual UI) — FAB needs eager response. Show / hide hysteresis
|
|
// avoids flicker at the boundary on tiny scrolls.
|
|
const FAB_SHOW_DISTANCE_PX = 150;
|
|
const FAB_HIDE_DISTANCE_PX = 50;
|
|
|
|
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<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) {
|
|
// 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.getSafeUserId());
|
|
if (!readUptoEventId) return undefined;
|
|
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
|
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
|
return {
|
|
readUptoEventId,
|
|
inLiveTimeline: latestTimeline === room.getLiveTimeline(),
|
|
scrollTo,
|
|
};
|
|
};
|
|
|
|
// Channels-mode visibility filter. Single source of truth for the
|
|
// /channels/* surface — called from BOTH the rail-endpoint scan
|
|
// (`isRenderableTimelineEvent`) AND the renderer null-gate so the two never
|
|
// disagree on which events count as visible.
|
|
//
|
|
// Thread-relation hiding delegates to SDK's `room.eventShouldLiveIn`
|
|
// (matrix-js-sdk room.ts:891) — same predicate Element-web uses in
|
|
// `MessagePanel.filterEventsThatShouldLiveInThisTimeline`. Canonical
|
|
// because: (a) handles thread-root visible-in-both-timelines correctly;
|
|
// (b) handles orphan thread relations whose root never landed; (c)
|
|
// classifies redactions via target's thread; (d) classifies pending
|
|
// local-echoes the same way the SDK does — kills the «message flashes
|
|
// in main timeline before drawer» blink that hand-rolled relType check
|
|
// missed because the SDK adds the thread relation to the local-echo
|
|
// content asynchronously vs the predicate's `getRelation()` read.
|
|
//
|
|
// Bridged-room exception: SDK still partitions thread events into the
|
|
// thread, but on bridged rooms (Telegram puppets etc.) we want the
|
|
// thread events visible in main timeline because the bridge has no
|
|
// thread surface. Override returns false (don't hide).
|
|
//
|
|
// Service events (call.*, rtc.*, poll.*) stay hand-filtered — these
|
|
// are not thread-related and the SDK predicate has no opinion on them.
|
|
const isChannelsModeHidden = (room: Room, event: MatrixEvent, isBridged: boolean): boolean => {
|
|
const partition = room.eventShouldLiveIn(event);
|
|
if (!partition.shouldLiveInRoom) {
|
|
if (isBridged) return false;
|
|
return true;
|
|
}
|
|
const eventType = event.getType();
|
|
if (eventType.startsWith('m.call.')) return true;
|
|
if (eventType === 'm.rtc.notification') return true;
|
|
if (eventType === 'm.rtc.member') return true;
|
|
if (eventType === 'org.matrix.msc4143.rtc.member') return true;
|
|
if (eventType === 'm.poll.start') return true;
|
|
if (eventType === 'm.poll.response') return true;
|
|
if (eventType === 'm.poll.end') return true;
|
|
if (eventType === 'org.matrix.msc3381.poll.start') return true;
|
|
if (eventType === 'org.matrix.msc3381.poll.response') return true;
|
|
if (eventType === 'org.matrix.msc3381.poll.end') return true;
|
|
// m.reference relations (e.g. polls' votes) — partition reports
|
|
// shouldLiveInRoom: true for these but we never render them in the
|
|
// channel center column. SDK has no special predicate; explicit drop.
|
|
if (event.getRelation()?.rel_type === 'm.reference') return true;
|
|
return false;
|
|
};
|
|
|
|
export function RoomTimeline({
|
|
room,
|
|
eventId,
|
|
roomInputRef,
|
|
editor,
|
|
bottomOverlayHeight = 0,
|
|
onComposerHiddenChange,
|
|
}: RoomTimelineProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
// After P3c every room renders Stream. The DM-vs-group split that drove the
|
|
// membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
|
|
// not the persisted `m.direct` flag — see plan §6.8.
|
|
const isOneOnOne = useIsOneOnOne();
|
|
const channelsMode = useChannelsMode();
|
|
// bridged-room check is cheap and stable for a route lifetime; recompute on
|
|
// each render is fine — `isBridgedRoom` walks the room state-event index,
|
|
// not the timeline.
|
|
const isBridged = channelsMode && isBridgedRoom(room);
|
|
// M2: «Reply in Thread» affordance is channels-mode-only. Bridged
|
|
// channels (Telegram puppets) get nothing because the bridge has no
|
|
// thread semantic — clicking would create a draft the bridge drops
|
|
// on send. Outside channels (DM/Bots/legacy /<space>/) the surface
|
|
// has no drawer and no thread UI per plan §6.7, so the button has
|
|
// nowhere to lead. Pre-M2 the button existed in those surfaces but
|
|
// never produced a visible thread.
|
|
const hideThreadReplyAffordance = !channelsMode || isBridged;
|
|
// M3a: thread summary pill rides a tighter gate — non-bridged channels
|
|
// are the only surface where threads are first-class. The card itself
|
|
// returns null when the root has no replies, so mounting on every
|
|
// visible row in channels-mode is safe (`useVirtualPaginator` keeps
|
|
// the rendered window bounded; SDK Room maxListeners is 100).
|
|
const showThreadSummary = channelsMode && !isBridged;
|
|
// M3: channels timeline uses avatar-first ChannelLayout (no rail, no
|
|
// bubble). DM/Bots stay on Stream rail. Bridged Telegram channels use
|
|
// Channel layout too — visually consistent with native channels — only
|
|
// the thread plus / cards differ (bridge has no thread semantic).
|
|
const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream';
|
|
// Channels main timeline shares the thread-drawer bubble silhouette:
|
|
// dark `Surface.Container` card with the username + time INSIDE the
|
|
// bubble. Stream layout (DM/Bots) keeps its native bubble header so
|
|
// the flag is gated on channels-mode only.
|
|
const channelHeaderInBubble = channelsMode;
|
|
// M2: when the thread drawer is open, the channel composer is
|
|
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
|
|
// the channel timeline's «Reply» menu would write a reply chip into
|
|
// an atom no surface reads, then fire `ReactEditor.focus(editor)`
|
|
// on a Slate editor that's no longer mounted (silent no-op) — dead
|
|
// click from the user's perspective. Hide the regular Reply
|
|
// affordance in this state. ThreadPlus stays gated separately by
|
|
// `hideThreadReplyAffordance` and remains active for re-opening the
|
|
// drawer to a different thread root.
|
|
const threadDrawerOpen = useThreadDrawerOpen();
|
|
const hideMainReplyAffordance = threadDrawerOpen;
|
|
// Captured for the channels-mode «Reply in Thread» branch inside
|
|
// useMessageInteractionHandlers — re-encoded via generatePath so
|
|
// passing the raw (already URL-encoded) values from useParams is fine.
|
|
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
|
|
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]);
|
|
|
|
// Reply-draft setter targets the MAIN composer of this room (channel
|
|
// / DM / legacy timeline). Drawer composer manages its own per-thread
|
|
// reply draft via DraftKey([roomId, rootId]) inside RoomInput.
|
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId)));
|
|
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 roomToParents = useAtomValue(roomToParentsAtom);
|
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
|
const spoilerClickHandler = useSpoilerClickHandler();
|
|
|
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
|
|
|
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
|
const readUptoEventIdRef = useRef<string>();
|
|
if (unreadInfo) {
|
|
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
|
|
}
|
|
|
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
|
const [atBottom, setAtBottom] = useState<boolean>(true);
|
|
const atBottomRef = useRef(atBottom);
|
|
atBottomRef.current = atBottom;
|
|
|
|
// Jump-to-latest FAB visibility — driven by distance from the live edge,
|
|
// not by the debounced auto-follow `atBottom`. Initial `true` because the
|
|
// common open-room state is "scrolled to bottom"; deep-links to a specific
|
|
// event will fire scroll events that correct the state.
|
|
const [fabHidden, setFabHidden] = useState(true);
|
|
const fabHiddenRef = useRef(fabHidden);
|
|
fabHiddenRef.current = fabHidden;
|
|
// Pulse counter — bumped on each fresh live message while the FAB is
|
|
// showing. Drives the `key` of an icon-wrap span so React remounts it and
|
|
// the CSS keyframes restart.
|
|
const [pulseCount, setPulseCount] = useState(0);
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(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<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 parseMemberEvent = useMemberEventParser();
|
|
|
|
const [timeline, setTimeline] = useState<Timeline>(() =>
|
|
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])
|
|
);
|
|
|
|
// M2 channels-mode: hidden events (thread replies, RTC, polls, etc.)
|
|
// are appended to the SDK timelineSet and bump `getTimelinesEventsCount`,
|
|
// but the live handler early-returns without mutating state. The
|
|
// virtual `range` is indexed against the FULL SDK event-pool (see
|
|
// `getInitialTimeline` — `range.end = evLength`), so a +1 advance on
|
|
// the next visible event would lag behind by N hidden events and
|
|
// miss the visible one in the rendered window. Track skipped count
|
|
// here; the next visible event consumes `skipped + 1` as its delta.
|
|
const skippedHiddenLiveEventsRef = useRef(0);
|
|
// Counter is meaningful only against the CURRENT linkedTimelines —
|
|
// after a full rebase (loadEventTimeline / refresh / jump-to-latest /
|
|
// jump-to-unread), range is recomputed against the fresh evLength,
|
|
// so any pre-rebase skipped count would over-shoot range on the next
|
|
// visible event. Track `linkedTimelines` reference identity (replaced
|
|
// on every rebase) and zero the counter alongside.
|
|
useEffect(() => {
|
|
skippedHiddenLiveEventsRef.current = 0;
|
|
}, [timeline.linkedTimelines]);
|
|
useLiveEventArrive(
|
|
room,
|
|
useCallback(
|
|
(mEvt: MatrixEvent) => {
|
|
// Hidden events: skip render-state mutation (no blink), but
|
|
// record the count so the next visible event can advance the
|
|
// range by the correct delta.
|
|
if (channelsMode && isChannelsModeHidden(room, mEvt, isBridged)) {
|
|
skippedHiddenLiveEventsRef.current += 1;
|
|
return;
|
|
}
|
|
|
|
// Visible event arrival — snapshot + reset the skipped counter.
|
|
// `liveDelta` covers all SDK-appended events since the last
|
|
// visible one (skipped hidden + this one) so the virtual range
|
|
// stays aligned with `getTimelinesEventsCount(linkedTimelines)`.
|
|
const liveDelta = skippedHiddenLiveEventsRef.current + 1;
|
|
skippedHiddenLiveEventsRef.current = 0;
|
|
|
|
// «Sending while scrolled up jumps the timeline to live» — Telegram /
|
|
// WhatsApp / Slack pattern. After P3c every room renders Stream, so
|
|
// the own-message follow-to-bottom guard is universal.
|
|
const isOwnLiveStreamMessage =
|
|
mEvt.getSender() === mx.getUserId() &&
|
|
!reactionOrEditEvent(mEvt) &&
|
|
(mEvt.getType() === MessageEvent.RoomMessage ||
|
|
mEvt.getType() === MessageEvent.RoomMessageEncrypted ||
|
|
mEvt.getType() === MessageEvent.Sticker);
|
|
|
|
// 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 || isOwnLiveStreamMessage) {
|
|
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.
|
|
const evtRoomId = mEvt.getRoomId();
|
|
if (evtRoomId) {
|
|
requestAnimationFrame(() => markAsRead(mx, evtRoomId, hideActivity));
|
|
}
|
|
}
|
|
|
|
if (!document.hasFocus() && !unreadInfo) {
|
|
setUnreadInfo(getRoomUnreadInfo(room));
|
|
}
|
|
|
|
scrollToBottomRef.current.count += 1;
|
|
// Always instant — never smooth on incoming. Smooth animation
|
|
// briefly takes the at-bottom anchor out of the
|
|
// IntersectionObserver's intersection box during the
|
|
// animation frame, which the 1s debounce sometimes turns
|
|
// into a false-positive «user scrolled away» → atBottom flips
|
|
// false → next message stops auto-following. Direct snap
|
|
// matches Discord / Telegram and the drawer (M4 fix in
|
|
// ThreadDrawer.tsx) — predictable, no animation surface for
|
|
// the observer to misinterpret.
|
|
scrollToBottomRef.current.smooth = false;
|
|
if (isOwnLiveStreamMessage) {
|
|
setAtBottom(true);
|
|
if (!atLiveEndRef.current) {
|
|
// Sending while viewing an older event intentionally jumps the DM back
|
|
// to live; clear stale deep-link focus before replacing the timeline.
|
|
setFocusItem(undefined);
|
|
}
|
|
}
|
|
|
|
setTimeline((ct) => {
|
|
if (!atLiveEndRef.current && isOwnLiveStreamMessage) {
|
|
return getInitialTimeline(room);
|
|
}
|
|
return {
|
|
...ct,
|
|
range: {
|
|
start: ct.range.start + liveDelta,
|
|
end: ct.range.end + liveDelta,
|
|
},
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
setTimeline((ct) => ({ ...ct }));
|
|
if (!unreadInfo) {
|
|
setUnreadInfo(getRoomUnreadInfo(room));
|
|
}
|
|
},
|
|
[mx, room, unreadInfo, hideActivity, channelsMode, isBridged]
|
|
)
|
|
);
|
|
|
|
// Pulse the FAB when a fresh message from another user lands on the live
|
|
// timeline while the user is scrolled away. Edits / reactions / own
|
|
// messages are filtered so the cue fires only for genuinely new content
|
|
// worth jumping back for.
|
|
useLiveEventArrive(
|
|
room,
|
|
useCallback(
|
|
(mEvt: MatrixEvent) => {
|
|
if (fabHiddenRef.current) return;
|
|
if (mEvt.getSender() === mx.getUserId()) return;
|
|
if (reactionOrEditEvent(mEvt)) return;
|
|
const type = mEvt.getType();
|
|
if (
|
|
type !== MessageEvent.RoomMessage &&
|
|
type !== MessageEvent.RoomMessageEncrypted &&
|
|
type !== MessageEvent.Sticker
|
|
) {
|
|
return;
|
|
}
|
|
setPulseCount((c) => c + 1);
|
|
},
|
|
[mx]
|
|
)
|
|
);
|
|
|
|
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]
|
|
);
|
|
|
|
const {
|
|
editId,
|
|
handleEdit,
|
|
handleOpenReply,
|
|
handleUserClick,
|
|
handleUsernameClick,
|
|
handleReplyClick,
|
|
handleReactionToggle,
|
|
} = useMessageInteractionHandlers({
|
|
room,
|
|
editor,
|
|
composerSuspended: threadDrawerOpen,
|
|
setReplyDraft,
|
|
onOpenEvent: handleOpenEvent,
|
|
channelsMode,
|
|
isBridged,
|
|
spaceIdOrAlias,
|
|
roomIdOrAlias,
|
|
});
|
|
|
|
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])
|
|
);
|
|
|
|
// Re-anchor to bottom when the overlay-composer height changes. At mount
|
|
// the composer measures async (RoomView's `useEffect` → `setComposerHeight`
|
|
// → CSS var → our paddingBottom grows), which adds N pixels to scrollHeight
|
|
// without bumping scrollTop, leaving the latest message hidden behind the
|
|
// composer. A layout effect on the prop catches every transition (initial
|
|
// 0 → N and subsequent multi-line/reply-preview growth) deterministically,
|
|
// unlike a ResizeObserver dispatch which can coalesce the mount and the
|
|
// resize into a single skipped first firing.
|
|
useLayoutEffect(() => {
|
|
if (!atBottomRef.current) return;
|
|
const el = getScrollElement();
|
|
if (el) scrollToBottom(el);
|
|
}, [bottomOverlayHeight, getScrollElement]);
|
|
|
|
// Single scroll listener covering two concerns:
|
|
// 1. FAB visibility (all platforms) — eager distance gate with show /
|
|
// hide hysteresis, decoupled from `atBottom`'s 1s debounce.
|
|
// 2. Composer slide/fade (Capacitor native only) — direction-tracked
|
|
// accumulator with asymmetric hysteresis; hide only fires past the
|
|
// near-bottom band so the timeline's bottom breathing room can be
|
|
// revealed by small scrolls without stripping the composer.
|
|
// The atBottom force-show effect below corrects composer state on
|
|
// programmatic jumps to bottom (jump-to-latest, send, layout re-anchor).
|
|
useEffect(() => {
|
|
const scrollEl = getScrollElement();
|
|
if (!scrollEl) return undefined;
|
|
const native = isNativePlatform();
|
|
|
|
let lastTop = scrollEl.scrollTop;
|
|
let accumulator = 0;
|
|
let lastDir: 1 | -1 | 0 = 0;
|
|
|
|
const handler = () => {
|
|
const top = scrollEl.scrollTop;
|
|
const distFromBottom = scrollEl.scrollHeight - top - scrollEl.clientHeight;
|
|
|
|
setFabHidden((prev) => {
|
|
if (distFromBottom > FAB_SHOW_DISTANCE_PX) return false;
|
|
if (distFromBottom <= FAB_HIDE_DISTANCE_PX) return true;
|
|
return prev;
|
|
});
|
|
|
|
if (!native || !onComposerHiddenChange) return;
|
|
const delta = top - lastTop;
|
|
lastTop = top;
|
|
if (delta === 0) return;
|
|
const dir: 1 | -1 = delta > 0 ? 1 : -1;
|
|
if (dir !== lastDir) {
|
|
accumulator = delta;
|
|
lastDir = dir;
|
|
} else {
|
|
accumulator += delta;
|
|
}
|
|
if (accumulator <= -COMPOSER_HIDE_DELTA_PX) {
|
|
if (distFromBottom > COMPOSER_NEAR_BOTTOM_PX) {
|
|
onComposerHiddenChange(true);
|
|
}
|
|
accumulator = 0;
|
|
} else if (accumulator >= COMPOSER_SHOW_DELTA_PX) {
|
|
onComposerHiddenChange(false);
|
|
accumulator = 0;
|
|
}
|
|
};
|
|
scrollEl.addEventListener('scroll', handler, { passive: true });
|
|
return () => scrollEl.removeEventListener('scroll', handler);
|
|
}, [getScrollElement, onComposerHiddenChange]);
|
|
|
|
useEffect(() => {
|
|
if (!isNativePlatform()) return;
|
|
if (atBottom && onComposerHiddenChange) onComposerHiddenChange(false);
|
|
}, [atBottom, onComposerHiddenChange]);
|
|
|
|
// Stay at bottom when scroll container resizes (e.g. Android keyboard open/close)
|
|
useResizeObserver(
|
|
useMemo(() => {
|
|
let mounted = false;
|
|
return (_entries: ResizeObserverEntry[]) => {
|
|
if (!mounted) {
|
|
mounted = true;
|
|
return;
|
|
}
|
|
if (atBottomRef.current) {
|
|
requestAnimationFrame(() => {
|
|
const el = getScrollElement();
|
|
if (el) scrollToBottom(el);
|
|
});
|
|
}
|
|
};
|
|
}, [getScrollElement]),
|
|
getScrollElement
|
|
);
|
|
|
|
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(),
|
|
// Forgiving threshold: 100px was too tight — single tall
|
|
// message + minor touch-scroll inertia easily took users out
|
|
// of «at-bottom» state, after which incoming events stopped
|
|
// auto-following. 400px gives roughly two-three Stream rows
|
|
// of slack before treating the scroll as a deliberate detach,
|
|
// matching Discord / Slack auto-follow generosity.
|
|
rootMargin: '400px',
|
|
}),
|
|
[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) => {
|
|
// Drawer-active: the focused `<Editable>` may be the drawer
|
|
// composer (also tagged `data-editable-name="RoomInput"`), so
|
|
// this selector alone can't distinguish main vs thread. Without
|
|
// this guard, ArrowUp in an empty drawer composer would put the
|
|
// CHANNEL timeline's latest event into edit mode — visible on
|
|
// desktop, where main timeline stays mounted next to the drawer.
|
|
if (threadDrawerOpen) return;
|
|
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;
|
|
handleEdit(editableEvtId);
|
|
evt.preventDefault();
|
|
}
|
|
},
|
|
[mx, room, editor, threadDrawerOpen, handleEdit]
|
|
)
|
|
);
|
|
|
|
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 { t } = useTranslation();
|
|
|
|
// Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one
|
|
// aggregate bubble per CALL SESSION. Each session is delimited by «joined
|
|
// count went from 0 → ≥1, then back to 0». A session's anchor is its
|
|
// earliest event; the sender of that anchor is the call initiator and
|
|
// decides bubble side (own/peer).
|
|
//
|
|
// Why per-session and not just per-`call_id`: legacy MSC3401 room-scoped
|
|
// calls use an empty `call_id` (see matrix-js-sdk MembershipManager
|
|
// `INFO_SLOT_ID_LEGACY_CASE`), so every historical call in the same DM
|
|
// room shares the same slot id. Splitting on the «count returned to 0»
|
|
// boundary keeps each call's metrics — connectedAt / endedAt / duration —
|
|
// scoped to its own session, otherwise the bubble would smear the entire
|
|
// call history into one super-aggregate.
|
|
//
|
|
// Forward chronological walk per linked timeline tracks: (1) joined count
|
|
// per session so we can pin the conversation-duration window to «both
|
|
// joined» → «one left» and exclude the ringing prelude; (2) each
|
|
// currently-joined membership's absolute expiry so a crashed peer
|
|
// (join with no leave) doesn't keep the bubble in «Ongoing» forever —
|
|
// once the SDK's DEFAULT_EXPIRE_DURATION elapses past
|
|
// created_ts + expires, we treat the stale join as gone. Linear in
|
|
// #call-member events (rare); matches the inline `streamRenderableItemHasBefore`
|
|
// IIFE pattern just below.
|
|
const callAnchors: Map<string, CallAggregate> = (() => {
|
|
type CallScan = {
|
|
slotId: string;
|
|
anchorEventId: string;
|
|
anchorSenderId: string;
|
|
startTs: number;
|
|
endTs: number;
|
|
// TS when joined count first reached 2 (both sides connected). Used as
|
|
// the duration window start; falls back to startTs on pagination edge.
|
|
connectedAt: number | null;
|
|
// TS when joined count dropped from 2 → 1 for the last time. Null
|
|
// while the conversation is still live (or never began).
|
|
endedAt: number | null;
|
|
// Running tally of currently-joined memberships (per stateKey).
|
|
joinedCount: number;
|
|
participants: Set<string>;
|
|
// state_key (mxid + device) -> is currently joined
|
|
memberStates: Map<string, boolean>;
|
|
// state_key -> absolute expiry timestamp of the active join (ms since
|
|
// epoch). Cleared on leave. Used to detect crashed clients.
|
|
joinedAbsoluteExpiries: Map<string, number>;
|
|
// At least one join observed in this session — guards session close
|
|
// and filters leave-only pagination fragments.
|
|
everJoined: boolean;
|
|
// Session terminated (count returned to 0 after everJoined). New
|
|
// events for this slot open a fresh session instead of appending.
|
|
closed: boolean;
|
|
};
|
|
// Per-slot ordered list of sessions. Newest session is `last(sessions)`;
|
|
// earlier entries are closed (count returned to 0).
|
|
const slotSessions = new Map<string, CallScan[]>();
|
|
// Hot data-pipeline path: nested for-of with early `continue` is the
|
|
// clearest expression of the state-machine. Array iteration helpers would
|
|
// require restructuring the dual-key membership ledger.
|
|
/* eslint-disable no-restricted-syntax, no-continue */
|
|
for (const tl of timeline.linkedTimelines) {
|
|
const events = tl.getEvents();
|
|
for (const ev of events) {
|
|
if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
|
|
const content = ev.getContent<SessionMembershipData>();
|
|
const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
|
|
let slotId: string | null = null;
|
|
if (typeof content.call_id === 'string') {
|
|
slotId = content.call_id;
|
|
} else if (typeof prevContent.call_id === 'string') {
|
|
slotId = prevContent.call_id;
|
|
}
|
|
if (slotId == null) continue;
|
|
// `anchorEventId` is the React key for the merged call bubble; an
|
|
// empty fallback would collide across multiple eventless rows.
|
|
const evId = ev.getId();
|
|
if (!evId) continue;
|
|
const ts = ev.getTs();
|
|
const isJoin = !!content.application;
|
|
const wasPreviouslyJoined = !!prevContent.application;
|
|
const sender = ev.getSender() ?? '';
|
|
const stateKey = ev.getStateKey() ?? '';
|
|
|
|
let sessions = slotSessions.get(slotId);
|
|
if (!sessions) {
|
|
sessions = [];
|
|
slotSessions.set(slotId, sessions);
|
|
}
|
|
let scan = sessions[sessions.length - 1];
|
|
if (!scan || scan.closed) {
|
|
// Don't open a session for a stray leave event with no prior join
|
|
// context (its session paginated out — there is nothing to render
|
|
// and absorbing it would corrupt the next real session's counts).
|
|
if (!isJoin && !wasPreviouslyJoined) continue;
|
|
scan = {
|
|
slotId,
|
|
anchorEventId: evId,
|
|
anchorSenderId: sender,
|
|
startTs: ts,
|
|
endTs: ts,
|
|
connectedAt: null,
|
|
endedAt: null,
|
|
joinedCount: 0,
|
|
participants: new Set(),
|
|
memberStates: new Map(),
|
|
joinedAbsoluteExpiries: new Map(),
|
|
everJoined: false,
|
|
closed: false,
|
|
};
|
|
sessions.push(scan);
|
|
}
|
|
|
|
if (ts < scan.startTs) {
|
|
scan.startTs = ts;
|
|
scan.anchorEventId = evId;
|
|
scan.anchorSenderId = sender;
|
|
}
|
|
if (ts > scan.endTs) scan.endTs = ts;
|
|
|
|
const prevJoined = scan.memberStates.get(stateKey) ?? false;
|
|
if (isJoin && !prevJoined) {
|
|
scan.joinedCount += 1;
|
|
scan.everJoined = true;
|
|
if (scan.joinedCount === 2 && scan.connectedAt === null) {
|
|
scan.connectedAt = ts;
|
|
}
|
|
} else if (!isJoin && prevJoined) {
|
|
if (scan.joinedCount === 2) {
|
|
scan.endedAt = ts;
|
|
}
|
|
scan.joinedCount = Math.max(0, scan.joinedCount - 1);
|
|
}
|
|
scan.memberStates.set(stateKey, isJoin);
|
|
if (isJoin) {
|
|
const createdTs = typeof content.created_ts === 'number' ? content.created_ts : ts;
|
|
// `expires === 0` (or any non-positive value) is a misbehaving
|
|
// client emitting an instantly-stale membership — refuse and fall
|
|
// back to the SDK default rather than declaring the join expired
|
|
// on arrival.
|
|
const expiresDelta =
|
|
typeof content.expires === 'number' && content.expires > 0
|
|
? content.expires
|
|
: DEFAULT_EXPIRE_DURATION;
|
|
scan.joinedAbsoluteExpiries.set(stateKey, createdTs + expiresDelta);
|
|
} else {
|
|
scan.joinedAbsoluteExpiries.delete(stateKey);
|
|
}
|
|
|
|
// A leave with `prev_content.application` proves this sender was
|
|
// previously joined even if their join event paginated out of view.
|
|
// Counting it keeps `wasAnswered` correct when the user scrolls
|
|
// into the middle of a call.
|
|
if (sender && (isJoin || wasPreviouslyJoined)) scan.participants.add(sender);
|
|
|
|
// Close the session once everyone has left. The next event for this
|
|
// slot opens a fresh session — this is what separates back-to-back
|
|
// calls that share the same legacy room-scoped slot id.
|
|
if (scan.joinedCount === 0 && scan.everJoined) {
|
|
scan.closed = true;
|
|
}
|
|
}
|
|
}
|
|
/* eslint-enable no-restricted-syntax, no-continue */
|
|
const now = Date.now();
|
|
// Consecutive unsuccessful sessions from the same caller within this
|
|
// window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam).
|
|
// Answered/ongoing calls always stand alone.
|
|
const MERGE_WINDOW_MS = 60 * 60 * 1000;
|
|
type SessionDisplay = {
|
|
scan: CallScan;
|
|
ongoing: boolean;
|
|
wasAnswered: boolean;
|
|
};
|
|
type DisplayUnit = {
|
|
// Anchor scan — its `anchorEventId` is the event the merged bubble
|
|
// attaches to in the timeline. We pick the LATEST scan in the group
|
|
// so the bubble shows at the most recent attempt's position, while
|
|
// earlier attempts disappear from view (the spam we want to hide).
|
|
anchor: SessionDisplay;
|
|
mergedCount: number;
|
|
// false once any answered/ongoing scan lands in the unit — sealed
|
|
// units never absorb later scans.
|
|
mergeable: boolean;
|
|
// Used by the merge predicate: the latest scan's endTs decides the
|
|
// gap to the next candidate.
|
|
lastEndTs: number;
|
|
};
|
|
const anchors = new Map<string, CallAggregate>();
|
|
// Second pass: collapse same-sender unanswered scans into merge units.
|
|
// Same justification as the upstream loop — pipeline expression with
|
|
// early `continue` is clearer than reduce+filter chains.
|
|
/* eslint-disable no-restricted-syntax, no-continue */
|
|
for (const sessions of slotSessions.values()) {
|
|
const units: DisplayUnit[] = [];
|
|
for (const scan of sessions) {
|
|
if (!scan.everJoined && scan.participants.size === 0) continue;
|
|
const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some((exp) => exp > now);
|
|
const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2;
|
|
const display: SessionDisplay = { scan, ongoing, wasAnswered };
|
|
const mergeable = !ongoing && !wasAnswered;
|
|
const last = units[units.length - 1];
|
|
if (
|
|
mergeable &&
|
|
last !== undefined &&
|
|
last.mergeable &&
|
|
last.anchor.scan.anchorSenderId === scan.anchorSenderId &&
|
|
scan.startTs - last.lastEndTs <= MERGE_WINDOW_MS
|
|
) {
|
|
last.anchor = display;
|
|
last.mergedCount += 1;
|
|
last.lastEndTs = scan.endTs;
|
|
} else {
|
|
units.push({
|
|
anchor: display,
|
|
mergedCount: 1,
|
|
mergeable,
|
|
lastEndTs: scan.endTs,
|
|
});
|
|
}
|
|
}
|
|
for (const unit of units) {
|
|
const { scan, ongoing, wasAnswered } = unit.anchor;
|
|
const conversationStart = wasAnswered ? scan.connectedAt ?? scan.startTs : null;
|
|
const conversationEnd = wasAnswered && !ongoing ? scan.endedAt ?? scan.endTs : null;
|
|
anchors.set(scan.anchorEventId, {
|
|
callId: scan.slotId,
|
|
startTs: scan.startTs,
|
|
endTs: ongoing ? null : scan.endTs,
|
|
conversationStart,
|
|
conversationEnd,
|
|
ongoing,
|
|
wasAnswered,
|
|
participants: Array.from(scan.participants),
|
|
anchorSenderId: scan.anchorSenderId,
|
|
mergedCount: unit.mergedCount,
|
|
});
|
|
}
|
|
}
|
|
/* eslint-enable no-restricted-syntax, no-continue */
|
|
return anchors;
|
|
})();
|
|
|
|
const renderMatrixEvent = useMatrixEventRenderer<
|
|
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
|
|
>(
|
|
{
|
|
// Suppress DM-call service events from the timeline. In encrypted
|
|
// DMs this takes effect after per-event decryption re-render via
|
|
// EncryptedContent; the first render shows the "not decrypted" placeholder.
|
|
// Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
|
|
// when MSC4075/MSC4310 stabilize.
|
|
'org.matrix.msc4075.rtc.notification': () => null,
|
|
'org.matrix.msc4310.rtc.decline': () => null,
|
|
[MessageEvent.RoomMessage]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
timelineSet,
|
|
collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
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 senderDisplayName =
|
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
edit={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={handleEdit}
|
|
reply={
|
|
replyEventId && (
|
|
<Reply
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
replyEventId={replyEventId}
|
|
threadRootId={threadRootId}
|
|
onClick={handleOpenReply}
|
|
getMemberPowerTag={getMemberPowerTag}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={isOneOnOne}
|
|
/>
|
|
)
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
memberPowerTag={getMemberPowerTag(senderId)}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={isOneOnOne}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
|
hideMainReplyAffordance={hideMainReplyAffordance}
|
|
threadSummary={
|
|
showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
|
|
}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
>
|
|
{mEvent.isRedacted() ? (
|
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
|
) : (
|
|
<RenderMessageContent
|
|
displayName={senderDisplayName}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
ts={mEvent.getTs()}
|
|
edited={!!editedEvent}
|
|
getContent={getContent}
|
|
mediaAutoLoad={mediaAutoLoad}
|
|
urlPreview={showUrlPreview}
|
|
htmlReactParserOptions={htmlReactParserOptions}
|
|
linkifyOpts={linkifyOpts}
|
|
outlineAttachment
|
|
eventId={mEvent.getId()}
|
|
/>
|
|
)}
|
|
</Message>
|
|
);
|
|
},
|
|
[MessageEvent.RoomMessageEncrypted]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
timelineSet,
|
|
collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
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;
|
|
|
|
return (
|
|
<EncryptedContent mEvent={mEvent}>
|
|
{() => {
|
|
// After decrypt, DM-call service events still route through
|
|
// this branch (outer typeToRenderer dispatched on the pre-decrypt
|
|
// 'm.room.encrypted' type). Drop the whole row instead of falling
|
|
// through to MessageUnsupportedContent. Keys mirror the hardcoded
|
|
// literals in the outer filter — migrate together.
|
|
const decryptedType = mEvent.getType();
|
|
if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
|
|
if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
edit={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={handleEdit}
|
|
reply={
|
|
replyEventId && (
|
|
<Reply
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
replyEventId={replyEventId}
|
|
threadRootId={threadRootId}
|
|
onClick={handleOpenReply}
|
|
getMemberPowerTag={getMemberPowerTag}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={isOneOnOne}
|
|
/>
|
|
)
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={isOneOnOne}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
|
hideMainReplyAffordance={hideMainReplyAffordance}
|
|
threadSummary={
|
|
showThreadSummary ? (
|
|
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
|
) : undefined
|
|
}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
>
|
|
{(() => {
|
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
|
if (decryptedType === MessageEvent.Sticker)
|
|
return (
|
|
<MSticker
|
|
content={mEvent.getContent()}
|
|
renderImageContent={(props) => (
|
|
<ImageContent
|
|
{...props}
|
|
autoPlay={mediaAutoLoad}
|
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
|
renderViewer={(p) => <ImageViewer {...p} />}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
if (decryptedType === 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 (
|
|
<RenderMessageContent
|
|
displayName={senderDisplayName}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
ts={mEvent.getTs()}
|
|
edited={!!editedEvent}
|
|
getContent={getContent}
|
|
mediaAutoLoad={mediaAutoLoad}
|
|
urlPreview={showUrlPreview}
|
|
htmlReactParserOptions={htmlReactParserOptions}
|
|
linkifyOpts={linkifyOpts}
|
|
outlineAttachment
|
|
eventId={mEvent.getId()}
|
|
/>
|
|
);
|
|
}
|
|
if (decryptedType === MessageEvent.RoomMessageEncrypted)
|
|
return (
|
|
<Text>
|
|
<MessageNotDecryptedContent />
|
|
</Text>
|
|
);
|
|
return (
|
|
<Text>
|
|
<MessageUnsupportedContent />
|
|
</Text>
|
|
);
|
|
})()}
|
|
</Message>
|
|
);
|
|
}}
|
|
</EncryptedContent>
|
|
);
|
|
},
|
|
[MessageEvent.Sticker]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
timelineSet,
|
|
collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
|
const hasReactions = reactions && reactions.length > 0;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
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}
|
|
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={isOneOnOne}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
|
hideMainReplyAffordance={hideMainReplyAffordance}
|
|
threadSummary={
|
|
showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
|
|
}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
>
|
|
{mEvent.isRedacted() ? (
|
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
|
) : (
|
|
<MSticker
|
|
content={mEvent.getContent()}
|
|
renderImageContent={(props) => (
|
|
<ImageContent
|
|
{...props}
|
|
autoPlay={mediaAutoLoad}
|
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
|
renderViewer={(p) => <ImageViewer {...p} />}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
</Message>
|
|
);
|
|
},
|
|
[StateEvent.RoomMember]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
_timelineSet,
|
|
_collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
// 1:1 rooms always hide membership/nick/avatar syslines — they are
|
|
// pure noise in DMs. Group rooms (3+) respect the per-user settings.
|
|
if (isOneOnOne) return null;
|
|
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);
|
|
return (
|
|
<SyslineMessage
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
body={parsed.body}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
/>
|
|
);
|
|
},
|
|
[StateEvent.RoomName]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
_timelineSet,
|
|
_collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName =
|
|
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
|
return (
|
|
<SyslineMessage
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
body={
|
|
<>
|
|
<b>{senderName}</b>
|
|
{t('Organisms.RoomCommon.changed_room_name')}
|
|
</>
|
|
}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
/>
|
|
);
|
|
},
|
|
[StateEvent.RoomTopic]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
_timelineSet,
|
|
_collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName =
|
|
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
|
return (
|
|
<SyslineMessage
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
body={
|
|
<>
|
|
<b>{senderName}</b>
|
|
{' changed room topic'}
|
|
</>
|
|
}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
/>
|
|
);
|
|
},
|
|
[StateEvent.RoomAvatar]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
_timelineSet,
|
|
_collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName =
|
|
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
|
return (
|
|
<SyslineMessage
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
body={
|
|
<>
|
|
<b>{senderName}</b>
|
|
{' changed room avatar'}
|
|
</>
|
|
}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
/>
|
|
);
|
|
},
|
|
[StateEvent.GroupCallMemberPrefix]: (
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
_timelineSet,
|
|
_collapse,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
) => {
|
|
// One aggregate bubble per call — only the call's anchor event
|
|
// renders, all other join/leave events for the same call_id are
|
|
// collapsed (null). `callAnchors` is computed above and mirrored
|
|
// in `isRenderableTimelineEvent` so rail-endpoints agree.
|
|
const aggregate = callAnchors.get(mEventId);
|
|
if (!aggregate) return null;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
return (
|
|
<CallMessage
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
aggregate={aggregate}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
streamRailStart={streamRailStart}
|
|
streamRailEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
channelHeaderInBubble={channelHeaderInBubble}
|
|
memberPowerTag={getMemberPowerTag(aggregate.anchorSenderId)}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={isOneOnOne}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
|
|
if (!showHiddenEvents) return null;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = <Time ts={mEvent.getTs()} compact />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
time={timeJSX}
|
|
railStart={streamRailStart}
|
|
railEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
iconSrc={Icons.Code}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' sent '}
|
|
<code className={customHtmlCss.Code}>{mEvent.getType()}</code>
|
|
{' state event'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
|
|
if (!showHiddenEvents) return null;
|
|
if (Object.keys(mEvent.getContent()).length === 0) return null;
|
|
if (mEvent.getRelation()) return null;
|
|
if (mEvent.isRedaction()) return null;
|
|
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = <Time ts={mEvent.getTs()} compact />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
time={timeJSX}
|
|
railStart={streamRailStart}
|
|
railEnd={streamRailEnd}
|
|
layout={messageLayout}
|
|
iconSrc={Icons.Code}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' sent '}
|
|
<code className={customHtmlCss.Code}>{mEvent.getType()}</code>
|
|
{' event'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
}
|
|
);
|
|
|
|
let prevEvent: MatrixEvent | undefined;
|
|
let isPrevRendered = false;
|
|
let newDivider = false;
|
|
let dayDivider = false;
|
|
|
|
// Keep this in sync with renderMatrixEvent early `return null` branches above:
|
|
// Stream rail endpoints (start/end) use this predicate to skip hidden
|
|
// service / reaction / edit events when deciding whether a visible row is
|
|
// the first or last timeline dot.
|
|
const isRenderableTimelineEvent = (event: MatrixEvent): boolean => {
|
|
const eventIdForRender = event.getId();
|
|
if (!eventIdForRender) return false;
|
|
|
|
const senderId = event.getSender();
|
|
if (senderId && ignoredUsersSet.has(senderId)) return false;
|
|
if (event.isRedacted() && !showHiddenEvents) return false;
|
|
if (reactionOrEditEvent(event)) return false;
|
|
|
|
// RTC service events (notifications / declines) — once decrypted, the
|
|
// SDK's getType() returns the inner type, so this filter catches them.
|
|
// While still encrypted+pending-decryption, the event passes the
|
|
// RoomMessageEncrypted branch below as «renderable»; once decryption
|
|
// completes, the timeline emits a fresh render that re-evaluates this
|
|
// predicate with the inner type and falls through to false. The
|
|
// transient one-frame mismatch on rail endpoint computation self-heals
|
|
// on the very next pass — no code path needed.
|
|
const eventType = event.getType();
|
|
if (eventType === 'org.matrix.msc4075.rtc.notification') return false;
|
|
if (eventType === 'org.matrix.msc4310.rtc.decline') return false;
|
|
|
|
// Channels-mode filter — see `isChannelsModeHidden` above for the rule
|
|
// set and rationale. Same helper used by the renderer null-gate so the
|
|
// rail-endpoint scan and the actual render always agree on visibility.
|
|
if (channelsMode && isChannelsModeHidden(room, event, isBridged)) return false;
|
|
|
|
if (eventType === StateEvent.RoomMember) {
|
|
// Mirror the membership-sysline gate from the renderer above so the
|
|
// rail-endpoint scan and the actual render agree on visibility.
|
|
if (isOneOnOne) return false;
|
|
const membershipChanged = isMembershipChanged(event);
|
|
if (membershipChanged && hideMembershipEvents) return false;
|
|
if (!membershipChanged && hideNickAvatarEvents) return false;
|
|
return true;
|
|
}
|
|
|
|
if (eventType === StateEvent.GroupCallMemberPrefix) {
|
|
// Mirror the renderer: only the per-call anchor event is renderable;
|
|
// all other joins/leaves for the same call_id collapse into the
|
|
// aggregate bubble. Rail-endpoint scan must agree with the renderer.
|
|
return callAnchors.has(event.getId() ?? '');
|
|
}
|
|
|
|
if (
|
|
eventType === MessageEvent.RoomMessage ||
|
|
eventType === MessageEvent.RoomMessageEncrypted ||
|
|
eventType === MessageEvent.Sticker ||
|
|
eventType === StateEvent.RoomName ||
|
|
eventType === StateEvent.RoomTopic ||
|
|
eventType === StateEvent.RoomAvatar
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (typeof event.getStateKey() === 'string') return showHiddenEvents;
|
|
if (!showHiddenEvents) return false;
|
|
if (Object.keys(event.getContent()).length === 0) return false;
|
|
if (event.getRelation()) return false;
|
|
// Note: redactions are intentionally NOT filtered here. The catch-all
|
|
// renderer still renders them when showHiddenEvents=true (dev tools),
|
|
// so the predicate must agree — otherwise rail endpoints would treat a
|
|
// visible redaction event as invisible and miscount «is there a renderable
|
|
// event before/after this row» on the dev-tools path.
|
|
|
|
return true;
|
|
};
|
|
|
|
const getTimelineItemEvent = (item: number): MatrixEvent | undefined => {
|
|
const [itemTimeline, itemBaseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
|
if (!itemTimeline) return undefined;
|
|
return getTimelineEvent(itemTimeline, getTimelineRelativeIndex(item, itemBaseIndex));
|
|
};
|
|
|
|
// Single forward + reverse pass that records, for each visible item, whether
|
|
// there is any RENDERABLE event before / after it. Used to compute Stream
|
|
// rail-start (no renderable before) and rail-end (no renderable after).
|
|
// Crucially this looks at renderability, not at `isPrevRendered` — the
|
|
// latter is mutated by reaction / edit / hidden service events and would
|
|
// otherwise reset rail-start in the middle of a continuous DM thread.
|
|
const { before: streamRenderableItemHasBefore, after: streamRenderableItemHasAfter } = (() => {
|
|
const before = new Map<number, boolean>();
|
|
const after = new Map<number, boolean>();
|
|
|
|
const items = getItems();
|
|
const renderableFlags = items.map((item) => {
|
|
const ev = getTimelineItemEvent(item);
|
|
return !!ev && isRenderableTimelineEvent(ev);
|
|
});
|
|
|
|
let seenBefore = false;
|
|
for (let index = 0; index < items.length; index += 1) {
|
|
before.set(items[index], seenBefore);
|
|
if (renderableFlags[index]) seenBefore = true;
|
|
}
|
|
|
|
let seenAfter = false;
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
after.set(items[index], seenAfter);
|
|
if (renderableFlags[index]) seenAfter = true;
|
|
}
|
|
|
|
return { before, after };
|
|
})();
|
|
|
|
const eventRenderer = (item: number) => {
|
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
|
if (!eventTimeline) return null;
|
|
const timelineSet = eventTimeline?.getTimelineSet();
|
|
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
|
const mEventId = mEvent?.getId();
|
|
|
|
if (!mEvent || !mEventId) return null;
|
|
|
|
const eventSender = mEvent.getSender();
|
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
|
return null;
|
|
}
|
|
if (mEvent.isRedacted() && !showHiddenEvents) {
|
|
return null;
|
|
}
|
|
|
|
if (!newDivider && readUptoEventIdRef.current) {
|
|
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
|
}
|
|
if (!dayDivider) {
|
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
|
}
|
|
|
|
const collapsed =
|
|
isPrevRendered &&
|
|
!dayDivider &&
|
|
(!newDivider || eventSender === mx.getUserId()) &&
|
|
prevEvent !== undefined &&
|
|
prevEvent.getSender() === eventSender &&
|
|
prevEvent.getType() === mEvent.getType() &&
|
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
|
|
|
// streamRailStart looks at the precomputed «is there a renderable event
|
|
// before me in the visible window» — not at `isPrevRendered`, which is
|
|
// false for the row right after a reaction / edit / hidden service event
|
|
// and would otherwise restart the rail mid-conversation. Symmetric with
|
|
// streamRailEnd: only declare a row to be the rail's first dot when the
|
|
// visible window is sitting at the genuine timeline start AND no further
|
|
// back-pagination is possible — otherwise the «origin» dot would be a
|
|
// lie about an earlier untouched history.
|
|
const streamRailStart =
|
|
rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false;
|
|
const streamRailEnd =
|
|
liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
|
|
|
|
// Channels-mode renderer gate — same helper as the predicate above so
|
|
// the rail-endpoint scan and the renderer never disagree on visibility.
|
|
const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged);
|
|
|
|
const eventJSX =
|
|
channelsModeHidden || reactionOrEditEvent(mEvent)
|
|
? null
|
|
: renderMatrixEvent(
|
|
mEvent.getType(),
|
|
typeof mEvent.getStateKey() === 'string',
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
timelineSet,
|
|
collapsed,
|
|
streamRailStart,
|
|
streamRailEnd
|
|
);
|
|
prevEvent = mEvent;
|
|
isPrevRendered = !!eventJSX;
|
|
|
|
if (newDivider && eventJSX) {
|
|
// TODO(P3c-followup): replace the legacy full-width unread divider with
|
|
// a Stream-native first-unread affordance — dot ring/pulse/brightness on
|
|
// the rail. The «Jump to unread» chip stays functional in the meantime.
|
|
newDivider = false;
|
|
}
|
|
|
|
const dayLabel = (() => {
|
|
if (today(mEvent.getTs())) return t('Room.today');
|
|
if (yesterday(mEvent.getTs())) return t('Room.yesterday');
|
|
return timeDayMonYear(mEvent.getTs());
|
|
})();
|
|
|
|
const renderDayDivider = () => (
|
|
<MessageBase
|
|
space={messageLayout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
|
|
>
|
|
{messageLayout === 'channel' ? (
|
|
<ChannelDayDivider label={dayLabel} />
|
|
) : (
|
|
<StreamDayDivider label={dayLabel} />
|
|
)}
|
|
</MessageBase>
|
|
);
|
|
|
|
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
|
|
|
|
if (eventJSX && dayDividerJSX) {
|
|
dayDivider = false;
|
|
return (
|
|
<React.Fragment key={mEventId}>
|
|
{dayDividerJSX}
|
|
{eventJSX}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
return eventJSX;
|
|
};
|
|
|
|
return (
|
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
|
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
|
<TimelineFloat position="Top">
|
|
<Chip
|
|
variant="Primary"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.MessageUnread} />}
|
|
onClick={handleJumpToUnread}
|
|
>
|
|
<Text size="L400">{t('Room.jump_to_unread')}</Text>
|
|
</Chip>
|
|
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
onClick={handleMarkAsRead}
|
|
>
|
|
<Text size="L400">{t('Room.mark_as_read')}</Text>
|
|
</Chip>
|
|
</TimelineFloat>
|
|
)}
|
|
<Scroll ref={scrollRef} visibility="Hover">
|
|
<Box
|
|
direction="Column"
|
|
justifyContent="End"
|
|
style={{
|
|
minHeight: '100%',
|
|
// Bottom padding reserves room for the overlay composer painted
|
|
// by RoomView. Driven by the same number passed in via
|
|
// `bottomOverlayHeight` to keep React state and CSS layout in
|
|
// sync — a layout effect (`bottomOverlayHeight` dep, above)
|
|
// re-anchors the scroll on every transition so the latest
|
|
// message stays flush above the composer.
|
|
paddingTop: config.space.S600,
|
|
paddingBottom: `calc(${config.space.S600} + ${bottomOverlayHeight}px)`,
|
|
}}
|
|
>
|
|
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
|
<div
|
|
style={{
|
|
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${toRem(
|
|
64
|
|
)}`,
|
|
}}
|
|
>
|
|
<RoomIntro room={room} />
|
|
</div>
|
|
)}
|
|
{(canPaginateBack || !rangeAtStart) && (
|
|
<>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase ref={observeBackAnchor}>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
</>
|
|
)}
|
|
|
|
{getItems().map(eventRenderer)}
|
|
|
|
{(!liveTimelineLinked || !rangeAtEnd) && (
|
|
<>
|
|
<MessageBase ref={observeFrontAnchor}>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
</>
|
|
)}
|
|
<span ref={atBottomAnchorRef} />
|
|
</Box>
|
|
</Scroll>
|
|
<button
|
|
type="button"
|
|
className={css.JumpToLatestFab}
|
|
onClick={handleJumpToLatest}
|
|
aria-label={t('Room.jump_to_latest')}
|
|
aria-hidden={fabHidden}
|
|
tabIndex={fabHidden ? -1 : 0}
|
|
data-hidden={fabHidden ? 'true' : 'false'}
|
|
style={{
|
|
// Sit S400 above the overlay; tracks composer growth (reply
|
|
// preview, multi-line typing) via the live composer height.
|
|
bottom: `calc(${bottomOverlayHeight}px + ${config.space.S400})`,
|
|
}}
|
|
>
|
|
<span
|
|
// Remounting on `pulseCount` change restarts the keyframes
|
|
// animation. pulseCount=0 mounts without the class so the FAB
|
|
// doesn't auto-pulse on first reveal.
|
|
key={pulseCount}
|
|
className={pulseCount === 0 ? undefined : css.FabIconPulseSlot}
|
|
style={pulseCount === 0 ? { display: 'flex' } : undefined}
|
|
>
|
|
<Icon size="300" src={Icons.ChevronBottom} />
|
|
</span>
|
|
</button>
|
|
</Box>
|
|
);
|
|
}
|