diff --git a/src/app/components/message/Time.css.ts b/src/app/components/message/Time.css.ts new file mode 100644 index 00000000..f2f5ac55 --- /dev/null +++ b/src/app/components/message/Time.css.ts @@ -0,0 +1,6 @@ +import { style } from '@vanilla-extract/css'; + +export const Time = style({ + fontWeight: 'inherit', + flexShrink: 0, +}); diff --git a/src/app/components/message/Time.tsx b/src/app/components/message/Time.tsx index 3eab5cc2..bc5e487a 100644 --- a/src/app/components/message/Time.tsx +++ b/src/app/components/message/Time.tsx @@ -1,12 +1,15 @@ import React, { ComponentProps } from 'react'; import { Text, as } from 'folds'; +import classNames from 'classnames'; import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; +import * as css from './Time.css'; export type TimeProps = { compact?: boolean; ts: number; hour24Clock: boolean; dateFormatString: string; + inheritPriority?: boolean; }; /** @@ -22,7 +25,7 @@ export type TimeProps = { * @returns {React.ReactElement} A element with the formatted date/time. */ export const Time = as<'span', TimeProps & ComponentProps>( - ({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => { + ({ compact, hour24Clock, dateFormatString, ts, inheritPriority, className, ...props }, ref) => { const formattedTime = timeHourMinute(ts, hour24Clock); let time = ''; @@ -33,11 +36,18 @@ export const Time = as<'span', TimeProps & ComponentProps>( } else if (yesterday(ts)) { time = `Yesterday ${formattedTime}`; } else { - time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`; + time = `${timeDayMonYear(ts, dateFormatString)}, ${formattedTime}`; } return ( - + {time} ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2281b59d..44696b0a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -77,6 +77,7 @@ import { decryptAllTimelineEvent, getEditedEvent, getEventReactions, + getEventThreadDetail, getLatestEditableEvt, getMemberDisplayName, getReactionContent, @@ -126,6 +127,17 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { ThreadSelector, ThreadSelectorContainer } from './message/thread-selector'; +import { + getEventIdAbsoluteIndex, + getFirstLinkedTimeline, + getLinkedTimelines, + getTimelineAndBaseIndex, + getTimelineEvent, + getTimelineRelativeIndex, + getTimelinesEventsCount, + timelineToEventsCount, +} from './utils'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -150,79 +162,6 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( ) ); -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; @@ -232,6 +171,14 @@ type RoomTimelineProps = { const PAGINATION_LIMIT = 80; +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; +}; + type Timeline = { linkedTimelines: EventTimeline[]; range: ItemRange; @@ -1034,6 +981,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const senderId = mEvent.getSender() ?? ''; const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const threadDetail = getEventThreadDetail(mEvent); return ( )} + + {threadDetail && ( + + + + )} ); }, diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 9b4bfd23..aa16c47c 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -69,6 +69,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { ThreadsMenu } from './threads-menu'; type RoomMenuProps = { room: Room; @@ -263,6 +264,7 @@ export function RoomViewHeader() { const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); + const [threadsMenuAnchor, setThreadsMenuAnchor] = useState(); const mDirects = useAtomValue(mDirectAtom); const pinnedEvents = useRoomPinnedEvents(room); @@ -295,6 +297,10 @@ export function RoomViewHeader() { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleOpenThreadsMenu: MouseEventHandler = (evt) => { + setThreadsMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + return ( @@ -424,6 +430,27 @@ export function RoomViewHeader() { )} + + + My Threads + + } + > + {(triggerRef) => ( + + + + )} + } /> + setThreadsMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setThreadsMenuAnchor(undefined)} /> + + } + /> {screenSize === ScreenSize.Desktop && ( {children}; +} + +type ThreadSelectorProps = { + room: Room; + threadDetail: IThreadBundledRelationship; + outlined?: boolean; + hour24Clock: boolean; + dateFormatString: string; +}; + +export function ThreadSelector({ + room, + threadDetail, + outlined, + hour24Clock, + dateFormatString, +}: ThreadSelectorProps) { + const latestEvent = threadDetail.latest_event; + + const latestSenderId = latestEvent.sender; + const latestDisplayName = + getMemberDisplayName(room, latestSenderId) ?? + getMxIdLocalPart(latestSenderId) ?? + latestSenderId; + + const latestEventTs = latestEvent.origin_server_ts; + + return ( + + + + + {threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'} + + + {latestSenderId && ( + <> + + + + Last reply by + {latestDisplayName} + + + + + + )} + + ); +} diff --git a/src/app/features/room/message/thread-selector/index.ts b/src/app/features/room/message/thread-selector/index.ts new file mode 100644 index 00000000..2dfa5d9e --- /dev/null +++ b/src/app/features/room/message/thread-selector/index.ts @@ -0,0 +1 @@ +export * from './ThreadSelector'; diff --git a/src/app/features/room/message/thread-selector/styles.css.ts b/src/app/features/room/message/thread-selector/styles.css.ts new file mode 100644 index 00000000..45bb7d62 --- /dev/null +++ b/src/app/features/room/message/thread-selector/styles.css.ts @@ -0,0 +1,37 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { ContainerColor } from '../../../../styles/ContainerColor.css'; + +export const ThreadSelectorContainer = style({ + marginTop: config.space.S200, +}); + +export const ThreadSelector = style([ + ContainerColor({ variant: 'SurfaceVariant' }), + { + padding: config.space.S200, + borderRadius: config.radii.R400, + cursor: 'pointer', + + selectors: { + '&:hover, &:focus-visible': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + '&:active': { + backgroundColor: color.SurfaceVariant.ContainerActive, + }, + }, + }, +]); + +export const ThreadSectorOutlined = style({ + borderWidth: config.borderWidth.B300, +}); + +export const ThreadSelectorDivider = style({ + height: toRem(16), +}); + +export const ThreadRepliesCount = style({ + color: color.Primary.Main, +}); diff --git a/src/app/features/room/threads-menu/ThreadsError.tsx b/src/app/features/room/threads-menu/ThreadsError.tsx new file mode 100644 index 00000000..c72b7202 --- /dev/null +++ b/src/app/features/room/threads-menu/ThreadsError.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Box, Icon, Icons, toRem, Text, config } from 'folds'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { BreakWord } from '../../../styles/Text.css'; + +export function ThreadsError({ error }: { error: Error }) { + return ( + + + + + {error.name} + + + {error.message} + + + + ); +} diff --git a/src/app/features/room/threads-menu/ThreadsLoading.tsx b/src/app/features/room/threads-menu/ThreadsLoading.tsx new file mode 100644 index 00000000..236435b2 --- /dev/null +++ b/src/app/features/room/threads-menu/ThreadsLoading.tsx @@ -0,0 +1,23 @@ +import { Box, config, Spinner } from 'folds'; +import React from 'react'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; + +export function ThreadsLoading() { + return ( + + + + ); +} diff --git a/src/app/features/room/threads-menu/ThreadsMenu.css.ts b/src/app/features/room/threads-menu/ThreadsMenu.css.ts new file mode 100644 index 00000000..96cb1725 --- /dev/null +++ b/src/app/features/room/threads-menu/ThreadsMenu.css.ts @@ -0,0 +1,18 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const ThreadsMenu = style({ + display: 'flex', + maxWidth: toRem(548), + width: '100vw', + maxHeight: '90vh', +}); + +export const ThreadsMenuHeader = style({ + paddingLeft: config.space.S400, + paddingRight: config.space.S200, +}); + +export const ThreadsMenuContent = style({ + paddingLeft: config.space.S200, +}); diff --git a/src/app/features/room/threads-menu/ThreadsMenu.tsx b/src/app/features/room/threads-menu/ThreadsMenu.tsx new file mode 100644 index 00000000..28e73f33 --- /dev/null +++ b/src/app/features/room/threads-menu/ThreadsMenu.tsx @@ -0,0 +1,99 @@ +/* eslint-disable react/destructuring-assignment */ +import React, { forwardRef, useMemo } from 'react'; +import { EventTimelineSet, Room } from 'matrix-js-sdk'; +import { Box, config, Header, Icon, IconButton, Icons, Menu, Scroll, Text, toRem } from 'folds'; +import * as css from './ThreadsMenu.css'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { useRoomMyThreads } from '../../../hooks/useRoomThreads'; +import { AsyncStatus } from '../../../hooks/useAsyncCallback'; +import { getLinkedTimelines, getTimelinesEventsCount } from '../utils'; +import { ThreadsTimeline } from './ThreadsTimeline'; +import { ThreadsLoading } from './ThreadsLoading'; +import { ThreadsError } from './ThreadsError'; + +const getTimelines = (timelineSet: EventTimelineSet) => { + const liveTimeline = timelineSet.getLiveTimeline(); + const linkedTimelines = getLinkedTimelines(liveTimeline); + + return linkedTimelines; +}; + +function NoThreads() { + return ( + + + + + No Threads Yet + + + Threads you’re participating in will appear here. + + + + ); +} + +type ThreadsMenuProps = { + room: Room; + requestClose: () => void; +}; +export const ThreadsMenu = forwardRef( + ({ room, requestClose }, ref) => { + const threadsState = useRoomMyThreads(room); + const threadsTimelineSet = + threadsState.status === AsyncStatus.Success ? threadsState.data : undefined; + + const linkedTimelines = useMemo(() => { + if (!threadsTimelineSet) return undefined; + return getTimelines(threadsTimelineSet); + }, [threadsTimelineSet]); + + const hasEvents = linkedTimelines && getTimelinesEventsCount(linkedTimelines) > 0; + + return ( + + +
+ + My Threads + + + + + + +
+ + {threadsState.status === AsyncStatus.Success && hasEvents ? ( + + ) : ( + + + {(threadsState.status === AsyncStatus.Loading || + threadsState.status === AsyncStatus.Idle) && } + {threadsState.status === AsyncStatus.Success && !hasEvents && } + {threadsState.status === AsyncStatus.Error && ( + + )} + + + )} + +
+
+ ); + } +); diff --git a/src/app/features/room/threads-menu/ThreadsTimeline.tsx b/src/app/features/room/threads-menu/ThreadsTimeline.tsx new file mode 100644 index 00000000..d6cbb62d --- /dev/null +++ b/src/app/features/room/threads-menu/ThreadsTimeline.tsx @@ -0,0 +1,481 @@ +/* eslint-disable react/destructuring-assignment */ +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Direction, EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk'; +import { Avatar, Box, Chip, config, Icon, Icons, Scroll, Text } from 'folds'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { SequenceCard } from '../../../components/sequence-card'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { + AvatarBase, + ImageContent, + MessageNotDecryptedContent, + MessageUnsupportedContent, + ModernLayout, + MSticker, + RedactedContent, + Reply, + Time, + Username, + UsernameBold, +} from '../../../components/message'; +import { UserAvatar } from '../../../components/user-avatar'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { + getEditedEvent, + getEventThreadDetail, + getMemberAvatarMxc, + getMemberDisplayName, +} from '../../../utils/room'; +import { GetContentCallback, MessageEvent } from '../../../../types/matrix/room'; +import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../../plugins/react-custom-html-parser'; +import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer'; +import { RenderMessageContent } from '../../../components/RenderMessageContent'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import * as customHtmlCss from '../../../styles/CustomHtml.css'; +import { EncryptedContent } from '../message'; +import { Image } from '../../../components/media'; +import { ImageViewer } from '../../../components/image-viewer'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { usePowerLevelsContext } from '../../../hooks/usePowerLevels'; +import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags'; +import { useTheme } from '../../../hooks/useTheme'; +import { PowerIcon } from '../../../components/power'; +import colorMXID from '../../../../util/colorMXID'; +import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { + GetMemberPowerTag, + getPowerTagIconSrc, + useAccessiblePowerTagColors, + useGetMemberPowerTag, +} from '../../../hooks/useMemberPowerTag'; +import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { ThreadSelector, ThreadSelectorContainer } from '../message/thread-selector'; +import { + getTimelineAndBaseIndex, + getTimelineEvent, + getTimelineRelativeIndex, + getTimelinesEventsCount, +} from '../utils'; +import { ThreadsLoading } from './ThreadsLoading'; +import { VirtualTile } from '../../../components/virtualizer'; +import { useAlive } from '../../../hooks/useAlive'; +import * as css from './ThreadsMenu.css'; + +type ThreadMessageProps = { + room: Room; + event: MatrixEvent; + renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>; + onOpen: (roomId: string, eventId: string) => void; + getMemberPowerTag: GetMemberPowerTag; + accessibleTagColors: Map; + legacyUsernameColor: boolean; + hour24Clock: boolean; + dateFormatString: string; +}; +function ThreadMessage({ + room, + event, + renderContent, + onOpen, + getMemberPowerTag, + accessibleTagColors, + legacyUsernameColor, + hour24Clock, + dateFormatString, +}: ThreadMessageProps) { + const useAuthentication = useMediaAuthentication(); + const mx = useMatrixClient(); + + const handleOpenClick: MouseEventHandler = (evt) => { + evt.stopPropagation(); + const evtId = evt.currentTarget.getAttribute('data-event-id'); + if (!evtId) return; + onOpen(room.roomId, evtId); + }; + + const renderOptions = () => ( + + + Open + + + ); + + const sender = event.getSender()!; + const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; + const senderAvatarMxc = getMemberAvatarMxc(room, sender); + const getContent = (() => event.getContent()) as GetContentCallback; + + const memberPowerTag = getMemberPowerTag(sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) + : undefined; + + const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor; + + return ( + + + } + /> + + + } + > + + + + + + {displayName} + + + {tagIconSrc && } + + + {renderOptions()} + + {event.replyEventId && ( + + )} + {renderContent(event.getType(), false, event, displayName, getContent)} + + ); +} + +type ThreadsTimelineProps = { + timelines: EventTimeline[]; + requestClose: () => void; +}; +export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProps) { + const mx = useMatrixClient(); + const { navigateRoom } = useRoomNavigate(); + const alive = useAlive(); + const scrollRef = useRef(null); + + const room = useRoom(); + 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 accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags); + + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + + const direct = useIsDirectRoom(); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) + ), + }), + [mx, room, mentionClickHandler] + ); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] + ); + + const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>( + { + [MessageEvent.RoomMessage]: (event, displayName, getContent) => { + if (event.isRedacted()) { + return ; + } + + const threadDetail = getEventThreadDetail(event); + + return ( + <> + + {threadDetail && ( + + + + )} + + ); + }, + [MessageEvent.RoomMessageEncrypted]: (mEvent, displayName) => { + const eventId = mEvent.getId()!; + const evtTimeline = room.getTimelineForEvent(eventId); + + return ( + + {() => { + if (mEvent.isRedacted()) return ; + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + if (mEvent.getType() === MessageEvent.RoomMessage) { + const editedEvent = + evtTimeline && getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet()); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? + mEvent.getContent()) as GetContentCallback; + + return ( + + ); + } + if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) + return ( + + + + ); + return ( + + + + ); + }} + + ); + }, + [MessageEvent.Sticker]: (event, displayName, getContent) => { + if (event.isRedacted()) { + return ; + } + + const threadDetail = getEventThreadDetail(event); + + return ( + <> + ( + } + renderViewer={(p) => } + /> + )} + /> + {threadDetail && ( + + + + )} + + ); + }, + }, + undefined, + (event) => { + if (event.isRedacted()) { + return ; + } + return ( + + + {event.getType()} + {' event'} + + + ); + } + ); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + requestClose(); + }; + + const eventsLength = getTimelinesEventsCount(timelines); + const timelineToPaginate = timelines[timelines.length - 1]; + const [paginationToken, setPaginationToken] = useState( + timelineToPaginate.getPaginationToken(Direction.Backward) + ); + + const virtualizer = useVirtualizer({ + count: eventsLength, + getScrollElement: () => scrollRef.current, + estimateSize: () => 122, + overscan: 10, + }); + const vItems = virtualizer.getVirtualItems(); + + const paginate = useCallback(async () => { + const moreToLoad = await mx.paginateEventTimeline(timelineToPaginate, { + backwards: true, + limit: 30, + }); + + if (alive()) { + setPaginationToken( + moreToLoad ? timelineToPaginate.getPaginationToken(Direction.Backward) : null + ); + } + }, [mx, alive, timelineToPaginate]); + + // auto paginate when scroll reach bottom + useEffect(() => { + const lastVItem = vItems.length > 0 ? vItems[vItems.length - 1] : undefined; + if (paginationToken && lastVItem && lastVItem.index === eventsLength - 1) { + paginate(); + } + }, [vItems, paginationToken, eventsLength, paginate]); + + return ( + + +
+ {vItems.map((vItem) => { + const reverseTimelineIndex = eventsLength - vItem.index - 1; + + const [timeline, baseIndex] = getTimelineAndBaseIndex(timelines, reverseTimelineIndex); + if (!timeline) return null; + const event = getTimelineEvent( + timeline, + getTimelineRelativeIndex(reverseTimelineIndex, baseIndex) + ); + + if (!event?.getId()) return null; + + return ( + + + + + + ); + })} +
+ {paginationToken && } +
+
+ ); +} diff --git a/src/app/features/room/threads-menu/index.ts b/src/app/features/room/threads-menu/index.ts new file mode 100644 index 00000000..98286736 --- /dev/null +++ b/src/app/features/room/threads-menu/index.ts @@ -0,0 +1 @@ +export * from './ThreadsMenu'; diff --git a/src/app/features/room/utils/index.ts b/src/app/features/room/utils/index.ts new file mode 100644 index 00000000..13d812d7 --- /dev/null +++ b/src/app/features/room/utils/index.ts @@ -0,0 +1 @@ +export * from './timeline'; diff --git a/src/app/features/room/utils/timeline.ts b/src/app/features/room/utils/timeline.ts new file mode 100644 index 00000000..f9c45525 --- /dev/null +++ b/src/app/features/room/utils/timeline.ts @@ -0,0 +1,66 @@ +import { Room, EventTimeline, Direction, MatrixEvent } from 'matrix-js-sdk'; + +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; +}; diff --git a/src/app/hooks/useRoomThreads.ts b/src/app/hooks/useRoomThreads.ts new file mode 100644 index 00000000..7d5fa807 --- /dev/null +++ b/src/app/hooks/useRoomThreads.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; +import { EventTimelineSet, Room } from 'matrix-js-sdk'; +import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback'; + +export const useRoomMyThreads = (room: Room): AsyncState => { + const [threadsState] = useAsyncCallbackValue( + useCallback(async () => { + await room.createThreadsTimelineSets(); + await room.fetchRoomThreads(); + + const timelineSet = room.threadsTimelineSets[0]; + if (timelineSet === undefined) { + throw new Error('Failed to fetch My Threads!'); + } + return timelineSet; + }, [room]) + ); + + return threadsState; +}; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index b4bba2ad..e80edb20 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -8,6 +8,7 @@ import { IPowerLevelsContent, IPushRule, IPushRules, + IThreadBundledRelationship, JoinRule, MatrixClient, MatrixEvent, @@ -16,6 +17,7 @@ import { RelationType, Room, RoomMember, + THREAD_RELATION_TYPE, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { AccountDataEvent } from '../../types/matrix/accountData'; @@ -551,3 +553,13 @@ export const guessPerfectParent = ( return perfectParent; }; + +export const getEventThreadDetail = ( + mEvent: MatrixEvent +): IThreadBundledRelationship | undefined => { + const details = mEvent.getServerAggregatedRelation( + THREAD_RELATION_TYPE.name + ); + + return details; +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 487c3f13..8ed4100e 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -42,6 +42,7 @@ export const initClient = async (session: Session): Promise => { export const startClient = async (mx: MatrixClient) => { await mx.startClient({ lazyLoadMembers: true, + threadSupport: true, }); };