+
{top}
-
+
{before && (
{before}
@@ -236,7 +255,7 @@ export const CustomEditor = forwardRef(
diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx
index 8a361a6..3dbd525 100644
--- a/src/app/components/room-card/RoomCard.tsx
+++ b/src/app/components/room-card/RoomCard.tsx
@@ -166,6 +166,7 @@ export const RoomCard = as<'div', RoomCardProps>(
const useAuthentication = useMediaAuthentication();
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
const joinedRoom = mx.getRoom(joinedRoomId);
+ const createEvent = joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomCreate) : undefined;
const [topicEvent, setTopicEvent] = useState(() =>
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
);
@@ -210,6 +211,8 @@ export const RoomCard = as<'div', RoomCardProps>(
const [showSpaceJoinPrompt, setShowSpaceJoinPrompt] = useState(false);
const isSpace = roomType === RoomType.Space || joinedRoom?.isSpaceRoom();
+ const isForum =
+ roomType === RoomType.Forum || createEvent?.getContent<{ type?: string }>().type === RoomType.Forum;
const handleJoinClick = () => {
if (isSpace) {
@@ -252,6 +255,11 @@ export const RoomCard = as<'div', RoomCardProps>(
Space
)}
+ {isForum && (
+
+ Forum
+
+ )}
{roomName}
diff --git a/src/app/components/speech-bubble/SpeechBubble.css.ts b/src/app/components/speech-bubble/SpeechBubble.css.ts
new file mode 100644
index 0000000..af80af1
--- /dev/null
+++ b/src/app/components/speech-bubble/SpeechBubble.css.ts
@@ -0,0 +1,60 @@
+import { globalStyle, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+const tailTopBase = {
+ content: '""',
+ position: 'absolute' as const,
+ top: toRem(-5),
+ left: 'var(--speech-tail-x, 50%)',
+ transform: 'translateX(-50%) rotate(45deg)',
+ width: toRem(10),
+ height: toRem(10),
+};
+
+export const SpeechBubble = style({
+ position: 'relative',
+ padding: `${toRem(8)} ${toRem(12)}`,
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ borderRadius: config.radii.R300,
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+});
+
+export const SpeechBubbleTailTop = style({
+ selectors: {
+ '&::before': {
+ ...tailTopBase,
+ backgroundColor: color.SurfaceVariant.Container,
+ },
+ },
+});
+
+/** Popover menus (kebab, etc.) — surface matches folds Menu. */
+export const SpeechBubbleMenu = style({
+ position: 'relative',
+ padding: 0,
+ backgroundColor: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ borderRadius: config.radii.R400,
+ border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+ boxShadow: '0 10px 28px rgb(0 0 0 / 28%), 0 2px 6px rgb(0 0 0 / 12%)',
+ overflow: 'visible',
+});
+
+export const SpeechBubbleMenuTailTop = style({
+ selectors: {
+ '&::before': {
+ ...tailTopBase,
+ backgroundColor: color.Surface.Container,
+ borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+ borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+ },
+ },
+});
+
+globalStyle(`${SpeechBubbleMenu} > *`, {
+ background: 'transparent',
+ border: 'none',
+ boxShadow: 'none',
+});
+
diff --git a/src/app/components/speech-bubble/SpeechBubble.tsx b/src/app/components/speech-bubble/SpeechBubble.tsx
new file mode 100644
index 0000000..bc56d19
--- /dev/null
+++ b/src/app/components/speech-bubble/SpeechBubble.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import classNames from 'classnames';
+import * as css from './SpeechBubble.css';
+
+type SpeechBubbleProps = {
+ children: React.ReactNode;
+ className?: string;
+ /** `menu` for dropdowns; `tooltip` for hover cards. */
+ variant?: 'tooltip' | 'menu';
+ /** Tail position. Currently only 'top' is implemented. */
+ tail?: 'top' | 'none';
+ /** Percentage string, e.g. '50%'. */
+ tailX?: string;
+ style?: React.CSSProperties;
+};
+
+export function SpeechBubble({
+ children,
+ className,
+ variant = 'tooltip',
+ tail = 'top',
+ tailX,
+ style,
+}: SpeechBubbleProps) {
+ const isMenu = variant === 'menu';
+
+ return (
+
+ {children}
+
+ );
+}
+
diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx
index 6ad469c..59a3a82 100644
--- a/src/app/features/create-room/CreateRoom.tsx
+++ b/src/app/features/create-room/CreateRoom.tsx
@@ -38,6 +38,7 @@ import {
RoomVersionSelector,
useAdditionalCreators,
} from '../../components/create-room';
+import { RoomType } from '../../../types/matrix/room';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock;
@@ -73,6 +74,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
const [federation, setFederation] = useState(true);
const [encryption, setEncryption] = useState(false);
const [knock, setKnock] = useState(false);
+ const [forumLayout, setForumLayout] = useState(false);
const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
@@ -118,6 +120,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
create({
version: selectedRoomVersion,
+ type: forumLayout ? RoomType.Forum : undefined,
parent: space,
kind,
name: roomName,
@@ -222,6 +225,25 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
}
/>
+
+
+ }
+ />
+
{advance && (allowKnock || allowKnockRestricted) && (
{
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
@@ -46,6 +53,11 @@ const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
return Icons.SpaceGlobe;
};
+const spaceTypeName: Record = {
+ [RoomType.Space]: 'Space',
+ [RoomType.Forum]: 'Forum Space',
+};
+
type CreateSpaceFormProps = {
defaultKind?: CreateRoomKind;
space?: Room;
@@ -74,12 +86,22 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
useAdditionalCreators();
const [federation, setFederation] = useState(true);
const [knock, setKnock] = useState(false);
+ const [spaceType, setSpaceType] = useState(RoomType.Space);
+ const [typeMenuAnchor, setTypeMenuAnchor] = useState();
const [advance, setAdvance] = useState(false);
+ const parentIsForum = Boolean(space && shouldShowForumLobby(space));
+
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
+ useEffect(() => {
+ if (parentIsForum && spaceType === RoomType.Forum) {
+ setSpaceType(RoomType.Space);
+ }
+ }, [parentIsForum, spaceType]);
+
const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private);
@@ -94,6 +116,15 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
const disabled = createState.status === AsyncStatus.Loading;
+ const handleOpenTypeMenu: MouseEventHandler = (evt) => {
+ setTypeMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleTypeSelect = (type: RoomType.Space | RoomType.Forum) => {
+ setSpaceType(type);
+ setTypeMenuAnchor(undefined);
+ };
+
const handleSubmit: FormEventHandler = (evt) => {
evt.preventDefault();
if (disabled) return;
@@ -119,7 +150,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
create({
version: selectedRoomVersion,
- type: RoomType.Space,
+ type: spaceType,
parent: space,
kind,
name: roomName,
@@ -128,7 +159,21 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
knock: roomKnock,
allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
- }).then((roomId) => {
+ }).then(async (roomId) => {
+ if (spaceType === RoomType.Forum) {
+ try {
+ await mx.sendStateEvent(
+ roomId,
+ StateEvent.PaarrotRoomKind,
+ { kind: 'forum_space' },
+ ''
+ );
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('Failed to set forum space marker state event:', err);
+ }
+ }
+
if (alive()) {
onCreate?.(roomId);
}
@@ -147,6 +192,84 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
getIcon={getCreateSpaceKindToIcon}
/>
+ {!parentIsForum && (
+
+ Type
+
+
+ }
+ onClick={handleOpenTypeMenu}
+ disabled={disabled}
+ >
+ {spaceTypeName[spaceType]}
+
+ setTypeMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
+ >
+ }
+ />
+
+
+ )}
+ {parentIsForum && (
+
+ Creates a forum category (subspace). Add topics inside it with Add Topic after selecting the
+ category in the filter bar.
+
+ )}
Name
1 ? parts[1] : '';
+}
+
+function matrixUserLocalpart(userId: string): string {
+ return userId.split(':')[0]?.replace('@', '') || userId;
+}
+
+type ForumAuthorIdentityProps = {
+ room: Room | null;
+ sender: string;
+ displayName?: string;
+ wrapTag?: 'strong' | 'span';
+ compact?: boolean;
+};
+
+/** matrixsso `author-identity` chip (avatar + name + server). */
+export function ForumAuthorIdentity({
+ room,
+ sender,
+ displayName,
+ wrapTag: WrapTag = 'strong',
+ compact = false,
+}: ForumAuthorIdentityProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const mxc = room ? getMemberAvatarMxc(room, sender) : undefined;
+ const avatarUrl = mxc
+ ? mxcUrlToHttp(mx, mxc, useAuthentication, compact ? 36 : 48, compact ? 36 : 48, 'crop') ??
+ undefined
+ : undefined;
+ const name =
+ displayName || (room ? getMemberDisplayName(room, sender) : undefined) || matrixUserLocalpart(sender);
+ const server = matrixUserServer(sender);
+
+ const identityClass = compact
+ ? `${theme.ForumAuthorIdentity} ${theme.ForumAuthorIdentityCompact}`
+ : theme.ForumAuthorIdentity;
+ const avatarClass = compact ? theme.ForumAuthorAvatarCompact : theme.ForumAuthorAvatar;
+
+ return (
+
+
+
+ (
+ {name.slice(0, 1).toUpperCase()}
+ )}
+ />
+
+
+
+
+ {name}
+
+ {server ? (
+
+ {server}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumAwareSpaceLayout.tsx b/src/app/features/forum/ForumAwareSpaceLayout.tsx
new file mode 100644
index 0000000..a29a7f8
--- /dev/null
+++ b/src/app/features/forum/ForumAwareSpaceLayout.tsx
@@ -0,0 +1,53 @@
+import React, { CSSProperties } from 'react';
+import { Outlet } from 'react-router-dom';
+import { Box } from 'folds';
+import classNames from 'classnames';
+import { useSpace } from '../../hooks/useSpace';
+import { PageRoot } from '../../components/page';
+import { AnimatedOutlet } from '../../components/AnimatedOutlet';
+import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
+import { SPACE_PATH } from '../../pages/paths';
+import { Space } from '../../pages/client/space/Space';
+import { shouldShowForumLobby } from '../../utils/room';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import * as theme from './forumTheme.css';
+
+const forumOutletShell: CSSProperties = {
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ minHeight: 0,
+ minWidth: 0,
+ overflow: 'hidden',
+};
+
+/**
+ * Forum: server list only — forum fills main pane.
+ * Other spaces: space sidebar + outlet.
+ */
+export function ForumAwareSpaceLayout() {
+ const space = useSpace();
+ const isForum = shouldShowForumLobby(space);
+
+ if (isForum) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumBoardContext.tsx b/src/app/features/forum/ForumBoardContext.tsx
new file mode 100644
index 0000000..33a4a55
--- /dev/null
+++ b/src/app/features/forum/ForumBoardContext.tsx
@@ -0,0 +1,32 @@
+import React, { createContext, useContext } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { useForumBoard, type ForumBoardScope } from './useForumBoard';
+
+type ForumBoardContextValue = ReturnType;
+
+const ForumBoardContext = createContext(null);
+
+export function ForumBoardProvider({
+ forumSpace,
+ scope,
+ children,
+}: {
+ forumSpace: Room;
+ scope: ForumBoardScope;
+ children: React.ReactNode;
+}) {
+ const value = useForumBoard(scope, forumSpace);
+ return {children};
+}
+
+export function useForumBoardContext(): ForumBoardContextValue {
+ const ctx = useContext(ForumBoardContext);
+ if (!ctx) {
+ throw new Error('useForumBoardContext must be used within ForumBoardProvider');
+ }
+ return ctx;
+}
+
+export function useForumBoardContextOptionally(): ForumBoardContextValue | null {
+ return useContext(ForumBoardContext);
+}
diff --git a/src/app/features/forum/ForumBoardDetail.tsx b/src/app/features/forum/ForumBoardDetail.tsx
new file mode 100644
index 0000000..01a3cf8
--- /dev/null
+++ b/src/app/features/forum/ForumBoardDetail.tsx
@@ -0,0 +1,93 @@
+import React, { useEffect, useRef } from 'react';
+import { Box, Icon, Icons, Text } from 'folds';
+import { useSetAtom } from 'jotai';
+import { Page, PageContent } from '../../components/page';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
+import { ThreadView } from '../room/ThreadView';
+import { RoomInput } from '../room/RoomInput';
+import { useEditor } from '../../components/editor';
+import { activeThreadIdAtomFamily } from '../../state/activeThread';
+import { useForumBoardContext } from './ForumBoardContext';
+import * as css from './ForumBoardView.css';
+
+/** Thread detail + composer (main pane); post list lives in ForumFeedSidebar. */
+export function ForumBoardDetail() {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const detailScrollRef = useRef(null);
+
+ const {
+ forumSpace,
+ query,
+ selectedPostId,
+ activeTopicRoomId,
+ error,
+ } = useForumBoardContext();
+
+ const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
+ const topicPowerLevels = usePowerLevels(activeTopicRoom ?? forumSpace);
+ const setActiveThreadId = useSetAtom(
+ activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
+ );
+
+ const topicSelected = Boolean(query.topic);
+
+ useEffect(() => {
+ if (selectedPostId && activeTopicRoomId) {
+ setActiveThreadId(selectedPostId);
+ return () => setActiveThreadId(undefined);
+ }
+ setActiveThreadId(undefined);
+ return undefined;
+ }, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
+
+ const showComposer = Boolean(activeTopicRoom && topicSelected);
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {selectedPostId && activeTopicRoom ? (
+
+
+
+ ) : (
+
+
+
+ {topicSelected
+ ? 'Select a post from the list'
+ : 'Choose a topic in the sidebar'}
+
+
+ )}
+
+ {showComposer && activeTopicRoom && (
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumBoardView.css.ts b/src/app/features/forum/ForumBoardView.css.ts
new file mode 100644
index 0000000..1377964
--- /dev/null
+++ b/src/app/features/forum/ForumBoardView.css.ts
@@ -0,0 +1,319 @@
+import { style } from '@vanilla-extract/css';
+import { color, config } from 'folds';
+
+const border = color.Surface.Container;
+const panelBg = color.Surface.Container;
+
+export const ForumBoardRoot = style({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minHeight: 0,
+ overflow: 'hidden',
+});
+
+export const ForumBoardGrid = style({
+ display: 'grid',
+ flex: 1,
+ minHeight: 0,
+ gap: config.space.S300,
+ padding: config.space.S300,
+ gridTemplateColumns: 'minmax(300px, 400px) minmax(0, 1fr)',
+ gridTemplateRows: 'auto auto minmax(0, 1fr)',
+});
+
+export const ForumFiltersCard = style({
+ gridColumn: '1',
+ gridRow: '1',
+ padding: config.space.S300,
+ borderRadius: config.radii.R400,
+ border: `1px solid ${border}`,
+ backgroundColor: panelBg,
+});
+
+export const ForumListCard = style({
+ gridColumn: '1',
+ gridRow: '2 / 4',
+ display: 'flex',
+ flexDirection: 'column',
+ minHeight: 0,
+ minWidth: 0,
+ overflow: 'hidden',
+ padding: config.space.S300,
+ borderRadius: config.radii.R400,
+ border: `1px solid ${border}`,
+ backgroundColor: panelBg,
+});
+
+export const ForumDetailPanel = style({
+ gridColumn: '2',
+ gridRow: '1 / 4',
+ display: 'flex',
+ flexDirection: 'column',
+ minHeight: 0,
+ minWidth: 0,
+ overflow: 'hidden',
+ borderRadius: config.radii.R400,
+ border: `1px solid ${border}`,
+ backgroundColor: panelBg,
+});
+
+export const ForumFilters = style({
+ display: 'grid',
+ gap: config.space.S200,
+ gridTemplateColumns: 'minmax(0, 1.3fr) minmax(0, 1.3fr) minmax(0, 1fr) auto',
+ alignItems: 'center',
+});
+
+export const ForumFilterSelect = style({
+ fontSize: '0.88rem',
+ height: '2rem',
+ minWidth: 0,
+ width: '100%',
+ padding: '0.2rem 0.5rem',
+ borderRadius: config.radii.R300,
+ border: `1px solid ${border}`,
+ background: 'transparent',
+ color: 'inherit',
+});
+
+export const ForumSearchField = style({
+ gridColumn: '1 / -1',
+});
+
+export const ForumSearchRow = style({
+ display: 'grid',
+ gridTemplateColumns: 'minmax(0, 1fr) auto',
+ gap: config.space.S100,
+ alignItems: 'center',
+ borderBottom: `1px solid ${border}`,
+ paddingBottom: config.space.S100,
+});
+
+export const ForumSearchInput = style({
+ background: 'transparent',
+ border: 0,
+ outline: 'none',
+ fontSize: '0.88rem',
+ height: '2rem',
+ width: '100%',
+ color: 'inherit',
+});
+
+export const ForumSortCycleBtn = style({
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ minWidth: '2.25rem',
+ height: '2rem',
+ padding: config.space.S100,
+ border: 0,
+ background: 'transparent',
+ color: 'inherit',
+ cursor: 'pointer',
+ borderRadius: config.radii.R300,
+ selectors: {
+ '&:hover': {
+ color: color.Primary.Main,
+ },
+ },
+});
+
+export const ForumNewPostBox = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: config.space.S300,
+ justifyContent: 'flex-end',
+ marginBottom: config.space.S300,
+ flexShrink: 0,
+});
+
+export const ForumSessionUser = style({
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: config.space.S200,
+ marginRight: 'auto',
+ minWidth: 0,
+});
+
+export const ForumPostScroll = style({
+ flex: '1 1 0',
+ minHeight: 0,
+ overflowY: 'auto',
+ overflowX: 'hidden',
+});
+
+export const ForumPostList = style({
+ display: 'grid',
+ gap: 0,
+ listStyle: 'none',
+ margin: 0,
+ padding: 0,
+});
+
+export const ForumPostItem = style({
+ borderTop: `1px solid ${border}`,
+ selectors: {
+ '&:first-child': {
+ borderTop: 0,
+ },
+ },
+});
+
+export const ForumPostLink = style({
+ display: 'grid',
+ rowGap: config.space.S100,
+ width: '100%',
+ padding: `${config.space.S300} ${config.space.S200}`,
+ boxSizing: 'border-box',
+ border: 0,
+ background: 'transparent',
+ color: 'inherit',
+ textAlign: 'left',
+ cursor: 'pointer',
+ font: 'inherit',
+ selectors: {
+ '&:hover': {
+ backgroundColor: color.Primary.Container,
+ },
+ '&[data-active="true"]': {
+ backgroundColor: color.Primary.Container,
+ },
+ '&[data-pinned="true"]': {
+ backgroundColor: color.Secondary.Container,
+ },
+ },
+});
+
+export const ForumPostHead = style({
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ gap: config.space.S200,
+ minWidth: 0,
+});
+
+export const ForumPostHeadMain = style({
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: config.space.S200,
+ flex: 1,
+ minWidth: 0,
+});
+
+export const ForumPostTitle = style({
+ flex: 1,
+ minWidth: 0,
+ fontSize: '1.02rem',
+ fontWeight: 700,
+ lineHeight: 1.3,
+ wordBreak: 'break-word',
+});
+
+export const ForumPostFoot = style({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: config.space.S200,
+ minWidth: 0,
+});
+
+export const ForumPostAuthor = style({
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: config.space.S100,
+ flex: 1,
+ minWidth: 0,
+ fontSize: '0.88rem',
+ color: color.Surface.OnContainer,
+});
+
+export const ForumAuthorName = style({
+ fontWeight: 600,
+});
+
+export const ForumAuthorServer = style({
+ color: color.Surface.OnContainer,
+ opacity: 0.75,
+ fontSize: '0.8rem',
+});
+
+export const ForumPostMeta = style({
+ display: 'flex',
+ alignItems: 'center',
+ flexShrink: 0,
+ gap: config.space.S200,
+ fontSize: '0.8rem',
+ color: color.Surface.OnContainer,
+ opacity: 0.85,
+ whiteSpace: 'nowrap',
+});
+
+export const ForumCategoryLabel = style({
+ textTransform: 'lowercase',
+});
+
+export const ForumReplyMeta = style({
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.2rem',
+ flexShrink: 0,
+});
+
+export const ForumReplyCount = style({
+ fontSize: '0.8rem',
+ fontWeight: 600,
+});
+
+export const ForumListFooter = style({
+ borderTop: `1px solid ${border}`,
+ padding: config.space.S300,
+ textAlign: 'center',
+ flexShrink: 0,
+});
+
+export const ForumLoadMoreBtn = style({
+ width: '100%',
+});
+
+export const ForumListLoading = style({
+ padding: config.space.S400,
+});
+
+export const ForumDetailScroll = style({
+ flex: 1,
+ minHeight: 0,
+ display: 'flex',
+ flexDirection: 'column',
+});
+
+export const ForumPageNav = style({
+ width: 'min(400px, 38vw)',
+ minWidth: '300px',
+ maxWidth: '420px',
+});
+
+export const ForumFeedSidebar = style({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minHeight: 0,
+ gap: config.space.S200,
+});
+
+export const ForumDetailOnlyRoot = style({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minHeight: 0,
+ height: '100%',
+});
+
+export const ForumDetailPanelFull = style({
+ flex: 1,
+ minHeight: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+});
diff --git a/src/app/features/forum/ForumBoardView.tsx b/src/app/features/forum/ForumBoardView.tsx
new file mode 100644
index 0000000..16c0729
--- /dev/null
+++ b/src/app/features/forum/ForumBoardView.tsx
@@ -0,0 +1,164 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { Box, Icon, Icons, Text } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { useSetAtom } from 'jotai';
+import { Page, PageContent } from '../../components/page';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
+import { ThreadView } from '../room/ThreadView';
+import { RoomInput } from '../room/RoomInput';
+import { useEditor } from '../../components/editor';
+import { activeThreadIdAtomFamily } from '../../state/activeThread';
+import { useForumBoard, type ForumBoardScope } from './useForumBoard';
+import { ForumFilterBar } from './ForumFilterBar';
+import { ForumPostList } from './ForumPostList';
+import { ForumNewPostBox } from './ForumNewPostBox';
+import type { ForumPublishedPost } from './types';
+import * as css from './ForumBoardView.css';
+
+type ForumBoardViewProps = {
+ forumSpace: Room;
+ scope: ForumBoardScope;
+};
+
+export function ForumBoardView({ forumSpace, scope }: ForumBoardViewProps) {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const detailScrollRef = useRef(null);
+ const {
+ sections,
+ threads,
+ query,
+ loadingSections,
+ loadingThreads,
+ error,
+ selectedPostId,
+ activeTopicRoomId,
+ setQueryParam,
+ setCategory,
+ selectPost,
+ loadMorePosts,
+ hasMorePosts,
+ loadingMore,
+ insertThreadFromNewPost,
+ recordThreadReply,
+ } = useForumBoard(scope, forumSpace);
+
+ const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
+ const topicPowerLevels = usePowerLevels(activeTopicRoom ?? forumSpace);
+ const setActiveThreadId = useSetAtom(
+ activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
+ );
+
+ const topicSelected = Boolean(query.topic || scope.topicRoomId);
+
+ useEffect(() => {
+ if (selectedPostId && activeTopicRoomId) {
+ setActiveThreadId(selectedPostId);
+ return () => setActiveThreadId(undefined);
+ }
+ setActiveThreadId(undefined);
+ return undefined;
+ }, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
+
+ const getTopicRoom = useCallback(
+ (roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
+ [mx]
+ );
+
+ const handlePostPublished = useCallback(
+ (published: ForumPublishedPost) => {
+ insertThreadFromNewPost(published);
+ selectPost(published.eventId, published.topicRoomId);
+ detailScrollRef.current?.scrollIntoView({ behavior: 'smooth' });
+ },
+ [insertThreadFromNewPost, selectPost]
+ );
+
+ const showComposer = Boolean(activeTopicRoom && (!selectedPostId || topicSelected));
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedPostId && activeTopicRoom ? (
+
+
+
+ ) : (
+
+
+
+ {topicSelected
+ ? 'Select a post or create a new one'
+ : 'Select a topic to browse posts'}
+
+
+ )}
+
+ {showComposer && activeTopicRoom && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumChatComposer.tsx b/src/app/features/forum/ForumChatComposer.tsx
new file mode 100644
index 0000000..36c4aa2
--- /dev/null
+++ b/src/app/features/forum/ForumChatComposer.tsx
@@ -0,0 +1,191 @@
+import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
+import { isKeyHotkey } from 'is-hotkey';
+import { Editor } from 'slate';
+import { ReactEditor } from 'slate-react';
+import { Icon, IconButton, Icons, Line, PopOut } from 'folds';
+import {
+ CustomEditor,
+ Toolbar,
+ EmoticonAutocomplete,
+ AutocompletePrefix,
+ AutocompleteQuery,
+ getAutocompleteQuery,
+ createEmoticonElement,
+ moveCursor,
+} from '../../components/editor';
+import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { useImagePackRooms } from '../../hooks/useImagePackRooms';
+import { useAtomValue } from 'jotai';
+import { roomToParentsAtom } from '../../state/room/roomToParents';
+import { Room } from 'matrix-js-sdk';
+import { mobileOrTablet } from '../../utils/user-agent';
+import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
+
+type ForumChatComposerProps = {
+ editor: Editor;
+ roomId: string;
+ room: Room;
+ placeholder?: string;
+ onSend?: () => void;
+ showAttachButton?: boolean;
+ maxHeight?: string;
+};
+
+/** Same chrome as {@link RoomInput} (attach, emoji, toolbar, send). */
+export function ForumChatComposer({
+ editor,
+ roomId,
+ room,
+ placeholder = 'Send a message…',
+ onSend,
+ showAttachButton = false,
+ maxHeight,
+}: ForumChatComposerProps) {
+ const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
+ const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const imagePackRooms = useImagePackRooms(roomId, roomToParents);
+ const emojiBtnRef = useRef(null);
+ const [autocompleteQuery, setAutocompleteQuery] =
+ useState>();
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (
+ onSend &&
+ (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt)))
+ ) {
+ evt.preventDefault();
+ onSend();
+ }
+ },
+ [enterForNewline, onSend]
+ );
+
+ const handleKeyUp: KeyboardEventHandler = useCallback(
+ (evt) => {
+ const prevWordRange = getAutocompleteQuery(editor, evt);
+ if (prevWordRange?.prefix === AutocompletePrefix.Emoticon) {
+ setAutocompleteQuery({ prefix: AutocompletePrefix.Emoticon, range: prevWordRange.range });
+ return;
+ }
+ setAutocompleteQuery(undefined);
+ },
+ [editor]
+ );
+
+ const handleEmoticonSelect = (key: string, shortcode: string) => {
+ editor.insertNode(createEmoticonElement(key, shortcode));
+ moveCursor(editor);
+ };
+
+ const handleCloseAutocomplete = useCallback(() => {
+ setAutocompleteQuery(undefined);
+ ReactEditor.focus(editor);
+ }, [editor]);
+
+ return (
+
+ {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+
+ )}
+
: undefined}
+ after={
+ <>
+
setToolbar(!toolbar)}
+ aria-label="Formatting"
+ >
+
+
+
+ {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
+ {
+ setEmojiBoardTab((t) => {
+ if (t) {
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ return undefined;
+ }
+ return t;
+ });
+ }}
+ />
+ }
+ >
+ setEmojiBoardTab(EmojiBoardTab.Emoji)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ type="button"
+ aria-label="Emoji"
+ >
+
+
+
+ )}
+
+
+ {onSend && (
+
+
+
+ )}
+ >
+ }
+ bottom={
+ toolbar ? (
+
+
+
+
+ ) : undefined
+ }
+ />
+
+ );
+}
diff --git a/src/app/features/forum/ForumFeedSidebar.tsx b/src/app/features/forum/ForumFeedSidebar.tsx
new file mode 100644
index 0000000..6439b7e
--- /dev/null
+++ b/src/app/features/forum/ForumFeedSidebar.tsx
@@ -0,0 +1,185 @@
+import React, { useCallback } from 'react';
+import { Box, Text } from 'folds';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getSpaceLobbyPath } from '../../pages/pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { useSpace } from '../../hooks/useSpace';
+import {
+ NavCategory,
+ NavCategoryHeader,
+ NavItem,
+ NavItemContent,
+ NavLink,
+} from '../../components/nav';
+import { RoomNavCategoryButton } from '../room-nav';
+import { useCategoryHandler } from '../../hooks/useCategoryHandler';
+import { makeNavCategoryId } from '../../state/closedNavCategories';
+import { useAtom } from 'jotai';
+import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
+import { useForumBoardContext } from './ForumBoardContext';
+import type { ForumPublishedPost } from './types';
+import { ForumFilterBar } from './ForumFilterBar';
+import { ForumNewPostBox } from './ForumNewPostBox';
+import { ForumPostList } from './ForumPostList';
+import * as css from './ForumBoardView.css';
+
+/**
+ * matrixsso-style left column: category/topic nav + filters + post list.
+ * Renders inside the space PageNav instead of Lobby / ROOMS.
+ */
+export function ForumFeedSidebar() {
+ const mx = useMatrixClient();
+ const space = useSpace();
+ const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
+ const lobbyPath = getSpaceLobbyPath(spaceIdOrAlias);
+
+ const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+ const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+ closedCategories.has(categoryId)
+ );
+
+ const {
+ sections,
+ threads,
+ query,
+ loadingSections,
+ loadingThreads,
+ error,
+ selectedPostId,
+ setQueryParam,
+ selectPost,
+ loadMorePosts,
+ hasMorePosts,
+ loadingMore,
+ insertThreadFromNewPost,
+ } = useForumBoardContext();
+
+ const topicSelected = Boolean(query.topic);
+ const lobbyBase = lobbyPath;
+
+ const buildLobbyTo = useCallback(
+ (params: Record) => {
+ const sp = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v) sp.set(k, v);
+ });
+ const q = sp.toString();
+ return q ? `${lobbyBase}?${q}` : lobbyBase;
+ },
+ [lobbyBase]
+ );
+
+ const getTopicRoom = useCallback(
+ (roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
+ [mx]
+ );
+
+ const handlePostPublished = useCallback(
+ (published: ForumPublishedPost) => {
+ insertThreadFromNewPost(published);
+ selectPost(published.eventId, published.topicRoomId);
+ },
+ [insertThreadFromNewPost, selectPost]
+ );
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ All posts
+
+
+
+
+
+ {sections.map((section) => {
+ const categoryId = makeNavCategoryId(space.roomId, `forum-${section.title}`);
+ const closed = closedCategories.has(categoryId);
+ const categoryActive = query.category === section.title;
+
+ return (
+
+
+
+ {section.title}
+
+
+ {!closed && (
+ <>
+
+
+
+
+ All topics
+
+
+
+
+ {section.topics.map((topic) => (
+
+
+
+
+ {topic.name}
+
+
+
+
+ ))}
+ >
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumFilterBar.tsx b/src/app/features/forum/ForumFilterBar.tsx
new file mode 100644
index 0000000..bf0a89b
--- /dev/null
+++ b/src/app/features/forum/ForumFilterBar.tsx
@@ -0,0 +1,151 @@
+import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
+import { Icon, IconButton, Text, Tooltip, TooltipProvider } from 'folds';
+import type { ForumBoardQuery, ForumSection, ForumThreadSummary } from './types';
+import { ForumSortIcons } from './forumLucideIcons';
+import { ForumTopicSearchInput } from './ForumTopicSearchInput';
+import * as t from './forumTheme.css';
+
+const SORT_CYCLE: NonNullable[] = ['hot', 'new', 'top'];
+const SORT_ICON = {
+ hot: ForumSortIcons.hot,
+ new: ForumSortIcons.new,
+ top: ForumSortIcons.top,
+} as const;
+const SORT_TITLE: Record, string> = {
+ hot: 'Hot — sort by activity',
+ new: 'New — newest first',
+ top: 'Top — most replies',
+};
+
+type ForumFilterBarProps = {
+ sections: ForumSection[];
+ threads: ForumThreadSummary[];
+ query: ForumBoardQuery;
+ hideCategoryTopic?: boolean;
+ onQueryChange: (key: string, value: string | null) => void;
+ onCategoryChange?: (category: string | null) => void;
+};
+
+export function ForumFilterBar({
+ sections,
+ threads,
+ query,
+ hideCategoryTopic = false,
+ onQueryChange,
+ onCategoryChange,
+}: ForumFilterBarProps) {
+ const categoryTopics = query.category
+ ? sections.find((s) => s.title === query.category)?.topics ?? []
+ : sections.flatMap((s) => s.topics);
+
+ const [searchDraft, setSearchDraft] = useState(query.q || '');
+
+ useEffect(() => {
+ setSearchDraft(query.q || '');
+ }, [query.q]);
+
+ const applySearch = useCallback(() => {
+ const trimmed = searchDraft.trim();
+ onQueryChange('q', trimmed ? trimmed : null);
+ }, [onQueryChange, searchDraft]);
+
+ const handleSearchSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ applySearch();
+ };
+
+ const cycleSort = useCallback(() => {
+ const current = query.sort || 'hot';
+ const idx = SORT_CYCLE.indexOf(current);
+ const next = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
+ onQueryChange('sort', next);
+ }, [query.sort, onQueryChange]);
+
+ const sort = query.sort || 'hot';
+
+ const handleCategoryChange = (value: string) => {
+ const category = value || null;
+ if (onCategoryChange) {
+ onCategoryChange(category);
+ return;
+ }
+ onQueryChange('category', category);
+ if (query.topic) {
+ onQueryChange('topic', null);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/features/forum/ForumMessageActions.tsx b/src/app/features/forum/ForumMessageActions.tsx
new file mode 100644
index 0000000..11beca5
--- /dev/null
+++ b/src/app/features/forum/ForumMessageActions.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { Icon, Icons } from 'folds';
+import * as theme from './forumTheme.css';
+
+type ForumMessageActionsProps = {
+ onReply: () => void;
+ replyActive?: boolean;
+};
+
+export function ForumMessageActions({ onReply, replyActive }: ForumMessageActionsProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumMessageBody.tsx b/src/app/features/forum/ForumMessageBody.tsx
new file mode 100644
index 0000000..e051615
--- /dev/null
+++ b/src/app/features/forum/ForumMessageBody.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Text } from 'folds';
+import * as theme from './forumTheme.css';
+
+type ForumMessageBodyProps = {
+ body: string;
+ bodyHtml?: string;
+};
+
+export function ForumMessageBody({ body, bodyHtml }: ForumMessageBodyProps) {
+ const plain = body.trim();
+ if (bodyHtml) {
+ return (
+
+ );
+ }
+ if (!plain) return null;
+ return (
+
+ {body}
+
+ );
+}
diff --git a/src/app/features/forum/ForumNewPostBox.tsx b/src/app/features/forum/ForumNewPostBox.tsx
new file mode 100644
index 0000000..e82ed7c
--- /dev/null
+++ b/src/app/features/forum/ForumNewPostBox.tsx
@@ -0,0 +1,68 @@
+import React, { useCallback, useState } from 'react';
+import { Box, Button, Icon, Icons, Text } from 'folds';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import type { ForumPost, ForumPublishedPost } from './types';
+import { ForumNewPostModal } from './ForumNewPostModal';
+import * as t from './forumTheme.css';
+
+function formatShowingPosts(count: number, loading: boolean, hasMore: boolean): string {
+ if (loading && count === 0) {
+ return 'Loading posts…';
+ }
+ const suffix = hasMore && count > 0 ? '+' : '';
+ const noun = count === 1 ? 'post' : 'posts';
+ return `Showing ${count}${suffix} ${noun}`;
+}
+
+type ForumNewPostBoxProps = {
+ sections: ForumSection[];
+ postCount: number;
+ loading?: boolean;
+ hasMore?: boolean;
+ defaultCategory?: string;
+ defaultTopicRoomId?: string;
+ onPublished?: (published: ForumPublishedPost) => void;
+};
+
+export function ForumNewPostBox({
+ sections,
+ postCount,
+ loading = false,
+ hasMore = false,
+ defaultCategory,
+ defaultTopicRoomId,
+ onPublished,
+}: ForumNewPostBoxProps) {
+ const mx = useMatrixClient();
+ const [modalOpen, setModalOpen] = useState(false);
+ const userId = mx.getUserId();
+
+ const handleOpen = useCallback(() => {
+ setModalOpen(true);
+ }, []);
+
+ return (
+ <>
+
+
+ {formatShowingPosts(postCount, loading, hasMore)}
+
+ {userId ? (
+
+ ) : null}
+
+ {modalOpen && userId && (
+ setModalOpen(false)}
+ onPublished={onPublished}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/app/features/forum/ForumNewPostModal.tsx b/src/app/features/forum/ForumNewPostModal.tsx
new file mode 100644
index 0000000..a0aaa50
--- /dev/null
+++ b/src/app/features/forum/ForumNewPostModal.tsx
@@ -0,0 +1,268 @@
+import React, { FormEventHandler, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { isKeyHotkey } from 'is-hotkey';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Line,
+ Scroll,
+ Text,
+ color,
+ config,
+ toRem,
+} from 'folds';
+import {
+ CustomEditor,
+ Toolbar,
+ isEmptyEditor,
+ resetEditor,
+ resetEditorHistory,
+ toMatrixCustomHTML,
+ toPlainText,
+ trimCustomHtml,
+ useEditor,
+} from '../../components/editor';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { stopPropagation } from '../../utils/keyboard';
+import { sendForumPost } from './forumFeed';
+import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
+import type { ForumPublishedPost, ForumSection } from './types';
+import { ForumTopicTargetFields } from './ForumTopicTargetFields';
+import * as t from './forumTheme.css';
+
+type ForumNewPostModalProps = {
+ sections: ForumSection[];
+ initialCategory?: string;
+ initialTopicRoomId?: string;
+ onClose: () => void;
+ onPublished?: (published: ForumPublishedPost) => void;
+};
+
+export function ForumNewPostModal({
+ sections,
+ initialCategory = '',
+ initialTopicRoomId = '',
+ onClose,
+ onPublished,
+}: ForumNewPostModalProps) {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const formRef = useRef(null);
+ const [title, setTitle] = useState('');
+ const [category, setCategory] = useState(initialCategory);
+ const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+
+ const categoryTopics = useMemo(() => {
+ if (category) {
+ return sections.find((s) => s.title === category)?.topics ?? [];
+ }
+ return sections.flatMap((s) => s.topics);
+ }, [category, sections]);
+
+ useEffect(() => {
+ if (!topicRoomId) return;
+ if (!categoryTopics.some((topic) => topic.roomId === topicRoomId)) {
+ setTopicRoomId('');
+ }
+ }, [categoryTopics, topicRoomId]);
+
+ const handleClose = useCallback(() => {
+ if (sending) return;
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ onClose();
+ }, [editor, onClose, sending]);
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (evt) => {
+ evt.preventDefault();
+ if (sending) return;
+
+ const trimmedTitle = title.trim();
+ const plainText = toPlainText(editor.children, false).trim();
+ const formattedHtml = trimCustomHtml(
+ toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
+ );
+
+ if (!topicRoomId) {
+ setError('Select a topic for this post.');
+ return;
+ }
+ if (!trimmedTitle) {
+ setError('Post title is required.');
+ return;
+ }
+ if (isEmptyEditor(editor)) {
+ setError('Post body is required.');
+ return;
+ }
+
+ setSending(true);
+ setError(null);
+ try {
+ const eventId = await sendForumPost(mx, topicRoomId, {
+ title: trimmedTitle,
+ plainText,
+ formattedHtml,
+ });
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ onPublished?.({
+ eventId,
+ topicRoomId,
+ title: trimmedTitle,
+ plainText,
+ formattedHtml,
+ });
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to publish post');
+ } finally {
+ setSending(false);
+ }
+ },
+ [editor, mx, onClose, onPublished, sending, title, topicRoomId]
+ );
+
+ const handleEditorKeyDown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isKeyHotkey('mod+enter', evt) && !sending) {
+ evt.preventDefault();
+ evt.currentTarget.closest('form')?.requestSubmit();
+ }
+ },
+ [sending]
+ );
+
+ const handleCategoryChange = useCallback((next: string) => {
+ setCategory(next);
+ setTopicRoomId('');
+ }, []);
+
+ return (
+ }>
+
+
+
+
+
+
+
+
+ setTitle(e.target.value)}
+ maxLength={140}
+ required
+ autoFocus
+ placeholder="Title"
+ aria-label="Title"
+ autoComplete="off"
+ disabled={sending}
+ />
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumPostAuthor.tsx b/src/app/features/forum/ForumPostAuthor.tsx
new file mode 100644
index 0000000..775939a
--- /dev/null
+++ b/src/app/features/forum/ForumPostAuthor.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { Room } from 'matrix-js-sdk';
+import { ForumAuthorIdentity } from './ForumAuthorIdentity';
+import * as theme from './forumTheme.css';
+
+export function ForumPostAuthor({
+ room,
+ sender,
+ displayName,
+}: {
+ room: Room | null;
+ sender: string;
+ displayName?: string;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/features/forum/ForumPostList.tsx b/src/app/features/forum/ForumPostList.tsx
new file mode 100644
index 0000000..515a83c
--- /dev/null
+++ b/src/app/features/forum/ForumPostList.tsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import type { ForumThreadSummary } from './types';
+import { formatForumTimeAgo } from './forumTime';
+import { ForumPostAuthor } from './ForumPostAuthor';
+import * as t from './forumTheme.css';
+
+function categoryTopicLabel(thread: ForumThreadSummary): string {
+ const section = thread.sectionTitle || '';
+ const topic = thread.topicName || '';
+ if (section && topic) return `${section}/${topic}`;
+ if (topic) return topic;
+ if (section) return `${section}/all topics`;
+ return 'all categories / all topics';
+}
+
+type ForumPostListProps = {
+ threads: ForumThreadSummary[];
+ selectedPostId: string | null;
+ loading: boolean;
+ getTopicRoom: (roomId: string | undefined) => Room | null;
+ onSelectPost: (eventId: string, topicRoomId?: string) => void;
+ hasMore?: boolean;
+ loadingMore?: boolean;
+ onLoadMore?: () => void;
+};
+
+export function ForumPostList({
+ threads,
+ selectedPostId,
+ loading,
+ getTopicRoom,
+ onSelectPost,
+ hasMore = false,
+ loadingMore = false,
+ onLoadMore,
+}: ForumPostListProps) {
+ return (
+ <>
+
+ {loading && threads.length === 0 && (
+
+
+
+ Loading posts…
+
+
+ )}
+ {!loading && threads.length === 0 && (
+
+ No threads match these filters.
+
+ )}
+
+ {threads.map((thread) => {
+ const isActive = thread.eventId === selectedPostId;
+ const topicRoom = getTopicRoom(thread.topicRoomId);
+ return (
+ -
+
+
+ );
+ })}
+
+
+ {hasMore && onLoadMore && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/app/features/forum/ForumRichReplyComposer.tsx b/src/app/features/forum/ForumRichReplyComposer.tsx
new file mode 100644
index 0000000..5d73f14
--- /dev/null
+++ b/src/app/features/forum/ForumRichReplyComposer.tsx
@@ -0,0 +1,119 @@
+import React, { FormEventHandler, KeyboardEventHandler, useCallback, useState } from 'react';
+import { isKeyHotkey } from 'is-hotkey';
+import { Button, Line, Text, color, toRem } from 'folds';
+import {
+ CustomEditor,
+ Toolbar,
+ isEmptyEditor,
+ resetEditor,
+ resetEditorHistory,
+ toMatrixCustomHTML,
+ toPlainText,
+ trimCustomHtml,
+ useEditor,
+} from '../../components/editor';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { sendForumReply, createForumReplyFromPayload } from './forumFeed';
+import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
+import type { ForumPost } from './types';
+import * as t from './forumTheme.css';
+
+type ForumRichReplyComposerProps = {
+ roomId: string;
+ threadRootId: string;
+ replyToEventId: string;
+ onSent?: (reply: ForumPost) => void;
+ onCancel?: () => void;
+};
+
+export function ForumRichReplyComposer({
+ roomId,
+ threadRootId,
+ replyToEventId,
+ onSent,
+ onCancel,
+}: ForumRichReplyComposerProps) {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState
(null);
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (evt) => {
+ evt.preventDefault();
+ if (sending || isEmptyEditor(editor)) return;
+
+ const plainText = toPlainText(editor.children, false).trim();
+ const formattedHtml = trimCustomHtml(
+ toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
+ );
+ if (!plainText) return;
+
+ setSending(true);
+ setError(null);
+ try {
+ const eventId = await sendForumReply(mx, roomId, threadRootId, replyToEventId, {
+ plainText,
+ formattedHtml,
+ });
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ onSent?.(
+ createForumReplyFromPayload(mx, roomId, eventId, replyToEventId, {
+ plainText,
+ formattedHtml,
+ })
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to send reply');
+ } finally {
+ setSending(false);
+ }
+ },
+ [editor, mx, onSent, replyToEventId, roomId, sending, threadRootId]
+ );
+
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isKeyHotkey('mod+enter', evt) && !sending) {
+ evt.preventDefault();
+ evt.currentTarget.closest('form')?.requestSubmit();
+ }
+ },
+ [sending]
+ );
+
+ return (
+
+ {error && (
+