From 154f234d0cdf8faa48c84e7bd52ea71847883a42 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:16:43 +0530 Subject: [PATCH 1/9] thread menu - WIP --- src/app/features/room/RoomViewHeader.tsx | 38 ++ .../room/threads-menu/ThreadsMenu.css.ts | 18 + .../room/threads-menu/ThreadsMenu.tsx | 456 ++++++++++++++++++ src/app/hooks/useRoomThreads.ts | 9 + 4 files changed, 521 insertions(+) create mode 100644 src/app/features/room/threads-menu/ThreadsMenu.css.ts create mode 100644 src/app/features/room/threads-menu/ThreadsMenu.tsx create mode 100644 src/app/hooks/useRoomThreads.ts diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 352ae4b5..b333436a 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -379,6 +379,44 @@ export function RoomViewHeader() { )} + + + Threads + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + + + )} + ; + onOpen: (roomId: string, eventId: string) => void; +}; +function Threads({ room, eventId, renderContent, onOpen }: ThreadsProps) { + const pinnedEvent = useRoomEvent(room, eventId); + const useAuthentication = useMediaAuthentication(); + const mx = useMatrixClient(); + const direct = useIsDirectRoom(); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + + const powerLevels = usePowerLevelsContext(); + const { getPowerLevel } = usePowerLevelsAPI(powerLevels); + const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const theme = useTheme(); + const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); + + const handleOpenClick: MouseEventHandler = (evt) => { + evt.stopPropagation(); + const evtId = evt.currentTarget.getAttribute('data-event-id'); + if (!evtId) return; + onOpen(room.roomId, evtId); + }; + + const renderOptions = () => ( + + + Open + + + ); + + if (pinnedEvent === undefined) return ; + if (pinnedEvent === null) + return ( + + + Failed to load message! + + {renderOptions()} + + ); + + const sender = pinnedEvent.getSender()!; + const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; + const senderAvatarMxc = getMemberAvatarMxc(room, sender); + const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; + + const senderPowerLevel = getPowerLevel(sender); + const powerLevelTag = getPowerLevelTag(senderPowerLevel); + const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined; + const tagIconSrc = powerLevelTag?.icon + ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) + : undefined; + + const usernameColor = legacyUsernameColor || direct ? colorMXID(sender) : tagColor; + + return ( + + + } + /> + + + } + > + + + + + + {displayName} + + + {tagIconSrc && } + + + {renderOptions()} + + {pinnedEvent.replyEventId && ( + + )} + {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} + + ); +} + +type ThreadsMenuProps = { + room: Room; + requestClose: () => void; +}; +export const ThreadsMenu = forwardRef( + ({ room, requestClose }, ref) => { + const mx = useMatrixClient(); + + const pinnedEvents = useRoomPinnedEvents(room); + const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]); + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const { navigateRoom } = useRoomNavigate(); + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: sortedPinnedEvent.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 75, + overscan: 4, + }); + + 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 ( + + ); + } + + return ( + + ); + }, + [MessageEvent.RoomMessageEncrypted]: (event, displayName) => { + const eventId = event.getId()!; + const evtTimeline = room.getTimelineForEvent(eventId); + + const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId); + + if (!mEvent || !evtTimeline) { + return ( + + + {event.getType()} + {' event'} + + + ); + } + + return ( + + {() => { + if (mEvent.isRedacted()) return ; + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + if (mEvent.getType() === MessageEvent.RoomMessage) { + const editedEvent = 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 ( + + ); + } + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + }, + }, + undefined, + (event) => { + if (event.isRedacted()) { + return ; + } + return ( + + + {event.getType()} + {' event'} + + + ); + } + ); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + requestClose(); + }; + + return ( + + +
+ + Threads + + + + + + +
+ + + + {sortedPinnedEvent.length > 0 ? ( +
+ {virtualizer.getVirtualItems().map((vItem) => { + const eventId = sortedPinnedEvent[vItem.index]; + if (!eventId) return null; + + return ( + + + + + + ); + })} +
+ ) : ( + + + + + No Threads + + + This room does not have any threads yet. + + + + )} +
+
+
+
+
+ ); + } +); diff --git a/src/app/hooks/useRoomThreads.ts b/src/app/hooks/useRoomThreads.ts new file mode 100644 index 00000000..8050a5d9 --- /dev/null +++ b/src/app/hooks/useRoomThreads.ts @@ -0,0 +1,9 @@ +// import { Room } from 'matrix-js-sdk'; +// import { useMatrixClient } from './useMatrixClient'; + +// export const useRoomThreads = (room: Room) => { +// const mx = useMatrixClient(); + +// mx.createThreadListMessagesRequest; +// mx.processThreadRoots; +// }; From 191460689580e31c304119a535676ae938ef3338 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:57:15 +0530 Subject: [PATCH 2/9] threads - WIP --- .../thread-selector/ThreadSelector.tsx | 90 ++++++++ src/app/components/thread-selector/index.ts | 1 + .../components/thread-selector/styles.css.ts | 20 ++ src/app/features/room/RoomTimeline.tsx | 9 + src/app/features/room/RoomViewHeader.tsx | 50 +++-- .../room/threads-menu/ThreadsMenu.tsx | 211 ++++++++++-------- src/app/features/room/threads-menu/index.ts | 1 + src/app/hooks/useRoomThreads.ts | 34 ++- src/app/utils/room.ts | 12 + 9 files changed, 312 insertions(+), 116 deletions(-) create mode 100644 src/app/components/thread-selector/ThreadSelector.tsx create mode 100644 src/app/components/thread-selector/index.ts create mode 100644 src/app/components/thread-selector/styles.css.ts create mode 100644 src/app/features/room/threads-menu/index.ts diff --git a/src/app/components/thread-selector/ThreadSelector.tsx b/src/app/components/thread-selector/ThreadSelector.tsx new file mode 100644 index 00000000..407f6b09 --- /dev/null +++ b/src/app/components/thread-selector/ThreadSelector.tsx @@ -0,0 +1,90 @@ +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import React, { ReactNode } from 'react'; +import classNames from 'classnames'; +import { IThreadBundledRelationship, Room } from 'matrix-js-sdk'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import * as css from './styles.css'; +import { UserAvatar } from '../user-avatar'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; + +export function ThreadSelectorContainer({ children }: { children: ReactNode }) { + return {children}; +} + +type ThreadSelectorProps = { + room: Room; + senderId: string; + threadDetail: IThreadBundledRelationship; + outlined?: boolean; +}; + +export function ThreadSelector({ room, senderId, threadDetail, outlined }: ThreadSelectorProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + + const latestEvent = threadDetail.latest_event; + const latestSenderId = latestEvent.sender; + const latestSenderAvatarMxc = getMemberAvatarMxc(room, latestSenderId); + const latestDisplayName = + getMemberDisplayName(room, latestSenderId) ?? + getMxIdLocalPart(latestSenderId) ?? + latestSenderId; + + const latestEventTs = latestEvent.origin_server_ts; + + return ( + + + + } + /> + + {latestSenderId && ( + + } + /> + + )} + + + + {threadDetail.count} Replies + + + {/* TODO: date */} + Last Reply by {latestDisplayName} at {new Date(latestEventTs).getTime()} + + + + + ); +} diff --git a/src/app/components/thread-selector/index.ts b/src/app/components/thread-selector/index.ts new file mode 100644 index 00000000..2dfa5d9e --- /dev/null +++ b/src/app/components/thread-selector/index.ts @@ -0,0 +1 @@ +export * from './ThreadSelector'; diff --git a/src/app/components/thread-selector/styles.css.ts b/src/app/components/thread-selector/styles.css.ts new file mode 100644 index 00000000..fe580b68 --- /dev/null +++ b/src/app/components/thread-selector/styles.css.ts @@ -0,0 +1,20 @@ +import { style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; + +export const ThreadSelectorContainer = style({ + paddingTop: config.space.S100, +}); + +export const ThreadSelector = style({ + padding: config.space.S200, + borderRadius: config.radii.R400, +}); + +export const ThreadSectorOutlined = style({ + borderWidth: config.borderWidth.B300, +}); + +export const ThreadRepliesCount = style({ + color: color.Primary.Main, + flexShrink: 0, +}); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2281b59d..93c3cf66 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,7 @@ 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 '../../components/thread-selector'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -1034,6 +1036,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 81fced54..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 ( @@ -430,35 +436,18 @@ export function RoomViewHeader() { offset={4} tooltip={ - Threads + My Threads } > {(triggerRef) => ( - {pinnedEvents.length > 0 && ( - - - {pinnedEvents.length} - - - )} - + )} @@ -481,6 +470,25 @@ export function RoomViewHeader() { } /> + setThreadsMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setThreadsMenuAnchor(undefined)} /> + + } + /> {screenSize === ScreenSize.Desktop && ( ; onOpen: (roomId: string, eventId: string) => void; + getMemberPowerTag: GetMemberPowerTag; + accessibleTagColors: Map; + legacyUsernameColor: boolean; + hour24Clock: boolean; + dateFormatString: string; }; -function Threads({ room, eventId, renderContent, onOpen }: ThreadsProps) { - const pinnedEvent = useRoomEvent(room, eventId); +function ThreadMessage({ + room, + event, + renderContent, + onOpen, + getMemberPowerTag, + accessibleTagColors, + legacyUsernameColor, + hour24Clock, + dateFormatString, +}: ThreadMessageProps) { const useAuthentication = useMediaAuthentication(); const mx = useMatrixClient(); - const direct = useIsDirectRoom(); - const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); - - const powerLevels = usePowerLevelsContext(); - const { getPowerLevel } = usePowerLevelsAPI(powerLevels); - const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); - const theme = useTheme(); - const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); const handleOpenClick: MouseEventHandler = (evt) => { evt.stopPropagation(); @@ -102,36 +116,31 @@ function Threads({ room, eventId, renderContent, onOpen }: ThreadsProps) { const renderOptions = () => ( - + Open ); - if (pinnedEvent === undefined) return ; - if (pinnedEvent === null) - return ( - - - Failed to load message! - - {renderOptions()} - - ); - - const sender = pinnedEvent.getSender()!; + const sender = event.getSender()!; const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; const senderAvatarMxc = getMemberAvatarMxc(room, sender); - const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; + const getContent = (() => event.getContent()) as GetContentCallback; - const senderPowerLevel = getPowerLevel(sender); - const powerLevelTag = getPowerLevelTag(senderPowerLevel); - const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined; - const tagIconSrc = powerLevelTag?.icon - ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) + 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 || direct ? colorMXID(sender) : tagColor; + const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor; return ( {tagIconSrc && } -