mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-05 06:50:28 +03:00
* load room on url change * add direct room list * render space room list * fix css syntax error * update scroll virtualizer * render subspaces room list * improve sidebar notification badge perf * add nav category components * add space recursive direct component * use nav category component in home, direct and space room list * add empty home and direct list layout * fix unread room menu ref * add more navigation items in room, direct and space tab * add more navigation * fix unread room menu to links * fix space lobby and search link * add explore navigation section * add notifications navigation menu * redirect to initial path after login * include unsupported room in rooms * move router hooks in hooks/router folder * add featured explore - WIP * load featured room with room summary * fix room card topic line clamp * add react query * load room summary using react query * add join button in room card * add content component * use content component in featured community content * fix content width * add responsive room card grid * fix async callback error status * add room card error button * fix client drawer shrink * add room topic viewer * open room card topic in viewer * fix room topic close btn * add get orphan parent util * add room card error dialog * add view featured room or space btn * refactor orphanParent to orphanParents * WIP - explore server * show space hint in room card * add room type filters * add per page item limit popout * reset scroll on public rooms load * refactor explore ui * refactor public rooms component * reset search on server change * fix typo * add empty featured section info * display user server on top * make server room card view btn clickable * add user server as default redirect for explore path * make home empty btn clickable * add thirdparty instance filter in server explore * remove since param on instance change * add server button in explore menu * rename notifications path to inbox * update react-virtual * Add notification messages inbox - WIP * add scroll top container component * add useInterval hook * add visibility change callback prop to scroll top container component * auto refresh notifications every 10 seconds * make message related component reusable * refactor matrix event renderer hoook * render notification message content * refactor matrix event renderer hook * update sequence card styles * move room navigate hook in global hooks * add open message button in notifications * add mark room as read button in notification group * show error in notification messages * add more featured spaces * render reply in notification messages * make notification message reply clickable * add outline prop for attachments * make old settings dialog viewable * add open featured communities as default config option * add invite count notification badge in sidebar and inbox menu * add element size observer hook * improve element size observer hook props * improve screen size hook * fix room avatar util function * allow Text props in Time component * fix dm room util function * add invitations * add no invites and notification cards * fix inbox tab unread badge visible without invite count * update folds and change inbox icon * memo search param construction * add message search in home * fix default message search order * fix display edited message new content * highlight search text in search messages * fix message search loading * disable log in production * add use space context * add useRoom context * fix space room list * fix inbox tab active state * add hook to get space child room recursive * add search for space * add virtual tile component * virtualize home and directs room list * update nav category component * use virtual tile component in more places * fix message highlight when click on reply twice * virtualize space room list * fix space room list lag issue * update folds * add room nav item component in space room list * use room nav item in home and direct room list * make space categories closable and save it in local storage * show unread room when category is collapsed * make home and direct room list category closable * rename room nav item show avatar prop * fix explore server category text alignment * rename closedRoomCategories to closedNavCategories * add nav category handler hook * save and restore last navigation path on space select * filter space rooms category by activity when it is closed * save and restore home and direct nav path state * save and restore inbox active path on open * save and restore explore tab active path * remove notification badge unread menu * add join room or space before navigate screen * move room component to features folder and add new room header * update folds * add room header menu * fix home room list activity sorting * do not hide selected room item on category closed in home and direct tab * replace old select room/tab call with navigate hook * improve state event hooks * show room card summary for joined rooms * prevent room from opening in wrong tab * only show message sender id on hover in modern layout * revert state event hooks changes * add key prop to room provider components * add welcome page * prevent excessive redirects * fix sidebar style with no spaces * move room settings in popup window * remove invite option from room settings * fix open room list search * add leave room prompt * standardize room and user avatar * fix avatar text size * add new reply layout * rename space hierarchy hook * add room topic hook * add room name hook * add room avatar hook and add direct room avatar util * space lobby - WIP * hide invalid space child event from space hierarchy in lobby * move lobby to features * fix element size observer hook width and height * add lobby header and hero section * add hierarchy room item error and loading state * add first and last child prop in sequence card * redirect to lobby from index path * memo and retry hierarchy room summary error * fix hierarchy room item styles * rename lobby hierarchy item card to room item card * show direct room avatar in space lobby * add hierarchy space item * add space item unknown room join button * fix space hierarchy hook refresh after new space join * change user avatar color and fallback render to user icon * change room avatar fallback to room icon * rename room/user avatar renderInitial prop to renderFallback * add room join and view button in space lobby * make power level api more reusable * fix space hierarchy not updating on child update * add menu to suggest or remove space children * show reply arrow in place of reply bend in message * fix typeerror in search because of wrong js-sdk t.ds * do not refetch hierarchy room summary on window focus * make room/user avatar un-draggable * change welcome page support button copy * drag-and-drop ordering of lobby spaces/rooms - WIP * add ASCIILexicalTable algorithms * fix wrong power level check in lobby items options * fix lobby can drop checks * fix join button error crash * fix reply spacing * fix m direct updated with other account data * add option to open room/space settings from lobby * add option in lobby to add new or existing room/spaces * fix room nav item selected styles * add space children reorder mechanism * fix space child reorder bug * fix hierarchy item sort function * Apply reorder of lobby into room list * add and improve space lobby menu items * add existing spaces menu in lobby * change restricted room allow params when dragging outside space * move featured servers config from homeserver list * removed unused features from space settings * add canonical alias as name fallback in lobby item * fix unreliable unread count update bug * fix after login redirect * fix room card topic hover style * Add dnd and folders in sidebar spaces * fix orphan space not visible in sidebar * fix sso login has mix of icon and button * fix space children not visible in home upon leaving space * recalculate notification on updating any space child * fix user color saturation/lightness * add user color to user avatar * add background colors to room avatar * show 2 length initial in sidebar space avatar * improve link color * add nav button component * open legacy create room and create direct * improve page route structure * handle hash router in path utils * mobile friendly router and navigation * make room header member drawer icon mobile friendly * setup index redirect for inbox and explore server route * add leave space prompt * improve member drawer filter menu * add space context menu * add context menu in home * add leave button in lobby items * render user tab avatar on sidebar * force overwrite netlify - test * netlify test * fix reset-password path without server redirected to login * add message link copy button in message menu * reset unread on sync prepared * fix stuck typing notifications * show typing indication in room nav item * refactor closedNavCategories atom to use userId in store key * refactor closedLobbyCategoriesAtom to include userId in store key * refactor navToActivePathAtom to use userId in storage key * remove unused file * refactor openedSidebarFolderAtom to include userId in storage key * add context menu for sidebar space tab * fix eslint not working * add option to pin/unpin child spaces * add context menu for directs tab * add context menu for direct and home tab * show lock icon for non-public space in header * increase matrix max listener count * wrap lobby add space room in callback hook
1614 lines
53 KiB
TypeScript
1614 lines
53 KiB
TypeScript
/* eslint-disable react/destructuring-assignment */
|
|
import React, {
|
|
Dispatch,
|
|
MouseEventHandler,
|
|
RefObject,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Direction,
|
|
EventTimeline,
|
|
EventTimelineSet,
|
|
EventTimelineSetHandlerMap,
|
|
IEncryptedFile,
|
|
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 { 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 {
|
|
decryptFile,
|
|
eventWithShortcode,
|
|
factoryEventSentBy,
|
|
getMxIdLocalPart,
|
|
isRoomId,
|
|
isUserId,
|
|
} 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 { getReactCustomHtmlParser } 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 { settingsAtom } from '../../state/settings';
|
|
import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
|
|
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 initMatrix from '../../../client/initMatrix';
|
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
|
import cons from '../../../client/state/cons';
|
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
|
import { Image } from '../../components/media';
|
|
import { ImageViewer } from '../../components/image-viewer';
|
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
|
|
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}
|
|
/>
|
|
)
|
|
);
|
|
|
|
const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
|
|
({ variant, children, ...props }, ref) => (
|
|
<Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
|
|
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
|
|
{children}
|
|
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
|
|
</Box>
|
|
)
|
|
);
|
|
|
|
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;
|
|
};
|
|
|
|
export const factoryGetFileSrcUrl =
|
|
(httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
|
|
if (encFile) {
|
|
if (typeof httpUrl !== 'string') throw new Error('Malformed event');
|
|
const encRes = await fetch(httpUrl, { method: 'GET' });
|
|
const encData = await encRes.arrayBuffer();
|
|
const decryptedBlob = await decryptFile(encData, mimeType, encFile);
|
|
return URL.createObjectURL(decryptedBlob);
|
|
}
|
|
return httpUrl;
|
|
};
|
|
|
|
type RoomTimelineProps = {
|
|
room: Room;
|
|
eventId?: string;
|
|
roomInputRef: RefObject<HTMLElement>;
|
|
editor: Editor;
|
|
};
|
|
|
|
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<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
|
|
if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
|
|
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 }: RoomTimelineProps) {
|
|
const mx = useMatrixClient();
|
|
const encryptedRoom = mx.isRoomEncrypted(room.roomId);
|
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
|
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 = encryptedRoom ? encUrlPreview : urlPreview;
|
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
|
const powerLevels = usePowerLevelsContext();
|
|
const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
|
|
const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
|
|
const canRedact = canDoAction('redact', myPowerLevel);
|
|
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
|
const [editId, setEditId] = useState<string>();
|
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
|
|
|
const imagePackRooms: Room[] = useMemo(() => {
|
|
const allParentSpaces = [
|
|
room.roomId,
|
|
...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
|
|
];
|
|
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
|
const r = mx.getRoom(rId);
|
|
if (r) list.push(r);
|
|
return list;
|
|
}, []);
|
|
}, [mx, room]);
|
|
|
|
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;
|
|
|
|
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 htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
|
() =>
|
|
getReactCustomHtmlParser(mx, room, {
|
|
handleSpoilerClick: (evt) => {
|
|
const target = evt.currentTarget;
|
|
if (target.getAttribute('aria-pressed') === 'true') {
|
|
evt.stopPropagation();
|
|
target.setAttribute('aria-pressed', 'false');
|
|
target.style.cursor = 'initial';
|
|
}
|
|
},
|
|
handleMentionClick: (evt) => {
|
|
const target = evt.currentTarget;
|
|
const mentionId = target.getAttribute('data-mention-id');
|
|
if (typeof mentionId !== 'string') return;
|
|
if (isUserId(mentionId)) {
|
|
openProfileViewer(mentionId, room.roomId);
|
|
return;
|
|
}
|
|
if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
|
|
if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
|
|
else navigateRoom(mentionId);
|
|
return;
|
|
}
|
|
openJoinAlias(mentionId);
|
|
},
|
|
}),
|
|
[mx, room, navigateRoom, navigateSpace]
|
|
);
|
|
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])
|
|
);
|
|
|
|
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())) {
|
|
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()));
|
|
}
|
|
|
|
if (document.hasFocus()) {
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = true;
|
|
} else if (!unreadInfo) {
|
|
setUnreadInfo(getRoomUnreadInfo(room));
|
|
}
|
|
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]
|
|
)
|
|
);
|
|
|
|
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(() => {
|
|
if (!unreadInfo) {
|
|
requestAnimationFrame(() => markAsRead(room.roomId));
|
|
return;
|
|
}
|
|
const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
|
|
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
|
if (latestTimeline === room.getLiveTimeline()) {
|
|
requestAnimationFrame(() => markAsRead(room.roomId));
|
|
}
|
|
}, [room, unreadInfo]);
|
|
|
|
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);
|
|
tryAutoMarkAsRead();
|
|
}
|
|
},
|
|
[debounceSetAtBottom, tryAutoMarkAsRead]
|
|
),
|
|
useCallback(
|
|
() => ({
|
|
root: getScrollElement(),
|
|
rootMargin: '100px',
|
|
}),
|
|
[getScrollElement]
|
|
),
|
|
useCallback(() => atBottomAnchorRef.current, [])
|
|
);
|
|
|
|
useDocumentFocusChange(
|
|
useCallback(
|
|
(inFocus) => {
|
|
if (inFocus && atBottomRef.current) {
|
|
tryAutoMarkAsRead();
|
|
}
|
|
},
|
|
[tryAutoMarkAsRead]
|
|
)
|
|
);
|
|
|
|
// 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);
|
|
}
|
|
},
|
|
[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(() => {
|
|
const handleFullRead = (rId: string) => {
|
|
if (rId !== room.roomId) return;
|
|
setUnreadInfo(undefined);
|
|
};
|
|
initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
|
|
return () => {
|
|
initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
|
|
};
|
|
}, [room]);
|
|
|
|
// 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 = () => {
|
|
setTimeline(getInitialTimeline(room));
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = false;
|
|
};
|
|
|
|
const handleJumpToUnread = () => {
|
|
if (unreadInfo?.readUptoEventId) {
|
|
setTimeline(getEmptyTimeline());
|
|
loadEventTimeline(unreadInfo.readUptoEventId);
|
|
}
|
|
};
|
|
|
|
const handleMarkAsRead = () => {
|
|
markAsRead(room.roomId);
|
|
};
|
|
|
|
const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
async (evt) => {
|
|
const replyId = evt.currentTarget.getAttribute('data-reply-id');
|
|
if (typeof replyId !== 'string') return;
|
|
const replyTimeline = getEventTimeline(room, replyId);
|
|
const absoluteIndex =
|
|
replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
|
|
|
|
if (typeof absoluteIndex === 'number') {
|
|
scrollToItem(absoluteIndex, {
|
|
behavior: 'smooth',
|
|
align: 'center',
|
|
stopInView: true,
|
|
});
|
|
setFocusItem({
|
|
index: absoluteIndex,
|
|
scrollTo: false,
|
|
highlight: true,
|
|
});
|
|
} else {
|
|
setTimeline(getEmptyTimeline());
|
|
loadEventTimeline(replyId);
|
|
}
|
|
},
|
|
[room, timeline, scrollToItem, loadEventTimeline]
|
|
);
|
|
|
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = 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;
|
|
}
|
|
openProfileViewer(userId, room.roomId);
|
|
},
|
|
[room]
|
|
);
|
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = 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<HTMLButtonElement> = 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 { body, formatted_body: formattedBody }: Record<string, string> =
|
|
editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
|
const senderId = replyEvt.getSender();
|
|
if (senderId && typeof body === 'string') {
|
|
setReplyDraft({
|
|
userId: senderId,
|
|
eventId: replyId,
|
|
body,
|
|
formattedBody,
|
|
});
|
|
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 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 } = 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}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
edit={editId === mEventId}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
canSendReaction={canSendReaction}
|
|
imagePackRooms={imagePackRooms}
|
|
relations={hasReactions ? reactionRelations : undefined}
|
|
onUserClick={handleUserClick}
|
|
onUsernameClick={handleUsernameClick}
|
|
onReplyClick={handleReplyClick}
|
|
onReactionToggle={handleReactionToggle}
|
|
onEditId={handleEdit}
|
|
reply={
|
|
replyEventId && (
|
|
<Reply
|
|
as="button"
|
|
mx={mx}
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
eventId={replyEventId}
|
|
data-reply-id={replyEventId}
|
|
onClick={handleOpenReply}
|
|
/>
|
|
)
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
{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}
|
|
outlineAttachment={messageLayout === 2}
|
|
/>
|
|
)}
|
|
</Message>
|
|
);
|
|
},
|
|
[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 } = mEvent;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
edit={editId === mEventId}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
canSendReaction={canSendReaction}
|
|
imagePackRooms={imagePackRooms}
|
|
relations={hasReactions ? reactionRelations : undefined}
|
|
onUserClick={handleUserClick}
|
|
onUsernameClick={handleUsernameClick}
|
|
onReplyClick={handleReplyClick}
|
|
onReactionToggle={handleReactionToggle}
|
|
onEditId={handleEdit}
|
|
reply={
|
|
replyEventId && (
|
|
<Reply
|
|
as="button"
|
|
mx={mx}
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
eventId={replyEventId}
|
|
data-reply-id={replyEventId}
|
|
onClick={handleOpenReply}
|
|
/>
|
|
)
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
<EncryptedContent mEvent={mEvent}>
|
|
{() => {
|
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
|
if (mEvent.getType() === MessageEvent.Sticker)
|
|
return (
|
|
<MSticker
|
|
content={mEvent.getContent()}
|
|
renderImageContent={(props) => (
|
|
<ImageContent
|
|
{...props}
|
|
autoPlay={mediaAutoLoad}
|
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
|
renderViewer={(p) => <ImageViewer {...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 (
|
|
<RenderMessageContent
|
|
displayName={senderDisplayName}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
ts={mEvent.getTs()}
|
|
edited={!!editedEvent}
|
|
getContent={getContent}
|
|
mediaAutoLoad={mediaAutoLoad}
|
|
urlPreview={showUrlPreview}
|
|
htmlReactParserOptions={htmlReactParserOptions}
|
|
outlineAttachment={messageLayout === 2}
|
|
/>
|
|
);
|
|
}
|
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
|
return (
|
|
<Text>
|
|
<MessageNotDecryptedContent />
|
|
</Text>
|
|
);
|
|
return (
|
|
<Text>
|
|
<MessageUnsupportedContent />
|
|
</Text>
|
|
);
|
|
}}
|
|
</EncryptedContent>
|
|
</Message>
|
|
);
|
|
},
|
|
[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;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
canSendReaction={canSendReaction}
|
|
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}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
{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) => {
|
|
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 = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={parsed.icon}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
{parsed.body}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[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 = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Hash}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' changed room name'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
|
|
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={messageLayout === 1} />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Hash}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' changed room topic'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
|
|
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={messageLayout === 1} />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Hash}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' changed room avatar'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
},
|
|
(mEventId, mEvent, item) => {
|
|
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={messageLayout === 1} />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
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) => {
|
|
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={messageLayout === 1} />;
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
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;
|
|
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;
|
|
|
|
if (!newDivider && readUptoEventIdRef.current) {
|
|
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
|
}
|
|
if (!dayDivider) {
|
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
|
}
|
|
|
|
const collapsed =
|
|
isPrevRendered &&
|
|
!dayDivider &&
|
|
(!newDivider || mEvent.getSender() === mx.getUserId()) &&
|
|
prevEvent !== undefined &&
|
|
prevEvent.getSender() === mEvent.getSender() &&
|
|
prevEvent.getType() === mEvent.getType() &&
|
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
|
|
|
const eventJSX = reactionOrEditEvent(mEvent)
|
|
? null
|
|
: renderMatrixEvent(
|
|
mEvent.getType(),
|
|
typeof mEvent.getStateKey() === 'string',
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
timelineSet,
|
|
collapsed
|
|
);
|
|
prevEvent = mEvent;
|
|
isPrevRendered = !!eventJSX;
|
|
|
|
const newDividerJSX =
|
|
newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
|
|
<MessageBase space={messageSpacing}>
|
|
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
|
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
|
<Text size="L400">New Messages</Text>
|
|
</Badge>
|
|
</TimelineDivider>
|
|
</MessageBase>
|
|
) : null;
|
|
|
|
const dayDividerJSX =
|
|
dayDivider && eventJSX ? (
|
|
<MessageBase space={messageSpacing}>
|
|
<TimelineDivider variant="Surface">
|
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
|
<Text size="L400">
|
|
{(() => {
|
|
if (today(mEvent.getTs())) return 'Today';
|
|
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
|
return timeDayMonthYear(mEvent.getTs());
|
|
})()}
|
|
</Text>
|
|
</Badge>
|
|
</TimelineDivider>
|
|
</MessageBase>
|
|
) : null;
|
|
|
|
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
|
if (newDividerJSX) newDivider = false;
|
|
if (dayDividerJSX) dayDivider = false;
|
|
|
|
return (
|
|
<React.Fragment key={mEventId}>
|
|
{newDividerJSX}
|
|
{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">Jump to Unread</Text>
|
|
</Chip>
|
|
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
onClick={handleMarkAsRead}
|
|
>
|
|
<Text size="L400">Mark as Read</Text>
|
|
</Chip>
|
|
</TimelineFloat>
|
|
)}
|
|
<Scroll ref={scrollRef} visibility="Hover">
|
|
<Box
|
|
direction="Column"
|
|
justifyContent="End"
|
|
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
|
>
|
|
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
|
<div
|
|
style={{
|
|
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
|
|
messageLayout === 1 ? config.space.S400 : toRem(64)
|
|
}`,
|
|
}}
|
|
>
|
|
<RoomIntro room={room} />
|
|
</div>
|
|
)}
|
|
{(canPaginateBack || !rangeAtStart) &&
|
|
(messageLayout === 1 ? (
|
|
<>
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder ref={observeBackAnchor} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<DefaultPlaceholder />
|
|
<DefaultPlaceholder />
|
|
<DefaultPlaceholder ref={observeBackAnchor} />
|
|
</>
|
|
))}
|
|
|
|
{getItems().map(eventRenderer)}
|
|
|
|
{(!liveTimelineLinked || !rangeAtEnd) &&
|
|
(messageLayout === 1 ? (
|
|
<>
|
|
<CompactPlaceholder ref={observeFrontAnchor} />
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder />
|
|
<CompactPlaceholder />
|
|
</>
|
|
) : (
|
|
<>
|
|
<DefaultPlaceholder ref={observeFrontAnchor} />
|
|
<DefaultPlaceholder />
|
|
<DefaultPlaceholder />
|
|
</>
|
|
))}
|
|
<span ref={atBottomAnchorRef} />
|
|
</Box>
|
|
</Scroll>
|
|
{!atBottom && (
|
|
<TimelineFloat position="Bottom">
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
|
onClick={handleJumpToLatest}
|
|
>
|
|
<Text size="L400">Jump to Latest</Text>
|
|
</Chip>
|
|
</TimelineFloat>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|