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;