From 3bb627ba5123e9bd12b21cfba088c613048763f0 Mon Sep 17 00:00:00 2001 From: Tomasz Sterna Date: Wed, 1 Apr 2026 20:10:50 +0200 Subject: [PATCH 01/11] feat: add m.forum room type and dedicated view with threads as topics --- .changeset/add-forum-room-type.md | 5 + .../create-room/CreateRoomTypeSelector.tsx | 26 + src/app/components/create-room/types.ts | 1 + src/app/components/create-room/utils.ts | 5 +- src/app/components/icons/roomIcons.tsx | 28 +- src/app/features/create-room/CreateRoom.tsx | 15 +- src/app/features/forum/ForumHeader.tsx | 262 ++++++++ src/app/features/forum/ForumHero.tsx | 85 +++ src/app/features/forum/ForumMenu.tsx | 176 ++++++ src/app/features/forum/ForumThreadItem.tsx | 128 ++++ src/app/features/forum/ForumView.css.ts | 26 + src/app/features/forum/ForumView.tsx | 557 ++++++++++++++++++ src/app/features/forum/index.ts | 1 + src/app/features/lobby/Lobby.tsx | 10 +- src/app/features/lobby/SpaceItem.tsx | 10 + src/app/features/room/RoomViewHeader.tsx | 48 +- src/app/features/room/ThreadRootItem.tsx | 214 +++++++ src/app/features/room/message/Message.tsx | 62 +- src/app/hooks/useRoomNavigate.ts | 29 +- src/app/pages/Router.tsx | 26 + src/app/pages/client/direct/Direct.tsx | 9 +- src/app/pages/client/home/Home.tsx | 8 +- src/app/pages/client/space/Space.tsx | 18 +- src/app/pages/pathUtils.ts | 27 + src/app/pages/paths.ts | 4 + src/app/utils/room.ts | 10 + src/types/matrix/room.ts | 6 + 27 files changed, 1730 insertions(+), 66 deletions(-) create mode 100644 .changeset/add-forum-room-type.md create mode 100644 src/app/features/forum/ForumHeader.tsx create mode 100644 src/app/features/forum/ForumHero.tsx create mode 100644 src/app/features/forum/ForumMenu.tsx create mode 100644 src/app/features/forum/ForumThreadItem.tsx create mode 100644 src/app/features/forum/ForumView.css.ts create mode 100644 src/app/features/forum/ForumView.tsx create mode 100644 src/app/features/forum/index.ts create mode 100644 src/app/features/room/ThreadRootItem.tsx diff --git a/.changeset/add-forum-room-type.md b/.changeset/add-forum-room-type.md new file mode 100644 index 000000000..648bd27aa --- /dev/null +++ b/.changeset/add-forum-room-type.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add the `m.forum` room type with a dedicated forum view that presents threads as topics. diff --git a/src/app/components/create-room/CreateRoomTypeSelector.tsx b/src/app/components/create-room/CreateRoomTypeSelector.tsx index 6ab26d7c1..57dd34825 100644 --- a/src/app/components/create-room/CreateRoomTypeSelector.tsx +++ b/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -71,6 +71,32 @@ export function CreateRoomTypeSelector({ + onSelect(CreateRoomType.ForumRoom)} + disabled={disabled} + > + + + + Forum Room + + + - Conversations split in topics. + + + + + ); } diff --git a/src/app/components/create-room/types.ts b/src/app/components/create-room/types.ts index 8b54587dd..5a54105bf 100644 --- a/src/app/components/create-room/types.ts +++ b/src/app/components/create-room/types.ts @@ -1,6 +1,7 @@ export enum CreateRoomType { TextRoom = 'text', VoiceRoom = 'voice', + ForumRoom = 'forum', } export enum CreateRoomAccess { diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index 520bc508a..37fdc358a 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -8,13 +8,14 @@ import type { import { JoinRule, RestrictedAllowType, EventType, RoomType } from '$types/matrix-sdk'; import type { StateEvents } from '$types/matrix-sdk'; +import type { CustomRoomType } from '$types/matrix/room'; import { getViaServers } from '$plugins/via-servers'; import { getMxIdServer } from '$utils/mxIdHelper'; import { CreateRoomAccess } from './types'; import * as prefix from '$unstable/prefixes'; export const createRoomCreationContent = ( - type: RoomType | undefined, + type: RoomType | CustomRoomType | undefined, allowFederation: boolean, additionalCreators: string[] | undefined ): object => { @@ -101,7 +102,7 @@ export const createVoiceRoomPowerLevelsOverride = () => ({ export type CreateRoomData = { version: string; - type?: RoomType; + type?: RoomType | CustomRoomType; parent?: Room; access: CreateRoomAccess; name: string; diff --git a/src/app/components/icons/roomIcons.tsx b/src/app/components/icons/roomIcons.tsx index 1709a7d66..bc40bf2da 100644 --- a/src/app/components/icons/roomIcons.tsx +++ b/src/app/components/icons/roomIcons.tsx @@ -1,14 +1,16 @@ import { JoinRule, RoomType } from '$types/matrix-sdk'; import type { ComponentType } from 'react'; import type { IconProps } from '@phosphor-icons/react'; -import { Globe, HashStraight, Lock, SpeakerHigh, SquaresFour } from './phosphor'; +import { CustomRoomType } from '$types/matrix/room'; +import { Chats, Globe, HashStraight, Lock, SpeakerHigh, SquaresFour } from './phosphor'; export type RoomPhosphorIcon = ComponentType; export type RoomIconOverlay = 'globe' | 'lock'; const isRegularRoom = (roomType?: string): boolean => - roomType !== RoomType.Space && roomType !== RoomType.UnstableCall; + roomType !== RoomType.Space && + roomType !== RoomType.UnstableCall; export function getRoomIconOverlay( roomType?: string, @@ -59,6 +61,17 @@ export function getRoomStandaloneIconComponent( return SpeakerHigh; } + if (roomType === CustomRoomType.Forum) { + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return Lock; + } + return Chats; + } + if (joinRule === JoinRule.Public) return Globe; if ( joinRule === JoinRule.Invite || @@ -95,5 +108,16 @@ export function getRoomIconComponent(roomType?: string, joinRule?: JoinRule): Ro return SpeakerHigh; } + if (roomType === CustomRoomType.Forum) { + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return Lock; + } + return Chats; + } + return HashStraight; } diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx index c41378017..791f6a9fa 100644 --- a/src/app/features/create-room/CreateRoom.tsx +++ b/src/app/features/create-room/CreateRoom.tsx @@ -27,12 +27,14 @@ import { getRoomStandaloneIconComponent } from '$components/icons/roomIcons'; import { CaretDown, CaretUp, + Chats, Hash, sizedIcon, SpeakerHigh, Warning, type IconSizeToken, } from '$components/icons/phosphor'; +import { CustomRoomType } from '$types/matrix/room'; import { createDebugLogger } from '$utils/debugLogger'; import { restrictedSupported, @@ -49,20 +51,20 @@ const getCreateRoomAccessToIcon = ( type?: CreateRoomType, size: IconSizeToken = '400' ): ReactNode => { - const isVoiceRoom = type === CreateRoomType.VoiceRoom; + let roomType: string | undefined; + if (type === CreateRoomType.VoiceRoom) roomType = RoomType.UnstableCall; + if (type === CreateRoomType.ForumRoom) roomType = CustomRoomType.Forum; let joinRule: JoinRule = JoinRule.Public; if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted; if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock; - return sizedIcon( - getRoomStandaloneIconComponent(isVoiceRoom ? RoomType.UnstableCall : undefined, joinRule), - size - ); + return sizedIcon(getRoomStandaloneIconComponent(roomType, joinRule), size); }; const getCreateRoomTypeToIcon = (type: CreateRoomType): ReactNode => { if (type === CreateRoomType.VoiceRoom) return sizedIcon(SpeakerHigh, '400'); + if (type === CreateRoomType.ForumRoom) return sizedIcon(Chats, '400'); return sizedIcon(Hash, '400'); }; @@ -144,8 +146,9 @@ export function CreateRoomForm({ roomKnock = knock; } - let roomType: RoomType | undefined; + let roomType: RoomType | CustomRoomType | undefined; if (type === CreateRoomType.VoiceRoom) roomType = RoomType.UnstableCall; + if (type === CreateRoomType.ForumRoom) roomType = CustomRoomType.Forum; debugLog.info('ui', 'Create room button clicked', { roomName, diff --git a/src/app/features/forum/ForumHeader.tsx b/src/app/features/forum/ForumHeader.tsx new file mode 100644 index 000000000..a0c60da68 --- /dev/null +++ b/src/app/features/forum/ForumHeader.tsx @@ -0,0 +1,262 @@ +import type { MouseEventHandler } from 'react'; +import { useEffect, useState } from 'react'; +import type { RectCords } from 'folds'; +import { + Avatar, + Badge, + Box, + IconButton, + PopOut, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import type { Room } from 'matrix-js-sdk'; +import { PageHeader } from '$components/page'; +import { useSetSetting, useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useRoomAvatar, useRoomName } from '$hooks/useRoomMeta'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { RoomAvatar } from '$components/room-avatar'; +import { + ArrowLeft, + composerIcon, + DotsThreeOutlineVerticalIcon, + PushPin, + UserCircle, +} from '$components/icons/phosphor'; +import { nameInitials } from '$utils/common'; +import type { IPowerLevels } from '$hooks/usePowerLevels'; +import { stopPropagation } from '$utils/keyboard'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { BackRouteHandler } from '$components/BackRouteHandler'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; +import { getPinsHash } from '$utils/room'; +import { RoomPinMenu } from '$features/room/room-pin-menu'; +import { ForumMenu } from './ForumMenu'; +import * as css from './ForumView.css'; + +type ForumHeaderProps = { + room: Room; + showProfile?: boolean; + powerLevels: IPowerLevels; +}; +export function ForumHeader({ room, showProfile, powerLevels }: ForumHeaderProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const [peopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [menuAnchor, setMenuAnchor] = useState(); + const [pinMenuAnchor, setPinMenuAnchor] = useState(); + const screenSize = useScreenSizeContext(); + const pinnedEvents = useRoomPinnedEvents(room); + const [currentHash, setCurrentHash] = useState(''); + + useEffect(() => { + getPinsHash(pinnedEvents) + .then(setCurrentHash) + .catch(() => undefined); + }, [pinnedEvents]); + + const name = useRoomName(room); + const avatarMxc = useRoomAvatar(room); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + const handleOpenPinMenu: MouseEventHandler = (evt) => { + setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + + {screenSize === ScreenSize.Mobile ? ( + <> + + + {(onBack) => ( + + {composerIcon(ArrowLeft)} + + )} + + + + {showProfile && ( + + {name} + + )} + + + ) : ( + <> + + + {showProfile && ( + <> + + {nameInitials(name)}} + /> + + + {name} + + + )} + + + )} + + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + {composerIcon(PushPin, { weight: pinMenuAnchor ? 'fill' : 'regular' })} + + )} + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} + currentHash={currentHash} + /> + + } + /> + {screenSize !== ScreenSize.Mobile && ( + + {peopleDrawer ? 'Hide Members' : 'Show Members'} + + } + > + {(triggerRef) => ( + setPeopleDrawer((drawer) => !drawer)} + > + {composerIcon(UserCircle, { weight: peopleDrawer ? 'fill' : 'regular' })} + + )} + + )} + + More Options + + } + > + {(triggerRef) => ( + + {composerIcon(DotsThreeOutlineVerticalIcon, { + weight: menuAnchor ? 'fill' : 'regular', + })} + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + /> + + } + /> + + + + ); +} diff --git a/src/app/features/forum/ForumHero.tsx b/src/app/features/forum/ForumHero.tsx new file mode 100644 index 000000000..490ae6a23 --- /dev/null +++ b/src/app/features/forum/ForumHero.tsx @@ -0,0 +1,85 @@ +import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import type { Room } from 'matrix-js-sdk'; +import { useRoomAvatar, useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { RoomAvatar } from '$components/room-avatar'; +import { nameInitials } from '$utils/common'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { RoomTopicViewer } from '$components/room-topic-viewer'; +import { PageHero } from '$components/page'; +import { onEnterOrSpace, stopPropagation } from '$utils/keyboard'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import * as css from './ForumView.css'; + +type ForumHeroProps = { + room: Room; +}; + +export function ForumHero({ room }: ForumHeroProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const name = useRoomName(room); + const topic = useRoomTopic(room); + const avatarMxc = useRoomAvatar(room); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + return ( + + {nameInitials(name)}} + /> + + } + title={name} + subTitle={ + topic && ( + + {(viewTopic, setViewTopic) => ( + <> + }> + + setViewTopic(false), + escapeDeactivates: stopPropagation, + }} + > + setViewTopic(false)} + /> + + + + setViewTopic(true)} + onKeyDown={onEnterOrSpace(() => setViewTopic(true))} + tabIndex={0} + className={css.ForumHeroTopic} + size="Inherit" + priority="300" + > + {topic} + + + )} + + ) + } + /> + ); +} diff --git a/src/app/features/forum/ForumMenu.tsx b/src/app/features/forum/ForumMenu.tsx new file mode 100644 index 000000000..b63234868 --- /dev/null +++ b/src/app/features/forum/ForumMenu.tsx @@ -0,0 +1,176 @@ +import { forwardRef, useState } from 'react'; +import { Box, Line, Menu, MenuItem, Text, config, toRem } from 'folds'; +import type { Room } from 'matrix-js-sdk'; +import { useNavigate } from 'react-router-dom'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { LeaveRoomPrompt } from '$components/leave-room-prompt'; +import { InviteUserPrompt } from '$components/invite-user-prompt'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useIsDirectRoom } from '$hooks/useRoom'; +import { useSpaceOptionally } from '$hooks/useSpace'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import type { IPowerLevels } from '$hooks/usePowerLevels'; +import { useOpenRoomSettings } from '$state/hooks/roomSettings'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { markAsRead } from '$utils/notifications'; +import { copyToClipboard } from '$utils/dom'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; +import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from '$pages/pathUtils'; +import { getMatrixToRoom } from '$plugins/matrix-to'; +import { getViaServers } from '$plugins/via-servers'; +import { + Checks, + GearSix, + Link, + menuIcon, + SignOut, + Terminal, + UserPlus, +} from '$components/icons/phosphor'; + +type ForumMenuProps = { + room: Room; + powerLevels: IPowerLevels; + requestClose: () => void; +}; +export const ForumMenu = forwardRef( + ({ room, powerLevels, requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openRoomSettings = useOpenRoomSettings(); + const navigate = useNavigate(); + const parentSpace = useSpaceOptionally(); + const isDirectRoom = useIsDirectRoom(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const handleMarkAsRead = () => { + markAsRead(mx, room.roomId, hideReads); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openRoomSettings(room.roomId); + requestClose(); + }; + + const handleOpenTimeline = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (parentSpace) { + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace.roomId); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias)); + } else if (isDirectRoom) { + navigate(getDirectRoomPath(roomIdOrAlias)); + } else { + navigate(getHomeRoomPath(roomIdOrAlias)); + } + requestClose(); + }; + + return ( + + {invitePrompt && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + + + + Mark as Read + + + + + + + + Invite + + + + + Copy Link + + + + + Room Settings + + + {developerTools && ( + + + Event Timeline + + + )} + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={menuIcon(SignOut)} + radii="300" + aria-pressed={promptLeave} + > + + Leave Room + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); + } +); diff --git a/src/app/features/forum/ForumThreadItem.tsx b/src/app/features/forum/ForumThreadItem.tsx new file mode 100644 index 000000000..192f02e1a --- /dev/null +++ b/src/app/features/forum/ForumThreadItem.tsx @@ -0,0 +1,128 @@ +import { Avatar, Box, Chip, Text, config } from 'folds'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { UserAvatar } from '$components/user-avatar'; +import { nicknamesAtom } from '$state/nicknames'; +import type { ThreadRootItemProps } from '$features/room/ThreadRootItem'; +import { ThreadRootItem } from '$features/room/ThreadRootItem'; +import { getThreadReplyEvents } from '$features/room/ThreadDrawer'; +import * as css from './ForumView.css'; + +type ForumThreadItemProps = ThreadRootItemProps & { + thread?: Thread; + onClick: (eventId: string) => void; +}; + +export function ForumThreadItem({ thread, onClick, ...rootProps }: ForumThreadItemProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const nicknames = useAtomValue(nicknamesAtom); + const { room, mEvent } = rootProps; + + const mEventId = mEvent.getId(); + + // Thread reply info for the chip โ€” uses the same reply resolution as the thread drawer. + const replies = mEventId ? getThreadReplyEvents(room, mEventId) : []; + const replyCount = replies.length; + + const uniqueSenders = thread + ? [...new Set(replies.map((ev) => ev.getSender()).filter((id): id is string => !!id))] + : []; + + const lastReply = replies.at(-1); + const lastSenderId = lastReply?.getSender() ?? ''; + const lastDisplayName = + getMemberDisplayName(room, lastSenderId, nicknames) ?? + getMxIdLocalPart(lastSenderId) ?? + lastSenderId; + const lastContent = lastReply?.getContent(); + const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; + + if (!mEventId) return null; + + const handleCardClick = (evt: React.MouseEvent) => { + // Don't open thread if the click originated from a button, link, or other interactive element + const target = evt.target as HTMLElement; + if (target.closest('button, a, [role="button"]')) return; + onClick(mEventId); + }; + + return ( + + + + + {/* Thread reply chip */} + + { + evt.stopPropagation(); + onClick(mEventId); + }} + before={ + uniqueSenders.length > 0 ? ( + + {uniqueSenders.slice(0, 3).map((sid, index) => { + const avatarMxc = getMemberAvatarMxc(room, sid); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 20, 20, 'crop') ?? + undefined) + : undefined; + const dn = + getMemberDisplayName(room, sid, nicknames) ?? getMxIdLocalPart(sid) ?? sid; + return ( + 0 ? '-4px' : 0 }}> + ( + + {dn[0]?.toUpperCase() ?? '?'} + + )} + /> + + ); + })} + + ) : undefined + } + > + + {replyCount} {replyCount === 1 ? 'reply' : 'replies'} + + {lastBody && ( + +  ยท {lastDisplayName}: {lastBody.slice(0, 60)} + + )} + + + + + ); +} diff --git a/src/app/features/forum/ForumView.css.ts b/src/app/features/forum/ForumView.css.ts new file mode 100644 index 000000000..23b759de1 --- /dev/null +++ b/src/app/features/forum/ForumView.css.ts @@ -0,0 +1,26 @@ +import { style } from '@vanilla-extract/css'; +import { config, color } from 'folds'; + +export const ForumHeroTopic = style({ + display: '-webkit-box', + WebkitLineClamp: 3, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + + ':hover': { + cursor: 'pointer', + opacity: config.opacity.P500, + textDecoration: 'underline', + }, +}); + +export const Header = style({ + borderBottomColor: 'transparent', +}); + +export const ForumThreadItem = style({ + paddingBottom: config.space.S200, + borderRadius: config.radii.R400, + backgroundColor: color.SurfaceVariant.Container, + cursor: 'pointer', +}); diff --git a/src/app/features/forum/ForumView.tsx b/src/app/features/forum/ForumView.tsx new file mode 100644 index 000000000..24c3a920c --- /dev/null +++ b/src/app/features/forum/ForumView.tsx @@ -0,0 +1,557 @@ +import type { MouseEventHandler } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, IconButton, Line, Scroll, Text, color, config } from 'folds'; +import { useAtom, useAtomValue } from 'jotai'; +import type { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk'; +import { Direction, EventType, RoomEvent } from 'matrix-js-sdk'; +import { type RoomEventHandlerMap } from 'matrix-js-sdk/lib/models/room'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { useRoom } from '$hooks/useRoom'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { Page, PageContent, PageContentCenter, PageHeroSection } from '$components/page'; +import { CaretUp, Chats, composerIcon, sizedIcon } from '$components/icons/phosphor'; +import { MembersDrawer } from '$features/room/MembersDrawer'; +import { ThreadDrawer, getThreadReplyEvents } from '$features/room/ThreadDrawer'; +import { useSetting } from '$state/hooks/settings'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { settingsAtom } from '$state/settings'; +import { ForumHeader } from './ForumHeader'; +import { ForumHero } from './ForumHero'; +import { ForumThreadItem } from './ForumThreadItem'; +import { ScrollTopContainer } from '$components/scroll-top-container'; +import { PowerLevelsContextProvider, usePowerLevels } from '$hooks/usePowerLevels'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomMembers } from '$hooks/useRoomMembers'; +import { reactionOrEditEvent } from '$utils/room'; +import { mxcUrlToHttp, toggleReaction } from '$utils/matrix'; +import { useStateEvent } from '$hooks/useStateEvent'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useEditor } from '$components/editor'; +import { RoomInputPlaceholder } from '$features/room/RoomInputPlaceholder'; +import { RoomTombstone } from '$features/room/RoomTombstone'; +import { RoomInput } from '$features/room/RoomInput'; +import { useImagePackRooms } from '$hooks/useImagePackRooms'; +import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { CustomStateEvent } from '$types/matrix/room'; +import type { RoomBannerContent } from '$types/matrix-sdk-events'; +import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; + +type ForumPost = { + eventId: string; + mEvent: MatrixEvent; + thread?: Thread; + ts: number; +}; + +/** + * Collect all top-level messages (not thread replies, not reactions/edits/redacted) + * and return them as ForumPost items, sorted by latest activity descending. + */ +const collectForumPosts = (room: Room): ForumPost[] => { + const threadMap = new Map(); + room.getThreads().forEach((thread) => { + threadMap.set(thread.id, thread); + }); + + const posts = new Map(); + + // Add all thread roots (even if not in the visible timeline) + threadMap.forEach((thread, threadId) => { + const { rootEvent } = thread; + if (!rootEvent) return; + // Skip redacted root messages with no visible replies + if (rootEvent.isRedacted()) { + const replies = getThreadReplyEvents(room, threadId); + if (replies.length === 0) return; + } + const lastTs = thread.events.at(-1)?.getTs() ?? rootEvent.getTs(); + posts.set(threadId, { + eventId: threadId, + mEvent: rootEvent, + thread, + ts: lastTs, + }); + }); + + // Add top-level timeline messages that are NOT thread replies + const timeline = room.getLiveTimeline(); + timeline.getEvents().forEach((ev) => { + const evId = ev.getId(); + if (!evId) return; + if (posts.has(evId)) return; // already added as thread root + if (ev.isRedacted()) return; + if (reactionOrEditEvent(ev)) return; + // Skip actual thread replies (rel_type: m.thread), but keep plain replies + // that just reference a thread root via m.in_reply_to + if (ev.getRelation()?.rel_type === 'm.thread') return; + if (ev.isState()) return; // skip state events + if (!ev.getContent()?.msgtype) return; // not a displayable message + + posts.set(evId, { + eventId: evId, + mEvent: ev, + thread: undefined, + ts: ev.getTs(), + }); + }); + + // Sort by latest activity descending + return Array.from(posts.values()).toSorted((a, b) => b.ts - a.ts); +}; + +export function ForumView() { + const mx = useMatrixClient(); + const room = useRoom(); + const powerLevels = usePowerLevels(room); + const members = useRoomMembers(mx, room.roomId); + + const useAuthentication = useMediaAuthentication(); + const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner); + const bannerMxc = bannerState?.getContent()?.url; + const bannerUrl = bannerMxc + ? (mxcUrlToHttp(mx, bannerMxc, useAuthentication) ?? undefined) + : undefined; + + const scrollRef = useRef(null); + const roomViewRef = useRef(null); + const heroSectionRef = useRef(null); + const editor = useEditor(); + const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const screenSize = useScreenSizeContext(); + const [onTop, setOnTop] = useState(true); + + const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); + const [updateKey, forceUpdate] = useState(0); + const [editId, setEditId] = useState(undefined); + + // Settings + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)), + mentionClickHandler + ), + }), + [mx, room, mentionClickHandler, settingsLinkBaseUrl] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [ + mx, + room, + settingsLinkBaseUrl, + linkifyOpts, + spoilerClickHandler, + mentionClickHandler, + useAuthentication, + ] + ); + + // Power levels & permissions + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(EventType.RoomRedaction, mx.getSafeUserId()); + const canSendReaction = permissions.event(EventType.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent('m.room.pinned_events', mx.getSafeUserId()); + const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + const tombstoneEvent = useStateEvent(room, EventType.RoomTombstone); + + // Image packs + const roomToParents = useAtomValue(roomToParentsAtom); + const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); + + // User profile popup + const openUserRoomProfile = useOpenUserRoomProfile(); + + // Fetch threads from server on mount (same as RoomViewHeader does) + useEffect(() => { + const scanTimelineForThreads = (timeline: EventTimeline) => { + const events = timeline.getEvents(); + const threadRoots = new Set(); + + events.forEach((event: MatrixEvent) => { + if (event.isThreadRoot) { + const rootId = event.getId(); + if (rootId && !room.getThread(rootId)) { + threadRoots.add(rootId); + } + } + + const { threadRootId } = event; + if (threadRootId && !room.getThread(threadRootId)) { + threadRoots.add(threadRootId); + } + }); + + threadRoots.forEach((rootId) => { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + room.createThread(rootId, rootEvent, [], false); + } + }); + }; + + const liveTimeline = room.getLiveTimeline(); + scanTimelineForThreads(liveTimeline); + + let backwardTimeline = liveTimeline.getNeighbouringTimeline(Direction.Backward); + while (backwardTimeline) { + scanTimelineForThreads(backwardTimeline); + backwardTimeline = backwardTimeline.getNeighbouringTimeline(Direction.Backward); + } + + // Initialize thread timeline sets then fetch threads from server + room + .createThreadsTimelineSets() + .then(() => room.fetchRoomThreads()) + .then(() => { + forceUpdate((n) => n + 1); + }) + .catch(() => { + // Silently ignore โ€” server may not support threads + }); + }, [room]); + + // Re-render when threads or timeline change + useEffect(() => { + const createdThreads = new Set(); + const onThreadNew: RoomEventHandlerMap[ThreadEvent.New] = () => { + forceUpdate((n) => n + 1); + }; + const onThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = () => { + forceUpdate((n) => n + 1); + }; + const onThreadReply: RoomEventHandlerMap[ThreadEvent.NewReply] = () => { + forceUpdate((n) => n + 1); + }; + const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (mEvent) => { + if (mEvent.isThreadRoot) { + const rootId = mEvent.getId(); + if (rootId && !room.getThread(rootId) && !createdThreads.has(rootId)) { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + createdThreads.add(rootId); + room.createThread(rootId, rootEvent, [], false); + } + } + forceUpdate((n) => n + 1); + return; + } + + const { threadRootId } = mEvent; + if (threadRootId) { + if (!room.getThread(threadRootId) && !createdThreads.has(threadRootId)) { + const rootEvent = room.findEventById(threadRootId); + if (rootEvent) { + createdThreads.add(threadRootId); + room.createThread(threadRootId, rootEvent, [], false); + } + } + forceUpdate((n) => n + 1); + return; + } + if (mEvent.isState()) return; + if (reactionOrEditEvent(mEvent)) return; + if (!mEvent.getContent()?.msgtype) return; + forceUpdate((n) => n + 1); + }; + const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent) => { + if (mEvent.threadRootId || mEvent.isThreadRoot) { + forceUpdate((n) => n + 1); + return; + } + forceUpdate((n) => n + 1); + }; + + const onUnreadNotifications = () => forceUpdate((n) => n + 1); + + room.on(RoomEvent.Timeline, onTimeline); + room.on(RoomEvent.Redaction, onRedaction); + room.on(RoomEvent.UnreadNotifications, onUnreadNotifications); + room.on(ThreadEvent.New, onThreadNew); + room.on(ThreadEvent.Update, onThreadUpdate); + room.on(ThreadEvent.NewReply, onThreadReply); + const cleanup = () => { + room.removeListener(RoomEvent.Timeline, onTimeline); + room.removeListener(RoomEvent.Redaction, onRedaction); + room.removeListener(RoomEvent.UnreadNotifications, onUnreadNotifications); + room.removeListener(ThreadEvent.New, onThreadNew); + room.removeListener(ThreadEvent.Update, onThreadUpdate); + room.removeListener(ThreadEvent.NewReply, onThreadReply); + }; + + return cleanup; + }, [room]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const posts = useMemo(() => collectForumPosts(room), [room, updateKey]); + + const handleOpenThread = useCallback( + (eventId: string) => { + setOpenThread(eventId); + }, + [setOpenThread] + ); + + const handleUserClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleUsernameClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + // In forum view, username click opens profile (no editor to insert mention into) + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleReplyClick: MouseEventHandler = useCallback( + (evt) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) return; + // In forum view, clicking reply opens the thread + setOpenThread(replyId); + }, + [setOpenThread] + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string) => { + const thread = room.getThread(targetEventId); + const threadTimelineSet = thread?.timelineSet; + toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet); + }, + [mx, room] + ); + + const handleEdit = useCallback((evtId?: string) => { + setEditId(evtId); + }, []); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + // Scroll to the post or open thread + setOpenThread(targetId); + }, + [setOpenThread] + ); + + return ( + + + + + + + + + + scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} + variant="SurfaceVariant" + radii="Pill" + outlined + size="300" + aria-label="Scroll to Top" + > + {composerIcon(CaretUp)} + + + + + + + {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + + You do not have permission to post in this room + + + )} + + )} + + {posts.map((post) => ( + + ))} + {posts.length === 0 && ( + + {sizedIcon(Chats, '400')} + + No posts yet. + + + )} + + + + + + {screenSize === ScreenSize.Desktop && openThreadId && ( + <> + + setOpenThread(undefined)} + /> + + )} + {screenSize === ScreenSize.Desktop && !openThreadId && isDrawer && ( + <> + + + + )} + {screenSize !== ScreenSize.Desktop && openThreadId && ( + setOpenThread(undefined)} + overlay + /> + )} + + + ); +} diff --git a/src/app/features/forum/index.ts b/src/app/features/forum/index.ts new file mode 100644 index 000000000..eb8d2d7a8 --- /dev/null +++ b/src/app/features/forum/index.ts @@ -0,0 +1 @@ +export { ForumView } from './ForumView'; diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index c8efb2843..31457c045 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -37,7 +37,8 @@ import { useCategoryHandler } from '$hooks/useCategoryHandler'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { allRoomsAtom } from '$state/room-list/roomList'; import { getCanonicalAliasOrRoomId, rateLimitedActions } from '$utils/matrix'; -import { getSpaceRoomPath } from '$pages/pathUtils'; +import { getSpaceRoomPath, getSpaceForumPath } from '$pages/pathUtils'; +import { CustomRoomType } from '$types/matrix/room'; import { ASCIILexicalTable, orderKeys } from '$utils/ASCIILexicalTable'; import { getStateEvent } from '$utils/room'; @@ -527,7 +528,12 @@ export function Lobby() { const rId = evt.currentTarget.getAttribute('data-room-id'); if (!rId) return; const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + const targetRoom = mx.getRoom(rId); + if (targetRoom?.getType() === CustomRoomType.Forum) { + navigate(getSpaceForumPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + } else { + navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + } }; const togglePinToSidebar = useCallback( diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 60ec814f9..971a3d728 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -294,6 +294,16 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { > Voice Room + handleCreateRoom(CreateRoomType.ForumRoom)} + after={} + > + Forum Room + Existing Room diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 632331b17..3d8eb9240 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -62,7 +62,15 @@ import { useIsDirectRoom, useRoom } from '$hooks/useRoom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useSpaceOptionally } from '$hooks/useSpace'; -import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; +import { + getHomeSearchPath, + getSpaceSearchPath, + getHomeForumPath, + getDirectForumPath, + getSpaceForumPath, + withSearchParam, +} from '$pages/pathUtils'; +import { CustomRoomType } from '$types/matrix/room'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -98,7 +106,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; import { ContainerColor } from '$styles/ContainerColor.css'; import { useRoomWidgets } from '$hooks/useRoomWidgets'; -import { hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room'; +import { getPinsHash, hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room'; import { DirectInvitePrompt } from '$components/direct-invite-prompt'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; @@ -115,16 +123,6 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; const log = createLogger('RoomViewHeader'); -async function getPinsHash(pinnedIds: string[]): Promise { - const sorted = [...pinnedIds].toSorted().join(','); - const encoder = new TextEncoder(); - const data = encoder.encode(sorted); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hashHex.slice(0, 10); -} - export interface PinReadMarker { hash: string; count: number; @@ -137,7 +135,9 @@ type RoomMenuProps = { }; const RoomMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const navigate = useNavigate(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -149,6 +149,9 @@ const RoomMenu = forwardRef(({ room, requestClose const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const { navigateRoom } = useRoomNavigate(); + const parentSpace = useSpaceOptionally(); + const isForum = room.getType() === CustomRoomType.Forum; + const isDirectRoom = useIsDirectRoom(); const [invitePrompt, setInvitePrompt] = useState(false); const [directInvitePrompt, setDirectInvitePrompt] = useState(false); @@ -197,12 +200,24 @@ const RoomMenu = forwardRef(({ room, requestClose }; const openSettings = useOpenRoomSettings(); - const parentSpace = useSpaceOptionally(); const handleOpenSettings = () => { openSettings(room.roomId, parentSpace?.roomId); requestClose(); }; + const handleOpenForumView = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (parentSpace) { + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace.roomId); + navigate(getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias)); + } else if (isDirectRoom) { + navigate(getDirectForumPath(roomIdOrAlias)); + } else { + navigate(getHomeForumPath(roomIdOrAlias)); + } + requestClose(); + }; + return ( {invitePrompt && ( @@ -315,6 +330,13 @@ const RoomMenu = forwardRef(({ room, requestClose )} + {(isForum || developerTools) && ( + + + Forum View + + + )} diff --git a/src/app/features/room/ThreadRootItem.tsx b/src/app/features/room/ThreadRootItem.tsx new file mode 100644 index 000000000..585696ee5 --- /dev/null +++ b/src/app/features/room/ThreadRootItem.tsx @@ -0,0 +1,214 @@ +import type { MouseEventHandler } from 'react'; +import { Box, Scroll, config } from 'folds'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { EventType } from 'matrix-js-sdk'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue } from 'jotai'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { Image } from '$components/media'; +import { ImageViewer } from '$components/image-viewer'; +import { getEditedEvent, getEventReactions, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { ImageContent, MSticker, RedactedContent, Reply } from '$components/message'; +import { RenderMessageContent } from '$components/RenderMessageContent'; +import type { MessageLayout, MessageSpacing } from '$state/settings'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import type { GetContentCallback } from '$types/matrix/room'; +import { nicknamesAtom } from '$state/nicknames'; +import { EncryptedContent, Message, Reactions } from './message'; + +export type ThreadRootItemProps = { + room: Room; + mEvent: MatrixEvent; + thread?: Thread; + editId: string | undefined; + onEditId: (id?: string) => void; + messageLayout: MessageLayout; + messageSpacing: MessageSpacing; + canDelete: boolean; + canSendReaction: boolean; + canPinEvent: boolean; + imagePackRooms: Room[]; + hour24Clock: boolean; + dateFormatString: string; + onUserClick: MouseEventHandler; + onUsernameClick: MouseEventHandler; + onReplyClick: MouseEventHandler; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + linkifyOpts: LinkifyOpts; + htmlReactParserOptions: HTMLReactParserOptions; + showHideReads: boolean; + showDeveloperTools: boolean; + onReferenceClick: MouseEventHandler; + hideReplyButton?: boolean; +}; + +export function ThreadRootItem({ + room, + mEvent, + thread, + editId, + onEditId, + messageLayout, + messageSpacing, + canDelete, + canSendReaction, + canPinEvent, + imagePackRooms, + hour24Clock, + dateFormatString, + onUserClick, + onUsernameClick, + onReplyClick, + onReactionToggle, + linkifyOpts, + htmlReactParserOptions, + showHideReads, + showDeveloperTools, + onReferenceClick, + hideReplyButton, +}: ThreadRootItemProps) { + const nicknames = useAtomValue(nicknamesAtom); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + + const mEventId = mEvent.getId(); + if (!mEventId) return null; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + + const timelineSet = thread?.timelineSet ?? room.getUnfilteredTimelineSet(); + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const editedNewContent = editedEvent?.getContent()['m.new_content']; + const baseContent = mEvent.getContent(); + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + + const { replyEventId } = mEvent; + const showUrlPreview = room.hasEncryptionStateEvent() ? false : urlPreview; + + return ( + <> + + ) + } + > + {mEvent.isRedacted() ? ( + + ) : ( + + + {() => { + if (mEvent.isRedacted()) { + return ( + + ); + } + + if (mEvent.getType() === (EventType.Sticker as string)) { + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + } + + return ( + + ); + }} + + + )} + + + {/* Reactions โ€” outside scroll so always visible */} + {hasReactions && reactionRelations && ( + + + + )} + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 045dd6973..f1edbb4df 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -255,6 +255,7 @@ export type MessageProps = { reply?: ReactNode; reactions?: ReactNode; hideReadReceipts?: boolean; + hideReplyButton?: boolean; showDeveloperTools?: boolean; memberPowerTag?: MemberPowerTag; hour24Clock: boolean; @@ -440,6 +441,7 @@ function MessageInternal( reply, reactions, hideReadReceipts, + hideReplyButton, showDeveloperTools, memberPowerTag, hour24Clock, @@ -1031,18 +1033,20 @@ function MessageInternal( )} - { - onReplyClick(ev); - setMobileOptionsOpen(false); - }} - data-event-id={mEvent.getId()} - variant="SurfaceVariant" - size="300" - radii="300" - > - {menuIcon(ArrowBendUpLeftIcon)} - + {!hideReplyButton && ( + { + onReplyClick(ev); + setMobileOptionsOpen(false); + }} + data-event-id={mEvent.getId()} + variant="SurfaceVariant" + size="300" + radii="300" + > + {menuIcon(ArrowBendUpLeftIcon)} + + )} {!isThreadedMessage && ( { @@ -1146,22 +1150,24 @@ function MessageInternal( )} {relations && } - { - onReplyClick( - evt as unknown as Parameters>[0] - ); - closeMenu(); - }} - > - - Reply - - + {!hideReplyButton && ( + { + onReplyClick( + evt as unknown as Parameters>[0] + ); + closeMenu(); + }} + > + + Reply + + + )} {!isThreadedMessage && ( { const navigate = useNavigate(); @@ -37,6 +41,7 @@ export const useRoomNavigate = () => { (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; + const isForum = mx.getRoom(roomId)?.getType() === CustomRoomType.Forum; const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { @@ -49,19 +54,31 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); + if (isForum && !openSpaceTimeline) { + navigate(getSpaceForumPath(pSpaceIdOrAlias, roomIdOrAlias), opts); + } else { + navigate( + getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), + opts + ); + } return; } if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + if (isForum) { + navigate(getDirectForumPath(roomIdOrAlias), opts); + } else { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + } return; } - navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + if (isForum) { + navigate(getHomeForumPath(roomIdOrAlias), opts); + } else { + navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + } }, [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 56c24a899..16310a508 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -14,6 +14,7 @@ import { SettingsRoute } from '$features/settings'; import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; +import { ForumView } from '$features/forum'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; @@ -48,6 +49,7 @@ import { LOBBY_PATH_SEGMENT, NOTIFICATIONS_PATH_SEGMENT, ROOM_PATH_SEGMENT, + ROOM_FORUM_PATH_SEGMENT, SEARCH_PATH_SEGMENT, SERVER_PATH_SEGMENT, CREATE_PATH, @@ -252,6 +254,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + const getToLink = (roomId: string) => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + if (mx.getRoom(roomId)?.getType() === CustomRoomType.Forum) { + return getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias); + } + return getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias); + }; const navigate = useNavigate(); const lastRoomId = useAtomValue(lastVisitedRoomIdAtom); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..fed0969ce 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -28,6 +28,9 @@ import { SPACE_ROOM_PATH, SPACE_SEARCH_PATH, CREATE_PATH, + HOME_ROOM_FORUM_PATH, + DIRECT_ROOM_FORUM_PATH, + SPACE_ROOM_FORUM_PATH, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -100,6 +103,14 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string return generatePath(HOME_ROOM_PATH, params); }; +export const getHomeForumPath = (roomIdOrAlias: string): string => { + const params = { + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(HOME_ROOM_FORUM_PATH, params); +}; + export const getDirectPath = (): string => DIRECT_PATH; export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH; export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => { @@ -111,6 +122,14 @@ export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): stri return generatePath(DIRECT_ROOM_PATH, params); }; +export const getDirectForumPath = (roomIdOrAlias: string): string => { + const params = { + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(DIRECT_ROOM_FORUM_PATH, params); +}; + export const getSpacePath = (spaceIdOrAlias: string): string => { const params = { spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), @@ -143,6 +162,14 @@ export const getSpaceRoomPath = ( return generatePath(SPACE_ROOM_PATH, params); }; +export const getSpaceForumPath = (spaceIdOrAlias: string, roomIdOrAlias: string): string => { + const params = { + spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(SPACE_ROOM_FORUM_PATH, params); +}; export const getExplorePath = (): string => EXPLORE_PATH; export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..04c4844cd 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -45,12 +45,14 @@ export type RoomSearchParams = { viaServers?: string; }; export const ROOM_PATH_SEGMENT = ':roomIdOrAlias/:eventId?/'; +export const ROOM_FORUM_PATH_SEGMENT = ':roomIdOrAlias/forum/'; export const HOME_PATH = '/home/'; export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`; export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`; export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`; export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`; +export const HOME_ROOM_FORUM_PATH = `/home/${ROOM_FORUM_PATH_SEGMENT}`; export const DIRECT_PATH = '/direct/'; export type DirectCreateSearchParams = { @@ -58,11 +60,13 @@ export type DirectCreateSearchParams = { }; export const DIRECT_CREATE_PATH = `/direct/${CREATE_PATH_SEGMENT}`; export const DIRECT_ROOM_PATH = `/direct/${ROOM_PATH_SEGMENT}`; +export const DIRECT_ROOM_FORUM_PATH = `/direct/${ROOM_FORUM_PATH_SEGMENT}`; export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${LOBBY_PATH_SEGMENT}`; export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${SEARCH_PATH_SEGMENT}`; export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${ROOM_PATH_SEGMENT}`; +export const SPACE_ROOM_FORUM_PATH = `/:spaceIdOrAlias/${ROOM_FORUM_PATH_SEGMENT}`; export const FEATURED_PATH_SEGMENT = 'featured/'; export const SERVER_PATH_SEGMENT = ':server/'; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 6d201f34a..3d9346566 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -1064,6 +1064,16 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent): boolean => { return false; }; +export async function getPinsHash(pinnedIds: string[]): Promise { + const sorted = [...pinnedIds].toSorted().join(','); + const encoder = new TextEncoder(); + const data = encoder.encode(sorted); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex.slice(0, 10); +} + export const isThreadRelationEvent = (mEvent: MatrixEvent, threadRootId?: string): boolean => { const relation = mEvent.getRelation?.() ?? diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index af32fe773..1fdaf3ecd 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -20,6 +20,12 @@ export const CustomStateEvent = { } as const; export type CustomStateEvent = (typeof CustomStateEvent)[keyof typeof CustomStateEvent]; +// Custom room types not covered by the Matrix SDK's RoomType enum. +export const CustomRoomType = { + Forum: 'm.forum', +} as const; +export type CustomRoomType = (typeof CustomRoomType)[keyof typeof CustomRoomType]; + export type MSpaceChildContent = { via: string[]; suggested?: boolean; From fafc72a722dbba5ed8a849ff9f45f5287f2f7f59 Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 24 Jun 2026 10:16:37 +0300 Subject: [PATCH 02/11] fmt Signed-off-by: Shea --- src/app/components/icons/roomIcons.tsx | 3 +-- src/app/features/room/message/Message.tsx | 11 +++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/components/icons/roomIcons.tsx b/src/app/components/icons/roomIcons.tsx index bc40bf2da..77cda41b1 100644 --- a/src/app/components/icons/roomIcons.tsx +++ b/src/app/components/icons/roomIcons.tsx @@ -9,8 +9,7 @@ export type RoomPhosphorIcon = ComponentType; export type RoomIconOverlay = 'globe' | 'lock'; const isRegularRoom = (roomType?: string): boolean => - roomType !== RoomType.Space && - roomType !== RoomType.UnstableCall; + roomType !== RoomType.Space && roomType !== RoomType.UnstableCall; export function getRoomIconOverlay( roomType?: string, diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index f1edbb4df..1586f6b16 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1158,12 +1158,19 @@ function MessageInternal( data-event-id={mEvent.getId()} onClick={(evt: React.MouseEvent) => { onReplyClick( - evt as unknown as Parameters>[0] + evt as unknown as Parameters< + MouseEventHandler + >[0] ); closeMenu(); }} > - + Reply From b43066b51035c995c8fc910a48912af562863ffb Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 24 Jun 2026 14:06:56 +0300 Subject: [PATCH 03/11] fix most issues Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 19 +++++-- .../components/message/MsgTypeRenderers.tsx | 39 ++++++++++++++ .../upload-board/UploadBoard.css.ts | 30 +++++++---- .../components/upload-board/UploadBoard.tsx | 28 ++++++---- src/app/features/forum/ForumView.tsx | 9 +++- src/app/features/room/RoomInput.tsx | 51 ++++++++++++++++++- src/app/features/room/ThreadBrowser.tsx | 28 ++++++++-- src/app/features/room/ThreadDrawer.tsx | 2 +- src/app/features/room/ThreadRootItem.tsx | 12 ++++- .../timeline/useTimelineEventRenderer.tsx | 50 +++++++++++------- 10 files changed, 217 insertions(+), 51 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e4e6c6430..e2811e206 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,7 +1,7 @@ import type { CSSProperties } from 'react'; import { memo, useMemo, useCallback } from 'react'; import type { IPreviewUrlResponse, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { MsgType } from '$types/matrix-sdk'; +import { EventType, MsgType } from '$types/matrix-sdk'; import { parseSettingsLink } from '$features/settings/settingsLink'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { testMatrixTo } from '$plugins/matrix-to'; @@ -23,6 +23,7 @@ import { MImage, MLocation, MNotice, + MStrickerWrappper, MText, MVideo, ReadPdfFile, @@ -48,7 +49,7 @@ import { TextViewer } from './text-viewer'; import { ClientSideHoverFreeze } from './ClientSideHoverFreeze'; import { CuteEventType, MCuteEvent } from './message/MCuteEvent'; import { PollEvent } from './message/PollEvent'; -import { M_TEXT } from 'matrix-js-sdk'; +import { M_POLL_START, M_TEXT } from 'matrix-js-sdk'; import type { IImageInfo } from '$types/matrix/common'; type RenderMessageContentProps = { @@ -70,6 +71,7 @@ type RenderMessageContentProps = { mEvent?: MatrixEvent; mx?: MatrixClient; room?: Room; + autoplayStickers?: boolean; }; const getMediaType = (url: string) => { @@ -106,6 +108,7 @@ function RenderMessageContentInternal({ mEvent, mx, room, + autoplayStickers, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -460,11 +463,21 @@ function RenderMessageContentInternal({ } /> ); - if (content['org.matrix.msc3381.poll.start']) { + if (content[M_POLL_START.name]) { if (mEvent && mx && room) return ; else return ; } + if (mEvent?.getType() === EventType.Sticker) { + return ( + + ); + } + return ( ); } +type MStrickerWrappperProps = { + mEvent: MatrixEvent; + autoplayStickers?: boolean; + mediaAutoLoad?: boolean; +}; + +export function MStrickerWrappper({ + mEvent, + autoplayStickers, + mediaAutoLoad, +}: MStrickerWrappperProps) { + return ( + ( + { + if (!autoplayStickers && p.src) { + return ( + + + + ); + } + return ; + }} + renderViewer={(p) => } + /> + )} + /> + ); +} diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts index 80c1b264d..9230919ed 100644 --- a/src/app/components/upload-board/UploadBoard.css.ts +++ b/src/app/components/upload-board/UploadBoard.css.ts @@ -1,4 +1,5 @@ import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; export const UploadBoardBase = style([ @@ -9,16 +10,27 @@ export const UploadBoardBase = style([ }, ]); -export const UploadBoardContainer = style([ - DefaultReset, - { - position: 'absolute', - bottom: config.space.S200, - left: 0, - right: 0, - zIndex: config.zIndex.Max, +export const UploadBoardContainer = recipe({ + base: [ + DefaultReset, + { + position: 'absolute', + left: 0, + right: 0, + zIndex: config.zIndex.Max, + }, + ], + variants: { + isBottom: { + true: { + top: config.space.S200, + }, + false: { + bottom: config.space.S200, + }, + }, }, -]); +}); export const UploadBoard = style({ maxWidth: toRem(400), diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx index 232d65fec..5d7eaa219 100644 --- a/src/app/components/upload-board/UploadBoard.tsx +++ b/src/app/components/upload-board/UploadBoard.tsx @@ -11,21 +11,27 @@ import * as css from './UploadBoard.css'; type UploadBoardProps = { header: ReactNode; + showUploadCardBottom?: boolean; }; -export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => ( - - - - - {children} - - - {header} +export const UploadBoard = as<'div', UploadBoardProps>( + ({ header, showUploadCardBottom, children, ...props }, ref) => ( + + + + + {children} + + + {header} + - -)); + ) +); export type UploadBoardImperativeHandlers = { handleSend: () => Promise }; diff --git a/src/app/features/forum/ForumView.tsx b/src/app/features/forum/ForumView.tsx index 24c3a920c..eec9ecc2b 100644 --- a/src/app/features/forum/ForumView.tsx +++ b/src/app/features/forum/ForumView.tsx @@ -50,6 +50,7 @@ import { renderMatrixMention, } from '$plugins/react-custom-html-parser'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import { GlobalModalManager } from '$components/message/modals/GlobalModalManager'; type ForumPost = { eventId: string; @@ -100,7 +101,6 @@ const collectForumPosts = (room: Room): ForumPost[] => { // that just reference a thread root via m.in_reply_to if (ev.getRelation()?.rel_type === 'm.thread') return; if (ev.isState()) return; // skip state events - if (!ev.getContent()?.msgtype) return; // not a displayable message posts.set(evId, { eventId: evId, @@ -397,6 +397,10 @@ export function ForumView() { [setOpenThread] ); + const [showInteractiveMap] = useSetting(settingsAtom, 'showInteractiveMap'); + const [showEncInteractiveMap] = useSetting(settingsAtom, 'showEncInteractiveMap'); + const showMaps = room.hasEncryptionStateEvent() ? showEncInteractiveMap : showInteractiveMap; + return ( @@ -463,6 +467,7 @@ export function ForumView() { editor={editor} roomId={room.roomId} fileDropContainerRef={roomViewRef} + showUploadCardBottom /> )} {!canMessage && ( @@ -505,6 +510,7 @@ export function ForumView() { showDeveloperTools={showDeveloperTools} onReferenceClick={handleOpenReply} onClick={handleOpenThread} + showMaps={showMaps} /> ))} {posts.length === 0 && ( @@ -552,6 +558,7 @@ export function ForumView() { /> )} + ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f3897edc1..e3d2e0711 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -269,10 +269,22 @@ interface RoomInputProps { room: Room; threadRootId?: string; onEditLastMessage?: () => void; + showUploadCardBottom?: boolean; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room, threadRootId, onEditLastMessage }, ref) => { + ( + { + editor, + fileDropContainerRef, + roomId, + room, + threadRootId, + onEditLastMessage, + showUploadCardBottom, + }, + ref + ) => { // When in thread mode, isolate drafts by thread root ID so thread replies // don't clobber the main room draft (and vice versa). const draftKey = threadRootId ?? roomId; @@ -1302,7 +1314,7 @@ export const RoomInput = forwardRef( return (
- {selectedFiles.length > 0 && ( + {selectedFiles.length > 0 && !showUploadCardBottom && ( ( } bottom={} /> + {selectedFiles.length > 0 && showUploadCardBottom && ( + setUploadBoard(!uploadBoard)} + uploadFamilyObserverAtom={uploadFamilyObserverAtom} + onSend={handleSendUpload} + imperativeHandlerRef={uploadBoardHandlers} + onCancel={handleCancelUpload} + /> + } + showUploadCardBottom + > + {uploadBoard && ( + + + {Array.from(selectedFiles) + .toReversed() + .map((fileItem) => ( + + ))} + + + )} + + )} {showSchedulePicker && ( void; onJump?: () => void; + showMaps?: boolean; }; -function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { +function ThreadPreview({ room, thread, onClick, onJump, showMaps }: ThreadPreviewProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -156,6 +162,13 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const displayName = getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + const timelineSet = thread?.timelineSet ?? room.getUnfilteredTimelineSet(); + const rootEventId = rootEvent.getId() ?? ''; + const editedEvent = getEditedEvent(rootEventId, rootEvent, timelineSet); + const editedNewContent = editedEvent?.getContent()['m.new_content']; + const baseContent = rootEvent.getContent(); + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : rootEvent.getOriginalContent(); const getContent = (() => rootEvent.getContent()) as GetContentCallback; const localReplyCount = thread.events.filter( @@ -254,7 +267,9 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { return ( ); }} @@ -316,6 +332,9 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr const canLoadMoreRef = useRef(false); canLoadMoreRef.current = canLoadMore; + const [showInteractiveMap] = useSetting(settingsAtom, 'showInteractiveMap'); + const [showEncInteractiveMap] = useSetting(settingsAtom, 'showEncInteractiveMap'); + const showMaps = room.hasEncryptionStateEvent() ? showEncInteractiveMap : showInteractiveMap; // On mount, set up thread event listeners, create the server-side thread // timeline sets, then fetch page 1 via paginate. The two operations are // sequenced in a single effect so that createThreadsTimelineSets() always @@ -481,7 +500,7 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr setCurWidth={setCurWidth} sidebarWidth={threadSidebarWidth} setSidebarWidth={setThreadSidebarWidth} - minValue={150} + minValue={250} maxValue={600} isReversed /> @@ -588,6 +607,7 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr thread={thread} onClick={onOpenThread} onJump={onClose} + showMaps={showMaps} /> ))} diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 5f680cce5..3a1a16807 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -817,7 +817,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra setCurWidth={setCurWidth} sidebarWidth={threadSidebarWidth} setSidebarWidth={setThreadSidebarWidth} - minValue={150} + minValue={250} maxValue={600} isReversed /> diff --git a/src/app/features/room/ThreadRootItem.tsx b/src/app/features/room/ThreadRootItem.tsx index 585696ee5..84b139129 100644 --- a/src/app/features/room/ThreadRootItem.tsx +++ b/src/app/features/room/ThreadRootItem.tsx @@ -18,6 +18,7 @@ import { useSetting } from '$state/hooks/settings'; import type { GetContentCallback } from '$types/matrix/room'; import { nicknamesAtom } from '$state/nicknames'; import { EncryptedContent, Message, Reactions } from './message'; +import { useMatrixClient } from '$hooks/useMatrixClient'; export type ThreadRootItemProps = { room: Room; @@ -43,6 +44,7 @@ export type ThreadRootItemProps = { showDeveloperTools: boolean; onReferenceClick: MouseEventHandler; hideReplyButton?: boolean; + showMaps?: boolean; }; export function ThreadRootItem({ @@ -69,7 +71,9 @@ export function ThreadRootItem({ showDeveloperTools, onReferenceClick, hideReplyButton, + showMaps, }: ThreadRootItemProps) { + const mx = useMatrixClient(); const nicknames = useAtomValue(nicknamesAtom); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); @@ -178,7 +182,9 @@ export function ThreadRootItem({ return ( ); }} diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 2d4e41e54..91e02ae8e 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -674,6 +674,7 @@ export function useTimelineEventRenderer({ displayName={senderDisplayName} msgType={((editedNewContent ?? safeContent) as { msgtype?: string }).msgtype ?? ''} ts={mEvent.getTs()} + mEvent={mEvent} edited={!!editedEvent} getContent={getContent} mediaAutoLoad={mediaAutoLoad} @@ -851,6 +852,7 @@ export function useTimelineEventRenderer({ } ts={mEvent.getTs()} edited={!!editedEvent} + mEvent={mEvent} getContent={getContent} mediaAutoLoad={mediaAutoLoad} bundledPreview={showBundledPreview} @@ -861,6 +863,7 @@ export function useTimelineEventRenderer({ outlineAttachment={messageLayout === MessageLayout.Bubble} mx={mx} room={room} + showMaps={showMaps} /> ); } @@ -897,6 +900,16 @@ export function useTimelineEventRenderer({ getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; const content = mEvent.getContent() ?? {}; + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + let editedNewContent: unknown; + if (editedEvent) { + editedNewContent = editedEvent.getContent()['m.new_content']; + } + const baseContent = mEvent.getContent() || {}; + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + return ( ) : ( - ( - { - if (!autoplayStickers && p.src) { - return ( - - - - ); - } - return ; - }} - renderViewer={(p) => } - /> - )} + )} @@ -1160,6 +1171,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + showMaps={showMaps} /> )} From c916f94783029c357ced3cf6295733542ab0ac2c Mon Sep 17 00:00:00 2001 From: Shea Date: Thu, 25 Jun 2026 23:29:48 +0300 Subject: [PATCH 04/11] merge divergence Signed-off-by: Shea --- .changeset/add-forum-room-type.md | 2 +- .changeset/fix-abandn.md | 5 + .changeset/fix-preserve-add-account-param.md | 5 + src/app/components/message/RenderBody.tsx | 10 +- src/app/components/message/modals/Options.tsx | 4 +- src/app/components/sidebar/Sidebar.css.ts | 2 +- src/app/features/forum/ForumView.tsx | 2 +- src/app/features/room/message/Message.tsx | 2 +- .../features/settings/cosmetics/Themes.tsx | 9 ++ src/app/features/settings/settingsLink.ts | 1 + src/app/pages/auth/login/Login.tsx | 8 +- src/app/pages/auth/register/Register.tsx | 9 +- src/app/pages/client/SidebarNav.tsx | 37 ++++-- src/app/pages/client/home/Home.tsx | 2 - .../client/sidebar/AccountSwitcherTab.tsx | 107 ++++++++++++------ src/app/pages/client/sidebar/CreateTab.tsx | 2 +- src/app/pages/client/sidebar/InboxTab.tsx | 4 +- src/app/pages/client/sidebar/SearchTab.tsx | 4 +- src/app/pages/client/sidebar/SettingsTab.tsx | 11 +- .../pages/client/sidebar/UnverifiedTab.tsx | 4 +- .../client/sidebar/UserQuickTools.css.ts | 6 +- .../pages/client/sidebar/UserQuickTools.tsx | 54 ++++----- src/app/state/settings.ts | 2 + src/types/matrix/room.ts | 3 +- 24 files changed, 194 insertions(+), 101 deletions(-) create mode 100644 .changeset/fix-abandn.md create mode 100644 .changeset/fix-preserve-add-account-param.md diff --git a/.changeset/add-forum-room-type.md b/.changeset/add-forum-room-type.md index 648bd27aa..b19c76ad7 100644 --- a/.changeset/add-forum-room-type.md +++ b/.changeset/add-forum-room-type.md @@ -2,4 +2,4 @@ default: minor --- -Add the `m.forum` room type with a dedicated forum view that presents threads as topics. +Add the `forum` room type with a dedicated forum view that presents threads as topics. diff --git a/.changeset/fix-abandn.md b/.changeset/fix-abandn.md new file mode 100644 index 000000000..099e68dd7 --- /dev/null +++ b/.changeset/fix-abandn.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix leaving modal looking outsized diff --git a/.changeset/fix-preserve-add-account-param.md b/.changeset/fix-preserve-add-account-param.md new file mode 100644 index 000000000..1b78177c7 --- /dev/null +++ b/.changeset/fix-preserve-add-account-param.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix registration not working when accessed from add account button diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index 67217c2fb..02105f762 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -119,6 +119,11 @@ function AbbreviationTerm({ text, definition }: AbbreviationTermProps) { return ( <> + {anchor && ( + + {null} + + )} {(triggerRef) => (
)} diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 31fa299a5..77e4da3b4 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -230,6 +230,7 @@ function ThemeVisualPreferences() { const [autoplayGifs, setAutoplayGifs] = useSetting(settingsAtom, 'autoplayGifs'); const [autoplayStickers, setAutoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); const [autoplayEmojis, setAutoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); + const [oldSidebar, setOldSidebar] = useSetting(settingsAtom, 'oldSidebar'); const [pixelatedImageRendering, setPixelatedImageRendering] = useSetting( settingsAtom, 'pixelatedImageRendering' @@ -337,6 +338,14 @@ function ThemeVisualPreferences() { after={} /> + + } + /> + @@ -93,7 +99,7 @@ export function Login() { )} - Do not have an account? Register + Do not have an account? Register
); diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index 73497255e..df9aa123e 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -6,7 +6,7 @@ import { useAuthServer } from '$hooks/useAuthServer'; import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows'; import { useParsedLoginFlows } from '$hooks/useParsedLoginFlows'; import { SupportedUIAFlowsLoader } from '$components/SupportedUIAFlowsLoader'; -import { getLoginPath } from '$pages/pathUtils'; +import { getLoginPath, withSearchParam } from '$pages/pathUtils'; import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; import type { RegisterPathSearchParams } from '$pages/paths'; import { SSOLogin } from '$pages/auth/SSOLogin'; @@ -33,6 +33,11 @@ export function Register() { // redirect to /login because only that path handle m.login.token const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server)); + const isAddingAccount = searchParams.get('addAccount') === '1'; + const loginUrl = isAddingAccount + ? withSearchParam(getLoginPath(server), { addAccount: '1' }) + : getLoginPath(server); + return ( @@ -91,7 +96,7 @@ export function Register() { )} - Already have an account? Login + Already have an account? Login ); diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index a44270828..99ca73d64 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -19,6 +19,7 @@ import { CreateTab } from './sidebar/CreateTab'; import { SearchTab } from './sidebar/SearchTab'; import { SettingsTab } from './sidebar/SettingsTab'; import { UserQuickTools } from './sidebar/UserQuickTools'; +import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; export function SidebarNav() { const scrollRef = useRef(null); @@ -29,11 +30,15 @@ export function SidebarNav() { const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts'); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); + const [roomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); + const screenSize = useScreenSizeContext(); + const compact = screenSize === ScreenSize.Mobile; + const width = roomSidebarWidth + 66; - const underOutstep = width < 190 + 66; - const isCollapsed = width < 50 + 66; + const isCollapsed = compact ? false : width < 190 + 66; const handleContextMenu: MouseEventHandler = (evt) => { const target = evt.target as HTMLElement; @@ -140,22 +145,36 @@ export function SidebarNav() { } sticky={ - {underOutstep && ( + + {oldSidebar ? ( <> - +
+ {/*PROBS ADD SETTINGSTAB HERE WHEN ADDING THE STATUSES*/} + +
- )} - - {isCollapsed && } + ) : ( + <> + {isCollapsed && ( + <> + + + + + )} - + + + + + )}
} /> - + {!oldSidebar && } ); } diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 6a4bb4637..667c7cf10 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -82,7 +82,6 @@ import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useClientConfig } from '$hooks/useClientConfig'; import { getMxIdServer } from '$utils/mxIdHelper'; -import { UserQuickTools } from '../sidebar/UserQuickTools'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; type HomeMenuProps = { @@ -560,7 +559,6 @@ export function Home() { setAnnouncement={setIsResizingSidebar} /> )} -
); } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 712b2af67..e46d427fe 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -14,6 +14,10 @@ import { toRem, Chip, Spinner, + Overlay, + OverlayBackdrop, + OverlayCenter, + Line, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; @@ -37,11 +41,12 @@ import { useUserProfile } from '$hooks/useUserProfile'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { useOpenSettings } from '$features/settings'; -import { Modal500 } from '$components/Modal500'; import { createLogger } from '$utils/debug'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; -import { Check, chipIcon, Plus } from '$components/icons/phosphor'; +import { Check, chipIcon, GearSix, menuIcon, Plus } from '$components/icons/phosphor'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; const log = createLogger('AccountSwitcherTab'); @@ -144,6 +149,7 @@ export function AccountSwitcherTab({ isBottom }: { isBottom?: boolean }) { const backgroundUnreads = useAtomValue(backgroundUnreadCountsAtom); const setBackgroundUnreads = useSetAtom(backgroundUnreadCountsAtom); const openSettings = useOpenSettings(); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); // Total unread count across all background sessions (for the sidebar badge). const totalBackgroundUnread = Object.entries(backgroundUnreads) @@ -245,6 +251,11 @@ export function AccountSwitcherTab({ isBottom }: { isBottom?: boolean }) { setTimeout(() => window.location.assign(url), 100); }; + const handleOpenSettings = () => { + setMenuAnchor(undefined); + openSettings(); + }; + const activeLocalPart = getMxIdLocalPart(activeSession?.userId ?? '') ?? activeSession?.userId ?? ''; const label = activeDisplayName ?? activeLocalPart; @@ -331,6 +342,24 @@ export function AccountSwitcherTab({ isBottom }: { isBottom?: boolean }) { Add Account + {/*This will defo need to be reverted w the new statuses, w the right changes in the SidebarNav to make the cog a permanent fixture but democracy wants old style*/} + {oldSidebar && ( + <> + + + Settings + + + )}
@@ -338,41 +367,53 @@ export function AccountSwitcherTab({ isBottom }: { isBottom?: boolean }) { /> {confirmSignOutSession && ( - setConfirmSignOutSession(undefined)}> - -
}> + + document.body, + clickOutsideDeactivates: true, + onDeactivate: () => setConfirmSignOutSession(undefined), + escapeDeactivates: stopPropagation, }} - variant="Surface" - size="500" > - - Sign out - -
- - - Are you sure you want to sign out of {confirmSignOutSession.userId}? - - - - - - -
-
+ + Sign out + + + + + Are you sure you want to sign out of {confirmSignOutSession.userId}? + + + + + + + + + + )} ); diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx index 5f09bccce..7b8295a4a 100644 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ b/src/app/pages/client/sidebar/CreateTab.tsx @@ -171,7 +171,7 @@ export function CreateTab() { ref={triggerRef} outlined onClick={handleMenu} - size="300" + size="400" > {(joinAddress && ) || (exploreSelected && ( diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index e69ba487a..fe3f636e9 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -49,10 +49,10 @@ export function InboxTab({ isBottom }: { isBottom?: boolean }) { ref={triggerRef} outlined onClick={handleInboxClick} - size="300" + size={'400'} > diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx index c3e072fbd..0a8045860 100644 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ b/src/app/pages/client/sidebar/SearchTab.tsx @@ -13,9 +13,9 @@ export function SearchTab({ isBottom }: { isBottom?: boolean }) { {(triggerRef) => ( - + diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index b0f5d35d3..37372e2eb 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,5 +1,5 @@ import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; -import { GearSix, getPhosphorSize } from '$components/icons/phosphor'; +import { GearSix, getPhosphorIconSize } from '$components/icons/phosphor'; import { useOpenSettings } from '$features/settings'; import { matchPath } from 'react-router-dom'; import { SETTINGS_PATH } from '$pages/paths'; @@ -10,10 +10,13 @@ export function SettingsTab({ isBottom }: { isBottom?: boolean }) { return ( - + {(triggerRef) => ( - - + + )} diff --git a/src/app/pages/client/sidebar/UnverifiedTab.tsx b/src/app/pages/client/sidebar/UnverifiedTab.tsx index 919927565..74070a011 100644 --- a/src/app/pages/client/sidebar/UnverifiedTab.tsx +++ b/src/app/pages/client/sidebar/UnverifiedTab.tsx @@ -52,7 +52,7 @@ function UnverifiedIndicator({ isBottom }: { isBottom?: boolean }) { > {(triggerRef) => ( )} diff --git a/src/app/pages/client/sidebar/UserQuickTools.css.ts b/src/app/pages/client/sidebar/UserQuickTools.css.ts index cda4ab95a..8cd733038 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.css.ts +++ b/src/app/pages/client/sidebar/UserQuickTools.css.ts @@ -4,11 +4,11 @@ import { color, config, toRem } from 'folds'; export const UserQuickTools = style({ backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, - position: 'fixed', + position: 'absolute', zIndex: '1000', - height: toRem(58), + height: toRem(74), bottom: '0', - left: '0', + left: toRem(-66), padding: config.space.S300, borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, }); diff --git a/src/app/pages/client/sidebar/UserQuickTools.tsx b/src/app/pages/client/sidebar/UserQuickTools.tsx index b5c02e921..783b0db2a 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -1,6 +1,5 @@ import { Box, config, toRem } from 'folds'; import { AccountSwitcherTab } from './AccountSwitcherTab'; -import { UnverifiedTab } from './UnverifiedTab'; import { InboxTab } from './InboxTab'; import { SearchTab } from './SearchTab'; import { SettingsTab } from './SettingsTab'; @@ -16,44 +15,45 @@ export function UserQuickTools({ underOutstep?: boolean; width: number; }) { - const [isResizingSidebar] = useAtom(isResizingSidebarAtom); - const underOutstep = width < 190 + 66; - const isCollapsed = width < 50 + 66; - const screenSize = useScreenSizeContext(); const compact = screenSize === ScreenSize.Mobile; + const [isResizingSidebar] = useAtom(isResizingSidebarAtom); + const isCollapsed = compact ? false : width < 190 + 66; + return ( <> {/* Doing it properly and nicely would require a major rewrite that would cause more trouble*/} {!isCollapsed && ( - - +
- {!underOutstep && ( - <> - - - - - )} - + + + {!isCollapsed && ( + <> + + + + + )} + - +
)} ); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 2bac0e9ed..0ec9d3bb2 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -181,6 +181,7 @@ export interface Settings { autoplayGifs: boolean; autoplayStickers: boolean; autoplayEmojis: boolean; + oldSidebar: boolean; pixelatedImageRendering: PixelatedImageRenderingMode; incomingInlineImagesDefaultHeight: number; incomingInlineImagesMaxHeight: number; @@ -333,6 +334,7 @@ export const defaultSettings: Settings = { autoplayGifs: true, autoplayStickers: true, autoplayEmojis: true, + oldSidebar: false, pixelatedImageRendering: 'smart', incomingInlineImagesDefaultHeight: 32, incomingInlineImagesMaxHeight: 64, diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 1fdaf3ecd..aef1b671b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -20,9 +20,8 @@ export const CustomStateEvent = { } as const; export type CustomStateEvent = (typeof CustomStateEvent)[keyof typeof CustomStateEvent]; -// Custom room types not covered by the Matrix SDK's RoomType enum. export const CustomRoomType = { - Forum: 'm.forum', + Forum: 'pl.chrome.forum', } as const; export type CustomRoomType = (typeof CustomRoomType)[keyof typeof CustomRoomType]; From 85f5b84f1e00acdee938a56f277286b8cfb1723a Mon Sep 17 00:00:00 2001 From: Tomasz Sterna Date: Wed, 1 Apr 2026 20:10:50 +0200 Subject: [PATCH 05/11] feat: add `forum` room type and dedicated view with threads as topics --- .changeset/add-forum-room-type.md | 5 + .../create-room/CreateRoomTypeSelector.tsx | 26 + src/app/components/create-room/types.ts | 1 + src/app/components/create-room/utils.ts | 5 +- src/app/components/icons/roomIcons.tsx | 28 +- src/app/components/message/modals/Options.tsx | 60 +- src/app/features/create-room/CreateRoom.tsx | 15 +- src/app/features/forum/ForumHeader.tsx | 262 ++++++++ src/app/features/forum/ForumHero.tsx | 85 +++ src/app/features/forum/ForumMenu.tsx | 176 ++++++ src/app/features/forum/ForumThreadItem.tsx | 128 ++++ src/app/features/forum/ForumView.css.ts | 26 + src/app/features/forum/ForumView.tsx | 557 ++++++++++++++++++ src/app/features/forum/index.ts | 1 + src/app/features/lobby/Lobby.tsx | 10 +- src/app/features/lobby/SpaceItem.tsx | 10 + src/app/features/room/RoomViewHeader.tsx | 48 +- src/app/features/room/ThreadRootItem.tsx | 214 +++++++ src/app/features/room/message/Message.tsx | 3 + src/app/hooks/useRoomNavigate.ts | 29 +- src/app/pages/Router.tsx | 26 + src/app/pages/client/direct/Direct.tsx | 9 +- src/app/pages/client/home/Home.tsx | 8 +- src/app/pages/client/space/Space.tsx | 18 +- src/app/pages/pathUtils.ts | 27 + src/app/pages/paths.ts | 4 + src/app/utils/room.ts | 10 + src/types/matrix/room.ts | 5 + 28 files changed, 1732 insertions(+), 64 deletions(-) create mode 100644 .changeset/add-forum-room-type.md create mode 100644 src/app/features/forum/ForumHeader.tsx create mode 100644 src/app/features/forum/ForumHero.tsx create mode 100644 src/app/features/forum/ForumMenu.tsx create mode 100644 src/app/features/forum/ForumThreadItem.tsx create mode 100644 src/app/features/forum/ForumView.css.ts create mode 100644 src/app/features/forum/ForumView.tsx create mode 100644 src/app/features/forum/index.ts create mode 100644 src/app/features/room/ThreadRootItem.tsx diff --git a/.changeset/add-forum-room-type.md b/.changeset/add-forum-room-type.md new file mode 100644 index 000000000..b19c76ad7 --- /dev/null +++ b/.changeset/add-forum-room-type.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add the `forum` room type with a dedicated forum view that presents threads as topics. diff --git a/src/app/components/create-room/CreateRoomTypeSelector.tsx b/src/app/components/create-room/CreateRoomTypeSelector.tsx index 6ab26d7c1..57dd34825 100644 --- a/src/app/components/create-room/CreateRoomTypeSelector.tsx +++ b/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -71,6 +71,32 @@ export function CreateRoomTypeSelector({
+ onSelect(CreateRoomType.ForumRoom)} + disabled={disabled} + > + + + + Forum Room + + + - Conversations split in topics. + + + + + ); } diff --git a/src/app/components/create-room/types.ts b/src/app/components/create-room/types.ts index 8b54587dd..5a54105bf 100644 --- a/src/app/components/create-room/types.ts +++ b/src/app/components/create-room/types.ts @@ -1,6 +1,7 @@ export enum CreateRoomType { TextRoom = 'text', VoiceRoom = 'voice', + ForumRoom = 'forum', } export enum CreateRoomAccess { diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index 520bc508a..37fdc358a 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -8,13 +8,14 @@ import type { import { JoinRule, RestrictedAllowType, EventType, RoomType } from '$types/matrix-sdk'; import type { StateEvents } from '$types/matrix-sdk'; +import type { CustomRoomType } from '$types/matrix/room'; import { getViaServers } from '$plugins/via-servers'; import { getMxIdServer } from '$utils/mxIdHelper'; import { CreateRoomAccess } from './types'; import * as prefix from '$unstable/prefixes'; export const createRoomCreationContent = ( - type: RoomType | undefined, + type: RoomType | CustomRoomType | undefined, allowFederation: boolean, additionalCreators: string[] | undefined ): object => { @@ -101,7 +102,7 @@ export const createVoiceRoomPowerLevelsOverride = () => ({ export type CreateRoomData = { version: string; - type?: RoomType; + type?: RoomType | CustomRoomType; parent?: Room; access: CreateRoomAccess; name: string; diff --git a/src/app/components/icons/roomIcons.tsx b/src/app/components/icons/roomIcons.tsx index 1709a7d66..bc40bf2da 100644 --- a/src/app/components/icons/roomIcons.tsx +++ b/src/app/components/icons/roomIcons.tsx @@ -1,14 +1,16 @@ import { JoinRule, RoomType } from '$types/matrix-sdk'; import type { ComponentType } from 'react'; import type { IconProps } from '@phosphor-icons/react'; -import { Globe, HashStraight, Lock, SpeakerHigh, SquaresFour } from './phosphor'; +import { CustomRoomType } from '$types/matrix/room'; +import { Chats, Globe, HashStraight, Lock, SpeakerHigh, SquaresFour } from './phosphor'; export type RoomPhosphorIcon = ComponentType; export type RoomIconOverlay = 'globe' | 'lock'; const isRegularRoom = (roomType?: string): boolean => - roomType !== RoomType.Space && roomType !== RoomType.UnstableCall; + roomType !== RoomType.Space && + roomType !== RoomType.UnstableCall; export function getRoomIconOverlay( roomType?: string, @@ -59,6 +61,17 @@ export function getRoomStandaloneIconComponent( return SpeakerHigh; } + if (roomType === CustomRoomType.Forum) { + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return Lock; + } + return Chats; + } + if (joinRule === JoinRule.Public) return Globe; if ( joinRule === JoinRule.Invite || @@ -95,5 +108,16 @@ export function getRoomIconComponent(roomType?: string, joinRule?: JoinRule): Ro return SpeakerHigh; } + if (roomType === CustomRoomType.Forum) { + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return Lock; + } + return Chats; + } + return HashStraight; } diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 5171c9b45..167f363b8 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -249,6 +249,7 @@ export function OptionQuickMenu({ onReplyClick, onEditId, hideReadReceipts, + hideReplyButton, showDeveloperTools, canPinEvent, cleanedDisplayName, @@ -297,18 +298,20 @@ export function OptionQuickMenu({ )} - { - onReplyClick(ev); - closeMenu(); - }} - data-event-id={mEvent.getId()} - variant="SurfaceVariant" - size="300" - radii="300" - > - {menuIcon(ArrowBendUpLeftIcon)} - + {!hideReplyButton && ( + { + onReplyClick(ev); + closeMenu(); + }} + data-event-id={mEvent.getId()} + variant="SurfaceVariant" + size="300" + radii="300" + > + {menuIcon(ArrowBendUpLeftIcon)} + + )} {!isThreadedMessage && ( { @@ -351,6 +354,7 @@ export function OptionQuickMenu({ onReplyClick={onReplyClick} onEditId={onEditId} hideReadReceipts={hideReadReceipts} + hideReplyButton={hideReplyButton} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} cleanedDisplayName={cleanedDisplayName} @@ -398,6 +402,7 @@ export type OptionMenuProps = { ) => void; onEditId?: (eventId?: string) => void; hideReadReceipts?: boolean; + hideReplyButton?: boolean; showDeveloperTools?: boolean; canPinEvent?: boolean; cleanedDisplayName?: string; @@ -423,6 +428,7 @@ export function OptionMenu({ onReplyClick, onEditId, hideReadReceipts, + hideReplyButton, showDeveloperTools, canPinEvent, cleanedDisplayName, @@ -574,20 +580,22 @@ export function OptionMenu({ )} {relations && } - { - onReplyClick(evt); - onTotalClose(); - }} - > - - Reply - - + {!hideReplyButton && ( + { + onReplyClick(evt); + onTotalClose(); + }} + > + + Reply + + + )} {!isThreadedMessage && ( { - const isVoiceRoom = type === CreateRoomType.VoiceRoom; + let roomType: string | undefined; + if (type === CreateRoomType.VoiceRoom) roomType = RoomType.UnstableCall; + if (type === CreateRoomType.ForumRoom) roomType = CustomRoomType.Forum; let joinRule: JoinRule = JoinRule.Public; if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted; if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock; - return sizedIcon( - getRoomStandaloneIconComponent(isVoiceRoom ? RoomType.UnstableCall : undefined, joinRule), - size - ); + return sizedIcon(getRoomStandaloneIconComponent(roomType, joinRule), size); }; const getCreateRoomTypeToIcon = (type: CreateRoomType): ReactNode => { if (type === CreateRoomType.VoiceRoom) return sizedIcon(SpeakerHigh, '400'); + if (type === CreateRoomType.ForumRoom) return sizedIcon(Chats, '400'); return sizedIcon(Hash, '400'); }; @@ -144,8 +146,9 @@ export function CreateRoomForm({ roomKnock = knock; } - let roomType: RoomType | undefined; + let roomType: RoomType | CustomRoomType | undefined; if (type === CreateRoomType.VoiceRoom) roomType = RoomType.UnstableCall; + if (type === CreateRoomType.ForumRoom) roomType = CustomRoomType.Forum; debugLog.info('ui', 'Create room button clicked', { roomName, diff --git a/src/app/features/forum/ForumHeader.tsx b/src/app/features/forum/ForumHeader.tsx new file mode 100644 index 000000000..a0c60da68 --- /dev/null +++ b/src/app/features/forum/ForumHeader.tsx @@ -0,0 +1,262 @@ +import type { MouseEventHandler } from 'react'; +import { useEffect, useState } from 'react'; +import type { RectCords } from 'folds'; +import { + Avatar, + Badge, + Box, + IconButton, + PopOut, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import type { Room } from 'matrix-js-sdk'; +import { PageHeader } from '$components/page'; +import { useSetSetting, useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useRoomAvatar, useRoomName } from '$hooks/useRoomMeta'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { RoomAvatar } from '$components/room-avatar'; +import { + ArrowLeft, + composerIcon, + DotsThreeOutlineVerticalIcon, + PushPin, + UserCircle, +} from '$components/icons/phosphor'; +import { nameInitials } from '$utils/common'; +import type { IPowerLevels } from '$hooks/usePowerLevels'; +import { stopPropagation } from '$utils/keyboard'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { BackRouteHandler } from '$components/BackRouteHandler'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; +import { getPinsHash } from '$utils/room'; +import { RoomPinMenu } from '$features/room/room-pin-menu'; +import { ForumMenu } from './ForumMenu'; +import * as css from './ForumView.css'; + +type ForumHeaderProps = { + room: Room; + showProfile?: boolean; + powerLevels: IPowerLevels; +}; +export function ForumHeader({ room, showProfile, powerLevels }: ForumHeaderProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const [peopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [menuAnchor, setMenuAnchor] = useState(); + const [pinMenuAnchor, setPinMenuAnchor] = useState(); + const screenSize = useScreenSizeContext(); + const pinnedEvents = useRoomPinnedEvents(room); + const [currentHash, setCurrentHash] = useState(''); + + useEffect(() => { + getPinsHash(pinnedEvents) + .then(setCurrentHash) + .catch(() => undefined); + }, [pinnedEvents]); + + const name = useRoomName(room); + const avatarMxc = useRoomAvatar(room); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + const handleOpenPinMenu: MouseEventHandler = (evt) => { + setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + + {screenSize === ScreenSize.Mobile ? ( + <> + + + {(onBack) => ( + + {composerIcon(ArrowLeft)} + + )} + + + + {showProfile && ( + + {name} + + )} + + + ) : ( + <> + + + {showProfile && ( + <> + + {nameInitials(name)}} + /> + + + {name} + + + )} + + + )} + + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + {composerIcon(PushPin, { weight: pinMenuAnchor ? 'fill' : 'regular' })} + + )} + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} + currentHash={currentHash} + /> + + } + /> + {screenSize !== ScreenSize.Mobile && ( + + {peopleDrawer ? 'Hide Members' : 'Show Members'} + + } + > + {(triggerRef) => ( + setPeopleDrawer((drawer) => !drawer)} + > + {composerIcon(UserCircle, { weight: peopleDrawer ? 'fill' : 'regular' })} + + )} + + )} + + More Options + + } + > + {(triggerRef) => ( + + {composerIcon(DotsThreeOutlineVerticalIcon, { + weight: menuAnchor ? 'fill' : 'regular', + })} + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + /> + + } + /> + + + + ); +} diff --git a/src/app/features/forum/ForumHero.tsx b/src/app/features/forum/ForumHero.tsx new file mode 100644 index 000000000..490ae6a23 --- /dev/null +++ b/src/app/features/forum/ForumHero.tsx @@ -0,0 +1,85 @@ +import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import type { Room } from 'matrix-js-sdk'; +import { useRoomAvatar, useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { RoomAvatar } from '$components/room-avatar'; +import { nameInitials } from '$utils/common'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { RoomTopicViewer } from '$components/room-topic-viewer'; +import { PageHero } from '$components/page'; +import { onEnterOrSpace, stopPropagation } from '$utils/keyboard'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import * as css from './ForumView.css'; + +type ForumHeroProps = { + room: Room; +}; + +export function ForumHero({ room }: ForumHeroProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const name = useRoomName(room); + const topic = useRoomTopic(room); + const avatarMxc = useRoomAvatar(room); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + return ( + + {nameInitials(name)}} + /> + + } + title={name} + subTitle={ + topic && ( + + {(viewTopic, setViewTopic) => ( + <> + }> + + setViewTopic(false), + escapeDeactivates: stopPropagation, + }} + > + setViewTopic(false)} + /> + + + + setViewTopic(true)} + onKeyDown={onEnterOrSpace(() => setViewTopic(true))} + tabIndex={0} + className={css.ForumHeroTopic} + size="Inherit" + priority="300" + > + {topic} + + + )} + + ) + } + /> + ); +} diff --git a/src/app/features/forum/ForumMenu.tsx b/src/app/features/forum/ForumMenu.tsx new file mode 100644 index 000000000..b63234868 --- /dev/null +++ b/src/app/features/forum/ForumMenu.tsx @@ -0,0 +1,176 @@ +import { forwardRef, useState } from 'react'; +import { Box, Line, Menu, MenuItem, Text, config, toRem } from 'folds'; +import type { Room } from 'matrix-js-sdk'; +import { useNavigate } from 'react-router-dom'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { LeaveRoomPrompt } from '$components/leave-room-prompt'; +import { InviteUserPrompt } from '$components/invite-user-prompt'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useIsDirectRoom } from '$hooks/useRoom'; +import { useSpaceOptionally } from '$hooks/useSpace'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import type { IPowerLevels } from '$hooks/usePowerLevels'; +import { useOpenRoomSettings } from '$state/hooks/roomSettings'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { markAsRead } from '$utils/notifications'; +import { copyToClipboard } from '$utils/dom'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; +import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from '$pages/pathUtils'; +import { getMatrixToRoom } from '$plugins/matrix-to'; +import { getViaServers } from '$plugins/via-servers'; +import { + Checks, + GearSix, + Link, + menuIcon, + SignOut, + Terminal, + UserPlus, +} from '$components/icons/phosphor'; + +type ForumMenuProps = { + room: Room; + powerLevels: IPowerLevels; + requestClose: () => void; +}; +export const ForumMenu = forwardRef( + ({ room, powerLevels, requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openRoomSettings = useOpenRoomSettings(); + const navigate = useNavigate(); + const parentSpace = useSpaceOptionally(); + const isDirectRoom = useIsDirectRoom(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const handleMarkAsRead = () => { + markAsRead(mx, room.roomId, hideReads); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openRoomSettings(room.roomId); + requestClose(); + }; + + const handleOpenTimeline = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (parentSpace) { + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace.roomId); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias)); + } else if (isDirectRoom) { + navigate(getDirectRoomPath(roomIdOrAlias)); + } else { + navigate(getHomeRoomPath(roomIdOrAlias)); + } + requestClose(); + }; + + return ( + + {invitePrompt && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + + + + Mark as Read + + + + + + + + Invite + + + + + Copy Link + + + + + Room Settings + + + {developerTools && ( + + + Event Timeline + + + )} + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={menuIcon(SignOut)} + radii="300" + aria-pressed={promptLeave} + > + + Leave Room + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); + } +); diff --git a/src/app/features/forum/ForumThreadItem.tsx b/src/app/features/forum/ForumThreadItem.tsx new file mode 100644 index 000000000..192f02e1a --- /dev/null +++ b/src/app/features/forum/ForumThreadItem.tsx @@ -0,0 +1,128 @@ +import { Avatar, Box, Chip, Text, config } from 'folds'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { UserAvatar } from '$components/user-avatar'; +import { nicknamesAtom } from '$state/nicknames'; +import type { ThreadRootItemProps } from '$features/room/ThreadRootItem'; +import { ThreadRootItem } from '$features/room/ThreadRootItem'; +import { getThreadReplyEvents } from '$features/room/ThreadDrawer'; +import * as css from './ForumView.css'; + +type ForumThreadItemProps = ThreadRootItemProps & { + thread?: Thread; + onClick: (eventId: string) => void; +}; + +export function ForumThreadItem({ thread, onClick, ...rootProps }: ForumThreadItemProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const nicknames = useAtomValue(nicknamesAtom); + const { room, mEvent } = rootProps; + + const mEventId = mEvent.getId(); + + // Thread reply info for the chip โ€” uses the same reply resolution as the thread drawer. + const replies = mEventId ? getThreadReplyEvents(room, mEventId) : []; + const replyCount = replies.length; + + const uniqueSenders = thread + ? [...new Set(replies.map((ev) => ev.getSender()).filter((id): id is string => !!id))] + : []; + + const lastReply = replies.at(-1); + const lastSenderId = lastReply?.getSender() ?? ''; + const lastDisplayName = + getMemberDisplayName(room, lastSenderId, nicknames) ?? + getMxIdLocalPart(lastSenderId) ?? + lastSenderId; + const lastContent = lastReply?.getContent(); + const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; + + if (!mEventId) return null; + + const handleCardClick = (evt: React.MouseEvent) => { + // Don't open thread if the click originated from a button, link, or other interactive element + const target = evt.target as HTMLElement; + if (target.closest('button, a, [role="button"]')) return; + onClick(mEventId); + }; + + return ( + + + + + {/* Thread reply chip */} + + { + evt.stopPropagation(); + onClick(mEventId); + }} + before={ + uniqueSenders.length > 0 ? ( + + {uniqueSenders.slice(0, 3).map((sid, index) => { + const avatarMxc = getMemberAvatarMxc(room, sid); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 20, 20, 'crop') ?? + undefined) + : undefined; + const dn = + getMemberDisplayName(room, sid, nicknames) ?? getMxIdLocalPart(sid) ?? sid; + return ( + 0 ? '-4px' : 0 }}> + ( + + {dn[0]?.toUpperCase() ?? '?'} + + )} + /> + + ); + })} + + ) : undefined + } + > + + {replyCount} {replyCount === 1 ? 'reply' : 'replies'} + + {lastBody && ( + +  ยท {lastDisplayName}: {lastBody.slice(0, 60)} + + )} + + + + + ); +} diff --git a/src/app/features/forum/ForumView.css.ts b/src/app/features/forum/ForumView.css.ts new file mode 100644 index 000000000..23b759de1 --- /dev/null +++ b/src/app/features/forum/ForumView.css.ts @@ -0,0 +1,26 @@ +import { style } from '@vanilla-extract/css'; +import { config, color } from 'folds'; + +export const ForumHeroTopic = style({ + display: '-webkit-box', + WebkitLineClamp: 3, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + + ':hover': { + cursor: 'pointer', + opacity: config.opacity.P500, + textDecoration: 'underline', + }, +}); + +export const Header = style({ + borderBottomColor: 'transparent', +}); + +export const ForumThreadItem = style({ + paddingBottom: config.space.S200, + borderRadius: config.radii.R400, + backgroundColor: color.SurfaceVariant.Container, + cursor: 'pointer', +}); diff --git a/src/app/features/forum/ForumView.tsx b/src/app/features/forum/ForumView.tsx new file mode 100644 index 000000000..da69bc755 --- /dev/null +++ b/src/app/features/forum/ForumView.tsx @@ -0,0 +1,557 @@ +import type { MouseEventHandler } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, IconButton, Line, Scroll, Text, color, config } from 'folds'; +import { useAtom, useAtomValue } from 'jotai'; +import type { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk'; +import { Direction, EventType, RoomEvent } from 'matrix-js-sdk'; +import { type RoomEventHandlerMap } from 'matrix-js-sdk/lib/models/room'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { useRoom } from '$hooks/useRoom'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { Page, PageContent, PageContentCenter, PageHeroSection } from '$components/page'; +import { CaretUp, Chats, composerIcon, sizedIcon } from '$components/icons/phosphor'; +import { MembersDrawer } from '$features/room/MembersDrawer'; +import { ThreadDrawer, getThreadReplyEvents } from '$features/room/ThreadDrawer'; +import { useSetting } from '$state/hooks/settings'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { settingsAtom } from '$state/settings'; +import { ForumHeader } from './ForumHeader'; +import { ForumHero } from './ForumHero'; +import { ForumThreadItem } from './ForumThreadItem'; +import { ScrollTopContainer } from '$components/scroll-top-container'; +import { PowerLevelsContextProvider, usePowerLevels } from '$hooks/usePowerLevels'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomMembers } from '$hooks/useRoomMembers'; +import { reactionOrEditEvent } from '$utils/room'; +import { mxcUrlToHttp, toggleReaction } from '$utils/matrix'; +import { useStateEvent } from '$hooks/useStateEvent'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useEditor } from '$components/editor'; +import { RoomInputPlaceholder } from '$features/room/RoomInputPlaceholder'; +import { RoomTombstone } from '$features/room/RoomTombstone'; +import { RoomInput } from '$features/room/RoomInput'; +import { useImagePackRooms } from '$hooks/useImagePackRooms'; +import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { CustomStateEvent } from '$types/matrix/room'; +import type { RoomBannerContent } from '$types/matrix-sdk-events'; +import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; + +type ForumPost = { + eventId: string; + mEvent: MatrixEvent; + thread?: Thread; + ts: number; +}; + +/** + * Collect all top-level messages (not thread replies, not reactions/edits/redacted) + * and return them as ForumPost items, sorted by latest activity descending. + */ +const collectForumPosts = (room: Room): ForumPost[] => { + const threadMap = new Map(); + room.getThreads().forEach((thread) => { + threadMap.set(thread.id, thread); + }); + + const posts = new Map(); + + // Add all thread roots (even if not in the visible timeline) + threadMap.forEach((thread, threadId) => { + const { rootEvent } = thread; + if (!rootEvent) return; + // Skip redacted root messages with no visible replies + if (rootEvent.isRedacted()) { + const replies = getThreadReplyEvents(room, threadId); + if (replies.length === 0) return; + } + const lastTs = thread.events.at(-1)?.getTs() ?? rootEvent.getTs(); + posts.set(threadId, { + eventId: threadId, + mEvent: rootEvent, + thread, + ts: lastTs, + }); + }); + + // Add top-level timeline messages that are NOT thread replies + const timeline = room.getLiveTimeline(); + timeline.getEvents().forEach((ev) => { + const evId = ev.getId(); + if (!evId) return; + if (posts.has(evId)) return; // already added as thread root + if (ev.isRedacted()) return; + if (reactionOrEditEvent(ev)) return; + // Skip actual thread replies (rel_type: m.thread), but keep plain replies + // that just reference a thread root via m.in_reply_to + if (ev.getRelation()?.rel_type === 'm.thread') return; + if (ev.isState()) return; // skip state events + if (!ev.getContent()?.msgtype) return; // not a displayable message + + posts.set(evId, { + eventId: evId, + mEvent: ev, + thread: undefined, + ts: ev.getTs(), + }); + }); + + // Sort by latest activity descending + return Array.from(posts.values()).toSorted((a, b) => b.ts - a.ts); +}; + +export function ForumView() { + const mx = useMatrixClient(); + const room = useRoom(); + const powerLevels = usePowerLevels(room); + const members = useRoomMembers(mx, room.roomId); + + const useAuthentication = useMediaAuthentication(); + const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner); + const bannerMxc = bannerState?.getContent()?.url; + const bannerUrl = bannerMxc + ? (mxcUrlToHttp(mx, bannerMxc, useAuthentication) ?? undefined) + : undefined; + + const scrollRef = useRef(null); + const roomViewRef = useRef(null); + const heroSectionRef = useRef(null); + const editor = useEditor(); + const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const screenSize = useScreenSizeContext(); + const [onTop, setOnTop] = useState(true); + + const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); + const [updateKey, forceUpdate] = useState(0); + const [editId, setEditId] = useState(undefined); + + // Settings + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)), + mentionClickHandler + ), + }), + [mx, room, mentionClickHandler, settingsLinkBaseUrl] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [ + mx, + room, + settingsLinkBaseUrl, + linkifyOpts, + spoilerClickHandler, + mentionClickHandler, + useAuthentication, + ] + ); + + // Power levels & permissions + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(EventType.RoomRedaction, mx.getSafeUserId()); + const canSendReaction = permissions.event(EventType.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent(EventType.RoomPinnedEvents, mx.getSafeUserId()); + const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + const tombstoneEvent = useStateEvent(room, EventType.RoomTombstone); + + // Image packs + const roomToParents = useAtomValue(roomToParentsAtom); + const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); + + // User profile popup + const openUserRoomProfile = useOpenUserRoomProfile(); + + // Fetch threads from server on mount (same as RoomViewHeader does) + useEffect(() => { + const scanTimelineForThreads = (timeline: EventTimeline) => { + const events = timeline.getEvents(); + const threadRoots = new Set(); + + events.forEach((event: MatrixEvent) => { + if (event.isThreadRoot) { + const rootId = event.getId(); + if (rootId && !room.getThread(rootId)) { + threadRoots.add(rootId); + } + } + + const { threadRootId } = event; + if (threadRootId && !room.getThread(threadRootId)) { + threadRoots.add(threadRootId); + } + }); + + threadRoots.forEach((rootId) => { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + room.createThread(rootId, rootEvent, [], false); + } + }); + }; + + const liveTimeline = room.getLiveTimeline(); + scanTimelineForThreads(liveTimeline); + + let backwardTimeline = liveTimeline.getNeighbouringTimeline(Direction.Backward); + while (backwardTimeline) { + scanTimelineForThreads(backwardTimeline); + backwardTimeline = backwardTimeline.getNeighbouringTimeline(Direction.Backward); + } + + // Initialize thread timeline sets then fetch threads from server + room + .createThreadsTimelineSets() + .then(() => room.fetchRoomThreads()) + .then(() => { + forceUpdate((n) => n + 1); + }) + .catch(() => { + // Silently ignore โ€” server may not support threads + }); + }, [room]); + + // Re-render when threads or timeline change + useEffect(() => { + const createdThreads = new Set(); + const onThreadNew: RoomEventHandlerMap[ThreadEvent.New] = () => { + forceUpdate((n) => n + 1); + }; + const onThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = () => { + forceUpdate((n) => n + 1); + }; + const onThreadReply: RoomEventHandlerMap[ThreadEvent.NewReply] = () => { + forceUpdate((n) => n + 1); + }; + const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (mEvent) => { + if (mEvent.isThreadRoot) { + const rootId = mEvent.getId(); + if (rootId && !room.getThread(rootId) && !createdThreads.has(rootId)) { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + createdThreads.add(rootId); + room.createThread(rootId, rootEvent, [], false); + } + } + forceUpdate((n) => n + 1); + return; + } + + const { threadRootId } = mEvent; + if (threadRootId) { + if (!room.getThread(threadRootId) && !createdThreads.has(threadRootId)) { + const rootEvent = room.findEventById(threadRootId); + if (rootEvent) { + createdThreads.add(threadRootId); + room.createThread(threadRootId, rootEvent, [], false); + } + } + forceUpdate((n) => n + 1); + return; + } + if (mEvent.isState()) return; + if (reactionOrEditEvent(mEvent)) return; + if (!mEvent.getContent()?.msgtype) return; + forceUpdate((n) => n + 1); + }; + const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent) => { + if (mEvent.threadRootId || mEvent.isThreadRoot) { + forceUpdate((n) => n + 1); + return; + } + forceUpdate((n) => n + 1); + }; + + const onUnreadNotifications = () => forceUpdate((n) => n + 1); + + room.on(RoomEvent.Timeline, onTimeline); + room.on(RoomEvent.Redaction, onRedaction); + room.on(RoomEvent.UnreadNotifications, onUnreadNotifications); + room.on(ThreadEvent.New, onThreadNew); + room.on(ThreadEvent.Update, onThreadUpdate); + room.on(ThreadEvent.NewReply, onThreadReply); + const cleanup = () => { + room.removeListener(RoomEvent.Timeline, onTimeline); + room.removeListener(RoomEvent.Redaction, onRedaction); + room.removeListener(RoomEvent.UnreadNotifications, onUnreadNotifications); + room.removeListener(ThreadEvent.New, onThreadNew); + room.removeListener(ThreadEvent.Update, onThreadUpdate); + room.removeListener(ThreadEvent.NewReply, onThreadReply); + }; + + return cleanup; + }, [room]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const posts = useMemo(() => collectForumPosts(room), [room, updateKey]); + + const handleOpenThread = useCallback( + (eventId: string) => { + setOpenThread(eventId); + }, + [setOpenThread] + ); + + const handleUserClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleUsernameClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + // In forum view, username click opens profile (no editor to insert mention into) + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleReplyClick: MouseEventHandler = useCallback( + (evt) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) return; + // In forum view, clicking reply opens the thread + setOpenThread(replyId); + }, + [setOpenThread] + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string) => { + const thread = room.getThread(targetEventId); + const threadTimelineSet = thread?.timelineSet; + toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet); + }, + [mx, room] + ); + + const handleEdit = useCallback((evtId?: string) => { + setEditId(evtId); + }, []); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + // Scroll to the post or open thread + setOpenThread(targetId); + }, + [setOpenThread] + ); + + return ( + + + + + + + + + + scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} + variant="SurfaceVariant" + radii="Pill" + outlined + size="300" + aria-label="Scroll to Top" + > + {composerIcon(CaretUp)} + + + + + + + {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + + You do not have permission to post in this room + + + )} + + )} + + {posts.map((post) => ( + + ))} + {posts.length === 0 && ( + + {sizedIcon(Chats, '400')} + + No posts yet. + + + )} + + + + + + {screenSize === ScreenSize.Desktop && openThreadId && ( + <> + + setOpenThread(undefined)} + /> + + )} + {screenSize === ScreenSize.Desktop && !openThreadId && isDrawer && ( + <> + + + + )} + {screenSize !== ScreenSize.Desktop && openThreadId && ( + setOpenThread(undefined)} + overlay + /> + )} + + + ); +} diff --git a/src/app/features/forum/index.ts b/src/app/features/forum/index.ts new file mode 100644 index 000000000..eb8d2d7a8 --- /dev/null +++ b/src/app/features/forum/index.ts @@ -0,0 +1 @@ +export { ForumView } from './ForumView'; diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index c8efb2843..31457c045 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -37,7 +37,8 @@ import { useCategoryHandler } from '$hooks/useCategoryHandler'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { allRoomsAtom } from '$state/room-list/roomList'; import { getCanonicalAliasOrRoomId, rateLimitedActions } from '$utils/matrix'; -import { getSpaceRoomPath } from '$pages/pathUtils'; +import { getSpaceRoomPath, getSpaceForumPath } from '$pages/pathUtils'; +import { CustomRoomType } from '$types/matrix/room'; import { ASCIILexicalTable, orderKeys } from '$utils/ASCIILexicalTable'; import { getStateEvent } from '$utils/room'; @@ -527,7 +528,12 @@ export function Lobby() { const rId = evt.currentTarget.getAttribute('data-room-id'); if (!rId) return; const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + const targetRoom = mx.getRoom(rId); + if (targetRoom?.getType() === CustomRoomType.Forum) { + navigate(getSpaceForumPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + } else { + navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + } }; const togglePinToSidebar = useCallback( diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 60ec814f9..971a3d728 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -294,6 +294,16 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { > Voice Room + handleCreateRoom(CreateRoomType.ForumRoom)} + after={} + > + Forum Room + Existing Room diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 632331b17..3d8eb9240 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -62,7 +62,15 @@ import { useIsDirectRoom, useRoom } from '$hooks/useRoom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useSpaceOptionally } from '$hooks/useSpace'; -import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; +import { + getHomeSearchPath, + getSpaceSearchPath, + getHomeForumPath, + getDirectForumPath, + getSpaceForumPath, + withSearchParam, +} from '$pages/pathUtils'; +import { CustomRoomType } from '$types/matrix/room'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -98,7 +106,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; import { ContainerColor } from '$styles/ContainerColor.css'; import { useRoomWidgets } from '$hooks/useRoomWidgets'; -import { hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room'; +import { getPinsHash, hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room'; import { DirectInvitePrompt } from '$components/direct-invite-prompt'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; @@ -115,16 +123,6 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; const log = createLogger('RoomViewHeader'); -async function getPinsHash(pinnedIds: string[]): Promise { - const sorted = [...pinnedIds].toSorted().join(','); - const encoder = new TextEncoder(); - const data = encoder.encode(sorted); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hashHex.slice(0, 10); -} - export interface PinReadMarker { hash: string; count: number; @@ -137,7 +135,9 @@ type RoomMenuProps = { }; const RoomMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const navigate = useNavigate(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -149,6 +149,9 @@ const RoomMenu = forwardRef(({ room, requestClose const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const { navigateRoom } = useRoomNavigate(); + const parentSpace = useSpaceOptionally(); + const isForum = room.getType() === CustomRoomType.Forum; + const isDirectRoom = useIsDirectRoom(); const [invitePrompt, setInvitePrompt] = useState(false); const [directInvitePrompt, setDirectInvitePrompt] = useState(false); @@ -197,12 +200,24 @@ const RoomMenu = forwardRef(({ room, requestClose }; const openSettings = useOpenRoomSettings(); - const parentSpace = useSpaceOptionally(); const handleOpenSettings = () => { openSettings(room.roomId, parentSpace?.roomId); requestClose(); }; + const handleOpenForumView = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (parentSpace) { + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace.roomId); + navigate(getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias)); + } else if (isDirectRoom) { + navigate(getDirectForumPath(roomIdOrAlias)); + } else { + navigate(getHomeForumPath(roomIdOrAlias)); + } + requestClose(); + }; + return ( {invitePrompt && ( @@ -315,6 +330,13 @@ const RoomMenu = forwardRef(({ room, requestClose )} + {(isForum || developerTools) && ( + + + Forum View + + + )} diff --git a/src/app/features/room/ThreadRootItem.tsx b/src/app/features/room/ThreadRootItem.tsx new file mode 100644 index 000000000..585696ee5 --- /dev/null +++ b/src/app/features/room/ThreadRootItem.tsx @@ -0,0 +1,214 @@ +import type { MouseEventHandler } from 'react'; +import { Box, Scroll, config } from 'folds'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { EventType } from 'matrix-js-sdk'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue } from 'jotai'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { Image } from '$components/media'; +import { ImageViewer } from '$components/image-viewer'; +import { getEditedEvent, getEventReactions, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { ImageContent, MSticker, RedactedContent, Reply } from '$components/message'; +import { RenderMessageContent } from '$components/RenderMessageContent'; +import type { MessageLayout, MessageSpacing } from '$state/settings'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import type { GetContentCallback } from '$types/matrix/room'; +import { nicknamesAtom } from '$state/nicknames'; +import { EncryptedContent, Message, Reactions } from './message'; + +export type ThreadRootItemProps = { + room: Room; + mEvent: MatrixEvent; + thread?: Thread; + editId: string | undefined; + onEditId: (id?: string) => void; + messageLayout: MessageLayout; + messageSpacing: MessageSpacing; + canDelete: boolean; + canSendReaction: boolean; + canPinEvent: boolean; + imagePackRooms: Room[]; + hour24Clock: boolean; + dateFormatString: string; + onUserClick: MouseEventHandler; + onUsernameClick: MouseEventHandler; + onReplyClick: MouseEventHandler; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + linkifyOpts: LinkifyOpts; + htmlReactParserOptions: HTMLReactParserOptions; + showHideReads: boolean; + showDeveloperTools: boolean; + onReferenceClick: MouseEventHandler; + hideReplyButton?: boolean; +}; + +export function ThreadRootItem({ + room, + mEvent, + thread, + editId, + onEditId, + messageLayout, + messageSpacing, + canDelete, + canSendReaction, + canPinEvent, + imagePackRooms, + hour24Clock, + dateFormatString, + onUserClick, + onUsernameClick, + onReplyClick, + onReactionToggle, + linkifyOpts, + htmlReactParserOptions, + showHideReads, + showDeveloperTools, + onReferenceClick, + hideReplyButton, +}: ThreadRootItemProps) { + const nicknames = useAtomValue(nicknamesAtom); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + + const mEventId = mEvent.getId(); + if (!mEventId) return null; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + + const timelineSet = thread?.timelineSet ?? room.getUnfilteredTimelineSet(); + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const editedNewContent = editedEvent?.getContent()['m.new_content']; + const baseContent = mEvent.getContent(); + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + + const { replyEventId } = mEvent; + const showUrlPreview = room.hasEncryptionStateEvent() ? false : urlPreview; + + return ( + <> + + ) + } + > + {mEvent.isRedacted() ? ( + + ) : ( + + + {() => { + if (mEvent.isRedacted()) { + return ( + + ); + } + + if (mEvent.getType() === (EventType.Sticker as string)) { + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + } + + return ( + + ); + }} + + + )} + + + {/* Reactions โ€” outside scroll so always visible */} + {hasReactions && reactionRelations && ( + + + + )} + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 0bf26f9d4..e3eeed192 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -104,6 +104,7 @@ export type MessageProps = { reply?: ReactNode; reactions?: ReactNode; hideReadReceipts?: boolean; + hideReplyButton?: boolean; showDeveloperTools?: boolean; memberPowerTag?: MemberPowerTag; hour24Clock: boolean; @@ -352,6 +353,7 @@ function MessageInternal( reply, reactions, hideReadReceipts, + hideReplyButton, showDeveloperTools, memberPowerTag, hour24Clock, @@ -937,6 +939,7 @@ function MessageInternal( onReplyClick={onReplyClick} onEditId={onEditId} hideReadReceipts={hideReadReceipts} + hideReplyButton={hideReplyButton} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} cleanedDisplayName={cleanedDisplayName} diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 8e4abb172..7aac844c0 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -5,9 +5,12 @@ import { useAtomValue } from 'jotai'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { getDirectRoomPath, + getDirectForumPath, getHomeRoomPath, + getHomeForumPath, getSpacePath, getSpaceRoomPath, + getSpaceForumPath, } from '$pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; @@ -16,6 +19,7 @@ import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useSelectedSpace } from './router/useSelectedSpace'; import { useMatrixClient } from './useMatrixClient'; +import { CustomRoomType } from '$types/matrix/room'; export const useRoomNavigate = () => { const navigate = useNavigate(); @@ -37,6 +41,7 @@ export const useRoomNavigate = () => { (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; + const isForum = mx.getRoom(roomId)?.getType() === CustomRoomType.Forum; const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { @@ -49,19 +54,31 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); + if (isForum && !openSpaceTimeline) { + navigate(getSpaceForumPath(pSpaceIdOrAlias, roomIdOrAlias), opts); + } else { + navigate( + getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), + opts + ); + } return; } if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + if (isForum) { + navigate(getDirectForumPath(roomIdOrAlias), opts); + } else { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + } return; } - navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + if (isForum) { + navigate(getHomeForumPath(roomIdOrAlias), opts); + } else { + navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + } }, [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 56c24a899..16310a508 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -14,6 +14,7 @@ import { SettingsRoute } from '$features/settings'; import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; +import { ForumView } from '$features/forum'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; @@ -48,6 +49,7 @@ import { LOBBY_PATH_SEGMENT, NOTIFICATIONS_PATH_SEGMENT, ROOM_PATH_SEGMENT, + ROOM_FORUM_PATH_SEGMENT, SEARCH_PATH_SEGMENT, SERVER_PATH_SEGMENT, CREATE_PATH, @@ -252,6 +254,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + const getToLink = (roomId: string) => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + if (mx.getRoom(roomId)?.getType() === CustomRoomType.Forum) { + return getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias); + } + return getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias); + }; const navigate = useNavigate(); const lastRoomId = useAtomValue(lastVisitedRoomIdAtom); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..fed0969ce 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -28,6 +28,9 @@ import { SPACE_ROOM_PATH, SPACE_SEARCH_PATH, CREATE_PATH, + HOME_ROOM_FORUM_PATH, + DIRECT_ROOM_FORUM_PATH, + SPACE_ROOM_FORUM_PATH, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -100,6 +103,14 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string return generatePath(HOME_ROOM_PATH, params); }; +export const getHomeForumPath = (roomIdOrAlias: string): string => { + const params = { + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(HOME_ROOM_FORUM_PATH, params); +}; + export const getDirectPath = (): string => DIRECT_PATH; export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH; export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => { @@ -111,6 +122,14 @@ export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): stri return generatePath(DIRECT_ROOM_PATH, params); }; +export const getDirectForumPath = (roomIdOrAlias: string): string => { + const params = { + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(DIRECT_ROOM_FORUM_PATH, params); +}; + export const getSpacePath = (spaceIdOrAlias: string): string => { const params = { spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), @@ -143,6 +162,14 @@ export const getSpaceRoomPath = ( return generatePath(SPACE_ROOM_PATH, params); }; +export const getSpaceForumPath = (spaceIdOrAlias: string, roomIdOrAlias: string): string => { + const params = { + spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(SPACE_ROOM_FORUM_PATH, params); +}; export const getExplorePath = (): string => EXPLORE_PATH; export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..04c4844cd 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -45,12 +45,14 @@ export type RoomSearchParams = { viaServers?: string; }; export const ROOM_PATH_SEGMENT = ':roomIdOrAlias/:eventId?/'; +export const ROOM_FORUM_PATH_SEGMENT = ':roomIdOrAlias/forum/'; export const HOME_PATH = '/home/'; export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`; export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`; export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`; export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`; +export const HOME_ROOM_FORUM_PATH = `/home/${ROOM_FORUM_PATH_SEGMENT}`; export const DIRECT_PATH = '/direct/'; export type DirectCreateSearchParams = { @@ -58,11 +60,13 @@ export type DirectCreateSearchParams = { }; export const DIRECT_CREATE_PATH = `/direct/${CREATE_PATH_SEGMENT}`; export const DIRECT_ROOM_PATH = `/direct/${ROOM_PATH_SEGMENT}`; +export const DIRECT_ROOM_FORUM_PATH = `/direct/${ROOM_FORUM_PATH_SEGMENT}`; export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${LOBBY_PATH_SEGMENT}`; export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${SEARCH_PATH_SEGMENT}`; export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${ROOM_PATH_SEGMENT}`; +export const SPACE_ROOM_FORUM_PATH = `/:spaceIdOrAlias/${ROOM_FORUM_PATH_SEGMENT}`; export const FEATURED_PATH_SEGMENT = 'featured/'; export const SERVER_PATH_SEGMENT = ':server/'; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 6d201f34a..3d9346566 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -1064,6 +1064,16 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent): boolean => { return false; }; +export async function getPinsHash(pinnedIds: string[]): Promise { + const sorted = [...pinnedIds].toSorted().join(','); + const encoder = new TextEncoder(); + const data = encoder.encode(sorted); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex.slice(0, 10); +} + export const isThreadRelationEvent = (mEvent: MatrixEvent, threadRootId?: string): boolean => { const relation = mEvent.getRelation?.() ?? diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index af32fe773..aef1b671b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -20,6 +20,11 @@ export const CustomStateEvent = { } as const; export type CustomStateEvent = (typeof CustomStateEvent)[keyof typeof CustomStateEvent]; +export const CustomRoomType = { + Forum: 'pl.chrome.forum', +} as const; +export type CustomRoomType = (typeof CustomRoomType)[keyof typeof CustomRoomType]; + export type MSpaceChildContent = { via: string[]; suggested?: boolean; From fb16552d568c9130fc34548de783fe8242b130a1 Mon Sep 17 00:00:00 2001 From: Tomasz Sterna Date: Wed, 1 Apr 2026 20:10:50 +0200 Subject: [PATCH 06/11] feat: add `forum` room type and dedicated view with threads as topics --- .changeset/add-forum-room-type.md | 5 + .../create-room/CreateRoomTypeSelector.tsx | 26 + src/app/components/create-room/types.ts | 1 + src/app/components/create-room/utils.ts | 5 +- src/app/components/icons/roomIcons.tsx | 28 +- src/app/components/message/modals/Options.tsx | 60 +- src/app/features/create-room/CreateRoom.tsx | 15 +- src/app/features/forum/ForumHeader.tsx | 262 ++++++++ src/app/features/forum/ForumHero.tsx | 85 +++ src/app/features/forum/ForumMenu.tsx | 176 ++++++ src/app/features/forum/ForumThreadItem.tsx | 128 ++++ src/app/features/forum/ForumView.css.ts | 26 + src/app/features/forum/ForumView.tsx | 557 ++++++++++++++++++ src/app/features/forum/index.ts | 1 + src/app/features/lobby/Lobby.tsx | 10 +- src/app/features/lobby/SpaceItem.tsx | 10 + src/app/features/room/RoomViewHeader.tsx | 48 +- src/app/features/room/ThreadRootItem.tsx | 214 +++++++ src/app/features/room/message/Message.tsx | 3 + src/app/hooks/useRoomNavigate.ts | 29 +- src/app/pages/Router.tsx | 26 + src/app/pages/client/direct/Direct.tsx | 9 +- src/app/pages/client/home/Home.tsx | 8 +- src/app/pages/client/space/Space.tsx | 18 +- src/app/pages/pathUtils.ts | 27 + src/app/pages/paths.ts | 4 + src/app/utils/room.ts | 10 + src/types/matrix/room.ts | 5 + 28 files changed, 1732 insertions(+), 64 deletions(-) create mode 100644 .changeset/add-forum-room-type.md create mode 100644 src/app/features/forum/ForumHeader.tsx create mode 100644 src/app/features/forum/ForumHero.tsx create mode 100644 src/app/features/forum/ForumMenu.tsx create mode 100644 src/app/features/forum/ForumThreadItem.tsx create mode 100644 src/app/features/forum/ForumView.css.ts create mode 100644 src/app/features/forum/ForumView.tsx create mode 100644 src/app/features/forum/index.ts create mode 100644 src/app/features/room/ThreadRootItem.tsx diff --git a/.changeset/add-forum-room-type.md b/.changeset/add-forum-room-type.md new file mode 100644 index 000000000..b19c76ad7 --- /dev/null +++ b/.changeset/add-forum-room-type.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add the `forum` room type with a dedicated forum view that presents threads as topics. diff --git a/src/app/components/create-room/CreateRoomTypeSelector.tsx b/src/app/components/create-room/CreateRoomTypeSelector.tsx index 6ab26d7c1..57dd34825 100644 --- a/src/app/components/create-room/CreateRoomTypeSelector.tsx +++ b/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -71,6 +71,32 @@ export function CreateRoomTypeSelector({ + onSelect(CreateRoomType.ForumRoom)} + disabled={disabled} + > + + + + Forum Room + + + - Conversations split in topics. + + + + + ); } diff --git a/src/app/components/create-room/types.ts b/src/app/components/create-room/types.ts index 8b54587dd..5a54105bf 100644 --- a/src/app/components/create-room/types.ts +++ b/src/app/components/create-room/types.ts @@ -1,6 +1,7 @@ export enum CreateRoomType { TextRoom = 'text', VoiceRoom = 'voice', + ForumRoom = 'forum', } export enum CreateRoomAccess { diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index 520bc508a..37fdc358a 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -8,13 +8,14 @@ import type { import { JoinRule, RestrictedAllowType, EventType, RoomType } from '$types/matrix-sdk'; import type { StateEvents } from '$types/matrix-sdk'; +import type { CustomRoomType } from '$types/matrix/room'; import { getViaServers } from '$plugins/via-servers'; import { getMxIdServer } from '$utils/mxIdHelper'; import { CreateRoomAccess } from './types'; import * as prefix from '$unstable/prefixes'; export const createRoomCreationContent = ( - type: RoomType | undefined, + type: RoomType | CustomRoomType | undefined, allowFederation: boolean, additionalCreators: string[] | undefined ): object => { @@ -101,7 +102,7 @@ export const createVoiceRoomPowerLevelsOverride = () => ({ export type CreateRoomData = { version: string; - type?: RoomType; + type?: RoomType | CustomRoomType; parent?: Room; access: CreateRoomAccess; name: string; diff --git a/src/app/components/icons/roomIcons.tsx b/src/app/components/icons/roomIcons.tsx index 1709a7d66..bc40bf2da 100644 --- a/src/app/components/icons/roomIcons.tsx +++ b/src/app/components/icons/roomIcons.tsx @@ -1,14 +1,16 @@ import { JoinRule, RoomType } from '$types/matrix-sdk'; import type { ComponentType } from 'react'; import type { IconProps } from '@phosphor-icons/react'; -import { Globe, HashStraight, Lock, SpeakerHigh, SquaresFour } from './phosphor'; +import { CustomRoomType } from '$types/matrix/room'; +import { Chats, Globe, HashStraight, Lock, SpeakerHigh, SquaresFour } from './phosphor'; export type RoomPhosphorIcon = ComponentType; export type RoomIconOverlay = 'globe' | 'lock'; const isRegularRoom = (roomType?: string): boolean => - roomType !== RoomType.Space && roomType !== RoomType.UnstableCall; + roomType !== RoomType.Space && + roomType !== RoomType.UnstableCall; export function getRoomIconOverlay( roomType?: string, @@ -59,6 +61,17 @@ export function getRoomStandaloneIconComponent( return SpeakerHigh; } + if (roomType === CustomRoomType.Forum) { + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return Lock; + } + return Chats; + } + if (joinRule === JoinRule.Public) return Globe; if ( joinRule === JoinRule.Invite || @@ -95,5 +108,16 @@ export function getRoomIconComponent(roomType?: string, joinRule?: JoinRule): Ro return SpeakerHigh; } + if (roomType === CustomRoomType.Forum) { + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return Lock; + } + return Chats; + } + return HashStraight; } diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 7a93733b7..9e6a1d9a9 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -282,6 +282,7 @@ export function OptionQuickMenu({ onReplyClick, onEditId, hideReadReceipts, + hideReplyButton, showDeveloperTools, canPinEvent, cleanedDisplayName, @@ -330,18 +331,20 @@ export function OptionQuickMenu({ )} - { - onReplyClick(ev); - closeMenu(); - }} - data-event-id={mEvent.getId()} - variant="SurfaceVariant" - size="300" - radii="300" - > - {menuIcon(ArrowBendUpLeftIcon)} - + {!hideReplyButton && ( + { + onReplyClick(ev); + closeMenu(); + }} + data-event-id={mEvent.getId()} + variant="SurfaceVariant" + size="300" + radii="300" + > + {menuIcon(ArrowBendUpLeftIcon)} + + )} {!isThreadedMessage && ( { @@ -384,6 +387,7 @@ export function OptionQuickMenu({ onReplyClick={onReplyClick} onEditId={onEditId} hideReadReceipts={hideReadReceipts} + hideReplyButton={hideReplyButton} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} cleanedDisplayName={cleanedDisplayName} @@ -431,6 +435,7 @@ export type OptionMenuProps = { ) => void; onEditId?: (eventId?: string) => void; hideReadReceipts?: boolean; + hideReplyButton?: boolean; showDeveloperTools?: boolean; canPinEvent?: boolean; cleanedDisplayName?: string; @@ -456,6 +461,7 @@ export function OptionMenu({ onReplyClick, onEditId, hideReadReceipts, + hideReplyButton, showDeveloperTools, canPinEvent, cleanedDisplayName, @@ -606,20 +612,22 @@ export function OptionMenu({ )} {relations && } - { - onReplyClick(evt); - onTotalClose(); - }} - > - - Reply - - + {!hideReplyButton && ( + { + onReplyClick(evt); + onTotalClose(); + }} + > + + Reply + + + )} {!isThreadedMessage && ( { - const isVoiceRoom = type === CreateRoomType.VoiceRoom; + let roomType: string | undefined; + if (type === CreateRoomType.VoiceRoom) roomType = RoomType.UnstableCall; + if (type === CreateRoomType.ForumRoom) roomType = CustomRoomType.Forum; let joinRule: JoinRule = JoinRule.Public; if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted; if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock; - return sizedIcon( - getRoomStandaloneIconComponent(isVoiceRoom ? RoomType.UnstableCall : undefined, joinRule), - size - ); + return sizedIcon(getRoomStandaloneIconComponent(roomType, joinRule), size); }; const getCreateRoomTypeToIcon = (type: CreateRoomType): ReactNode => { if (type === CreateRoomType.VoiceRoom) return sizedIcon(SpeakerHigh, '400'); + if (type === CreateRoomType.ForumRoom) return sizedIcon(Chats, '400'); return sizedIcon(Hash, '400'); }; @@ -144,8 +146,9 @@ export function CreateRoomForm({ roomKnock = knock; } - let roomType: RoomType | undefined; + let roomType: RoomType | CustomRoomType | undefined; if (type === CreateRoomType.VoiceRoom) roomType = RoomType.UnstableCall; + if (type === CreateRoomType.ForumRoom) roomType = CustomRoomType.Forum; debugLog.info('ui', 'Create room button clicked', { roomName, diff --git a/src/app/features/forum/ForumHeader.tsx b/src/app/features/forum/ForumHeader.tsx new file mode 100644 index 000000000..a0c60da68 --- /dev/null +++ b/src/app/features/forum/ForumHeader.tsx @@ -0,0 +1,262 @@ +import type { MouseEventHandler } from 'react'; +import { useEffect, useState } from 'react'; +import type { RectCords } from 'folds'; +import { + Avatar, + Badge, + Box, + IconButton, + PopOut, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import type { Room } from 'matrix-js-sdk'; +import { PageHeader } from '$components/page'; +import { useSetSetting, useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useRoomAvatar, useRoomName } from '$hooks/useRoomMeta'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { RoomAvatar } from '$components/room-avatar'; +import { + ArrowLeft, + composerIcon, + DotsThreeOutlineVerticalIcon, + PushPin, + UserCircle, +} from '$components/icons/phosphor'; +import { nameInitials } from '$utils/common'; +import type { IPowerLevels } from '$hooks/usePowerLevels'; +import { stopPropagation } from '$utils/keyboard'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { BackRouteHandler } from '$components/BackRouteHandler'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; +import { getPinsHash } from '$utils/room'; +import { RoomPinMenu } from '$features/room/room-pin-menu'; +import { ForumMenu } from './ForumMenu'; +import * as css from './ForumView.css'; + +type ForumHeaderProps = { + room: Room; + showProfile?: boolean; + powerLevels: IPowerLevels; +}; +export function ForumHeader({ room, showProfile, powerLevels }: ForumHeaderProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const [peopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [menuAnchor, setMenuAnchor] = useState(); + const [pinMenuAnchor, setPinMenuAnchor] = useState(); + const screenSize = useScreenSizeContext(); + const pinnedEvents = useRoomPinnedEvents(room); + const [currentHash, setCurrentHash] = useState(''); + + useEffect(() => { + getPinsHash(pinnedEvents) + .then(setCurrentHash) + .catch(() => undefined); + }, [pinnedEvents]); + + const name = useRoomName(room); + const avatarMxc = useRoomAvatar(room); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + const handleOpenPinMenu: MouseEventHandler = (evt) => { + setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + + {screenSize === ScreenSize.Mobile ? ( + <> + + + {(onBack) => ( + + {composerIcon(ArrowLeft)} + + )} + + + + {showProfile && ( + + {name} + + )} + + + ) : ( + <> + + + {showProfile && ( + <> + + {nameInitials(name)}} + /> + + + {name} + + + )} + + + )} + + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + {composerIcon(PushPin, { weight: pinMenuAnchor ? 'fill' : 'regular' })} + + )} + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} + currentHash={currentHash} + /> + + } + /> + {screenSize !== ScreenSize.Mobile && ( + + {peopleDrawer ? 'Hide Members' : 'Show Members'} + + } + > + {(triggerRef) => ( + setPeopleDrawer((drawer) => !drawer)} + > + {composerIcon(UserCircle, { weight: peopleDrawer ? 'fill' : 'regular' })} + + )} + + )} + + More Options + + } + > + {(triggerRef) => ( + + {composerIcon(DotsThreeOutlineVerticalIcon, { + weight: menuAnchor ? 'fill' : 'regular', + })} + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + /> + + } + /> + + + + ); +} diff --git a/src/app/features/forum/ForumHero.tsx b/src/app/features/forum/ForumHero.tsx new file mode 100644 index 000000000..490ae6a23 --- /dev/null +++ b/src/app/features/forum/ForumHero.tsx @@ -0,0 +1,85 @@ +import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import type { Room } from 'matrix-js-sdk'; +import { useRoomAvatar, useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { RoomAvatar } from '$components/room-avatar'; +import { nameInitials } from '$utils/common'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { RoomTopicViewer } from '$components/room-topic-viewer'; +import { PageHero } from '$components/page'; +import { onEnterOrSpace, stopPropagation } from '$utils/keyboard'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import * as css from './ForumView.css'; + +type ForumHeroProps = { + room: Room; +}; + +export function ForumHero({ room }: ForumHeroProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const name = useRoomName(room); + const topic = useRoomTopic(room); + const avatarMxc = useRoomAvatar(room); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + return ( + + {nameInitials(name)}} + /> + + } + title={name} + subTitle={ + topic && ( + + {(viewTopic, setViewTopic) => ( + <> + }> + + setViewTopic(false), + escapeDeactivates: stopPropagation, + }} + > + setViewTopic(false)} + /> + + + + setViewTopic(true)} + onKeyDown={onEnterOrSpace(() => setViewTopic(true))} + tabIndex={0} + className={css.ForumHeroTopic} + size="Inherit" + priority="300" + > + {topic} + + + )} + + ) + } + /> + ); +} diff --git a/src/app/features/forum/ForumMenu.tsx b/src/app/features/forum/ForumMenu.tsx new file mode 100644 index 000000000..b63234868 --- /dev/null +++ b/src/app/features/forum/ForumMenu.tsx @@ -0,0 +1,176 @@ +import { forwardRef, useState } from 'react'; +import { Box, Line, Menu, MenuItem, Text, config, toRem } from 'folds'; +import type { Room } from 'matrix-js-sdk'; +import { useNavigate } from 'react-router-dom'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { LeaveRoomPrompt } from '$components/leave-room-prompt'; +import { InviteUserPrompt } from '$components/invite-user-prompt'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useIsDirectRoom } from '$hooks/useRoom'; +import { useSpaceOptionally } from '$hooks/useSpace'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import type { IPowerLevels } from '$hooks/usePowerLevels'; +import { useOpenRoomSettings } from '$state/hooks/roomSettings'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { markAsRead } from '$utils/notifications'; +import { copyToClipboard } from '$utils/dom'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; +import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from '$pages/pathUtils'; +import { getMatrixToRoom } from '$plugins/matrix-to'; +import { getViaServers } from '$plugins/via-servers'; +import { + Checks, + GearSix, + Link, + menuIcon, + SignOut, + Terminal, + UserPlus, +} from '$components/icons/phosphor'; + +type ForumMenuProps = { + room: Room; + powerLevels: IPowerLevels; + requestClose: () => void; +}; +export const ForumMenu = forwardRef( + ({ room, powerLevels, requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openRoomSettings = useOpenRoomSettings(); + const navigate = useNavigate(); + const parentSpace = useSpaceOptionally(); + const isDirectRoom = useIsDirectRoom(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const handleMarkAsRead = () => { + markAsRead(mx, room.roomId, hideReads); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openRoomSettings(room.roomId); + requestClose(); + }; + + const handleOpenTimeline = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (parentSpace) { + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace.roomId); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias)); + } else if (isDirectRoom) { + navigate(getDirectRoomPath(roomIdOrAlias)); + } else { + navigate(getHomeRoomPath(roomIdOrAlias)); + } + requestClose(); + }; + + return ( + + {invitePrompt && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + + + + Mark as Read + + + + + + + + Invite + + + + + Copy Link + + + + + Room Settings + + + {developerTools && ( + + + Event Timeline + + + )} + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={menuIcon(SignOut)} + radii="300" + aria-pressed={promptLeave} + > + + Leave Room + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); + } +); diff --git a/src/app/features/forum/ForumThreadItem.tsx b/src/app/features/forum/ForumThreadItem.tsx new file mode 100644 index 000000000..192f02e1a --- /dev/null +++ b/src/app/features/forum/ForumThreadItem.tsx @@ -0,0 +1,128 @@ +import { Avatar, Box, Chip, Text, config } from 'folds'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { UserAvatar } from '$components/user-avatar'; +import { nicknamesAtom } from '$state/nicknames'; +import type { ThreadRootItemProps } from '$features/room/ThreadRootItem'; +import { ThreadRootItem } from '$features/room/ThreadRootItem'; +import { getThreadReplyEvents } from '$features/room/ThreadDrawer'; +import * as css from './ForumView.css'; + +type ForumThreadItemProps = ThreadRootItemProps & { + thread?: Thread; + onClick: (eventId: string) => void; +}; + +export function ForumThreadItem({ thread, onClick, ...rootProps }: ForumThreadItemProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const nicknames = useAtomValue(nicknamesAtom); + const { room, mEvent } = rootProps; + + const mEventId = mEvent.getId(); + + // Thread reply info for the chip โ€” uses the same reply resolution as the thread drawer. + const replies = mEventId ? getThreadReplyEvents(room, mEventId) : []; + const replyCount = replies.length; + + const uniqueSenders = thread + ? [...new Set(replies.map((ev) => ev.getSender()).filter((id): id is string => !!id))] + : []; + + const lastReply = replies.at(-1); + const lastSenderId = lastReply?.getSender() ?? ''; + const lastDisplayName = + getMemberDisplayName(room, lastSenderId, nicknames) ?? + getMxIdLocalPart(lastSenderId) ?? + lastSenderId; + const lastContent = lastReply?.getContent(); + const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; + + if (!mEventId) return null; + + const handleCardClick = (evt: React.MouseEvent) => { + // Don't open thread if the click originated from a button, link, or other interactive element + const target = evt.target as HTMLElement; + if (target.closest('button, a, [role="button"]')) return; + onClick(mEventId); + }; + + return ( + + + + + {/* Thread reply chip */} + + { + evt.stopPropagation(); + onClick(mEventId); + }} + before={ + uniqueSenders.length > 0 ? ( + + {uniqueSenders.slice(0, 3).map((sid, index) => { + const avatarMxc = getMemberAvatarMxc(room, sid); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 20, 20, 'crop') ?? + undefined) + : undefined; + const dn = + getMemberDisplayName(room, sid, nicknames) ?? getMxIdLocalPart(sid) ?? sid; + return ( + 0 ? '-4px' : 0 }}> + ( + + {dn[0]?.toUpperCase() ?? '?'} + + )} + /> + + ); + })} + + ) : undefined + } + > + + {replyCount} {replyCount === 1 ? 'reply' : 'replies'} + + {lastBody && ( + +  ยท {lastDisplayName}: {lastBody.slice(0, 60)} + + )} + + + + + ); +} diff --git a/src/app/features/forum/ForumView.css.ts b/src/app/features/forum/ForumView.css.ts new file mode 100644 index 000000000..23b759de1 --- /dev/null +++ b/src/app/features/forum/ForumView.css.ts @@ -0,0 +1,26 @@ +import { style } from '@vanilla-extract/css'; +import { config, color } from 'folds'; + +export const ForumHeroTopic = style({ + display: '-webkit-box', + WebkitLineClamp: 3, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + + ':hover': { + cursor: 'pointer', + opacity: config.opacity.P500, + textDecoration: 'underline', + }, +}); + +export const Header = style({ + borderBottomColor: 'transparent', +}); + +export const ForumThreadItem = style({ + paddingBottom: config.space.S200, + borderRadius: config.radii.R400, + backgroundColor: color.SurfaceVariant.Container, + cursor: 'pointer', +}); diff --git a/src/app/features/forum/ForumView.tsx b/src/app/features/forum/ForumView.tsx new file mode 100644 index 000000000..da69bc755 --- /dev/null +++ b/src/app/features/forum/ForumView.tsx @@ -0,0 +1,557 @@ +import type { MouseEventHandler } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, IconButton, Line, Scroll, Text, color, config } from 'folds'; +import { useAtom, useAtomValue } from 'jotai'; +import type { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk'; +import { Direction, EventType, RoomEvent } from 'matrix-js-sdk'; +import { type RoomEventHandlerMap } from 'matrix-js-sdk/lib/models/room'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { useRoom } from '$hooks/useRoom'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { Page, PageContent, PageContentCenter, PageHeroSection } from '$components/page'; +import { CaretUp, Chats, composerIcon, sizedIcon } from '$components/icons/phosphor'; +import { MembersDrawer } from '$features/room/MembersDrawer'; +import { ThreadDrawer, getThreadReplyEvents } from '$features/room/ThreadDrawer'; +import { useSetting } from '$state/hooks/settings'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { settingsAtom } from '$state/settings'; +import { ForumHeader } from './ForumHeader'; +import { ForumHero } from './ForumHero'; +import { ForumThreadItem } from './ForumThreadItem'; +import { ScrollTopContainer } from '$components/scroll-top-container'; +import { PowerLevelsContextProvider, usePowerLevels } from '$hooks/usePowerLevels'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomMembers } from '$hooks/useRoomMembers'; +import { reactionOrEditEvent } from '$utils/room'; +import { mxcUrlToHttp, toggleReaction } from '$utils/matrix'; +import { useStateEvent } from '$hooks/useStateEvent'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useEditor } from '$components/editor'; +import { RoomInputPlaceholder } from '$features/room/RoomInputPlaceholder'; +import { RoomTombstone } from '$features/room/RoomTombstone'; +import { RoomInput } from '$features/room/RoomInput'; +import { useImagePackRooms } from '$hooks/useImagePackRooms'; +import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { CustomStateEvent } from '$types/matrix/room'; +import type { RoomBannerContent } from '$types/matrix-sdk-events'; +import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; + +type ForumPost = { + eventId: string; + mEvent: MatrixEvent; + thread?: Thread; + ts: number; +}; + +/** + * Collect all top-level messages (not thread replies, not reactions/edits/redacted) + * and return them as ForumPost items, sorted by latest activity descending. + */ +const collectForumPosts = (room: Room): ForumPost[] => { + const threadMap = new Map(); + room.getThreads().forEach((thread) => { + threadMap.set(thread.id, thread); + }); + + const posts = new Map(); + + // Add all thread roots (even if not in the visible timeline) + threadMap.forEach((thread, threadId) => { + const { rootEvent } = thread; + if (!rootEvent) return; + // Skip redacted root messages with no visible replies + if (rootEvent.isRedacted()) { + const replies = getThreadReplyEvents(room, threadId); + if (replies.length === 0) return; + } + const lastTs = thread.events.at(-1)?.getTs() ?? rootEvent.getTs(); + posts.set(threadId, { + eventId: threadId, + mEvent: rootEvent, + thread, + ts: lastTs, + }); + }); + + // Add top-level timeline messages that are NOT thread replies + const timeline = room.getLiveTimeline(); + timeline.getEvents().forEach((ev) => { + const evId = ev.getId(); + if (!evId) return; + if (posts.has(evId)) return; // already added as thread root + if (ev.isRedacted()) return; + if (reactionOrEditEvent(ev)) return; + // Skip actual thread replies (rel_type: m.thread), but keep plain replies + // that just reference a thread root via m.in_reply_to + if (ev.getRelation()?.rel_type === 'm.thread') return; + if (ev.isState()) return; // skip state events + if (!ev.getContent()?.msgtype) return; // not a displayable message + + posts.set(evId, { + eventId: evId, + mEvent: ev, + thread: undefined, + ts: ev.getTs(), + }); + }); + + // Sort by latest activity descending + return Array.from(posts.values()).toSorted((a, b) => b.ts - a.ts); +}; + +export function ForumView() { + const mx = useMatrixClient(); + const room = useRoom(); + const powerLevels = usePowerLevels(room); + const members = useRoomMembers(mx, room.roomId); + + const useAuthentication = useMediaAuthentication(); + const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner); + const bannerMxc = bannerState?.getContent()?.url; + const bannerUrl = bannerMxc + ? (mxcUrlToHttp(mx, bannerMxc, useAuthentication) ?? undefined) + : undefined; + + const scrollRef = useRef(null); + const roomViewRef = useRef(null); + const heroSectionRef = useRef(null); + const editor = useEditor(); + const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const screenSize = useScreenSizeContext(); + const [onTop, setOnTop] = useState(true); + + const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); + const [updateKey, forceUpdate] = useState(0); + const [editId, setEditId] = useState(undefined); + + // Settings + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)), + mentionClickHandler + ), + }), + [mx, room, mentionClickHandler, settingsLinkBaseUrl] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [ + mx, + room, + settingsLinkBaseUrl, + linkifyOpts, + spoilerClickHandler, + mentionClickHandler, + useAuthentication, + ] + ); + + // Power levels & permissions + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(EventType.RoomRedaction, mx.getSafeUserId()); + const canSendReaction = permissions.event(EventType.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent(EventType.RoomPinnedEvents, mx.getSafeUserId()); + const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + const tombstoneEvent = useStateEvent(room, EventType.RoomTombstone); + + // Image packs + const roomToParents = useAtomValue(roomToParentsAtom); + const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); + + // User profile popup + const openUserRoomProfile = useOpenUserRoomProfile(); + + // Fetch threads from server on mount (same as RoomViewHeader does) + useEffect(() => { + const scanTimelineForThreads = (timeline: EventTimeline) => { + const events = timeline.getEvents(); + const threadRoots = new Set(); + + events.forEach((event: MatrixEvent) => { + if (event.isThreadRoot) { + const rootId = event.getId(); + if (rootId && !room.getThread(rootId)) { + threadRoots.add(rootId); + } + } + + const { threadRootId } = event; + if (threadRootId && !room.getThread(threadRootId)) { + threadRoots.add(threadRootId); + } + }); + + threadRoots.forEach((rootId) => { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + room.createThread(rootId, rootEvent, [], false); + } + }); + }; + + const liveTimeline = room.getLiveTimeline(); + scanTimelineForThreads(liveTimeline); + + let backwardTimeline = liveTimeline.getNeighbouringTimeline(Direction.Backward); + while (backwardTimeline) { + scanTimelineForThreads(backwardTimeline); + backwardTimeline = backwardTimeline.getNeighbouringTimeline(Direction.Backward); + } + + // Initialize thread timeline sets then fetch threads from server + room + .createThreadsTimelineSets() + .then(() => room.fetchRoomThreads()) + .then(() => { + forceUpdate((n) => n + 1); + }) + .catch(() => { + // Silently ignore โ€” server may not support threads + }); + }, [room]); + + // Re-render when threads or timeline change + useEffect(() => { + const createdThreads = new Set(); + const onThreadNew: RoomEventHandlerMap[ThreadEvent.New] = () => { + forceUpdate((n) => n + 1); + }; + const onThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = () => { + forceUpdate((n) => n + 1); + }; + const onThreadReply: RoomEventHandlerMap[ThreadEvent.NewReply] = () => { + forceUpdate((n) => n + 1); + }; + const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (mEvent) => { + if (mEvent.isThreadRoot) { + const rootId = mEvent.getId(); + if (rootId && !room.getThread(rootId) && !createdThreads.has(rootId)) { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + createdThreads.add(rootId); + room.createThread(rootId, rootEvent, [], false); + } + } + forceUpdate((n) => n + 1); + return; + } + + const { threadRootId } = mEvent; + if (threadRootId) { + if (!room.getThread(threadRootId) && !createdThreads.has(threadRootId)) { + const rootEvent = room.findEventById(threadRootId); + if (rootEvent) { + createdThreads.add(threadRootId); + room.createThread(threadRootId, rootEvent, [], false); + } + } + forceUpdate((n) => n + 1); + return; + } + if (mEvent.isState()) return; + if (reactionOrEditEvent(mEvent)) return; + if (!mEvent.getContent()?.msgtype) return; + forceUpdate((n) => n + 1); + }; + const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent) => { + if (mEvent.threadRootId || mEvent.isThreadRoot) { + forceUpdate((n) => n + 1); + return; + } + forceUpdate((n) => n + 1); + }; + + const onUnreadNotifications = () => forceUpdate((n) => n + 1); + + room.on(RoomEvent.Timeline, onTimeline); + room.on(RoomEvent.Redaction, onRedaction); + room.on(RoomEvent.UnreadNotifications, onUnreadNotifications); + room.on(ThreadEvent.New, onThreadNew); + room.on(ThreadEvent.Update, onThreadUpdate); + room.on(ThreadEvent.NewReply, onThreadReply); + const cleanup = () => { + room.removeListener(RoomEvent.Timeline, onTimeline); + room.removeListener(RoomEvent.Redaction, onRedaction); + room.removeListener(RoomEvent.UnreadNotifications, onUnreadNotifications); + room.removeListener(ThreadEvent.New, onThreadNew); + room.removeListener(ThreadEvent.Update, onThreadUpdate); + room.removeListener(ThreadEvent.NewReply, onThreadReply); + }; + + return cleanup; + }, [room]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const posts = useMemo(() => collectForumPosts(room), [room, updateKey]); + + const handleOpenThread = useCallback( + (eventId: string) => { + setOpenThread(eventId); + }, + [setOpenThread] + ); + + const handleUserClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleUsernameClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + // In forum view, username click opens profile (no editor to insert mention into) + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleReplyClick: MouseEventHandler = useCallback( + (evt) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) return; + // In forum view, clicking reply opens the thread + setOpenThread(replyId); + }, + [setOpenThread] + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string) => { + const thread = room.getThread(targetEventId); + const threadTimelineSet = thread?.timelineSet; + toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet); + }, + [mx, room] + ); + + const handleEdit = useCallback((evtId?: string) => { + setEditId(evtId); + }, []); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + // Scroll to the post or open thread + setOpenThread(targetId); + }, + [setOpenThread] + ); + + return ( + + + + + + + + + + scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} + variant="SurfaceVariant" + radii="Pill" + outlined + size="300" + aria-label="Scroll to Top" + > + {composerIcon(CaretUp)} + + + + + + + {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + + You do not have permission to post in this room + + + )} + + )} + + {posts.map((post) => ( + + ))} + {posts.length === 0 && ( + + {sizedIcon(Chats, '400')} + + No posts yet. + + + )} + + + + + + {screenSize === ScreenSize.Desktop && openThreadId && ( + <> + + setOpenThread(undefined)} + /> + + )} + {screenSize === ScreenSize.Desktop && !openThreadId && isDrawer && ( + <> + + + + )} + {screenSize !== ScreenSize.Desktop && openThreadId && ( + setOpenThread(undefined)} + overlay + /> + )} + + + ); +} diff --git a/src/app/features/forum/index.ts b/src/app/features/forum/index.ts new file mode 100644 index 000000000..eb8d2d7a8 --- /dev/null +++ b/src/app/features/forum/index.ts @@ -0,0 +1 @@ +export { ForumView } from './ForumView'; diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index c8efb2843..31457c045 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -37,7 +37,8 @@ import { useCategoryHandler } from '$hooks/useCategoryHandler'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { allRoomsAtom } from '$state/room-list/roomList'; import { getCanonicalAliasOrRoomId, rateLimitedActions } from '$utils/matrix'; -import { getSpaceRoomPath } from '$pages/pathUtils'; +import { getSpaceRoomPath, getSpaceForumPath } from '$pages/pathUtils'; +import { CustomRoomType } from '$types/matrix/room'; import { ASCIILexicalTable, orderKeys } from '$utils/ASCIILexicalTable'; import { getStateEvent } from '$utils/room'; @@ -527,7 +528,12 @@ export function Lobby() { const rId = evt.currentTarget.getAttribute('data-room-id'); if (!rId) return; const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + const targetRoom = mx.getRoom(rId); + if (targetRoom?.getType() === CustomRoomType.Forum) { + navigate(getSpaceForumPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + } else { + navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); + } }; const togglePinToSidebar = useCallback( diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 60ec814f9..971a3d728 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -294,6 +294,16 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { > Voice Room + handleCreateRoom(CreateRoomType.ForumRoom)} + after={} + > + Forum Room + Existing Room diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 632331b17..3d8eb9240 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -62,7 +62,15 @@ import { useIsDirectRoom, useRoom } from '$hooks/useRoom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useSpaceOptionally } from '$hooks/useSpace'; -import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; +import { + getHomeSearchPath, + getSpaceSearchPath, + getHomeForumPath, + getDirectForumPath, + getSpaceForumPath, + withSearchParam, +} from '$pages/pathUtils'; +import { CustomRoomType } from '$types/matrix/room'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -98,7 +106,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; import { ContainerColor } from '$styles/ContainerColor.css'; import { useRoomWidgets } from '$hooks/useRoomWidgets'; -import { hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room'; +import { getPinsHash, hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room'; import { DirectInvitePrompt } from '$components/direct-invite-prompt'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; @@ -115,16 +123,6 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; const log = createLogger('RoomViewHeader'); -async function getPinsHash(pinnedIds: string[]): Promise { - const sorted = [...pinnedIds].toSorted().join(','); - const encoder = new TextEncoder(); - const data = encoder.encode(sorted); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hashHex.slice(0, 10); -} - export interface PinReadMarker { hash: string; count: number; @@ -137,7 +135,9 @@ type RoomMenuProps = { }; const RoomMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const navigate = useNavigate(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -149,6 +149,9 @@ const RoomMenu = forwardRef(({ room, requestClose const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const { navigateRoom } = useRoomNavigate(); + const parentSpace = useSpaceOptionally(); + const isForum = room.getType() === CustomRoomType.Forum; + const isDirectRoom = useIsDirectRoom(); const [invitePrompt, setInvitePrompt] = useState(false); const [directInvitePrompt, setDirectInvitePrompt] = useState(false); @@ -197,12 +200,24 @@ const RoomMenu = forwardRef(({ room, requestClose }; const openSettings = useOpenRoomSettings(); - const parentSpace = useSpaceOptionally(); const handleOpenSettings = () => { openSettings(room.roomId, parentSpace?.roomId); requestClose(); }; + const handleOpenForumView = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (parentSpace) { + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace.roomId); + navigate(getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias)); + } else if (isDirectRoom) { + navigate(getDirectForumPath(roomIdOrAlias)); + } else { + navigate(getHomeForumPath(roomIdOrAlias)); + } + requestClose(); + }; + return ( {invitePrompt && ( @@ -315,6 +330,13 @@ const RoomMenu = forwardRef(({ room, requestClose )} + {(isForum || developerTools) && ( + + + Forum View + + + )} diff --git a/src/app/features/room/ThreadRootItem.tsx b/src/app/features/room/ThreadRootItem.tsx new file mode 100644 index 000000000..585696ee5 --- /dev/null +++ b/src/app/features/room/ThreadRootItem.tsx @@ -0,0 +1,214 @@ +import type { MouseEventHandler } from 'react'; +import { Box, Scroll, config } from 'folds'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { EventType } from 'matrix-js-sdk'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue } from 'jotai'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { Image } from '$components/media'; +import { ImageViewer } from '$components/image-viewer'; +import { getEditedEvent, getEventReactions, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { ImageContent, MSticker, RedactedContent, Reply } from '$components/message'; +import { RenderMessageContent } from '$components/RenderMessageContent'; +import type { MessageLayout, MessageSpacing } from '$state/settings'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import type { GetContentCallback } from '$types/matrix/room'; +import { nicknamesAtom } from '$state/nicknames'; +import { EncryptedContent, Message, Reactions } from './message'; + +export type ThreadRootItemProps = { + room: Room; + mEvent: MatrixEvent; + thread?: Thread; + editId: string | undefined; + onEditId: (id?: string) => void; + messageLayout: MessageLayout; + messageSpacing: MessageSpacing; + canDelete: boolean; + canSendReaction: boolean; + canPinEvent: boolean; + imagePackRooms: Room[]; + hour24Clock: boolean; + dateFormatString: string; + onUserClick: MouseEventHandler; + onUsernameClick: MouseEventHandler; + onReplyClick: MouseEventHandler; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + linkifyOpts: LinkifyOpts; + htmlReactParserOptions: HTMLReactParserOptions; + showHideReads: boolean; + showDeveloperTools: boolean; + onReferenceClick: MouseEventHandler; + hideReplyButton?: boolean; +}; + +export function ThreadRootItem({ + room, + mEvent, + thread, + editId, + onEditId, + messageLayout, + messageSpacing, + canDelete, + canSendReaction, + canPinEvent, + imagePackRooms, + hour24Clock, + dateFormatString, + onUserClick, + onUsernameClick, + onReplyClick, + onReactionToggle, + linkifyOpts, + htmlReactParserOptions, + showHideReads, + showDeveloperTools, + onReferenceClick, + hideReplyButton, +}: ThreadRootItemProps) { + const nicknames = useAtomValue(nicknamesAtom); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + + const mEventId = mEvent.getId(); + if (!mEventId) return null; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + + const timelineSet = thread?.timelineSet ?? room.getUnfilteredTimelineSet(); + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const editedNewContent = editedEvent?.getContent()['m.new_content']; + const baseContent = mEvent.getContent(); + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + + const { replyEventId } = mEvent; + const showUrlPreview = room.hasEncryptionStateEvent() ? false : urlPreview; + + return ( + <> + + ) + } + > + {mEvent.isRedacted() ? ( + + ) : ( + + + {() => { + if (mEvent.isRedacted()) { + return ( + + ); + } + + if (mEvent.getType() === (EventType.Sticker as string)) { + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + } + + return ( + + ); + }} + + + )} + + + {/* Reactions โ€” outside scroll so always visible */} + {hasReactions && reactionRelations && ( + + + + )} + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 0bf26f9d4..e3eeed192 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -104,6 +104,7 @@ export type MessageProps = { reply?: ReactNode; reactions?: ReactNode; hideReadReceipts?: boolean; + hideReplyButton?: boolean; showDeveloperTools?: boolean; memberPowerTag?: MemberPowerTag; hour24Clock: boolean; @@ -352,6 +353,7 @@ function MessageInternal( reply, reactions, hideReadReceipts, + hideReplyButton, showDeveloperTools, memberPowerTag, hour24Clock, @@ -937,6 +939,7 @@ function MessageInternal( onReplyClick={onReplyClick} onEditId={onEditId} hideReadReceipts={hideReadReceipts} + hideReplyButton={hideReplyButton} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} cleanedDisplayName={cleanedDisplayName} diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 8e4abb172..7aac844c0 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -5,9 +5,12 @@ import { useAtomValue } from 'jotai'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { getDirectRoomPath, + getDirectForumPath, getHomeRoomPath, + getHomeForumPath, getSpacePath, getSpaceRoomPath, + getSpaceForumPath, } from '$pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; @@ -16,6 +19,7 @@ import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useSelectedSpace } from './router/useSelectedSpace'; import { useMatrixClient } from './useMatrixClient'; +import { CustomRoomType } from '$types/matrix/room'; export const useRoomNavigate = () => { const navigate = useNavigate(); @@ -37,6 +41,7 @@ export const useRoomNavigate = () => { (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; + const isForum = mx.getRoom(roomId)?.getType() === CustomRoomType.Forum; const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { @@ -49,19 +54,31 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); + if (isForum && !openSpaceTimeline) { + navigate(getSpaceForumPath(pSpaceIdOrAlias, roomIdOrAlias), opts); + } else { + navigate( + getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), + opts + ); + } return; } if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + if (isForum) { + navigate(getDirectForumPath(roomIdOrAlias), opts); + } else { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + } return; } - navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + if (isForum) { + navigate(getHomeForumPath(roomIdOrAlias), opts); + } else { + navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + } }, [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 56c24a899..16310a508 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -14,6 +14,7 @@ import { SettingsRoute } from '$features/settings'; import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; +import { ForumView } from '$features/forum'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; @@ -48,6 +49,7 @@ import { LOBBY_PATH_SEGMENT, NOTIFICATIONS_PATH_SEGMENT, ROOM_PATH_SEGMENT, + ROOM_FORUM_PATH_SEGMENT, SEARCH_PATH_SEGMENT, SERVER_PATH_SEGMENT, CREATE_PATH, @@ -252,6 +254,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + const getToLink = (roomId: string) => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + if (mx.getRoom(roomId)?.getType() === CustomRoomType.Forum) { + return getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias); + } + return getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias); + }; const navigate = useNavigate(); const lastRoomId = useAtomValue(lastVisitedRoomIdAtom); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..fed0969ce 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -28,6 +28,9 @@ import { SPACE_ROOM_PATH, SPACE_SEARCH_PATH, CREATE_PATH, + HOME_ROOM_FORUM_PATH, + DIRECT_ROOM_FORUM_PATH, + SPACE_ROOM_FORUM_PATH, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -100,6 +103,14 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string return generatePath(HOME_ROOM_PATH, params); }; +export const getHomeForumPath = (roomIdOrAlias: string): string => { + const params = { + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(HOME_ROOM_FORUM_PATH, params); +}; + export const getDirectPath = (): string => DIRECT_PATH; export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH; export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => { @@ -111,6 +122,14 @@ export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): stri return generatePath(DIRECT_ROOM_PATH, params); }; +export const getDirectForumPath = (roomIdOrAlias: string): string => { + const params = { + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(DIRECT_ROOM_FORUM_PATH, params); +}; + export const getSpacePath = (spaceIdOrAlias: string): string => { const params = { spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), @@ -143,6 +162,14 @@ export const getSpaceRoomPath = ( return generatePath(SPACE_ROOM_PATH, params); }; +export const getSpaceForumPath = (spaceIdOrAlias: string, roomIdOrAlias: string): string => { + const params = { + spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + }; + + return generatePath(SPACE_ROOM_FORUM_PATH, params); +}; export const getExplorePath = (): string => EXPLORE_PATH; export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..04c4844cd 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -45,12 +45,14 @@ export type RoomSearchParams = { viaServers?: string; }; export const ROOM_PATH_SEGMENT = ':roomIdOrAlias/:eventId?/'; +export const ROOM_FORUM_PATH_SEGMENT = ':roomIdOrAlias/forum/'; export const HOME_PATH = '/home/'; export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`; export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`; export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`; export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`; +export const HOME_ROOM_FORUM_PATH = `/home/${ROOM_FORUM_PATH_SEGMENT}`; export const DIRECT_PATH = '/direct/'; export type DirectCreateSearchParams = { @@ -58,11 +60,13 @@ export type DirectCreateSearchParams = { }; export const DIRECT_CREATE_PATH = `/direct/${CREATE_PATH_SEGMENT}`; export const DIRECT_ROOM_PATH = `/direct/${ROOM_PATH_SEGMENT}`; +export const DIRECT_ROOM_FORUM_PATH = `/direct/${ROOM_FORUM_PATH_SEGMENT}`; export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${LOBBY_PATH_SEGMENT}`; export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${SEARCH_PATH_SEGMENT}`; export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${ROOM_PATH_SEGMENT}`; +export const SPACE_ROOM_FORUM_PATH = `/:spaceIdOrAlias/${ROOM_FORUM_PATH_SEGMENT}`; export const FEATURED_PATH_SEGMENT = 'featured/'; export const SERVER_PATH_SEGMENT = ':server/'; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 6d201f34a..3d9346566 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -1064,6 +1064,16 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent): boolean => { return false; }; +export async function getPinsHash(pinnedIds: string[]): Promise { + const sorted = [...pinnedIds].toSorted().join(','); + const encoder = new TextEncoder(); + const data = encoder.encode(sorted); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex.slice(0, 10); +} + export const isThreadRelationEvent = (mEvent: MatrixEvent, threadRootId?: string): boolean => { const relation = mEvent.getRelation?.() ?? diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index af32fe773..aef1b671b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -20,6 +20,11 @@ export const CustomStateEvent = { } as const; export type CustomStateEvent = (typeof CustomStateEvent)[keyof typeof CustomStateEvent]; +export const CustomRoomType = { + Forum: 'pl.chrome.forum', +} as const; +export type CustomRoomType = (typeof CustomRoomType)[keyof typeof CustomRoomType]; + export type MSpaceChildContent = { via: string[]; suggested?: boolean; From bc585e4fa342fc496ace1e0f8fad7a1e84ad6416 Mon Sep 17 00:00:00 2001 From: Tomasz Sterna Date: Wed, 1 Apr 2026 20:10:50 +0200 Subject: [PATCH 07/11] feat: add m.forum room type and dedicated view with threads as topics --- .changeset/add-forum-room-type.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/add-forum-room-type.md b/.changeset/add-forum-room-type.md index b19c76ad7..7d36ed45b 100644 --- a/.changeset/add-forum-room-type.md +++ b/.changeset/add-forum-room-type.md @@ -2,4 +2,4 @@ default: minor --- -Add the `forum` room type with a dedicated forum view that presents threads as topics. +Add the `forum` room type with a dedicated forum view that presents threads as topics. \ No newline at end of file From 504f8d2445d2d09cc445b6c64351f73c25f5e618 Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 24 Jun 2026 10:16:37 +0300 Subject: [PATCH 08/11] fmt Signed-off-by: Shea --- src/app/components/icons/roomIcons.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/components/icons/roomIcons.tsx b/src/app/components/icons/roomIcons.tsx index bc40bf2da..77cda41b1 100644 --- a/src/app/components/icons/roomIcons.tsx +++ b/src/app/components/icons/roomIcons.tsx @@ -9,8 +9,7 @@ export type RoomPhosphorIcon = ComponentType; export type RoomIconOverlay = 'globe' | 'lock'; const isRegularRoom = (roomType?: string): boolean => - roomType !== RoomType.Space && - roomType !== RoomType.UnstableCall; + roomType !== RoomType.Space && roomType !== RoomType.UnstableCall; export function getRoomIconOverlay( roomType?: string, From 82fd895c7133378e9709cb7632f8e0f718e2c43a Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 24 Jun 2026 14:06:56 +0300 Subject: [PATCH 09/11] fix most issues Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 19 +++++-- .../components/message/MsgTypeRenderers.tsx | 39 ++++++++++++++ .../upload-board/UploadBoard.css.ts | 30 +++++++---- .../components/upload-board/UploadBoard.tsx | 28 ++++++---- src/app/features/forum/ForumView.tsx | 9 +++- src/app/features/room/RoomInput.tsx | 51 ++++++++++++++++++- src/app/features/room/ThreadBrowser.tsx | 28 ++++++++-- src/app/features/room/ThreadDrawer.tsx | 2 +- src/app/features/room/ThreadRootItem.tsx | 12 ++++- .../timeline/useTimelineEventRenderer.tsx | 50 +++++++++++------- 10 files changed, 217 insertions(+), 51 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e4e6c6430..e2811e206 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,7 +1,7 @@ import type { CSSProperties } from 'react'; import { memo, useMemo, useCallback } from 'react'; import type { IPreviewUrlResponse, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { MsgType } from '$types/matrix-sdk'; +import { EventType, MsgType } from '$types/matrix-sdk'; import { parseSettingsLink } from '$features/settings/settingsLink'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { testMatrixTo } from '$plugins/matrix-to'; @@ -23,6 +23,7 @@ import { MImage, MLocation, MNotice, + MStrickerWrappper, MText, MVideo, ReadPdfFile, @@ -48,7 +49,7 @@ import { TextViewer } from './text-viewer'; import { ClientSideHoverFreeze } from './ClientSideHoverFreeze'; import { CuteEventType, MCuteEvent } from './message/MCuteEvent'; import { PollEvent } from './message/PollEvent'; -import { M_TEXT } from 'matrix-js-sdk'; +import { M_POLL_START, M_TEXT } from 'matrix-js-sdk'; import type { IImageInfo } from '$types/matrix/common'; type RenderMessageContentProps = { @@ -70,6 +71,7 @@ type RenderMessageContentProps = { mEvent?: MatrixEvent; mx?: MatrixClient; room?: Room; + autoplayStickers?: boolean; }; const getMediaType = (url: string) => { @@ -106,6 +108,7 @@ function RenderMessageContentInternal({ mEvent, mx, room, + autoplayStickers, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -460,11 +463,21 @@ function RenderMessageContentInternal({ } /> ); - if (content['org.matrix.msc3381.poll.start']) { + if (content[M_POLL_START.name]) { if (mEvent && mx && room) return ; else return ; } + if (mEvent?.getType() === EventType.Sticker) { + return ( + + ); + } + return ( ); } +type MStrickerWrappperProps = { + mEvent: MatrixEvent; + autoplayStickers?: boolean; + mediaAutoLoad?: boolean; +}; + +export function MStrickerWrappper({ + mEvent, + autoplayStickers, + mediaAutoLoad, +}: MStrickerWrappperProps) { + return ( + ( + { + if (!autoplayStickers && p.src) { + return ( + + + + ); + } + return ; + }} + renderViewer={(p) => } + /> + )} + /> + ); +} diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts index 80c1b264d..9230919ed 100644 --- a/src/app/components/upload-board/UploadBoard.css.ts +++ b/src/app/components/upload-board/UploadBoard.css.ts @@ -1,4 +1,5 @@ import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; export const UploadBoardBase = style([ @@ -9,16 +10,27 @@ export const UploadBoardBase = style([ }, ]); -export const UploadBoardContainer = style([ - DefaultReset, - { - position: 'absolute', - bottom: config.space.S200, - left: 0, - right: 0, - zIndex: config.zIndex.Max, +export const UploadBoardContainer = recipe({ + base: [ + DefaultReset, + { + position: 'absolute', + left: 0, + right: 0, + zIndex: config.zIndex.Max, + }, + ], + variants: { + isBottom: { + true: { + top: config.space.S200, + }, + false: { + bottom: config.space.S200, + }, + }, }, -]); +}); export const UploadBoard = style({ maxWidth: toRem(400), diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx index 232d65fec..5d7eaa219 100644 --- a/src/app/components/upload-board/UploadBoard.tsx +++ b/src/app/components/upload-board/UploadBoard.tsx @@ -11,21 +11,27 @@ import * as css from './UploadBoard.css'; type UploadBoardProps = { header: ReactNode; + showUploadCardBottom?: boolean; }; -export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => ( - - - - - {children} - - - {header} +export const UploadBoard = as<'div', UploadBoardProps>( + ({ header, showUploadCardBottom, children, ...props }, ref) => ( + + + + + {children} + + + {header} + - -)); + ) +); export type UploadBoardImperativeHandlers = { handleSend: () => Promise }; diff --git a/src/app/features/forum/ForumView.tsx b/src/app/features/forum/ForumView.tsx index da69bc755..5d908cbc6 100644 --- a/src/app/features/forum/ForumView.tsx +++ b/src/app/features/forum/ForumView.tsx @@ -50,6 +50,7 @@ import { renderMatrixMention, } from '$plugins/react-custom-html-parser'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import { GlobalModalManager } from '$components/message/modals/GlobalModalManager'; type ForumPost = { eventId: string; @@ -100,7 +101,6 @@ const collectForumPosts = (room: Room): ForumPost[] => { // that just reference a thread root via m.in_reply_to if (ev.getRelation()?.rel_type === 'm.thread') return; if (ev.isState()) return; // skip state events - if (!ev.getContent()?.msgtype) return; // not a displayable message posts.set(evId, { eventId: evId, @@ -397,6 +397,10 @@ export function ForumView() { [setOpenThread] ); + const [showInteractiveMap] = useSetting(settingsAtom, 'showInteractiveMap'); + const [showEncInteractiveMap] = useSetting(settingsAtom, 'showEncInteractiveMap'); + const showMaps = room.hasEncryptionStateEvent() ? showEncInteractiveMap : showInteractiveMap; + return ( @@ -463,6 +467,7 @@ export function ForumView() { editor={editor} roomId={room.roomId} fileDropContainerRef={roomViewRef} + showUploadCardBottom /> )} {!canMessage && ( @@ -505,6 +510,7 @@ export function ForumView() { showDeveloperTools={showDeveloperTools} onReferenceClick={handleOpenReply} onClick={handleOpenThread} + showMaps={showMaps} /> ))} {posts.length === 0 && ( @@ -552,6 +558,7 @@ export function ForumView() { /> )} + ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index aff2088f9..9831ec8ce 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -274,10 +274,22 @@ interface RoomInputProps { room: Room; threadRootId?: string; onEditLastMessage?: () => void; + showUploadCardBottom?: boolean; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room, threadRootId, onEditLastMessage }, ref) => { + ( + { + editor, + fileDropContainerRef, + roomId, + room, + threadRootId, + onEditLastMessage, + showUploadCardBottom, + }, + ref + ) => { // When in thread mode, isolate drafts by thread root ID so thread replies // don't clobber the main room draft (and vice versa). const draftKey = threadRootId ?? roomId; @@ -1323,7 +1335,7 @@ export const RoomInput = forwardRef( return (
- {selectedFiles.length > 0 && ( + {selectedFiles.length > 0 && !showUploadCardBottom && ( ( } bottom={} /> + {selectedFiles.length > 0 && showUploadCardBottom && ( + setUploadBoard(!uploadBoard)} + uploadFamilyObserverAtom={uploadFamilyObserverAtom} + onSend={handleSendUpload} + imperativeHandlerRef={uploadBoardHandlers} + onCancel={handleCancelUpload} + /> + } + showUploadCardBottom + > + {uploadBoard && ( + + + {Array.from(selectedFiles) + .toReversed() + .map((fileItem) => ( + + ))} + + + )} + + )} {showSchedulePicker && ( void; onJump?: () => void; + showMaps?: boolean; }; -function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { +function ThreadPreview({ room, thread, onClick, onJump, showMaps }: ThreadPreviewProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -156,6 +162,13 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const displayName = getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + const timelineSet = thread?.timelineSet ?? room.getUnfilteredTimelineSet(); + const rootEventId = rootEvent.getId() ?? ''; + const editedEvent = getEditedEvent(rootEventId, rootEvent, timelineSet); + const editedNewContent = editedEvent?.getContent()['m.new_content']; + const baseContent = rootEvent.getContent(); + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : rootEvent.getOriginalContent(); const getContent = (() => rootEvent.getContent()) as GetContentCallback; const localReplyCount = thread.events.filter( @@ -254,7 +267,9 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { return ( ); }} @@ -316,6 +332,9 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr const canLoadMoreRef = useRef(false); canLoadMoreRef.current = canLoadMore; + const [showInteractiveMap] = useSetting(settingsAtom, 'showInteractiveMap'); + const [showEncInteractiveMap] = useSetting(settingsAtom, 'showEncInteractiveMap'); + const showMaps = room.hasEncryptionStateEvent() ? showEncInteractiveMap : showInteractiveMap; // On mount, set up thread event listeners, create the server-side thread // timeline sets, then fetch page 1 via paginate. The two operations are // sequenced in a single effect so that createThreadsTimelineSets() always @@ -481,7 +500,7 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr setCurWidth={setCurWidth} sidebarWidth={threadSidebarWidth} setSidebarWidth={setThreadSidebarWidth} - minValue={150} + minValue={250} maxValue={600} isReversed /> @@ -588,6 +607,7 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr thread={thread} onClick={onOpenThread} onJump={onClose} + showMaps={showMaps} /> ))} diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 5f680cce5..3a1a16807 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -817,7 +817,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra setCurWidth={setCurWidth} sidebarWidth={threadSidebarWidth} setSidebarWidth={setThreadSidebarWidth} - minValue={150} + minValue={250} maxValue={600} isReversed /> diff --git a/src/app/features/room/ThreadRootItem.tsx b/src/app/features/room/ThreadRootItem.tsx index 585696ee5..84b139129 100644 --- a/src/app/features/room/ThreadRootItem.tsx +++ b/src/app/features/room/ThreadRootItem.tsx @@ -18,6 +18,7 @@ import { useSetting } from '$state/hooks/settings'; import type { GetContentCallback } from '$types/matrix/room'; import { nicknamesAtom } from '$state/nicknames'; import { EncryptedContent, Message, Reactions } from './message'; +import { useMatrixClient } from '$hooks/useMatrixClient'; export type ThreadRootItemProps = { room: Room; @@ -43,6 +44,7 @@ export type ThreadRootItemProps = { showDeveloperTools: boolean; onReferenceClick: MouseEventHandler; hideReplyButton?: boolean; + showMaps?: boolean; }; export function ThreadRootItem({ @@ -69,7 +71,9 @@ export function ThreadRootItem({ showDeveloperTools, onReferenceClick, hideReplyButton, + showMaps, }: ThreadRootItemProps) { + const mx = useMatrixClient(); const nicknames = useAtomValue(nicknamesAtom); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); @@ -178,7 +182,9 @@ export function ThreadRootItem({ return ( ); }} diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 2d4e41e54..91e02ae8e 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -674,6 +674,7 @@ export function useTimelineEventRenderer({ displayName={senderDisplayName} msgType={((editedNewContent ?? safeContent) as { msgtype?: string }).msgtype ?? ''} ts={mEvent.getTs()} + mEvent={mEvent} edited={!!editedEvent} getContent={getContent} mediaAutoLoad={mediaAutoLoad} @@ -851,6 +852,7 @@ export function useTimelineEventRenderer({ } ts={mEvent.getTs()} edited={!!editedEvent} + mEvent={mEvent} getContent={getContent} mediaAutoLoad={mediaAutoLoad} bundledPreview={showBundledPreview} @@ -861,6 +863,7 @@ export function useTimelineEventRenderer({ outlineAttachment={messageLayout === MessageLayout.Bubble} mx={mx} room={room} + showMaps={showMaps} /> ); } @@ -897,6 +900,16 @@ export function useTimelineEventRenderer({ getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; const content = mEvent.getContent() ?? {}; + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + let editedNewContent: unknown; + if (editedEvent) { + editedNewContent = editedEvent.getContent()['m.new_content']; + } + const baseContent = mEvent.getContent() || {}; + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + return ( ) : ( - ( - { - if (!autoplayStickers && p.src) { - return ( - - - - ); - } - return ; - }} - renderViewer={(p) => } - /> - )} + )} @@ -1160,6 +1171,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + showMaps={showMaps} /> )} From 9ecbfa8a71acff5e6ad12137a9572c0a98ad58aa Mon Sep 17 00:00:00 2001 From: Shea Date: Thu, 25 Jun 2026 23:29:48 +0300 Subject: [PATCH 10/11] merge divergence Signed-off-by: Shea --- .changeset/add-forum-room-type.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/add-forum-room-type.md b/.changeset/add-forum-room-type.md index 7d36ed45b..b19c76ad7 100644 --- a/.changeset/add-forum-room-type.md +++ b/.changeset/add-forum-room-type.md @@ -2,4 +2,4 @@ default: minor --- -Add the `forum` room type with a dedicated forum view that presents threads as topics. \ No newline at end of file +Add the `forum` room type with a dedicated forum view that presents threads as topics. From 4b95856adc88bf74cf06b51f80338cda31b483d8 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:28:51 +0000 Subject: [PATCH 11/11] Fix polls not showing in threads --- src/app/components/message/PollEvent.tsx | 2 +- src/app/hooks/timeline/useProcessedTimeline.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index 6e0651c32..478b1b768 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -214,7 +214,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { rel_type: 'm.reference', event_id: eventId, }, - 'org.matrix.msc3381.poll.end': {}, + [M_POLL_END.name]: {}, [M_TEXT.name]: endText, body: endText, msgtype: 'm.text', diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 45545867e..dba7ab54e 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -408,6 +408,8 @@ export function useProcessedTimeline({ 'm.room.name', 'm.room.topic', 'm.room.avatar', + 'm.poll.start', + 'org.matrix.msc3381.poll.start', 'org.matrix.msc3401.call.member', ].includes(type);