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 (
+
+ );
+ }
+);
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
+
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 (