feat: Integrate forum layout support across various components and enhance room handling logic

This commit is contained in:
2026-07-06 01:06:20 +10:00
parent d1626e3585
commit c57797ffe4
62 changed files with 6876 additions and 385 deletions

View File

@@ -85,6 +85,8 @@ type CustomEditorProps = {
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
/** Stretch the editable area to fill the parent (e.g. post modal). */
fillHeight?: boolean;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
@@ -101,6 +103,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
before,
after,
maxHeight = '50vh',
fillHeight = false,
editor,
placeholder,
onKeyDown,
@@ -223,11 +226,27 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[]
);
const scrollStyle = fillHeight
? { flex: '1 1 auto', minHeight: maxHeight, height: '100%' }
: { maxHeight };
return (
<div className={css.Editor} ref={ref}>
<div
className={css.Editor}
ref={ref}
style={
fillHeight
? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: maxHeight }
: undefined
}
>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
<Box
alignItems="Start"
grow={fillHeight ? 'Yes' : undefined}
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
>
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
@@ -236,7 +255,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
style={scrollStyle}
size="300"
visibility="Hover"
hideTrack

View File

@@ -28,14 +28,18 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = {
children: ReactNode;
};
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
export function PageNav({
size,
children,
className,
}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={css.PageNav({ size })}
className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box grow="Yes" direction="Column">

View File

@@ -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>(
<Text size="L400">Space</Text>
</Badge>
)}
{isForum && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Forum</Text>
</Badge>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<RoomCardName>{roomName}</RoomCardName>

View File

@@ -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',
});

View File

@@ -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 (
<div
className={classNames(
isMenu ? css.SpeechBubbleMenu : css.SpeechBubble,
tail === 'top' ? (isMenu ? css.SpeechBubbleMenuTailTop : css.SpeechBubbleTailTop) : undefined,
className
)}
style={{
...(tailX ? ({ ['--speech-tail-x' as string]: tailX } as React.CSSProperties) : null),
...style,
}}
>
{children}
</div>
);
}

View File

@@ -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
}
/>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Forum Layout"
description="Creates this room with m.forum type so Paarrot can apply forum-specific UI."
after={
<Switch
variant="Primary"
value={forumLayout}
onChange={setForumLayout}
disabled={disabled}
/>
}
/>
</SequenceCard>
{advance && (allowKnock || allowKnockRestricted) && (
<SequenceCard
style={{ padding: config.space.S300 }}

View File

@@ -1,5 +1,6 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import React, { FormEventHandler, MouseEventHandler, useCallback, useEffect, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
@@ -9,6 +10,10 @@ import {
Icon,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Switch,
Text,
@@ -38,7 +43,9 @@ import {
RoomVersionSelector,
useAdditionalCreators,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { stopPropagation } from '../../utils/keyboard';
import { shouldShowForumLobby } from '../../utils/room';
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
@@ -46,6 +53,11 @@ const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
return Icons.SpaceGlobe;
};
const spaceTypeName: Record<RoomType.Space | RoomType.Forum, string> = {
[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 | RoomType.Forum>(RoomType.Space);
const [typeMenuAnchor, setTypeMenuAnchor] = useState<RectCords>();
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<HTMLButtonElement> = (evt) => {
setTypeMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleTypeSelect = (type: RoomType.Space | RoomType.Forum) => {
setSpaceType(type);
setTypeMenuAnchor(undefined);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (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}
/>
</Box>
{!parentIsForum && (
<Box direction="Column" gap="100">
<Text size="L400">Type</Text>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
title="Space Type"
description="Choose standard space navigation or forum-style space behavior."
after={
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleOpenTypeMenu}
disabled={disabled}
>
<Text size="T300">{spaceTypeName[spaceType]}</Text>
</Button>
<PopOut
anchor={typeMenuAnchor}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTypeMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant={spaceType === RoomType.Space ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleTypeSelect(RoomType.Space)}
>
<Text size="T300">Space</Text>
</MenuItem>
<MenuItem
size="300"
variant={spaceType === RoomType.Forum ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleTypeSelect(RoomType.Forum)}
>
<Text size="T300">Forum Space</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
}
/>
</SequenceCard>
</Box>
)}
{parentIsForum && (
<Text size="T200" priority="300">
Creates a forum category (subspace). Add topics inside it with Add Topic after selecting the
category in the filter bar.
</Text>
)}
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { Avatar, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { UserAvatar } from '../../components/user-avatar';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import * as theme from './forumTheme.css';
function matrixUserServer(userId: string): string {
const parts = userId.split(':');
return parts.length > 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 (
<div className={identityClass}>
<span className={avatarClass} aria-hidden>
<Avatar size="200" radii="300" shrink="No">
<UserAvatar
userId={sender}
src={avatarUrl}
alt={name}
renderFallback={() => (
<Text size="L400">{name.slice(0, 1).toUpperCase()}</Text>
)}
/>
</Avatar>
</span>
<span className={theme.ForumAuthorMeta}>
<WrapTag className={theme.ForumSenderLabel} title={sender}>
{name}
</WrapTag>
{server ? (
<span className={theme.ForumAuthorServer} title="Matrix homeserver">
{server}
</span>
) : null}
</span>
</div>
);
}

View File

@@ -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 (
<Box grow="Yes" className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
<div style={forumOutletShell}>
<Outlet />
</div>
</Box>
);
}
return (
<PageRoot
nav={
<MobileFriendlyPageNav path={SPACE_PATH}>
<Space />
</MobileFriendlyPageNav>
}
>
<AnimatedOutlet />
</PageRoot>
);
}

View File

@@ -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<typeof useForumBoard>;
const ForumBoardContext = createContext<ForumBoardContextValue | null>(null);
export function ForumBoardProvider({
forumSpace,
scope,
children,
}: {
forumSpace: Room;
scope: ForumBoardScope;
children: React.ReactNode;
}) {
const value = useForumBoard(scope, forumSpace);
return <ForumBoardContext.Provider value={value}>{children}</ForumBoardContext.Provider>;
}
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);
}

View File

@@ -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<HTMLDivElement>(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 (
<Page>
<PageContent>
<Box className={css.ForumDetailOnlyRoot}>
{error && (
<Box style={{ padding: '0.5rem 1rem' }}>
<Text style={{ color: 'var(--fc-critical, #f38ba8)' }}>{error}</Text>
</Box>
)}
<Box ref={detailScrollRef} className={css.ForumDetailPanelFull}>
{selectedPostId && activeTopicRoom ? (
<PowerLevelsContextProvider value={topicPowerLevels}>
<ThreadView room={activeTopicRoom} threadRootId={selectedPostId} />
</PowerLevelsContextProvider>
) : (
<Box grow="Yes" alignItems="Center" justifyContent="Center" direction="Column" gap="200">
<Icon src={Icons.Thread} size="600" />
<Text priority="300">
{topicSelected
? 'Select a post from the list'
: 'Choose a topic in the sidebar'}
</Text>
</Box>
)}
{showComposer && activeTopicRoom && (
<Box
shrink="No"
direction="Column"
style={{ padding: '0.75rem', borderTop: '1px solid var(--fc-surface-container)' }}
>
<PowerLevelsContextProvider value={topicPowerLevels}>
<RoomInput
room={activeTopicRoom}
editor={editor}
roomId={activeTopicRoom.roomId}
fileDropContainerRef={detailScrollRef}
/>
</PowerLevelsContextProvider>
</Box>
)}
</Box>
</Box>
</PageContent>
</Page>
);
}

View File

@@ -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',
});

View File

@@ -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<HTMLDivElement>(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 (
<Page>
<PageContent>
<Box className={css.ForumBoardRoot}>
{error && (
<Box style={{ padding: '0.5rem 1rem' }}>
<Text style={{ color: 'var(--fc-critical, #f38ba8)' }}>{error}</Text>
</Box>
)}
<Box className={css.ForumBoardGrid}>
<Box className={css.ForumFiltersCard}>
<ForumFilterBar
sections={sections}
threads={threads}
query={query}
hideCategoryTopic={Boolean(scope.topicRoomId)}
onQueryChange={setQueryParam}
onCategoryChange={setCategory}
/>
</Box>
<Box className={css.ForumListCard}>
<ForumNewPostBox
sections={sections}
postCount={threads.length}
loading={loadingSections || loadingThreads}
hasMore={hasMorePosts}
defaultCategory={query.category}
defaultTopicRoomId={activeTopicRoomId ?? query.topic}
onPublished={handlePostPublished}
/>
<ForumPostList
threads={threads}
selectedPostId={selectedPostId}
loading={loadingSections || loadingThreads}
getTopicRoom={getTopicRoom}
onSelectPost={selectPost}
hasMore={hasMorePosts}
loadingMore={loadingMore}
onLoadMore={loadMorePosts}
/>
</Box>
<Box className={css.ForumDetailPanel}>
<Box ref={detailScrollRef} className={css.ForumDetailScroll}>
{selectedPostId && activeTopicRoom ? (
<PowerLevelsContextProvider value={topicPowerLevels}>
<ThreadView room={activeTopicRoom} threadRootId={selectedPostId} />
</PowerLevelsContextProvider>
) : (
<Box grow="Yes" alignItems="Center" justifyContent="Center" direction="Column" gap="200">
<Icon src={Icons.Thread} size="600" />
<Text priority="300">
{topicSelected
? 'Select a post or create a new one'
: 'Select a topic to browse posts'}
</Text>
</Box>
)}
{showComposer && activeTopicRoom && (
<Box
shrink="No"
direction="Column"
style={{ padding: '0.75rem', borderTop: '1px solid var(--fc-surface-container)' }}
>
<PowerLevelsContextProvider value={topicPowerLevels}>
<RoomInput
room={activeTopicRoom}
editor={editor}
roomId={activeTopicRoom.roomId}
fileDropContainerRef={detailScrollRef}
/>
</PowerLevelsContextProvider>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
</PageContent>
</Page>
);
}

View File

@@ -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<HTMLButtonElement>(null);
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
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 (
<div>
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editor={editor}
placeholder={placeholder}
maxHeight={maxHeight}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
before={showAttachButton ? <PluginButtonSlot location="composer-actions" /> : undefined}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
type="button"
onClick={() => setToolbar(!toolbar)}
aria-label="Formatting"
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
}
>
<IconButton
ref={emojiBtnRef}
aria-pressed={emojiBoardTab === EmojiBoardTab.Emoji}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
type="button"
aria-label="Emoji"
>
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
<PluginButtonSlot location="text-composer-toolbar" />
{onSend && (
<IconButton
type="button"
onClick={onSend}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Send"
>
<Icon src={Icons.Send} />
</IconButton>
)}
</>
}
bottom={
toolbar ? (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
) : undefined
}
/>
</div>
);
}

View File

@@ -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<string, string | undefined>) => {
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 (
<Box className={css.ForumFeedSidebar} direction="Column" gap="200">
{error && (
<Text size="T200" style={{ color: 'var(--fc-critical, #f38ba8)' }}>
{error}
</Text>
)}
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={!query.topic && !query.category}>
<NavLink to={buildLobbyTo({})}>
<NavItemContent>
<Text size="L400">All posts</Text>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
{sections.map((section) => {
const categoryId = makeNavCategoryId(space.roomId, `forum-${section.title}`);
const closed = closedCategories.has(categoryId);
const categoryActive = query.category === section.title;
return (
<NavCategory key={section.title}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closed}
>
{section.title}
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closed && (
<>
<NavItem variant="Background" radii="400" aria-selected={categoryActive && !query.topic}>
<NavLink to={buildLobbyTo({ category: section.title })}>
<NavItemContent>
<Text size="T200" priority="300">
All topics
</Text>
</NavItemContent>
</NavLink>
</NavItem>
{section.topics.map((topic) => (
<NavItem
key={topic.roomId}
variant="Background"
radii="400"
aria-selected={query.topic === topic.roomId}
>
<NavLink to={buildLobbyTo({ category: section.title, topic: topic.roomId })}>
<NavItemContent>
<Text size="L400" truncate>
{topic.name}
</Text>
</NavItemContent>
</NavLink>
</NavItem>
))}
</>
)}
</NavCategory>
);
})}
<Box className={css.ForumFiltersCard} shrink="No" style={{ padding: 0, border: 'none' }}>
<ForumFilterBar
sections={sections}
threads={threads}
query={query}
hideCategoryTopic
onQueryChange={setQueryParam}
/>
</Box>
<Box className={css.ForumListCard} grow="Yes" style={{ padding: 0, border: 'none', minHeight: 0 }}>
<ForumNewPostBox
sections={sections}
postCount={threads.length}
loading={loadingSections || loadingThreads}
hasMore={hasMorePosts}
defaultCategory={query.category}
defaultTopicRoomId={query.topic}
onPublished={handlePostPublished}
/>
<ForumPostList
threads={threads}
selectedPostId={selectedPostId}
loading={loadingSections || loadingThreads}
getTopicRoom={getTopicRoom}
onSelectPost={selectPost}
hasMore={hasMorePosts}
loadingMore={loadingMore}
onLoadMore={loadMorePosts}
/>
</Box>
</Box>
);
}

View File

@@ -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<ForumBoardQuery['sort']>[] = ['hot', 'new', 'top'];
const SORT_ICON = {
hot: ForumSortIcons.hot,
new: ForumSortIcons.new,
top: ForumSortIcons.top,
} as const;
const SORT_TITLE: Record<NonNullable<ForumBoardQuery['sort']>, 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<HTMLFormElement> = (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 (
<form
className={hideCategoryTopic ? t.ForumFiltersCompact : t.ForumFilters}
onSubmit={handleSearchSubmit}
id="forumFilterForm"
>
<ForumTopicSearchInput value={searchDraft} onChange={setSearchDraft} threads={threads} />
{!hideCategoryTopic && (
<select
className={t.ForumFilterSelect}
aria-label="Category"
value={query.category || ''}
onChange={(e) => handleCategoryChange(e.target.value)}
>
<option value="">All categories</option>
{sections.map((section) => (
<option key={section.title} value={section.title}>
{section.title}
</option>
))}
</select>
)}
{!hideCategoryTopic && (
<select
className={t.ForumFilterSelect}
aria-label="Topic"
value={query.topic || ''}
onChange={(e) => onQueryChange('topic', e.target.value || null)}
>
<option value="">All topics</option>
{categoryTopics.length === 0 && query.category ? (
<option value="" disabled>
No topics yet use Add Topic in the menu
</option>
) : null}
{categoryTopics.map((topic) => (
<option key={topic.roomId} value={topic.roomId}>
{topic.name}
</option>
))}
</select>
)}
<TooltipProvider
position="Bottom"
align="Center"
offset={4}
tooltip={
<Tooltip>
<Text>{SORT_TITLE[sort]}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
type="button"
className={t.ForumSortCycleBtn}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
onClick={cycleSort}
aria-label={SORT_TITLE[sort]}
>
<Icon src={SORT_ICON[sort]} size="200" />
</IconButton>
)}
</TooltipProvider>
</form>
);
}

View File

@@ -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 (
<div className={theme.ForumMessageActions}>
<button
type="button"
className={
replyActive
? `${theme.ForumMessageActionBtn} ${theme.ForumMessageActionBtnActive}`
: theme.ForumMessageActionBtn
}
onClick={onReply}
aria-label="Reply"
title="Reply"
aria-pressed={replyActive}
>
<Icon className={theme.ForumMessageActionIcon} src={Icons.ReplyArrow} size="200" />
</button>
</div>
);
}

View File

@@ -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 (
<div
className={theme.ForumMessageBodyRich}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
);
}
if (!plain) return null;
return (
<Text as="p" className={theme.ForumMessageBody} size="T400">
{body}
</Text>
);
}

View File

@@ -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 (
<>
<Box className={t.ForumNewPostBox}>
<Text as="p" className={t.ForumPostCountMeta} size="T200" priority="300">
{formatShowingPosts(postCount, loading, hasMore)}
</Text>
{userId ? (
<Button variant="Secondary" size="300" radii="300" onClick={handleOpen}>
<Icon src={Icons.Plus} size="100" />
<Text size="B400">New post</Text>
</Button>
) : null}
</Box>
{modalOpen && userId && (
<ForumNewPostModal
sections={sections}
initialCategory={defaultCategory}
initialTopicRoomId={defaultTopicRoomId}
onClose={() => setModalOpen(false)}
onPublished={onPublished}
/>
)}
</>
);
}

View File

@@ -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<HTMLFormElement>(null);
const [title, setTitle] = useState('');
const [category, setCategory] = useState(initialCategory);
const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement> = 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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: !sending,
escapeDeactivates: stopPropagation,
}}
>
<Modal
size="500"
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 'min(90vh, 52rem)',
maxWidth: 'min(720px, calc(100vw - 2rem))',
width: '100%',
}}
>
<Header
size="500"
style={{
padding: config.space.S300,
paddingLeft: config.space.S400,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes">
<Text size="H4">New post</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleClose} disabled={sending} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack style={{ flex: '1 1 auto', minHeight: 0 }}>
<Box
as="form"
ref={formRef}
className={t.ForumPostModalForm}
onSubmit={handleSubmit}
direction="Column"
style={{ padding: config.space.S400 }}
>
<ForumTopicTargetFields
sections={sections}
category={category}
topicRoomId={topicRoomId}
onCategoryChange={handleCategoryChange}
onTopicChange={setTopicRoomId}
disabled={sending}
/>
<input
id="forum-post-title"
className={t.ForumFilterSelect}
name="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={140}
required
autoFocus
placeholder="Title"
aria-label="Title"
autoComplete="off"
disabled={sending}
/>
<Box className={t.ForumPostModalBodyField}>
<Box className={t.ForumPostModalEditorWrap}>
<CustomEditor
editor={editor}
fillHeight
placeholder="Write your post…"
maxHeight={toRem(280)}
onKeyDown={handleEditorKeyDown}
bottom={
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
}
/>
</Box>
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
<Box className={t.ForumPostModalActions}>
<Button
type="button"
variant="SurfaceVariant"
size="400"
radii="300"
onClick={handleClose}
disabled={sending}
>
<Text size="B400">Cancel</Text>
</Button>
<Button type="submit" variant="Primary" size="400" radii="300" disabled={sending}>
<Text size="B400">{sending ? 'Publishing…' : 'Publish post'}</Text>
</Button>
</Box>
</Box>
</Scroll>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -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 (
<div className={theme.ForumPostAuthor}>
<ForumAuthorIdentity
room={room}
sender={sender}
displayName={displayName}
wrapTag="span"
compact
/>
</div>
);
}

View File

@@ -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 (
<>
<div className={t.ForumPostScroll}>
{loading && threads.length === 0 && (
<Box alignItems="Center" gap="200" style={{ padding: '1rem' }}>
<Spinner size="200" />
<Text size="T300" priority="300">
Loading posts
</Text>
</Box>
)}
{!loading && threads.length === 0 && (
<Text as="p" className={t.ForumListLoading} size="T300" priority="300">
No threads match these filters.
</Text>
)}
<ul className={t.ForumPostList}>
{threads.map((thread) => {
const isActive = thread.eventId === selectedPostId;
const topicRoom = getTopicRoom(thread.topicRoomId);
return (
<li key={thread.eventId} className={t.ForumPostItem}>
<button
type="button"
className={t.ForumPostLink}
data-active={isActive ? 'true' : undefined}
data-pinned={thread.isPinned ? 'true' : undefined}
onClick={() => onSelectPost(thread.eventId, thread.topicRoomId)}
>
<div className={t.ForumPostHead}>
<div className={t.ForumPostHeadMain}>
{thread.isPinned && <Icon src={Icons.Pin} size="100" filled />}
<span className={t.ForumPostTitle} data-role="title">
{thread.title}
</span>
</div>
{thread.totalReplies > 0 && (
<Box as="span" className={t.ForumReplyMeta} alignItems="Center" gap="100" aria-hidden>
{thread.totalReplies > 1 && (
<Text as="span" className={t.ForumReplyCount} size="L400">
{thread.totalReplies}
</Text>
)}
<Icon
src={thread.totalReplies === 1 ? Icons.Mail : Icons.MailPlus}
size="100"
/>
</Box>
)}
</div>
<div className={t.ForumPostFoot}>
<ForumPostAuthor
room={topicRoom}
sender={thread.sender}
displayName={thread.senderDisplayName}
/>
<div className={t.ForumPostMeta}>
<span className={t.ForumCategoryLabel}>{categoryTopicLabel(thread)}</span>
<Text as="span" size="L400" priority="300">
{formatForumTimeAgo(thread.lastActivityTs)}
</Text>
</div>
</div>
</button>
</li>
);
})}
</ul>
</div>
{hasMore && onLoadMore && (
<div className={t.ForumListFooter}>
<Button
variant="Secondary"
size="300"
radii="300"
fill="Soft"
onClick={onLoadMore}
disabled={loadingMore}
>
{loadingMore ? <Spinner size="100" /> : <Text size="B400">Load older posts</Text>}
</Button>
</div>
)}
</>
);
}

View File

@@ -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<string | null>(null);
const handleSubmit: FormEventHandler<HTMLFormElement> = 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 (
<form className={t.ForumFeedReplyForm} onSubmit={handleSubmit}>
<div className={t.ForumInlineReplyEditorWrap}>
<CustomEditor
editor={editor}
placeholder="Write a reply…"
maxHeight={toRem(240)}
onKeyDown={handleKeyDown}
bottom={
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
}
/>
</div>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
<div className={t.ForumReplyFormActions}>
{onCancel && (
<Button type="button" variant="SurfaceVariant" size="300" onClick={onCancel} disabled={sending}>
<Text size="B400">Cancel</Text>
</Button>
)}
<Button type="submit" className={t.ForumReplySubmitBtn} variant="Primary" size="300" disabled={sending}>
<Text size="B400">{sending ? 'Sending…' : 'Send reply'}</Text>
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,30 @@
import React, { ReactNode, useMemo } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useSpace } from '../../hooks/useSpace';
import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
import { ForumBoardProvider } from './ForumBoardContext';
/** Shares forum board state between space sidebar and lobby/room outlet. */
export function ForumSpaceLayout({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const space = useSpace();
const selectedRoomId = useSelectedRoom();
const scope = useMemo(() => {
const room = selectedRoomId ? mx.getRoom(selectedRoomId) : null;
const topicRoomId =
room && room.roomId !== space.roomId && !isForumContainer(room) ? room.roomId : undefined;
return { forumSpaceId: space.roomId, topicRoomId };
}, [mx, selectedRoomId, space.roomId]);
if (!shouldShowForumLobby(space)) {
return children;
}
return (
<ForumBoardProvider forumSpace={space} scope={scope}>
{children}
</ForumBoardProvider>
);
}

View File

@@ -0,0 +1,186 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { PowerLevelsContextProvider } from '../../hooks/usePowerLevels';
import { Page, PageContent } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { LobbyHeader } from '../lobby/LobbyHeader';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { useForumBoardContext } from './ForumBoardContext';
import { ForumFilterBar } from './ForumFilterBar';
import { ForumPostList } from './ForumPostList';
import { ForumNewPostBox } from './ForumNewPostBox';
import { ForumThreadDetail } from './ForumThreadDetail';
import type { ForumPost, ForumPublishedPost } from './types';
import * as theme from './forumTheme.css';
/** matrixsso-style feed in the Cinny main pane (Paarrot theme + folds icons). */
export function ForumSpaceShell() {
const mx = useMatrixClient();
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
const {
forumSpace,
sections,
threads,
query,
loadingSections,
loadingThreads,
error,
selectedPostId,
activeTopicRoomId,
setQueryParam,
setCategory,
selectPost,
loadMorePosts,
hasMorePosts,
loadingMore,
insertThreadFromNewPost,
recordThreadReply,
} = useForumBoardContext();
const spacePowerLevels = usePowerLevels(forumSpace);
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
const topicSelected = Boolean(query.topic);
const setActiveThreadId = useSetAtom(
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
);
useEffect(() => {
if (selectedPostId && activeTopicRoomId) {
setActiveThreadId(selectedPostId);
return () => setActiveThreadId(undefined);
}
setActiveThreadId(undefined);
return undefined;
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
useEffect(() => {
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
setOptimisticPost(null);
}
}, [optimisticPost, selectedPostId]);
const selectedThread = useMemo(
() => threads.find((t) => t.eventId === selectedPostId),
[threads, selectedPostId]
);
const getTopicRoom = useCallback(
(roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
[mx]
);
const handlePostPublished = useCallback(
(published: ForumPublishedPost) => {
const post = insertThreadFromNewPost(published);
if (post) {
setOptimisticPost(post);
}
selectPost(published.eventId, published.topicRoomId);
},
[insertThreadFromNewPost, selectPost]
);
const handleReplySent = useCallback(
(_reply: ForumPost) => {
if (selectedPostId) {
recordThreadReply(selectedPostId);
}
},
[recordThreadReply, selectedPostId]
);
const showThread = Boolean(selectedPostId && activeTopicRoom);
const threadInitialPost =
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
return (
<PowerLevelsContextProvider value={spacePowerLevels}>
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
<PageContent className={theme.ForumPageContent}>
<main id="topicLiveApp" className={theme.ForumTopicLiveApp}>
<section className={theme.ForumBlenderLayout}>
<div className={theme.ForumLeftColumn}>
<div className={theme.ForumLeftHeader}>
<LobbyHeader showSpaceToolbar powerLevels={spacePowerLevels} />
</div>
<section className={theme.ForumFiltersCard}>
<ForumFilterBar
sections={sections}
threads={threads}
query={query}
onQueryChange={setQueryParam}
onCategoryChange={setCategory}
/>
</section>
<section className={theme.ForumListCard}>
{error && (
<Text as="p" className={theme.ForumListLoading} style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
<ForumNewPostBox
sections={sections}
postCount={threads.length}
loading={loadingSections || loadingThreads}
hasMore={hasMorePosts}
defaultCategory={query.category}
defaultTopicRoomId={query.topic}
onPublished={handlePostPublished}
/>
<ForumPostList
threads={threads}
selectedPostId={selectedPostId}
loading={loadingSections || loadingThreads}
getTopicRoom={getTopicRoom}
onSelectPost={selectPost}
hasMore={hasMorePosts}
loadingMore={loadingMore}
onLoadMore={loadMorePosts}
/>
</section>
</div>
<aside className={theme.ForumDetailPane}>
{showThread && activeTopicRoom ? (
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
<ForumThreadDetail
roomId={activeTopicRoom.roomId}
rootEventId={selectedPostId!}
titleHint={selectedThread?.title}
initialPost={threadInitialPost}
onReplySent={handleReplySent}
/>
</div>
) : (
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
<Box
className={theme.ForumEmptyDetail}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Thread} size="600" />
<Text size="T300" priority="300">
{topicSelected
? 'Select a post to read the thread'
: 'Pick a category and topic, then choose a post'}
</Text>
</Box>
</div>
)}
</aside>
</section>
</main>
</PageContent>
</Page>
</PowerLevelsContextProvider>
);
}

View File

@@ -0,0 +1,223 @@
// @refresh reset
import React, { useEffect, useState } from 'react';
import { Box, Spinner, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { formatForumTimeAgo } from './forumTime';
import { loadPostThread } from './forumFeed';
import { appendReplyToThread } from './forumTopicHelpers';
import type { ForumPost } from './types';
import { ForumAuthorIdentity } from './ForumAuthorIdentity';
import { ForumMessageBody } from './ForumMessageBody';
import { ForumMessageActions } from './ForumMessageActions';
import { ForumRichReplyComposer } from './ForumRichReplyComposer';
import * as theme from './forumTheme.css';
function ForumThreadReply({
reply,
room,
threadRootId,
roomId,
depth,
onReplySent,
}: {
reply: ForumPost;
room: Room | null;
threadRootId: string;
roomId: string;
depth: number;
onReplySent: (reply: ForumPost) => void;
}) {
const [replyOpen, setReplyOpen] = useState(false);
return (
<article className={theme.ForumThreadReply} data-event-id={reply.eventId}>
<div className={theme.ForumThreadReplyInner}>
<header className={theme.ForumPostHeader}>
<div className={theme.ForumPostHeaderMain}>
<ForumAuthorIdentity room={room} sender={reply.sender} displayName={reply.senderDisplayName} />
<time className={theme.ForumPostTime} dateTime={new Date(reply.timestamp).toISOString()}>
{formatForumTimeAgo(reply.timestamp)}
</time>
</div>
</header>
{reply.replyToDeleted && (
<p className={theme.ForumReplyToDeleted} role="note">
Replying to a deleted message
</p>
)}
<ForumMessageBody body={reply.body} bodyHtml={reply.bodyHtml} />
{!replyOpen && (
<div className={theme.ForumReplyActionsFooter}>
<ForumMessageActions onReply={() => setReplyOpen(true)} />
</div>
)}
{replyOpen && (
<div className={theme.ForumMessageActionPanels}>
<ForumRichReplyComposer
roomId={roomId}
threadRootId={threadRootId}
replyToEventId={reply.eventId}
onSent={(sentReply) => {
setReplyOpen(false);
onReplySent(sentReply);
}}
onCancel={() => setReplyOpen(false)}
/>
</div>
)}
</div>
{reply.replies.length > 0 && (
<div className={theme.ForumThreadChildren}>
{reply.replies.map((child) => (
<ForumThreadReply
key={child.eventId}
reply={child}
room={room}
threadRootId={threadRootId}
roomId={roomId}
depth={depth + 1}
onReplySent={onReplySent}
/>
))}
</div>
)}
</article>
);
}
type ForumThreadDetailProps = {
roomId: string;
rootEventId: string;
titleHint?: string;
initialPost?: ForumPost | null;
onReplySent?: (reply: ForumPost) => void;
};
export function ForumThreadDetail({
roomId,
rootEventId,
titleHint,
initialPost,
onReplySent,
}: ForumThreadDetailProps) {
const mx = useMatrixClient();
const [post, setPost] = useState<ForumPost | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rootReplyOpen, setRootReplyOpen] = useState(false);
const room = mx.getRoom(roomId);
const handleReplySent = (reply: ForumPost) => {
setPost((current) => {
if (!current || !reply.replyToEventId) return current;
return appendReplyToThread(current, reply.replyToEventId, reply);
});
onReplySent?.(reply);
};
useEffect(() => {
let cancelled = false;
if (initialPost?.eventId === rootEventId) {
setPost(initialPost);
setError(null);
setLoading(false);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
loadPostThread(mx, roomId, rootEventId)
.then((loaded) => {
if (cancelled) return;
setPost(loaded);
if (!loaded) setError('Post not found.');
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Could not load this thread.');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [mx, roomId, rootEventId, initialPost]);
if (loading) {
return (
<Box alignItems="Center" gap="200" style={{ padding: '1rem' }}>
<Spinner size="200" />
<Text size="T300" priority="300">
Loading
</Text>
</Box>
);
}
if (error || !post) {
return (
<Text as="p" className={theme.ForumListLoading} size="T300" priority="300">
{error || 'Post not found.'}
</Text>
);
}
const title = post.title || titleHint || '(untitled post)';
return (
<article className={theme.ForumTopicDetailRoot} data-event-id={post.eventId}>
<header className={theme.ForumTopicDetailHead}>
<div className={theme.ForumTopicDetailHeadMain}>
<h1 className={theme.ForumDetailTitle}>{title}</h1>
<div className={theme.ForumTopicDetailAuthor}>
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
<span className={theme.ForumPostTime}>· {formatForumTimeAgo(post.timestamp)}</span>
</div>
</div>
</header>
<div className={theme.ForumTopicDetailOp}>
<ForumMessageBody body={post.body} bodyHtml={post.bodyHtml} />
{!rootReplyOpen && (
<div className={theme.ForumReplyActionsFooter}>
<ForumMessageActions onReply={() => setRootReplyOpen(true)} />
</div>
)}
{rootReplyOpen && (
<div className={theme.ForumMessageActionPanels}>
<ForumRichReplyComposer
roomId={roomId}
threadRootId={rootEventId}
replyToEventId={post.eventId}
onSent={(sentReply) => {
setRootReplyOpen(false);
handleReplySent(sentReply);
}}
onCancel={() => setRootReplyOpen(false)}
/>
</div>
)}
</div>
{post.replies.length > 0 && (
<div className={theme.ForumThread} id="topicReplyThread">
{post.replies.map((reply) => (
<ForumThreadReply
key={reply.eventId}
reply={reply}
room={room}
threadRootId={rootEventId}
roomId={roomId}
depth={0}
onReplySent={handleReplySent}
/>
))}
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,403 @@
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { Icon, IconButton, Icons } from 'folds';
import {
TOPIC_SEARCH_QUALIFIERS,
collectAuthorHintsFromThreads,
collectTitleHintsFromThreads,
insertTopicSearchQualifierAt,
insertTopicSearchValueAt,
matchAuthorHints,
matchTitleHints,
matchTopicSearchQualifiers,
parseTopicSearchQuery,
readActiveQualifierValueAt,
readQualifierPartialAt,
type TopicSearchAuthorHint,
type TopicSearchQualifier,
} from './topicSearch';
import type { ForumThreadSummary } from './types';
import * as t from './forumTheme.css';
type ForumTopicSearchInputProps = {
value: string;
onChange: (value: string) => void;
threads?: ForumThreadSummary[];
};
type SuggestionMode = 'qualifier' | 'author' | 'title';
export function ForumTopicSearchInput({ value, onChange, threads = [] }: ForumTopicSearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLSpanElement>(null);
const pillsRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const [caret, setCaret] = useState(0);
const [highlightIndex, setHighlightIndex] = useState(0);
const [tabPartial, setTabPartial] = useState('');
const authorHints = useMemo(() => collectAuthorHintsFromThreads(threads), [threads]);
const titleHints = useMemo(() => collectTitleHintsFromThreads(threads), [threads]);
const qualifierPartial = readQualifierPartialAt(value, caret);
const valueContext = readActiveQualifierValueAt(value, caret);
const qualifierMatches = qualifierPartial
? matchTopicSearchQualifiers(qualifierPartial)
: TOPIC_SEARCH_QUALIFIERS;
const authorMatches = useMemo(
() =>
valueContext?.key === 'from' ? matchAuthorHints(valueContext.partial, authorHints) : [],
[authorHints, valueContext]
);
const titleMatches = useMemo(
() =>
valueContext?.key === 'intitle' ? matchTitleHints(valueContext.partial, titleHints) : [],
[titleHints, valueContext]
);
const suggestionMode: SuggestionMode | null = useMemo(() => {
if (!focused) {
return null;
}
if (valueContext?.key === 'from' && authorHints.length > 0) {
return 'author';
}
if (valueContext?.key === 'intitle' && titleHints.length > 0) {
return 'title';
}
if (!qualifierPartial || qualifierMatches.length > 0) {
return 'qualifier';
}
return null;
}, [
focused,
valueContext,
authorHints.length,
titleHints.length,
qualifierPartial,
qualifierMatches.length,
]);
const suggestionCount =
suggestionMode === 'author'
? authorMatches.length
: suggestionMode === 'title'
? titleMatches.length
: suggestionMode === 'qualifier'
? qualifierMatches.length
: 0;
const showPills = suggestionMode !== null && suggestionCount > 0;
const parsed = parseTopicSearchQuery(value);
const positionFloatingPills = useCallback(() => {
const input = inputRef.current;
const root = pillsRef.current;
const mirror = mirrorRef.current;
if (!input || !root || root.hidden) {
return;
}
const caretPos = input.selectionStart ?? input.value.length;
const textBefore = input.value.slice(0, caretPos).replace(/\s/g, '\u00a0') || '\u200b';
if (mirror) {
const inputStyle = window.getComputedStyle(input);
mirror.textContent = textBefore;
mirror.style.font = inputStyle.font;
mirror.style.letterSpacing = inputStyle.letterSpacing;
}
const mirrorWidth = mirror?.offsetWidth ?? 0;
const maxLeft = Math.max(0, input.clientWidth - root.offsetWidth);
const left = Math.min(Math.max(0, mirrorWidth - 12), maxLeft);
root.style.left = `${left}px`;
const caretLeft = Math.min(Math.max(12, mirrorWidth), input.clientWidth - 12);
root.style.setProperty('--forum-search-caret', `${caretLeft - left}px`);
}, []);
useEffect(() => {
positionFloatingPills();
}, [value, caret, showPills, positionFloatingPills]);
useEffect(() => {
setHighlightIndex(0);
setTabPartial('');
}, [suggestionMode, valueContext?.partial, qualifierPartial]);
const applyInputUpdate = useCallback(
(nextValue: string, nextCursor: number) => {
onChange(nextValue);
setTabPartial('');
setHighlightIndex(0);
window.requestAnimationFrame(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.setSelectionRange(nextCursor, nextCursor);
setCaret(nextCursor);
positionFloatingPills();
});
},
[onChange, positionFloatingPills]
);
const insertQualifier = useCallback(
(qualifier: TopicSearchQualifier) => {
const input = inputRef.current;
if (!input) return;
const caretPos = input.selectionStart ?? input.value.length;
const { value: nextValue, cursor } = insertTopicSearchQualifierAt(value, caretPos, qualifier);
applyInputUpdate(nextValue, cursor);
},
[applyInputUpdate, value]
);
const insertValue = useCallback(
(replacement: string) => {
const input = inputRef.current;
if (!input) return;
const caretPos = input.selectionStart ?? input.value.length;
const { value: nextValue, cursor } = insertTopicSearchValueAt(value, caretPos, replacement);
applyInputUpdate(nextValue, cursor);
},
[applyInputUpdate, value]
);
const selectAtIndex = useCallback(
(index: number) => {
if (suggestionMode === 'author') {
const hint = authorMatches[index];
if (hint) {
insertValue(hint.displayName);
}
return;
}
if (suggestionMode === 'title') {
const title = titleMatches[index];
if (title) {
insertValue(title);
}
return;
}
if (suggestionMode === 'qualifier') {
const qualifier = qualifierMatches[index];
if (qualifier) {
insertQualifier(qualifier);
}
}
},
[authorMatches, insertQualifier, insertValue, qualifierMatches, suggestionMode, titleMatches]
);
const selectHighlighted = useCallback(() => {
selectAtIndex(highlightIndex);
}, [highlightIndex, selectAtIndex]);
const moveHighlight = useCallback(
(delta: number) => {
if (!suggestionCount) return;
setHighlightIndex((idx) => (idx + delta + suggestionCount) % suggestionCount);
},
[suggestionCount]
);
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
const pillsVisible = showPills && suggestionCount > 0;
if (pillsVisible && (event.key === 'ArrowRight' || event.key === 'ArrowDown')) {
event.preventDefault();
moveHighlight(1);
return;
}
if (pillsVisible && (event.key === 'ArrowLeft' || event.key === 'ArrowUp')) {
event.preventDefault();
moveHighlight(-1);
return;
}
if (
pillsVisible &&
event.key === 'Enter' &&
(suggestionMode === 'author' ||
suggestionMode === 'title' ||
(suggestionMode === 'qualifier' && qualifierPartial))
) {
event.preventDefault();
selectHighlighted();
return;
}
if (event.key === 'Escape' && pillsVisible) {
event.preventDefault();
setTabPartial('');
setHighlightIndex(0);
inputRef.current?.blur();
return;
}
if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) {
return;
}
if (!pillsVisible) {
return;
}
event.preventDefault();
const tabKey =
suggestionMode === 'author'
? `author:${valueContext?.partial ?? ''}`
: suggestionMode === 'title'
? `title:${valueContext?.partial ?? ''}`
: `qualifier:${qualifierPartial}`;
const nextIndex =
tabKey === tabPartial ? (highlightIndex + 1) % suggestionCount : 0;
setTabPartial(tabKey);
setHighlightIndex(nextIndex);
selectAtIndex(nextIndex);
};
const syncCaret = () => {
const input = inputRef.current;
if (!input) return;
setCaret(input.selectionStart ?? input.value.length);
};
const pillsLabel =
suggestionMode === 'author'
? 'Author suggestions'
: suggestionMode === 'title'
? 'Title suggestions'
: 'Search qualifiers';
const matchKeys = new Set(qualifierMatches.map((item) => item.key));
return (
<div className={t.ForumSearchField}>
<div className={classNames(t.ForumSearchRow, t.ForumSearchRowAnchor)}>
<span ref={mirrorRef} className={t.ForumSearchCaretMirror} aria-hidden />
<input
ref={inputRef}
className={t.ForumSearchInput}
type="search"
name="q"
value={value}
onChange={(event) => {
onChange(event.target.value);
syncCaret();
}}
onFocus={() => {
setFocused(true);
syncCaret();
}}
onBlur={() => {
window.setTimeout(() => setFocused(false), 120);
}}
onClick={syncCaret}
onKeyUp={syncCaret}
onKeyDown={handleKeyDown}
placeholder="Search posts…"
title="Examples: intitle:release inpost:bug inthread:thanks from:alice"
aria-label="Search posts"
aria-controls="forumSearchPills"
autoComplete="off"
/>
<div
ref={pillsRef}
id="forumSearchPills"
className={t.ForumSearchPills}
role="listbox"
aria-label={pillsLabel}
hidden={!showPills}
>
<div className={t.ForumSearchPillsInner}>
{suggestionMode === 'author' &&
authorMatches.map((hint, index) => (
<button
key={hint.userId}
type="button"
role="option"
aria-selected={index === highlightIndex}
className={classNames(t.ForumSearchPill, t.ForumSearchPillValue, {
[t.ForumSearchPillHighlighted]: index === highlightIndex,
})}
title={`${hint.displayName} (${hint.userId})`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertValue(hint.displayName)}
>
{hint.displayName}
<span style={{ opacity: 0.65 }}> @{hint.localpart}</span>
</button>
))}
{suggestionMode === 'title' &&
titleMatches.map((title, index) => (
<button
key={title}
type="button"
role="option"
aria-selected={index === highlightIndex}
className={classNames(t.ForumSearchPill, t.ForumSearchPillValue, {
[t.ForumSearchPillHighlighted]: index === highlightIndex,
})}
title={title}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertValue(title)}
>
{title}
</button>
))}
{suggestionMode === 'qualifier' &&
TOPIC_SEARCH_QUALIFIERS.map((qualifier) => {
const visible = !qualifierPartial || matchKeys.has(qualifier.key);
const matchIndex = qualifierMatches.findIndex((item) => item.key === qualifier.key);
const highlighted = visible && matchIndex === highlightIndex;
const inUse = Boolean(parsed.qualifiers[qualifier.key]?.length);
return (
<button
key={qualifier.key}
type="button"
role="option"
aria-selected={highlighted}
className={classNames(t.ForumSearchPill, {
[t.ForumSearchPillHighlighted]: highlighted,
[t.ForumSearchPillInUse]: inUse,
})}
data-qualifier={qualifier.key}
title={qualifier.label}
aria-label={`${qualifier.label} (${qualifier.token})`}
hidden={!visible}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertQualifier(qualifier)}
>
{qualifier.token}
</button>
);
})}
</div>
</div>
<IconButton
className={t.ForumSearchSubmit}
type="submit"
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
aria-label="Search"
>
<Icon src={Icons.Search} size="200" />
</IconButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import React, { useMemo } from 'react';
import type { ForumSection } from './types';
import * as t from './forumTheme.css';
type ForumTopicTargetFieldsProps = {
sections: ForumSection[];
category: string;
topicRoomId: string;
onCategoryChange: (category: string) => void;
onTopicChange: (topicRoomId: string) => void;
disabled?: boolean;
};
export function ForumTopicTargetFields({
sections,
category,
topicRoomId,
onCategoryChange,
onTopicChange,
disabled = false,
}: ForumTopicTargetFieldsProps) {
const categoryTopics = useMemo(() => {
if (category) {
return sections.find((s) => s.title === category)?.topics ?? [];
}
return sections.flatMap((s) => s.topics);
}, [category, sections]);
return (
<div className={t.ForumTopicTargetFields}>
<select
className={t.ForumFilterSelect}
aria-label="Category"
value={category}
disabled={disabled}
onChange={(e) => onCategoryChange(e.target.value)}
>
<option value="">All categories</option>
{sections.map((section) => (
<option key={section.title} value={section.title}>
{section.title}
</option>
))}
</select>
<select
className={t.ForumFilterSelect}
aria-label="Topic"
value={topicRoomId}
disabled={disabled || categoryTopics.length === 0}
onChange={(e) => onTopicChange(e.target.value)}
>
<option value="">Select a topic</option>
{categoryTopics.length === 0 && category ? (
<option value="" disabled>
No topics yet use Add Topic
</option>
) : null}
{categoryTopics.map((topic) => (
<option key={topic.roomId} value={topic.roomId}>
{topic.name}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,546 @@
import {
Direction,
EventType,
type IEventRelation,
type IRoomEvent,
type MatrixClient,
RelationType,
} from 'matrix-js-sdk';
import type { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { getMemberDisplayName, getSpaceChildren, isSpace } from '../../utils/room';
import type { ForumPost, ForumTopic } from './types';
import { annotatePostsWithRoomScope } from './forumTopicHelpers';
import { bodyHtmlFromMessageContent } from './forumRichText';
type RawRelation = {
rel_type?: string;
event_id?: string;
'm.in_reply_to'?: { event_id?: string };
};
function threadRelationFromContent(content: Record<string, unknown>): RawRelation | null {
const relates = content['m.relates_to'];
if (!relates || typeof relates !== 'object') return null;
return relates as RawRelation;
}
function parseBundledThreadReplyCount(event: IRoomEvent): number | undefined {
const relations = event.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
const thread = relations?.['m.thread'] as { count?: unknown } | undefined;
const count = Number(thread?.count);
if (!Number.isFinite(count) || count < 0) return undefined;
return Math.floor(count);
}
function postTitleFromContent(content: Record<string, unknown>): { title: string; body: string } {
const rawBody = typeof content.body === 'string' ? content.body : '';
const customTitle =
typeof content['com.matrixsso.title'] === 'string'
? String(content['com.matrixsso.title']).trim()
: '';
const firstLine = rawBody.split(/\r?\n/, 1)[0]?.trim() ?? '';
const title = (customTitle || firstLine || '(untitled post)').slice(0, 160);
let body = rawBody;
if (customTitle && rawBody.startsWith(customTitle)) {
body = rawBody.slice(customTitle.length).replace(/^\s+/, '');
} else {
const [leadingBlock, ...restBlocks] = rawBody.split(/\r?\n\r?\n/);
if (restBlocks.length > 0 && leadingBlock.trim() === firstLine) {
body = restBlocks.join('\n\n').trim();
} else if (firstLine && rawBody.startsWith(firstLine)) {
body = rawBody.slice(firstLine.length).replace(/^\s+/, '');
}
}
return { title, body: body || rawBody };
}
function sortRepliesRecursively(posts: ForumPost[]) {
posts.sort((a, b) => a.timestamp - b.timestamp);
for (const post of posts) {
if (post.replies.length > 0) sortRepliesRecursively(post.replies);
}
}
function buildPosts(mx: MatrixClient, roomId: string, timelineEvents: IRoomEvent[]): ForumPost[] {
const redactedIds = new Set(
timelineEvents.filter((e) => e.unsigned?.redacted_because).map((e) => e.event_id)
);
const normalized = timelineEvents
.filter(
(event) =>
event.type === EventType.RoomMessage &&
event.event_id &&
event.content &&
!redactedIds.has(event.event_id)
)
.map((event) => {
const content = event.content as Record<string, unknown>;
const relation = threadRelationFromContent(content);
const isThreadReply =
relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread';
if (typeof content.body !== 'string') return null;
const { title, body } = postTitleFromContent(content);
const customTitle =
typeof content['com.matrixsso.title'] === 'string'
? String(content['com.matrixsso.title']).trim()
: '';
const bodyHtml = bodyHtmlFromMessageContent(content, {
stripTitleHeading: Boolean(customTitle),
});
const room = mx.getRoom(roomId);
const displayName = room
? getMemberDisplayName(room, event.sender || '') ?? event.sender
: event.sender;
return {
eventId: event.event_id!,
title,
body,
bodyHtml,
sender: event.sender || 'unknown',
senderDisplayName: displayName,
timestamp: event.origin_server_ts || 0,
relation,
bundledReplyCount: !isThreadReply ? parseBundledThreadReplyCount(event) : undefined,
};
})
.filter((e): e is NonNullable<typeof e> => e !== null)
.sort((a, b) => a.timestamp - b.timestamp);
const postsById = new Map<string, ForumPost>();
for (const event of normalized) {
postsById.set(event.eventId, {
eventId: event.eventId,
title: event.title,
body: event.body,
bodyHtml: event.bodyHtml,
sender: event.sender,
senderDisplayName: event.senderDisplayName,
timestamp: event.timestamp,
bundledReplyCount: event.bundledReplyCount,
replies: [],
});
}
const roots: ForumPost[] = [];
const placedReplyIds = new Set<string>();
for (const event of normalized) {
const current = postsById.get(event.eventId);
if (!current) continue;
const relation = event.relation;
let threadRootId: string | null = null;
let parentEventId: string | null = null;
if (relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread') {
threadRootId = relation.event_id ? String(relation.event_id) : null;
}
if (relation?.['m.in_reply_to']?.event_id) {
parentEventId = String(relation['m.in_reply_to'].event_id);
}
if (!threadRootId) {
roots.push(current);
continue;
}
if (placedReplyIds.has(event.eventId)) continue;
if (parentEventId && parentEventId !== threadRootId) {
const parent = postsById.get(parentEventId);
if (parent) {
current.replyToEventId = parentEventId;
parent.replies.push(current);
placedReplyIds.add(event.eventId);
continue;
}
current.replyToEventId = parentEventId;
current.replyToDeleted = true;
const threadRoot = postsById.get(threadRootId);
if (threadRoot) {
threadRoot.replies.push(current);
placedReplyIds.add(event.eventId);
}
continue;
}
const threadRoot = postsById.get(threadRootId);
if (threadRoot) {
threadRoot.replies.push(current);
placedReplyIds.add(event.eventId);
}
}
sortRepliesRecursively(roots);
return roots.sort((a, b) => b.timestamp - a.timestamp);
}
export async function listForumTopics(mx: MatrixClient, spaceRoomId: string): Promise<ForumTopic[]> {
const rooms: IHierarchyRoom[] = [];
let nextBatch: string | undefined;
for (let page = 0; page < 5; page += 1) {
const hierarchy = await mx.getRoomHierarchy(spaceRoomId, 100, 3, false, nextBatch);
rooms.push(...hierarchy.rooms);
nextBatch = hierarchy.next_batch;
if (!nextBatch) break;
}
const parentByRoomId = new Map<string, string>();
for (const room of rooms) {
if (!room?.room_id || !Array.isArray(room.children_state)) continue;
for (const childState of room.children_state) {
if (childState?.type !== 'm.space.child' || typeof childState.state_key !== 'string') continue;
parentByRoomId.set(childState.state_key, room.room_id);
}
}
const fromHierarchy = rooms
.filter((room) => room?.room_id && room.room_id !== spaceRoomId)
.map((room) => ({
roomId: room.room_id,
name: String(room.name || room.canonical_alias || room.room_id),
topic: typeof room.topic === 'string' ? room.topic : undefined,
canonicalAlias: typeof room.canonical_alias === 'string' ? room.canonical_alias : undefined,
memberCount:
typeof room.num_joined_members === 'number' ? room.num_joined_members : undefined,
isSpace: room.room_type === 'm.space' || room.room_type === 'm.forum',
parentRoomId: parentByRoomId.get(room.room_id),
}));
if (fromHierarchy.length > 0) {
return fromHierarchy.sort((a, b) => a.name.localeCompare(b.name));
}
// Fallback: joined space children (when hierarchy API returns nothing useful)
const topics: ForumTopic[] = [];
const visited = new Set<string>();
const collectFromSpace = (parentId: string, depth: number) => {
if (depth > 3 || visited.has(parentId)) return;
visited.add(parentId);
const parentRoom = mx.getRoom(parentId);
if (!parentRoom) return;
for (const childId of getSpaceChildren(parentRoom)) {
if (childId === spaceRoomId || visited.has(childId)) continue;
const child = mx.getRoom(childId);
const name = child?.name || childId;
const childIsSpace = isSpace(child ?? null);
topics.push({
roomId: childId,
name,
isSpace: childIsSpace,
parentRoomId: parentId,
});
if (childIsSpace) collectFromSpace(childId, depth + 1);
}
};
collectFromSpace(spaceRoomId, 0);
return topics.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listTopicFeedPosts(
mx: MatrixClient,
roomId: string,
options: { from?: string; minRoots?: number } = {}
): Promise<{ posts: ForumPost[]; nextBatch: string | null }> {
const pageSize = 40;
const minRoots = options.minRoots ?? 8;
let merged: ForumPost[] = [];
let nextBatch = options.from;
const maxPages = options.from ? 5 : 10;
for (let page = 0; page < maxPages; page += 1) {
const response = await mx.createMessagesRequest(
roomId,
nextBatch ?? null,
pageSize,
Direction.Backward
);
const chunk = [...(response.chunk || [])].reverse();
const pagePosts = buildPosts(mx, roomId, chunk);
merged = page === 0 && !options.from ? pagePosts : mergeRootPosts(merged, pagePosts);
nextBatch = response.end ?? undefined;
if (!nextBatch || merged.length >= minRoots) break;
}
return { posts: merged, nextBatch: nextBatch ?? null };
}
function mergeRootPosts(existing: ForumPost[], incoming: ForumPost[]): ForumPost[] {
const byId = new Map(existing.map((post) => [post.eventId, post]));
for (const post of incoming) {
const previous = byId.get(post.eventId);
if (!previous) {
byId.set(post.eventId, post);
continue;
}
previous.replies = [...previous.replies, ...post.replies];
}
return [...byId.values()].sort((a, b) => b.timestamp - a.timestamp);
}
async function fetchRootEvent(
mx: MatrixClient,
roomId: string,
rootEventId: string
): Promise<IRoomEvent | null> {
const rootId = rootEventId.trim();
if (!rootId) return null;
try {
return (await mx.fetchRoomEvent(roomId, rootId)) as IRoomEvent;
} catch {
const room = mx.getRoom(roomId);
const local = room?.findEventById(rootId);
if (local) {
return local.event as IRoomEvent;
}
return null;
}
}
export async function loadPostThread(
mx: MatrixClient,
roomId: string,
rootEventId: string
): Promise<ForumPost | null> {
const rootId = rootEventId.trim();
const rootEvent = await fetchRootEvent(mx, roomId, rootId);
if (!rootEvent?.event_id) return null;
const events: IRoomEvent[] = [rootEvent];
const collectRelations = async (
relationType: RelationType | null,
eventType: EventType | null
) => {
let from: string | undefined;
for (let page = 0; page < 20; page += 1) {
const relPayload = await mx.fetchRelations(roomId, rootId, relationType, eventType, {
from,
limit: 100,
dir: Direction.Backward,
recurse: true,
});
if (relPayload.chunk?.length) {
events.push(...(relPayload.chunk as IRoomEvent[]));
}
from = relPayload.next_batch ?? undefined;
if (!from) break;
}
};
try {
await collectRelations(RelationType.Thread, EventType.RoomMessage);
} catch {
try {
await collectRelations(null, EventType.RoomMessage);
} catch {
// Still show root without replies.
}
}
const seen = new Set<string>();
const unique: IRoomEvent[] = [];
for (const event of events) {
const id = event.event_id;
if (!id || seen.has(id)) continue;
seen.add(id);
unique.push(event);
}
const posts = buildPosts(mx, roomId, unique);
return posts.find((post) => post.eventId === rootId) ?? posts[0] ?? null;
}
export async function loadAggregatedTopicFeed(
mx: MatrixClient,
scopes: Array<{ roomId: string; sectionTitle?: string; topicName?: string }>
): Promise<ForumPost[]> {
const allPosts: ForumPost[] = [];
for (const scope of scopes) {
const { posts } = await listTopicFeedPosts(mx, scope.roomId, { minRoots: 5 });
annotatePostsWithRoomScope(posts, scope.roomId, scope);
allPosts.push(...posts);
}
return allPosts.sort((a, b) => b.timestamp - a.timestamp);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export type ForumNewPostPayload = {
title: string;
plainText: string;
formattedHtml: string;
};
/** Top-level forum thread root (matrixsso-compatible). */
export async function sendForumPost(
mx: MatrixClient,
roomId: string,
post: ForumNewPostPayload
): Promise<string> {
const title = post.title.trim();
const plainText = post.plainText.trim();
if (!title) {
throw new Error('Post title is required.');
}
if (title.length > 140) {
throw new Error('Post title is too long.');
}
if (!plainText) {
throw new Error('Post body is required.');
}
let formattedHtml = post.formattedHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
if (!formattedHtml) {
formattedHtml = `<p>${escapeHtml(plainText)}</p>`;
}
const res = await mx.sendMessage(roomId, {
msgtype: 'm.text',
body: `${title}\n\n${plainText}`,
'com.matrixsso.title': title,
format: 'org.matrix.custom.html',
formatted_body: `<h1>${escapeHtml(title)}</h1>${formattedHtml}`,
});
return res.event_id;
}
export type ForumReplyPayload = {
plainText: string;
formattedHtml?: string;
};
/** Thread reply (matrixsso: m.thread + m.in_reply_to on parent message). */
export function sendForumReply(
mx: MatrixClient,
roomId: string,
threadRootId: string,
replyToEventId: string,
message: string | ForumReplyPayload
): Promise<string> {
const rootId = threadRootId.trim();
const parentId = replyToEventId.trim() || rootId;
if (!rootId) {
throw new Error('Thread root event id is required.');
}
const plainText = (typeof message === 'string' ? message : message.plainText).trim();
if (!plainText) {
throw new Error('Reply message is required.');
}
let formattedHtml =
typeof message === 'string' ? '' : String(message.formattedHtml || '').trim();
formattedHtml = formattedHtml.replace(/(<br\s*\/?>\s*)+$/gi, '');
const content: Record<string, unknown> = {
msgtype: 'm.text',
body: plainText,
'm.relates_to': {
rel_type: RelationType.Thread,
event_id: rootId,
is_falling_back: false,
'm.in_reply_to': { event_id: parentId },
},
};
if (formattedHtml) {
content.format = 'org.matrix.custom.html';
content.formatted_body = formattedHtml;
}
return mx.sendMessage(roomId, content).then((res) => res.event_id);
}
export function createForumPostFromPayload(
mx: MatrixClient,
roomId: string,
eventId: string,
payload: ForumNewPostPayload,
meta: { topicRoomId?: string; sectionTitle?: string; topicName?: string } = {}
): ForumPost {
const userId = mx.getUserId() || 'unknown';
const room = mx.getRoom(roomId);
const displayName = room
? getMemberDisplayName(room, userId) ?? userId
: userId;
const title = payload.title.trim();
const plainText = payload.plainText.trim();
let formattedHtml = payload.formattedHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
if (!formattedHtml) {
formattedHtml = `<p>${escapeHtml(plainText)}</p>`;
}
return {
eventId,
title,
body: plainText,
bodyHtml: bodyHtmlFromMessageContent(
{
format: 'org.matrix.custom.html',
formatted_body: `<h1>${escapeHtml(title)}</h1>${formattedHtml}`,
},
{ stripTitleHeading: true }
),
sender: userId,
senderDisplayName: displayName,
timestamp: Date.now(),
topicRoomId: meta.topicRoomId ?? roomId,
sectionTitle: meta.sectionTitle,
topicName: meta.topicName,
bundledReplyCount: 0,
replies: [],
};
}
export function createForumReplyFromPayload(
mx: MatrixClient,
roomId: string,
eventId: string,
replyToEventId: string,
payload: ForumReplyPayload
): ForumPost {
const userId = mx.getUserId() || 'unknown';
const room = mx.getRoom(roomId);
const displayName = room
? getMemberDisplayName(room, userId) ?? userId
: userId;
const plainText = payload.plainText.trim();
let formattedHtml = String(payload.formattedHtml || '').trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
const content: Record<string, unknown> = { body: plainText };
if (formattedHtml) {
content.format = 'org.matrix.custom.html';
content.formatted_body = formattedHtml;
}
return {
eventId,
title: '',
body: plainText,
bodyHtml: bodyHtmlFromMessageContent(content),
sender: userId,
senderDisplayName: displayName,
timestamp: Date.now(),
replyToEventId,
replies: [],
};
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import type { IconSrc } from 'folds';
/** Lucide paths (ISC) — same icons as matrixsso sort toggle (flame / sparkles / crown). */
type SvgNode = [tag: string, attrs: Record<string, string>];
const FLAME: SvgNode[] = [
['path', { d: 'M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4' }],
];
const SPARKLES: SvgNode[] = [
[
'path',
{
d: 'M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z',
},
],
['path', { d: 'M20 2v4' }],
['path', { d: 'M22 4h-4' }],
['circle', { cx: '4', cy: '20', r: '2' }],
];
const CROWN: SvgNode[] = [
[
'path',
{
d: 'M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z',
},
],
['path', { d: 'M5 21h14' }],
];
function lucideIcon(nodes: SvgNode): IconSrc {
return () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
focusable="false"
>
{nodes.map(([tag, attrs]) =>
React.createElement(tag, { key: `${tag}-${attrs.d ?? attrs.cx ?? ''}`, ...attrs })
)}
</svg>
);
}
/** hot → flame, new → sparkles, top → crown (matrixsso SORT_LUCIDE_ICONS) */
export const ForumSortIcons = {
hot: lucideIcon(FLAME),
new: lucideIcon(SPARKLES),
top: lucideIcon(CROWN),
} as const;

View File

@@ -0,0 +1,28 @@
import type { OutputOptions } from '../../components/editor/output';
import { sanitizeCustomHtml } from '../../utils/sanitize';
export const FORUM_EDITOR_OUTPUT_OPTS: OutputOptions = {
allowTextFormatting: true,
allowInlineMarkdown: true,
allowBlockMarkdown: true,
};
export function stripLeadingTitleHeading(html: string): string {
return html.replace(/^<h1[^>]*>[\s\S]*?<\/h1>\s*/i, '').trim();
}
export function bodyHtmlFromMessageContent(
content: Record<string, unknown>,
options: { stripTitleHeading?: boolean } = {}
): string | undefined {
if (content.format !== 'org.matrix.custom.html') return undefined;
const raw = content.formatted_body;
if (typeof raw !== 'string' || !raw.trim()) return undefined;
let html = raw.trim();
if (options.stripTitleHeading) {
html = stripLeadingTitleHeading(html);
}
const sanitized = sanitizeCustomHtml(html);
return sanitized || undefined;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
/** matrixsso-style relative time (e.g. "4m", "3h", "now"). */
export function formatForumTimeAgo(timestamp: number): string {
const ms = timestamp;
if (!Number.isFinite(ms) || ms <= 0) {
return 'now';
}
const seconds = Math.floor((Date.now() - ms) / 1000);
if (seconds < 45) {
return 'now';
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h`;
}
const days = Math.floor(hours / 24);
if (days < 7) {
return `${days}d`;
}
const weeks = Math.floor(days / 7);
if (weeks < 5) {
return `${weeks}w`;
}
const months = Math.floor(days / 30);
if (months < 12) {
return `${months}mo`;
}
const years = Math.floor(days / 365);
return `${years}y`;
}

View File

@@ -0,0 +1,125 @@
import type { ForumPost, ForumSection, ForumThreadSummary, ForumTopic } from './types';
import {
applyTopicStatusSort,
filterPostsBySearch,
parseTopicSearchQuery,
} from './topicSearch';
export function countAllReplies(replies: ForumPost[] | undefined): number {
let total = 0;
for (const reply of replies || []) {
total += 1;
total += countAllReplies(reply.replies);
}
return total;
}
export function maxThreadTimestamp(post: ForumPost): number {
let maxValue = post.timestamp;
const stack = [...(post.replies || [])];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
if (current.timestamp > maxValue) maxValue = current.timestamp;
if (current.replies?.length) stack.push(...current.replies);
}
return maxValue;
}
export function buildThreadSummaries(posts: ForumPost[]): ForumThreadSummary[] {
return posts.map((post) => ({
eventId: post.eventId,
title: post.title || post.body.slice(0, 160) || '(no title)',
body: post.body,
sender: post.sender,
senderDisplayName: post.senderDisplayName,
timestamp: post.timestamp,
topicRoomId: post.topicRoomId,
sectionTitle: post.sectionTitle,
topicName: post.topicName,
totalReplies: post.bundledReplyCount ?? countAllReplies(post.replies),
lastActivityTs: maxThreadTimestamp(post),
isPinned: false,
}));
}
export function buildTopicThreads(
posts: ForumPost[],
query: { q?: string; status?: string; sort?: string } = {}
): ForumThreadSummary[] {
const parsed = parseTopicSearchQuery(query.q || '');
const filtered = filterPostsBySearch(posts, parsed);
const summaries = buildThreadSummaries(filtered);
return applyTopicStatusSort(summaries, query.status || 'all', query.sort || 'hot');
}
export function buildForumSections(topics: ForumTopic[]): ForumSection[] {
const spaces = topics.filter((topic) => topic.isSpace);
const postableTopics = topics.filter((topic) => !topic.isSpace);
const sections: ForumSection[] = [];
for (const space of spaces) {
const childTopics = postableTopics.filter((topic) => topic.parentRoomId === space.roomId);
sections.push({
spaceId: space.roomId,
title: space.name,
description: space.topic || null,
topics: childTopics.sort((a, b) => a.name.localeCompare(b.name)),
});
}
const coveredTopicIds = new Set(
sections.flatMap((section) => section.topics.map((topic) => topic.roomId))
);
const uncategorizedTopics = postableTopics.filter((topic) => !coveredTopicIds.has(topic.roomId));
if (uncategorizedTopics.length > 0) {
sections.push({
title: 'General Topics',
description: null,
topics: uncategorizedTopics.sort((a, b) => a.name.localeCompare(b.name)),
});
}
return sections;
}
/** Insert a reply under the parent event (immutable). */
export function appendReplyToThread(
root: ForumPost,
parentEventId: string,
reply: ForumPost
): ForumPost {
const walk = (node: ForumPost): ForumPost => {
if (node.eventId === parentEventId) {
return {
...node,
replies: [...node.replies, reply].sort((a, b) => a.timestamp - b.timestamp),
};
}
let changed = false;
const nextReplies = node.replies.map((child) => {
const updated = walk(child);
if (updated !== child) changed = true;
return updated;
});
if (!changed) return node;
return { ...node, replies: nextReplies };
};
return walk(root);
}
export function annotatePostsWithRoomScope(
posts: ForumPost[],
roomId: string,
scope: { sectionTitle?: string; topicName?: string } | null = null
): void {
const sectionTitle = scope?.sectionTitle ? String(scope.sectionTitle) : '';
const topicName = scope?.topicName ? String(scope.topicName) : '';
for (const post of posts) {
post.topicRoomId = roomId;
if (sectionTitle) post.sectionTitle = sectionTitle;
if (topicName) post.topicName = topicName;
}
}

View File

@@ -0,0 +1,7 @@
export { ForumBoardView } from './ForumBoardView';
export { ForumSortIcons } from './forumLucideIcons';
export { ForumSpaceShell } from './ForumSpaceShell';
export { ForumThreadDetail } from './ForumThreadDetail';
export { ForumAwareSpaceLayout } from './ForumAwareSpaceLayout';
export { ForumSpaceLayout } from './ForumSpaceLayout';
export type { ForumBoardScope } from './useForumBoard';

View File

@@ -0,0 +1,430 @@
import type { ForumPost } from './types';
export type ParsedTopicSearch = {
isActive: boolean;
freeText: string;
terms: string[];
qualifiers: {
intitle: string[];
inpost: string[];
inthread: string[];
from: string[];
};
};
export type TopicSearchQualifier = {
key: keyof ParsedTopicSearch['qualifiers'];
token: string;
label: string;
aliases: string[];
};
const QUALIFIER_RE = /\b(intitle|inpost|inbody|inthread|inreply|from):\s*("([^"]+)"|(\S+))/gi;
export const TOPIC_SEARCH_QUALIFIERS: TopicSearchQualifier[] = [
{ key: 'intitle', token: 'intitle:', label: 'Title', aliases: [] },
{ key: 'inpost', token: 'inpost:', label: 'Post body', aliases: ['inbody'] },
{ key: 'inthread', token: 'inthread:', label: 'In thread', aliases: ['inreply'] },
{ key: 'from', token: 'from:', label: 'Author', aliases: [] },
];
export function readQualifierPartialAt(value: string, cursor: number): string {
const before = String(value || '').slice(0, Math.max(0, cursor));
const match = before.match(/(?:^|\s)([^\s:]*)$/);
return match ? String(match[1] || '').toLowerCase() : '';
}
export function matchTopicSearchQualifiers(partial: string): TopicSearchQualifier[] {
const prefix = String(partial || '').toLowerCase();
if (!prefix) {
return TOPIC_SEARCH_QUALIFIERS.slice();
}
return TOPIC_SEARCH_QUALIFIERS.filter((qualifier) => {
if (qualifier.key.startsWith(prefix)) {
return true;
}
return qualifier.aliases.some((alias) => alias.startsWith(prefix));
});
}
export type TopicSearchAuthorHint = {
userId: string;
displayName: string;
localpart: string;
};
export type QualifierValueContext = {
key: keyof ParsedTopicSearch['qualifiers'];
partial: string;
valueStart: number;
};
const VALUE_QUALIFIER_KEYS = new Set<keyof ParsedTopicSearch['qualifiers']>([
'intitle',
'inpost',
'inthread',
'from',
]);
function normalizeQualifierKey(raw: string): keyof ParsedTopicSearch['qualifiers'] | null {
const key = raw.toLowerCase();
if (key === 'inbody') return 'inpost';
if (key === 'inreply') return 'inthread';
if (key === 'intitle' || key === 'inpost' || key === 'inthread' || key === 'from') {
return key;
}
return null;
}
export function formatSearchValue(value: string): string {
const trimmed = String(value || '').trim();
if (!trimmed) return '';
if (/[\s:"]/.test(trimmed)) {
return `"${trimmed.replace(/"/g, '')}"`;
}
return trimmed;
}
/** Partial value typed after a qualifier token (e.g. `from:ali` or `intitle:"rel`). */
export function readActiveQualifierValueAt(value: string, cursor: number): QualifierValueContext | null {
const before = String(value || '').slice(0, Math.max(0, cursor));
const match = before.match(
/(?:^|\s)(intitle|inpost|inbody|inthread|inreply|from):\s*(?:"([^"]*)"?|(\S*))$/i
);
if (!match) {
return null;
}
const key = normalizeQualifierKey(String(match[1] || ''));
if (!key || !VALUE_QUALIFIER_KEYS.has(key)) {
return null;
}
const partial = match[2] !== undefined ? String(match[2] || '') : String(match[3] || '');
const matchStart = before.length - match[0].length;
const qualifierLen = String(match[1] || '').length;
const afterColon = before.slice(matchStart + qualifierLen + 1);
const leadingSpace = afterColon.match(/^\s*/)?.[0]?.length ?? 0;
let valueStart = matchStart + qualifierLen + 1 + leadingSpace;
if (afterColon.trimStart().startsWith('"')) {
valueStart += 1;
}
return { key, partial, valueStart };
}
export function insertTopicSearchValueAt(
value: string,
cursor: number,
replacement: string
): { value: string; cursor: number } {
const ctx = readActiveQualifierValueAt(value, cursor);
if (!ctx) {
return { value, cursor };
}
const formatted = formatSearchValue(replacement);
const prefix = value.slice(0, ctx.valueStart);
const suffix = value.slice(cursor);
const spacer = suffix.length && suffix[0] === ' ' ? '' : ' ';
const nextValue = `${prefix}${formatted}${spacer}${suffix}`;
const nextCursor = ctx.valueStart + formatted.length + spacer.length;
return { value: nextValue, cursor: nextCursor };
}
export function collectAuthorHintsFromThreads(
threads: Array<{ sender: string; senderDisplayName?: string }>
): TopicSearchAuthorHint[] {
const map = new Map<string, TopicSearchAuthorHint>();
for (const thread of threads) {
const userId = thread.sender;
if (!userId || map.has(userId)) {
continue;
}
const localpart = userId.startsWith('@') ? userId.slice(1).split(':')[0] : userId;
const displayName = (thread.senderDisplayName || localpart).trim() || localpart;
map.set(userId, { userId, displayName, localpart });
}
return [...map.values()].sort(
(a, b) => a.displayName.localeCompare(b.displayName) || a.localpart.localeCompare(b.localpart)
);
}
export function collectTitleHintsFromThreads(threads: Array<{ title: string }>): string[] {
const titles = new Set<string>();
for (const thread of threads) {
const title = String(thread.title || '').trim();
if (title) {
titles.add(title);
}
}
return [...titles].sort((a, b) => a.localeCompare(b));
}
export function matchAuthorHints(
partial: string,
hints: TopicSearchAuthorHint[]
): TopicSearchAuthorHint[] {
const prefix = String(partial || '').trim().toLowerCase();
return hints
.filter((hint) => {
if (!prefix) {
return true;
}
return (
hint.localpart.toLowerCase().includes(prefix) ||
hint.displayName.toLowerCase().includes(prefix) ||
hint.userId.toLowerCase().includes(prefix)
);
})
.slice(0, 12);
}
export function matchTitleHints(partial: string, titles: string[]): string[] {
const prefix = String(partial || '').trim().toLowerCase();
return titles
.filter((title) => !prefix || title.toLowerCase().includes(prefix))
.slice(0, 12);
}
export function insertTopicSearchQualifierAt(
value: string,
cursor: number,
qualifier: TopicSearchQualifier
): { value: string; cursor: number } {
const input = String(value || '');
const caret = Math.max(0, Math.min(cursor, input.length));
const partial = readQualifierPartialAt(input, caret);
const tokenStart = caret - partial.length;
const prefix = input.slice(0, tokenStart);
const suffix = input.slice(caret);
const spacer = suffix.length && suffix[0] === ' ' ? '' : ' ';
const nextValue = `${prefix}${qualifier.token}${spacer}${suffix}`;
const nextCursor = tokenStart + qualifier.token.length + spacer.length;
return { value: nextValue, cursor: nextCursor };
}
function escapeRegex(value: string): string {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function collectTerms(qualifiers: ParsedTopicSearch['qualifiers'], freeText: string): string[] {
const terms = new Set<string>();
for (const list of Object.values(qualifiers)) {
for (const value of list) {
const trimmed = String(value || '').trim();
if (trimmed) {
terms.add(trimmed.toLowerCase());
}
}
}
if (freeText) {
const quoted = [...freeText.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
let scratch = freeText;
for (const phrase of quoted) {
if (phrase.trim()) {
terms.add(phrase.trim().toLowerCase());
}
scratch = scratch.replace(`"${phrase}"`, ' ');
}
for (const word of scratch.split(/\s+/)) {
const trimmed = word.trim();
if (trimmed) {
terms.add(trimmed.toLowerCase());
}
}
}
return [...terms];
}
export function parseTopicSearchQuery(raw: string): ParsedTopicSearch {
const input = String(raw || '').trim();
const qualifiers: ParsedTopicSearch['qualifiers'] = {
intitle: [],
inpost: [],
inthread: [],
from: [],
};
if (!input) {
return { isActive: false, qualifiers, freeText: '', terms: [] };
}
let remainder = input;
for (const match of input.matchAll(QUALIFIER_RE)) {
const key = String(match[1] || '').toLowerCase();
const value = String(match[3] || match[4] || '').trim();
if (value) {
if (key === 'inbody') {
qualifiers.inpost.push(value);
} else if (key === 'inreply') {
qualifiers.inthread.push(value);
} else if (key === 'intitle') {
qualifiers.intitle.push(value);
} else if (key === 'inpost') {
qualifiers.inpost.push(value);
} else if (key === 'inthread') {
qualifiers.inthread.push(value);
} else if (key === 'from') {
qualifiers.from.push(value);
}
}
remainder = remainder.replace(match[0], ' ');
}
const freeText = remainder.replace(/\s+/g, ' ').trim();
const terms = collectTerms(qualifiers, freeText);
const isActive = Boolean(
freeText ||
qualifiers.intitle.length ||
qualifiers.inpost.length ||
qualifiers.inthread.length ||
qualifiers.from.length
);
return { isActive, qualifiers, freeText, terms };
}
function nodeText(node: ForumPost): string {
return [node.title || '', node.body || '', node.sender || '', node.senderDisplayName || '']
.join('\n')
.toLowerCase();
}
function walkReplyTree(node: ForumPost, visit: (node: ForumPost) => void): void {
for (const reply of node.replies || []) {
visit(reply);
walkReplyTree(reply, visit);
}
}
function termsMatchAll(terms: string[], haystack: string): boolean {
const text = String(haystack || '').toLowerCase();
return terms.every((term) => text.includes(String(term || '').toLowerCase()));
}
function termsMatchAny(terms: string[], haystack: string): boolean {
const text = String(haystack || '').toLowerCase();
return terms.some((term) => text.includes(String(term || '').toLowerCase()));
}
export function threadMatchesSearch(post: ForumPost, parsed: ParsedTopicSearch): boolean {
if (!parsed?.isActive || !post) {
return true;
}
const { qualifiers, freeText, terms } = parsed;
const titleHay = String(post.title || '').toLowerCase();
const bodyHay = String(post.body || '').toLowerCase();
const senderHay = `${post.sender || ''}\n${post.senderDisplayName || ''}`.toLowerCase();
const fullHay = `${titleHay}\n${bodyHay}\n${senderHay}`;
if (qualifiers.intitle.length && !termsMatchAll(qualifiers.intitle, titleHay)) {
return false;
}
if (qualifiers.inpost.length && !termsMatchAll(qualifiers.inpost, bodyHay)) {
return false;
}
if (qualifiers.from.length && !termsMatchAll(qualifiers.from, senderHay)) {
return false;
}
if (qualifiers.inthread.length) {
let replyHit = false;
walkReplyTree(post, (reply) => {
if (termsMatchAll(qualifiers.inthread, nodeText(reply))) {
replyHit = true;
}
});
if (!replyHit) {
return false;
}
}
if (freeText) {
let anywhere = termsMatchAny(terms, fullHay);
if (!anywhere) {
walkReplyTree(post, (reply) => {
if (termsMatchAny(terms, nodeText(reply))) {
anywhere = true;
}
});
}
if (!anywhere) {
return false;
}
}
return true;
}
export function filterPostsBySearch(posts: ForumPost[], parsed: ParsedTopicSearch): ForumPost[] {
if (!parsed?.isActive) {
return posts || [];
}
return (posts || []).filter((post) => threadMatchesSearch(post, parsed));
}
export function applyTopicStatusSort<T extends { lastActivityTs: number; timestamp: number; totalReplies: number; eventId: string }>(
threads: T[],
statusFilter: string,
sortBy: string
): T[] {
let filtered = threads || [];
if (statusFilter === 'open') {
filtered = filtered.filter((thread) => thread.lastActivityTs >= Date.now() - 30 * 86400000);
} else if (statusFilter === 'quiet') {
filtered = filtered.filter((thread) => thread.lastActivityTs < Date.now() - 30 * 86400000);
}
const sorted = [...filtered];
if (sortBy === 'top') {
sorted.sort(
(a, b) =>
b.totalReplies - a.totalReplies ||
b.lastActivityTs - a.lastActivityTs ||
b.eventId.localeCompare(a.eventId)
);
} else if (sortBy === 'new') {
sorted.sort((a, b) => b.timestamp - a.timestamp || b.eventId.localeCompare(a.eventId));
} else {
sorted.sort((a, b) => {
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
return hotB - hotA || b.lastActivityTs - a.lastActivityTs || b.eventId.localeCompare(a.eventId);
});
}
return sorted;
}
export function highlightSearchTerms(text: string, terms: string[]): string {
if (!terms.length) {
return text;
}
const usable = [...new Set(terms.map((term) => String(term || '').trim()).filter(Boolean))].sort(
(a, b) => b.length - a.length
);
let result = text;
for (const term of usable) {
const re = new RegExp(`(${escapeRegex(term)})`, 'gi');
result = result.replace(re, '<mark class="search-hit">$1</mark>');
}
return result;
}

View File

@@ -0,0 +1,68 @@
export type ForumTopic = {
roomId: string;
name: string;
topic?: string;
canonicalAlias?: string;
memberCount?: number;
isSpace: boolean;
parentRoomId?: string;
};
export type ForumSection = {
/** Child space room id when this section is a subspace; omitted for “General Topics”. */
spaceId?: string;
title: string;
description: string | null;
topics: ForumTopic[];
};
export type ForumPost = {
eventId: string;
title: string;
body: string;
bodyHtml?: string;
sender: string;
senderDisplayName?: string;
timestamp: number;
topicRoomId?: string;
sectionTitle?: string;
topicName?: string;
bundledReplyCount?: number;
replies: ForumPost[];
replyToEventId?: string;
replyToDeleted?: boolean;
};
export type ForumThreadSummary = {
eventId: string;
title: string;
body: string;
sender: string;
senderDisplayName?: string;
timestamp: number;
topicRoomId?: string;
sectionTitle?: string;
topicName?: string;
totalReplies: number;
lastActivityTs: number;
isPinned?: boolean;
};
export type ForumPublishedPost = {
eventId: string;
topicRoomId: string;
title: string;
plainText: string;
formattedHtml: string;
};
export type ForumBoardQuery = {
category?: string;
topic?: string;
post?: string;
/** Room for the selected post (thread detail only; does not change filter dropdowns). */
postRoom?: string;
q?: string;
status?: 'all' | 'open' | 'quiet';
sort?: 'hot' | 'new' | 'top';
};

View File

@@ -0,0 +1,375 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { createRoomModalAtom } from '../../state/createRoomModal';
import { createSpaceModalAtom } from '../../state/createSpaceModal';
import { StateEvent } from '../../../types/matrix/room';
import {
buildForumSections,
buildTopicThreads,
buildThreadSummaries,
} from './forumTopicHelpers';
import { applyTopicStatusSort } from './topicSearch';
import {
createForumPostFromPayload,
listForumTopics,
listTopicFeedPosts,
loadAggregatedTopicFeed,
} from './forumFeed';
import type { ForumBoardQuery, ForumPublishedPost, ForumSection, ForumThreadSummary } from './types';
export type ForumBoardScope = {
forumSpaceId: string;
/** When set, only show posts from this topic room. */
topicRoomId?: string;
};
function scopesFromSelection(
sections: ForumSection[],
category?: string,
topic?: string
): Array<{ roomId: string; sectionTitle?: string; topicName?: string }> {
if (topic) {
for (const section of sections) {
const match = section.topics.find((t) => t.roomId === topic);
if (match) {
return [{ roomId: match.roomId, sectionTitle: section.title, topicName: match.name }];
}
}
return [{ roomId: topic }];
}
if (category) {
const section = sections.find((s) => s.title === category);
if (!section) return [];
return section.topics.map((t) => ({
roomId: t.roomId,
sectionTitle: section.title,
topicName: t.name,
}));
}
return sections.flatMap((section) =>
section.topics.map((t) => ({
roomId: t.roomId,
sectionTitle: section.title,
topicName: t.name,
}))
);
}
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
const mx = useMatrixClient();
const [searchParams, setSearchParams] = useSearchParams();
const filterQuery: ForumBoardQuery = useMemo(
() => ({
category: searchParams.get('category') || undefined,
topic: scope.topicRoomId || searchParams.get('topic') || undefined,
q: searchParams.get('q') || undefined,
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
}),
[searchParams, scope.topicRoomId]
);
const query: ForumBoardQuery = useMemo(
() => ({
...filterQuery,
post: searchParams.get('post') || undefined,
postRoom: searchParams.get('postRoom') || undefined,
}),
[filterQuery, searchParams]
);
const [sections, setSections] = useState<ForumSection[]>([]);
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
const [loadingSections, setLoadingSections] = useState(true);
const [loadingThreads, setLoadingThreads] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const setQueryParam = useCallback(
(key: string, value: string | null) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (!value) next.delete(key);
else next.set(key, value);
if (key !== 'post') {
next.delete('post');
next.delete('postRoom');
}
return next;
},
{ replace: true }
);
},
[setSearchParams]
);
const setCategory = useCallback(
(category: string | null) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (!category) next.delete('category');
else next.set('category', category);
next.delete('topic');
next.delete('post');
next.delete('postRoom');
return next;
},
{ replace: true }
);
},
[setSearchParams]
);
const loadSections = useCallback(async () => {
setLoadingSections(true);
setError(null);
try {
const topics = await listForumTopics(mx, scope.forumSpaceId);
setSections(buildForumSections(topics));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum categories');
} finally {
setLoadingSections(false);
}
}, [mx, scope.forumSpaceId]);
const refreshThreads = useCallback(async () => {
setLoadingThreads(true);
setError(null);
try {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
if (roomScopes.length === 0) {
setThreads([]);
return;
}
let posts;
if (roomScopes.length === 1) {
const result = await listTopicFeedPosts(mx, roomScopes[0].roomId);
posts = result.posts;
setPostsNextBatch(result.nextBatch);
const scopeMeta = roomScopes[0];
posts.forEach((post) => {
post.topicRoomId = scopeMeta.roomId;
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
});
} else {
posts = await loadAggregatedTopicFeed(mx, roomScopes);
setPostsNextBatch(null);
}
setThreads(buildTopicThreads(posts, filterQuery));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
} finally {
setLoadingThreads(false);
}
}, [mx, sections, filterQuery]);
useEffect(() => {
loadSections();
}, [loadSections]);
useEffect(() => {
const handleSpaceChild = (event: MatrixEvent) => {
if (event.getType() !== StateEvent.SpaceChild) return;
const roomId = event.getRoomId();
if (!roomId) return;
if (
roomId === forumSpace.roomId ||
sections.some((section) => section.spaceId === roomId)
) {
loadSections();
}
};
mx.on(RoomStateEvent.Events, handleSpaceChild);
return () => {
mx.removeListener(RoomStateEvent.Events, handleSpaceChild);
};
}, [mx, forumSpace.roomId, sections, loadSections]);
const createSpaceModal = useAtomValue(createSpaceModalAtom);
const createRoomModal = useAtomValue(createRoomModalAtom);
const prevCreateSpaceModal = useRef(createSpaceModal);
const prevCreateRoomModal = useRef(createRoomModal);
useEffect(() => {
if (prevCreateSpaceModal.current && !createSpaceModal) {
loadSections();
}
if (prevCreateRoomModal.current && !createRoomModal) {
loadSections();
}
prevCreateSpaceModal.current = createSpaceModal;
prevCreateRoomModal.current = createRoomModal;
}, [createSpaceModal, createRoomModal, loadSections]);
useEffect(() => {
if (!loadingSections) {
refreshThreads();
}
}, [loadingSections, refreshThreads]);
useEffect(() => {
const timer = window.setInterval(() => {
refreshThreads();
}, 30_000);
return () => window.clearInterval(timer);
}, [refreshThreads]);
const selectedPostId = query.post ?? null;
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
const activeTopicRoomId =
query.postRoom || selectedThread?.topicRoomId || query.topic || scope.topicRoomId || null;
const selectPost = useCallback(
(eventId: string | null, topicRoomId?: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (!eventId) {
next.delete('post');
next.delete('postRoom');
} else {
next.set('post', eventId);
if (topicRoomId) next.set('postRoom', topicRoomId);
else next.delete('postRoom');
}
return next;
},
{ replace: true }
);
},
[setSearchParams]
);
const insertThreadFromNewPost = useCallback(
(published: ForumPublishedPost): ForumPost | null => {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
const inScope =
filterQuery.topic === published.topicRoomId ||
(!filterQuery.topic &&
(filterQuery.category
? roomScopes.some((s) => s.roomId === published.topicRoomId)
: true));
const scopeMeta = roomScopes.find((s) => s.roomId === published.topicRoomId);
const post = createForumPostFromPayload(
mx,
published.topicRoomId,
published.eventId,
{
title: published.title,
plainText: published.plainText,
formattedHtml: published.formattedHtml,
},
{
topicRoomId: published.topicRoomId,
sectionTitle: scopeMeta?.sectionTitle,
topicName: scopeMeta?.topicName,
}
);
if (inScope) {
const [summary] = buildThreadSummaries([post]);
if (summary) {
setThreads((prev) => {
const without = prev.filter((t) => t.eventId !== summary.eventId);
const merged = [summary, ...without];
return applyTopicStatusSort(merged, filterQuery.status || 'all', filterQuery.sort || 'hot');
});
}
}
return post;
},
[mx, sections, filterQuery]
);
const recordThreadReply = useCallback((rootEventId: string) => {
setThreads((prev) =>
prev.map((t) =>
t.eventId === rootEventId
? {
...t,
totalReplies: t.totalReplies + 1,
lastActivityTs: Date.now(),
}
: t
)
);
}, []);
const loadMorePosts = useCallback(async () => {
if (!postsNextBatch || loadingMore) return;
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
if (roomScopes.length !== 1) return;
setLoadingMore(true);
setError(null);
try {
const scopeMeta = roomScopes[0];
const result = await listTopicFeedPosts(mx, scopeMeta.roomId, {
from: postsNextBatch,
minRoots: 1,
});
setPostsNextBatch(result.nextBatch);
result.posts.forEach((post) => {
post.topicRoomId = scopeMeta.roomId;
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
});
setThreads((prev) => {
const existingIds = new Set(prev.map((t) => t.eventId));
const added = buildTopicThreads(result.posts, filterQuery).filter(
(t) => !existingIds.has(t.eventId)
);
const merged = [...prev, ...added];
const sort = filterQuery.sort || 'hot';
merged.sort((a, b) => {
if (sort === 'new') return b.timestamp - a.timestamp;
if (sort === 'top') return b.totalReplies - a.totalReplies;
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
return hotB - hotA;
});
return merged;
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load older posts');
} finally {
setLoadingMore(false);
}
}, [postsNextBatch, loadingMore, sections, filterQuery, mx]);
return {
forumSpace,
sections,
threads,
query,
loadingSections,
loadingThreads,
error,
selectedPostId,
selectedThread,
activeTopicRoomId,
setQueryParam,
setCategory,
selectPost,
refreshThreads,
insertThreadFromNewPost,
recordThreadReply,
loadMorePosts,
hasMorePosts: Boolean(postsNextBatch),
loadingMore,
};
}

View File

@@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { isSpace } from '../../utils/room';
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
export function JoinBeforeNavigate({
@@ -23,7 +24,7 @@ export function JoinBeforeNavigate({
const screenSize = useScreenSizeContext();
const handleView = (roomId: string) => {
if (mx.getRoom(roomId)?.isSpaceRoom()) {
if (isSpace(mx.getRoom(roomId) ?? null)) {
navigateSpace(roomId);
return;
}

View File

@@ -40,7 +40,8 @@ import { getSpaceRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { getStateEvent, shouldShowForumLobby } from '../../utils/room';
import { ForumSpaceShell } from '../forum/ForumSpaceShell';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
@@ -149,7 +150,19 @@ const useCanDropLobbyItem = (
return canDrop;
};
function ForumSpaceLobby() {
return <ForumSpaceShell />;
}
export function Lobby() {
const space = useSpace();
if (shouldShowForumLobby(space)) {
return <ForumSpaceLobby />;
}
return <SpaceCardLobby />;
}
function SpaceCardLobby() {
const navigate = useNavigate();
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);

View File

@@ -1,4 +1,4 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import React, { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import {
Avatar,
Box,
@@ -38,6 +38,12 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { AddExistingModal } from '../add-existing';
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
import { SpaceOptionsMenu, type OpenAddExistingOptions } from '../space/SpaceOptionsMenu';
import type { HierarchyItemSpace } from '../../hooks/useSpaceHierarchy';
import { StateEvent } from '../../../types/matrix/room';
import { useForumBoardContextOptionally } from '../forum/ForumBoardContext';
type LobbyMenuProps = {
powerLevels: IPowerLevels;
@@ -137,14 +143,19 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
type LobbyHeaderProps = {
showProfile?: boolean;
/** Space name on the left + add room/space + ⋮ menu (forum toolbar). */
showSpaceToolbar?: boolean;
powerLevels: IPowerLevels;
};
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: LobbyHeaderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const space = useSpace();
const creators = useRoomCreators(space);
const spacePermissions = useRoomPermissions(creators, powerLevels);
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
const screenSize = useScreenSizeContext();
const name = useRoomName(space);
@@ -153,10 +164,192 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const forumBoard = useForumBoardContextOptionally();
const menuWrapperRef = useRef<HTMLDivElement | null>(null);
const menuTriggerElRef = useRef<HTMLButtonElement | null>(null);
const addParent = useMemo(() => {
const rootId = space.roomId;
if (!forumBoard) {
return { roomId: rootId, showAddSpace: true };
}
if (forumBoard.query.category) {
const section = forumBoard.sections.find((s) => s.title === forumBoard.query.category);
if (section?.spaceId) {
return { roomId: section.spaceId, showAddSpace: false };
}
}
return { roomId: rootId, showAddSpace: true };
}, [forumBoard, space.roomId]);
const addParentItem: HierarchyItemSpace = useMemo(
() => ({
roomId: addParent.roomId,
content: { via: [] },
ts: 0,
space: true,
parentId: addParent.roomId === space.roomId ? undefined : space.roomId,
}),
[addParent.roomId, space.roomId]
);
const canEditChildren = Boolean(
spacePermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
menuTriggerElRef.current = evt.currentTarget;
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
useEffect(() => {
if (!showSpaceToolbar || !menuAnchor || addExisting) return;
let outsideTimer: number | undefined;
const clearOutsideTimer = () => {
if (outsideTimer) {
window.clearTimeout(outsideTimer);
outsideTimer = undefined;
}
};
const closeMenu = () => {
clearOutsideTimer();
setMenuAnchor(undefined);
};
const isInside = (node: Node | null) => {
if (!node) return false;
return Boolean(
menuWrapperRef.current?.contains(node) || menuTriggerElRef.current?.contains(node)
);
};
const handlePointerDown = (evt: MouseEvent | TouchEvent) => {
const node = evt.target as Node | null;
if (!isInside(node)) {
closeMenu();
}
};
const handlePointerMove = (evt: PointerEvent | MouseEvent) => {
const node = evt.target as Node | null;
const inside = isInside(node);
if (inside) {
clearOutsideTimer();
return;
}
if (!outsideTimer) {
outsideTimer = window.setTimeout(closeMenu, 3000);
}
};
document.addEventListener('mousedown', handlePointerDown, true);
document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('pointermove', handlePointerMove, true);
document.addEventListener('mousemove', handlePointerMove, true);
return () => {
clearOutsideTimer();
document.removeEventListener('mousedown', handlePointerDown, true);
document.removeEventListener('touchstart', handlePointerDown, true);
document.removeEventListener('pointermove', handlePointerMove, true);
document.removeEventListener('mousemove', handlePointerMove, true);
};
}, [addExisting, menuAnchor, showSpaceToolbar]);
if (showSpaceToolbar) {
return (
<>
<PageHeader outlined>
<Box grow="Yes" alignItems="Center" gap="300">
<Avatar size="300" shrink="No">
<RoomAvatar
roomId={space.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
/>
</Avatar>
<Text size="H3" truncate style={{ minWidth: 0 }}>
{name}
</Text>
<Box grow="Yes" />
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<div ref={menuWrapperRef}>
<SpeechBubble variant="menu" tail="top" tailX="calc(100% - 22px)">
<SpaceOptionsMenu
room={space}
requestClose={() => setMenuAnchor(undefined)}
onOpenAddExisting={(options) => {
setAddExisting(options);
setMenuAnchor(undefined);
}}
forumAdd={
forumBoard && canEditChildren
? {
item: addParentItem,
showAddSpace: addParent.showAddSpace,
}
: undefined
}
/>
</SpeechBubble>
</div>
</FocusTrap>
}
/>
</Box>
</PageHeader>
{addExisting && (
<AddExistingModal
parentId={addExisting.parentId}
space={addExisting.space}
requestClose={() => setAddExisting(null)}
/>
)}
</>
);
}
return (
<PageHeader className={showProfile ? undefined : css.Header} balance>
<Box grow="Yes" alignItems="Center" gap="200">

View File

@@ -0,0 +1,171 @@
import React, { MouseEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Box, Chip, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { stopPropagation } from '../../utils/keyboard';
function AddRoomButton({ item, roomLabel }: { item: HierarchyItem; roomLabel?: string }) {
const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoomModal(item.roomId);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<>
<PopOut
anchor={cords}
position="Bottom"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
>
<Text size="T300">New Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Primary"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddRoom}
aria-pressed={!!cords}
>
<Text size="B300">{roomLabel ?? 'Add Room'}</Text>
</Chip>
</PopOut>
{addExisting && (
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</>
);
}
function AddSpaceButton({ item, spaceLabel }: { item: HierarchyItem; spaceLabel?: string }) {
const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as string);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<>
<PopOut
anchor={cords}
position="Bottom"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateSpace}
>
<Text size="T300">New Space</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Space</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="SurfaceVariant"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddSpace}
aria-pressed={!!cords}
>
<Text size="B300">{spaceLabel ?? 'Add Space'}</Text>
</Chip>
</PopOut>
{addExisting && (
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</>
);
}
type SpaceAddControlsProps = {
item: HierarchyItem;
/** Show “Add Space” (root / top-level space only). */
showAddSpace?: boolean;
roomLabel?: string;
spaceLabel?: string;
};
/** Add room / add sub-space chips from the space lobby. */
export function SpaceAddControls({
item,
showAddSpace = false,
roomLabel,
spaceLabel,
}: SpaceAddControlsProps) {
return (
<Box direction="Row" alignItems="Center" gap="200">
<AddRoomButton item={item} roomLabel={roomLabel} />
{showAddSpace && <AddSpaceButton item={item} spaceLabel={spaceLabel} />}
</Box>
);
}

View File

@@ -73,7 +73,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
const subspaces = useMemo(() => {
const s: Map<string, IHierarchyRoom> = new Map();
rooms.forEach((r) => {
if (r.room_type === RoomType.Space) {
if (r.room_type === RoomType.Space || r.room_type === RoomType.Forum) {
s.set(r.room_id, r);
}
});

View File

@@ -33,9 +33,7 @@ import { useDraggableItem } from './DnD';
import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { SpaceAddControls } from './SpaceAddControls';
function SpaceProfileLoading() {
return (
@@ -240,141 +238,6 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
);
}
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoomModal(item.roomId);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
>
<Text size="T300">New Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Primary"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddRoom}
aria-pressed={!!cords}
>
<Text size="B300">Add Room</Text>
</Chip>
{addExisting && (
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as any);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateSpace}
>
<Text size="T300">New Space</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Space</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="SurfaceVariant"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddSpace}
aria-pressed={!!cords}
>
<Text size="B300">Add Space</Text>
</Chip>
{addExisting && (
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
@@ -484,8 +347,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
</Box>
{space && canEditChild && (
<Box shrink="No" alignItems="Inherit" gap="200">
<AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />}
<SpaceAddControls item={item} showAddSpace={item.parentId === undefined} />
</Box>
)}
</Box>

View File

@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
import { color } from 'folds';
export const ForumBanner = style({
borderLeft: `3px solid ${color.Primary.Main}`,
background: color.Primary.Container,
color: color.Primary.OnContainer,
});

View File

@@ -0,0 +1,50 @@
import React, { useEffect } from 'react';
import { Room } from 'matrix-js-sdk';
import { useSearchParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { findForumRootSpace, shouldShowForumLobby } from '../../utils/room';
import { ForumSpaceShell } from '../forum/ForumSpaceShell';
import { RoomView } from './RoomView';
type ForumRoomViewProps = {
room: Room;
eventId?: string;
};
export function ForumRoomView({ room, eventId }: ForumRoomViewProps) {
const mx = useMatrixClient();
const space = useSpaceOptionally();
const roomToParents = useAtomValue(roomToParentsAtom);
const [, setSearchParams] = useSearchParams();
const forumSpaceId =
space && shouldShowForumLobby(space)
? space.roomId
: findForumRootSpace(mx, room.roomId, roomToParents);
const forumSpace = forumSpaceId ? mx.getRoom(forumSpaceId) : null;
useEffect(() => {
if (!eventId) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set('post', eventId);
if (!next.get('topic') && room.roomId !== forumSpaceId) {
next.set('topic', room.roomId);
}
return next;
},
{ replace: true }
);
}, [eventId, setSearchParams, room.roomId, forumSpaceId]);
if (forumSpace && shouldShowForumLobby(forumSpace)) {
return <ForumSpaceShell />;
}
return <RoomView room={room} eventId={eventId} />;
}

View File

@@ -15,6 +15,8 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { isForum } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView';
export function Room() {
const { eventId } = useParams();
@@ -28,6 +30,7 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx);
const forumRoom = isForum(room);
// Update titlebar with current room ID
useEffect(() => {
@@ -50,7 +53,7 @@ export function Room() {
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
<RoomView room={room} eventId={eventId} />
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
{screenSize === ScreenSize.Desktop && isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />

View File

@@ -132,9 +132,10 @@ interface RoomInputProps {
room: Room;
/** When provided, all messages are sent as replies in this thread. */
threadRootId?: string;
onMessageSent?: () => void;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
({ editor, fileDropContainerRef, roomId, room, threadRootId, onMessageSent }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -480,7 +481,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setReplyDraft(undefined);
setCommandHint(undefined);
sendTypingStatus(false);
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
onMessageSent?.();
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons, onMessageSent]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {

View File

@@ -48,6 +48,7 @@ import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { copyToClipboard } from '../../utils/dom';
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
@@ -316,35 +317,19 @@ function CallIndicator({ roomId }: CallIndicatorProps) {
position="Bottom"
offset={8}
tooltip={
<Box
direction="Column"
gap="50"
style={{
padding: `${toRem(8)} ${toRem(12)}`,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderRadius: toRem(8),
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
position: 'relative',
}}
>
{/* Speech bubble arrow */}
<div
style={{
position: 'absolute',
top: toRem(-6),
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: `${toRem(6)} solid transparent`,
borderRight: `${toRem(6)} solid transparent`,
borderBottom: `${toRem(6)} solid ${color.SurfaceVariant.Container}`,
}}
/>
<Text size="T300" style={{ fontWeight: 600, color: color.SurfaceVariant.OnContainer }}>{displayName}</Text>
<Text size="T200" style={{ opacity: 0.7, color: color.SurfaceVariant.OnContainer }}>{member.userId}</Text>
</Box>
<SpeechBubble tail="top" tailX="50%">
<Box direction="Column" gap="50">
<Text
size="T300"
style={{ fontWeight: 600, color: color.SurfaceVariant.OnContainer }}
>
{displayName}
</Text>
<Text size="T200" style={{ opacity: 0.7, color: color.SurfaceVariant.OnContainer }}>
{member.userId}
</Text>
</Box>
</SpeechBubble>
}
>
{(triggerRef) => (
@@ -490,7 +475,11 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
);
}
export function RoomViewHeader() {
type RoomViewHeaderProps = {
forumLayout?: boolean;
};
export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const navigate = useNavigate();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -564,9 +553,16 @@ export function RoomViewHeader() {
</Avatar>
)}
<Box direction="Column">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
<Box alignItems="Center" gap="100">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
{forumLayout && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="T200">Forum</Text>
</Badge>
)}
</Box>
{topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (

View File

@@ -0,0 +1,306 @@
import React, { forwardRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { Box, Icon, Icons, Line, Menu, MenuItem, Text, config, toRem } from 'folds';
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomsUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { copyToClipboard } from '../../utils/dom';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
type ForumAddOptions = {
item: HierarchyItem;
showAddSpace: boolean;
};
export type OpenAddExistingOptions = {
parentId: string;
space: boolean;
};
type SpaceOptionsMenuProps = {
room: Room;
requestClose: () => void;
forumAdd?: ForumAddOptions;
/** Render Add Existing outside the popout (forum kebab). */
onOpenAddExisting?: (options: OpenAddExistingOptions) => void;
};
export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps>(
({ room, requestClose, forumAdd, onOpenAddExisting }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const [invitePrompt, setInvitePrompt] = useState(false);
const [addExistingRoom, setAddExistingRoom] = useState(false);
const [addExistingSpace, setAddExistingSpace] = useState(false);
const openCreateRoomModal = useOpenCreateRoomModal();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
markRoomsAsRead(allChild, hideActivity);
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 = () => {
openSpaceSettings(room.roomId);
requestClose();
};
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
const handleAddTopic = () => {
if (!forumAdd) return;
openCreateRoomModal(forumAdd.item.roomId);
requestClose();
};
const handleAddCategory = () => {
if (!forumAdd) return;
openCreateSpaceModal(forumAdd.item.roomId);
requestClose();
};
const handleAddExistingTopic = () => {
if (!forumAdd) return;
if (onOpenAddExisting) {
onOpenAddExisting({ parentId: forumAdd.item.roomId, space: false });
requestClose();
return;
}
setAddExistingRoom(true);
};
const handleAddExistingCategory = () => {
if (!forumAdd) return;
if (onOpenAddExisting) {
onOpenAddExisting({ parentId: forumAdd.item.roomId, space: true });
requestClose();
return;
}
setAddExistingSpace(true);
};
return (
<>
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{forumAdd && (
<>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleAddTopic}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Plus} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Add Topic
</Text>
</MenuItem>
<MenuItem
onClick={handleAddExistingTopic}
size="300"
radii="300"
fill="None"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Add Existing Topic
</Text>
</MenuItem>
{forumAdd.showAddSpace && (
<>
<MenuItem
onClick={handleAddCategory}
size="300"
after={<Icon size="100" src={Icons.Plus} />}
radii="300"
fill="None"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Add Section
</Text>
</MenuItem>
<MenuItem
onClick={handleAddExistingCategory}
size="300"
radii="300"
fill="None"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Add Existing Section
</Text>
</MenuItem>
</>
)}
</Box>
<Line variant="Surface" size="300" />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
</Text>
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
{forumAdd && addExistingRoom && (
<AddExistingModal parentId={forumAdd.item.roomId} requestClose={() => setAddExistingRoom(false)} />
)}
{forumAdd && addExistingSpace && (
<AddExistingModal
space
parentId={forumAdd.item.roomId}
requestClose={() => setAddExistingSpace(false)}
/>
)}
</>
);
}
);

View File

@@ -11,14 +11,15 @@ export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHan
);
export const useFileDropZone = (
zoneRef: RefObject<HTMLElement>,
zoneRef: RefObject<HTMLElement> | undefined,
onDrop: (file: File[]) => void
): boolean => {
const dragStateRef = useRef<'start' | 'leave' | 'over'>();
const [active, setActive] = useState(false);
useEffect(() => {
const target = zoneRef.current;
const target = zoneRef?.current;
if (!target) return undefined;
const handleDrop = (evt: DragEvent) => {
evt.preventDefault();
dragStateRef.current = undefined;
@@ -35,7 +36,9 @@ export const useFileDropZone = (
}, [zoneRef, onDrop]);
useEffect(() => {
const target = zoneRef.current;
const target = zoneRef?.current;
if (!target) return undefined;
const handleDragEnter = (evt: DragEvent) => {
if (evt.dataTransfer?.types.includes('Files')) {
dragStateRef.current = 'start';

View File

@@ -9,7 +9,7 @@ import {
getSpaceRoomPath,
} from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent } from '../utils/room';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedSpace } from './router/useSelectedSpace';
@@ -34,9 +34,15 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
const room = mx.getRoom(roomId);
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
if (room && isSpace(room) && !openSpaceTimeline) {
navigate(getSpacePath(roomIdOrAlias), opts);
return;
}
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
let parentSpace: string;

View File

@@ -6,7 +6,7 @@ import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query';
import { useMatrixClient } from './useMatrixClient';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
import { getAllParents, getStateEvents, isSpace, isValidChild } from '../utils/room';
import { isRoomId } from '../utils/matrix';
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
import { useStateEventCallback } from './useStateEventCallback';
@@ -64,7 +64,7 @@ const getHierarchySpaces = (
// because we can not find if a childId is space without joining
// or requesting room summary, we will look it into spaceRooms local
// cache which we maintain as we load summary in UI.
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
if (isSpace(getRoom(childId) ?? null) || spaceRooms.has(childId)) {
const childItem: HierarchyItemSpace = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
@@ -114,7 +114,7 @@ const getSpaceHierarchy = (
if (!isValidChild(childEvent)) return;
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return;
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
if (isSpace(getRoom(childId) ?? null) || spaceRooms.has(childId)) return;
const childItem: HierarchyItemRoom = {
roomId: childId,
@@ -189,7 +189,7 @@ const getSpaceJoinedHierarchy = (
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return false;
const room = getRoom(childId);
if (!room || room.isSpaceRoom()) return false;
if (!room || isSpace(room)) return false;
return true;
});

View File

@@ -11,6 +11,7 @@ import { MatrixClient } from 'matrix-js-sdk';
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
import { getCallService } from './features/call/useCall';
import { RoomType, StateEvent } from '../types/matrix/room';
/**
* Global reference to the router navigate function
@@ -23,6 +24,21 @@ let navigateFunction: ((path: string) => void) | null = null;
*/
let isInitialized = false;
/**
* @param {any} room
* @returns {boolean}
*/
function isSpaceLikeRoom(room: any): boolean {
if (!room) return false;
if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) return true;
const roomKindEvent = room.currentState?.getStateEvents?.(StateEvent.PaarrotRoomKind, '');
const roomKind = roomKindEvent?.getContent?.()?.kind;
if (roomKind === 'forum_space') return true;
const createEvent = room.currentState?.getStateEvents?.(StateEvent.RoomCreate, '');
const roomType = createEvent?.getContent?.()?.type;
return roomType === RoomType.Forum;
}
/**
* Set the navigate function for programmatic navigation
* Call this from your router setup
@@ -291,7 +307,7 @@ async function changeChannel(matrixClient: any, roomId: string) {
if (isDirect) {
// Navigate to direct message
path = getDirectRoomPath(roomIdOrAlias);
} else if (room.isSpaceRoom()) {
} else if (isSpaceLikeRoom(room)) {
// Navigate to space
path = getSpaceRoomPath(roomIdOrAlias, roomIdOrAlias);
} else {
@@ -316,7 +332,7 @@ async function getChannels(matrixClient: any) {
const rooms = matrixClient?.getRooms() || [];
return rooms
.filter((room: any) => !room.isSpaceRoom())
.filter((room: any) => !isSpaceLikeRoom(room))
.map((room: any) => {
// Extract server domain from roomId (!localpart:domain.tld)
const serverMatch = room.roomId.match(/:(.+)$/);

View File

@@ -43,6 +43,8 @@ import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumSpaceLayout } from '../features/forum/ForumSpaceLayout';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
@@ -236,15 +238,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
path={SPACE_PATH}
element={
<RouteSpaceProvider>
<PageRoot
nav={
<MobileFriendlyPageNav path={SPACE_PATH}>
<Space />
</MobileFriendlyPageNav>
}
>
<AnimatedOutlet />
</PageRoot>
<ForumSpaceLayout>
<ForumAwareSpaceLayout />
</ForumSpaceLayout>
</RouteSpaceProvider>
}
>

View File

@@ -5,7 +5,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useOrphanRooms } from '../../../state/hooks/roomList';
import { StateEvent } from '../../../../types/matrix/room';
import { RoomType, StateEvent } from '../../../../types/matrix/room';
export const useHomeRooms = () => {
const mx = useMatrixClient();
@@ -15,6 +15,25 @@ export const useHomeRooms = () => {
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
const rooms = useMemo(() => {
const isSpaceLikeRoom = (roomId: string): boolean => {
const room = mx.getRoom(roomId);
if (!room) return false;
if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) {
return true;
}
const roomKindEvent = room.currentState.getStateEvents(StateEvent.PaarrotRoomKind, '');
const roomKind = roomKindEvent?.getContent<{ kind?: string }>()?.kind;
if (roomKind === 'forum_space') {
return true;
}
const createEvent = room.currentState.getStateEvents(StateEvent.RoomCreate, '');
const roomType = createEvent?.getContent<{ type?: string }>()?.type;
return roomType === RoomType.Space || roomType === RoomType.Forum;
};
// Build a set of all sub-room IDs from ALL joined rooms
const allSubRoomIds = new Set<string>();
mx.getRooms().forEach((room) => {
@@ -24,7 +43,7 @@ export const useHomeRooms = () => {
});
// Return only rooms that are not sub-rooms of another room
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId));
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId));
}, [mx, orphanRooms]);
return rooms;

View File

@@ -6,7 +6,7 @@ import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace';
import { getAllParents, getSpaceChildren } from '../../../utils/room';
import { getAllParents, getSpaceChildren, isSpace } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
@@ -38,7 +38,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
if (developerTools && isSpace(room) && room.roomId === space.roomId) {
// allow to view space timeline
return (
<RoomProvider key={room.roomId} value={room}>

View File

@@ -85,165 +85,8 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
markRoomsAsRead(allChild, hideActivity);
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 = () => {
openSpaceSettings(room.roomId);
requestClose();
};
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
</Text>
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
import { isSpace, shouldShowForumLobby } from '../../../utils/room';
import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
function SpaceHeader() {
const space = useSpace();
@@ -298,7 +141,7 @@ function SpaceHeader() {
escapeDeactivates: stopPropagation,
}}
>
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
<SpaceOptionsMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
@@ -455,7 +298,7 @@ export function Space() {
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
const room = mx.getRoom(parentRoomId);
if (!room || room.isSpaceRoom()) return;
if (!room || isSpace(room)) return;
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
@@ -484,7 +327,7 @@ export function Space() {
// Add sub-rooms after each non-space room
const room = mx.getRoom(entry.roomId);
if (room && !room.isSpaceRoom()) {
if (room && !isSpace(room)) {
addSubRooms(entry.roomId, 0);
}
});
@@ -506,6 +349,10 @@ export function Space() {
const getToLink = (roomId: string) =>
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
if (shouldShowForumLobby(space)) {
return null;
}
return (
<PageNav>
<SpaceHeader />
@@ -616,7 +463,7 @@ export function Space() {
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
if (isSpace(room)) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (

View File

@@ -72,25 +72,101 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool
return content?.is_direct === true;
};
/**
* Read room create type with a resilient fallback for rooms whose live timeline state
* does not currently expose m.room.create.
*/
const getRoomCreateType = (room: Room): string | undefined => {
const roomKindEvent = room.currentState?.getStateEvents(StateEvent.PaarrotRoomKind, '');
const roomKind = roomKindEvent?.getContent<{ kind?: string }>()?.kind;
if (roomKind === 'forum_space') {
return RoomType.Forum;
}
const liveCreate = getStateEvent(room, StateEvent.RoomCreate);
const liveType = liveCreate?.getContent<{ type?: string }>()?.type;
if (typeof liveType === 'string') {
return liveType;
}
const stateCreate = room.currentState?.getStateEvents(StateEvent.RoomCreate, '');
const stateType = stateCreate?.getContent<{ type?: string }>()?.type;
if (typeof stateType === 'string') {
return stateType;
}
return undefined;
};
export const isSpace = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
if (!event) return false;
return event.getContent().type === RoomType.Space;
const roomType = getRoomCreateType(room);
return roomType === RoomType.Space || roomType === RoomType.Forum;
};
export const isForum = (room: Room | null): boolean => {
if (!room) return false;
return getRoomCreateType(room) === RoomType.Forum;
};
export const getPaarrotRoomKind = (room: Room | null): string | undefined => {
if (!room) return undefined;
const kind = room.currentState
?.getStateEvents(StateEvent.PaarrotRoomKind, '')
?.getContent<{ kind?: string }>()?.kind;
return typeof kind === 'string' ? kind : undefined;
};
/**
* m.forum space root (forum board lobby), not a plain m.space or a lone m.forum topic channel.
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;
* those rooms still have m.space.child entries like a normal space.
*/
export const isForumContainer = (room: Room | null): boolean => {
if (!room) return false;
if (getPaarrotRoomKind(room) === 'forum_space') return true;
if (getRoomCreateType(room) !== RoomType.Forum) return false;
if (room.isSpaceRoom()) return true;
return getSpaceChildren(room).length > 0;
};
/** @deprecated Use isForumContainer */
export const isForumSpace = isForumContainer;
export const shouldShowForumLobby = isForumContainer;
/** Nearest m.forum / forum_space ancestor for a topic room. */
export const findForumRootSpace = (
mx: MatrixClient,
roomId: string,
roomToParents: RoomToParents
): string | undefined => {
const ordered = [roomId, ...Array.from(getAllParents(roomToParents, roomId))];
for (const id of ordered) {
const room = mx.getRoom(id);
if (room && shouldShowForumLobby(room)) return id;
}
return undefined;
};
/** @deprecated Use findForumRootSpace */
export const findForumSpaceAncestor = findForumRootSpace;
export const isRoom = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
if (!event) return true;
return event.getContent().type !== RoomType.Space;
const roomType = getRoomCreateType(room);
if (!roomType) return true;
return roomType !== RoomType.Space && roomType !== RoomType.Forum;
};
export const isUnsupportedRoom = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist
return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
const roomType = getRoomCreateType(room);
if (!roomType) return true; // Consider room unsupported if m.room.create event doesn't exist
return (
roomType !== RoomType.Space &&
roomType !== RoomType.Forum
);
};
export function isValidChild(mEvent: MatrixEvent): boolean {