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

+ Replying to a deleted message +

+ )} + + {!replyOpen && ( +
+ setReplyOpen(true)} /> +
+ )} + {replyOpen && ( +
+ { + setReplyOpen(false); + onReplySent(sentReply); + }} + onCancel={() => setReplyOpen(false)} + /> +
+ )} +
+ {reply.replies.length > 0 && ( +
+ {reply.replies.map((child) => ( + + ))} +
+ )} +
+ ); +} + +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + Loading… + + + ); + } + + if (error || !post) { + return ( + + {error || 'Post not found.'} + + ); + } + + const title = post.title || titleHint || '(untitled post)'; + + return ( +
+
+
+

{title}

+
+ + · {formatForumTimeAgo(post.timestamp)} +
+
+
+
+ + {!rootReplyOpen && ( +
+ setRootReplyOpen(true)} /> +
+ )} + {rootReplyOpen && ( +
+ { + setRootReplyOpen(false); + handleReplySent(sentReply); + }} + onCancel={() => setRootReplyOpen(false)} + /> +
+ )} +
+ {post.replies.length > 0 && ( +
+ {post.replies.map((reply) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/features/forum/ForumTopicSearchInput.tsx b/src/app/features/forum/ForumTopicSearchInput.tsx new file mode 100644 index 0000000..0ebaeb9 --- /dev/null +++ b/src/app/features/forum/ForumTopicSearchInput.tsx @@ -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(null); + const mirrorRef = useRef(null); + const pillsRef = useRef(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 = (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 ( +
+
+ + { + 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" + /> + + + + +
+
+ ); +} diff --git a/src/app/features/forum/ForumTopicTargetFields.tsx b/src/app/features/forum/ForumTopicTargetFields.tsx new file mode 100644 index 0000000..426af47 --- /dev/null +++ b/src/app/features/forum/ForumTopicTargetFields.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/src/app/features/forum/forumFeed.ts b/src/app/features/forum/forumFeed.ts new file mode 100644 index 0000000..15f7989 --- /dev/null +++ b/src/app/features/forum/forumFeed.ts @@ -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): 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 | 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): { 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; + 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 => e !== null) + .sort((a, b) => a.timestamp - b.timestamp); + + const postsById = new Map(); + 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(); + + 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 { + 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(); + 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(); + + 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 { + 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 { + 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(); + 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 { + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +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 { + 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(/(\s*)+$/gi, ''); + if (!formattedHtml) { + formattedHtml = `

${escapeHtml(plainText)}

`; + } + + 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: `

${escapeHtml(title)}

${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 { + 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(/(\s*)+$/gi, ''); + + const content: Record = { + 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(/(\s*)+$/gi, ''); + if (!formattedHtml) { + formattedHtml = `

${escapeHtml(plainText)}

`; + } + + return { + eventId, + title, + body: plainText, + bodyHtml: bodyHtmlFromMessageContent( + { + format: 'org.matrix.custom.html', + formatted_body: `

${escapeHtml(title)}

${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(/(\s*)+$/gi, ''); + + const content: Record = { 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: [], + }; +} diff --git a/src/app/features/forum/forumLucideIcons.tsx b/src/app/features/forum/forumLucideIcons.tsx new file mode 100644 index 0000000..a52a71e --- /dev/null +++ b/src/app/features/forum/forumLucideIcons.tsx @@ -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]; + +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 () => ( + + {nodes.map(([tag, attrs]) => + React.createElement(tag, { key: `${tag}-${attrs.d ?? attrs.cx ?? ''}`, ...attrs }) + )} + + ); +} + +/** hot → flame, new → sparkles, top → crown (matrixsso SORT_LUCIDE_ICONS) */ +export const ForumSortIcons = { + hot: lucideIcon(FLAME), + new: lucideIcon(SPARKLES), + top: lucideIcon(CROWN), +} as const; diff --git a/src/app/features/forum/forumRichText.ts b/src/app/features/forum/forumRichText.ts new file mode 100644 index 0000000..b969b9d --- /dev/null +++ b/src/app/features/forum/forumRichText.ts @@ -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(/^]*>[\s\S]*?<\/h1>\s*/i, '').trim(); +} + +export function bodyHtmlFromMessageContent( + content: Record, + 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; +} diff --git a/src/app/features/forum/forumTheme.css.ts b/src/app/features/forum/forumTheme.css.ts new file mode 100644 index 0000000..8f8ec01 --- /dev/null +++ b/src/app/features/forum/forumTheme.css.ts @@ -0,0 +1,1121 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, DefaultReset, toRem } from 'folds'; +import * as editorCss from '../../components/editor/Editor.css'; + +/** Left sidebar panels (reversed vs thread pane). */ +const sidebarPanel = { + backgroundColor: color.Background.Container, + boxSizing: 'border-box' as const, +}; + +const muted = color.SurfaceVariant.OnContainer; +const borderSubtle = color.Background.ContainerLine; + +/** matrixsso spacing (#topicLiveApp / #topicPostDetail / .blender-*) */ +const forumSidebarPad = toRem(12.8); +const forumDetailPad = toRem(16); +const forumDetailPadRight = `calc(${forumDetailPad} + 0.25rem)`; + +/** Thread rail + reply card geometry */ +const threadLineWidth = '4px'; +const threadLineRadius = '4px'; +const threadLineGap = toRem(9.6); +const threadRailGap = '11px'; +const threadLevelIndent = '11px'; +const threadLineBase = color.Surface.ContainerLine; +const threadLineChild = color.SurfaceVariant.ContainerLine; +const threadLineNested = color.Background.ContainerLine; +const threadLineActive = color.Primary.Main; + +const threadLineBefore = { + borderRadius: threadLineRadius, + bottom: 0, + content: '""', + left: 0, + position: 'absolute' as const, + top: 0, + transition: 'background 0.15s ease', + width: threadLineWidth, +}; + +export const ForumAppRoot = style([ + DefaultReset, + { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + height: '100%', + overflow: 'hidden', + backgroundColor: color.Background.Container, + color: color.Background.OnContainer, + boxSizing: 'border-box', + }, +]); + +export const ForumPageContent = style({ + display: 'flex', + flex: 1, + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + padding: 0, +}); + +export const ForumTopicLiveApp = style({ + display: 'flex', + flex: 1, + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + width: '100%', +}); + +export const ForumHasSelectedPost = style({}); + +export const ForumBlenderLayout = style({ + display: 'grid', + flex: 1, + minHeight: 0, + overflow: 'hidden', + gap: 0, + gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)', + alignItems: 'stretch', +}); + +export const ForumLeftColumn = style([ + DefaultReset, + sidebarPanel, + { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, + overflow: 'hidden', + borderRight: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + }, +]); + +export const ForumLeftHeader = style({ + flexShrink: 0, + borderBottom: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, +}); + +export const ForumNeoPanel = style([DefaultReset, sidebarPanel]); + +export const ForumBlenderFeed = style({ + display: 'contents', +}); + +export const ForumFiltersCard = style({ + flexShrink: 0, + padding: forumSidebarPad, + minWidth: 0, + borderBottom: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, +}); + +export const ForumListCard = style({ + display: 'flex', + flex: '1 1 0', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, + overflow: 'hidden', + padding: forumSidebarPad, +}); + +export const ForumDetailPane = style({ + display: 'flex', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, + overflow: 'hidden', + backgroundColor: color.Surface.Container, +}); + +export const ForumTopicTargetFields = style({ + display: 'grid', + gap: config.space.S300, + gridTemplateColumns: '1fr 1fr', + minWidth: 0, + width: '100%', +}); + +/** @deprecated use ForumDetailPane */ +export const ForumDetailAside = ForumDetailPane; + +export const ForumTopicPostDetail = style({ + flex: '1 1 0', + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + overscrollBehavior: 'contain', + padding: `0 ${forumDetailPad} ${forumDetailPad}`, + paddingRight: forumDetailPadRight, + backgroundColor: color.Surface.Container, + boxSizing: 'border-box', +}); + +export const ForumTopicPostDetailWithThread = style({}); + +export const ForumComposerBar = style({ + borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + flexShrink: 0, + padding: config.space.S300, + backgroundColor: color.Surface.Container, +}); + +export const ForumFilters = style({ + display: 'grid', + gap: config.space.S200, + gridTemplateColumns: 'minmax(0, 1.3fr) minmax(0, 1.3fr) auto', + alignItems: 'center', + width: '100%', +}); + +export const ForumFiltersCompact = style({ + display: 'grid', + gap: config.space.S200, + gridTemplateColumns: 'auto', + alignItems: 'center', + width: '100%', +}); + +export const ForumSortCycleBtn = style({ + alignItems: 'center', + background: 'transparent', + border: 0, + boxShadow: 'none', + color: color.SurfaceVariant.OnContainer, + cursor: 'pointer', + display: 'inline-flex', + flexShrink: 0, + height: toRem(32), + justifyContent: 'center', + minWidth: toRem(36), + padding: config.space.S100, + selectors: { + '&:hover': { + background: 'transparent', + boxShadow: 'none', + color: color.Primary.Main, + }, + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: 1, + }, + }, +}); + +export const ForumFilterSelect = style([ + DefaultReset, + { + appearance: 'none', + background: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R300, + color: color.SurfaceVariant.OnContainer, + fontFamily: 'inherit', + fontSize: toRem(13), + lineHeight: 1.3, + height: toRem(32), + minWidth: 0, + padding: `0 ${config.space.S200}`, + width: '100%', + selectors: { + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: 1, + }, + }, + }, +]); + +export const ForumSearchField = style({ + gridColumn: '1 / -1', + minWidth: 0, + width: '100%', +}); + +export const ForumSearchRowAnchor = style({ + position: 'relative', + zIndex: 40, +}); + +export const ForumSearchRow = style({ + display: 'grid', + alignItems: 'center', + gap: config.space.S100, + gridTemplateColumns: 'minmax(0, 1fr) auto', + borderBottom: `${config.borderWidth.B300} solid ${borderSubtle}`, + paddingBottom: config.space.S100, +}); + +export const ForumSearchCaretMirror = style({ + fontSize: toRem(14), + left: toRem(2), + lineHeight: toRem(32), + pointerEvents: 'none', + position: 'absolute', + top: toRem(3), + visibility: 'hidden', + whiteSpace: 'pre', +}); + +export const ForumSearchInput = style([ + DefaultReset, + { + background: 'transparent', + border: 0, + outline: 'none', + minWidth: 0, + width: '100%', + height: toRem(32), + fontSize: toRem(14), + color: color.Background.OnContainer, + padding: `${toRem(3)} ${toRem(2)}`, + }, +]); + +export const ForumSearchPills = style({ + background: color.Surface.Container, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + borderRadius: config.radii.R400, + boxShadow: '0 10px 28px rgb(0 0 0 / 28%), 0 2px 6px rgb(0 0 0 / 12%)', + left: 0, + maxWidth: 'calc(100% - 2.5rem)', + padding: `${toRem(5)} ${toRem(6)}`, + pointerEvents: 'auto', + position: 'absolute', + top: 'calc(100% + 0.35rem)', + zIndex: 50, + selectors: { + '&::before': { + background: color.Surface.Container, + borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + content: '""', + height: toRem(7), + left: 'var(--forum-search-caret, 0.75rem)', + position: 'absolute', + top: toRem(-4), + transform: 'rotate(45deg)', + width: toRem(7), + }, + '&[hidden]': { + display: 'none', + }, + }, +}); + +export const ForumSearchPillsInner = style({ + display: 'flex', + flexWrap: 'nowrap', + gap: toRem(5), + overflowX: 'auto', + scrollbarWidth: 'thin', +}); + +export const ForumSearchPill = style([ + DefaultReset, + { + background: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.Pill, + color: muted, + cursor: 'pointer', + flex: '0 0 auto', + fontFamily: 'ui-monospace, monospace', + fontSize: toRem(11), + fontWeight: 600, + lineHeight: 1, + padding: `${toRem(3)} ${toRem(7)}`, + whiteSpace: 'nowrap', + selectors: { + '&:hover': { + background: color.Primary.Container, + borderColor: color.Primary.Main, + color: color.Primary.Main, + }, + '&[hidden]': { + display: 'none', + }, + }, + }, +]); + +export const ForumSearchPillHighlighted = style({ + background: color.Primary.Container, + borderColor: color.Primary.Main, + color: color.Primary.Main, +}); + +export const ForumSearchPillInUse = style({ + borderColor: color.Primary.Main, + color: color.Primary.Main, +}); + +export const ForumSearchPillValue = style({ + fontFamily: 'inherit', + maxWidth: toRem(200), + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +export const ForumSearchSubmit = style({ + background: 'transparent', + border: 0, + boxShadow: 'none', + color: muted, + selectors: { + '&:hover': { + background: 'transparent', + color: color.Primary.Main, + }, + }, +}); + +export const ForumNewPostBox = style({ + alignItems: 'center', + display: 'flex', + gap: config.space.S300, + justifyContent: 'flex-end', + marginBottom: forumSidebarPad, + flexShrink: 0, +}); + +export const ForumPostCountMeta = style({ + color: muted, + fontSize: toRem(13), + lineHeight: 1.3, + marginRight: 'auto', + minWidth: 0, +}); + +export const ForumPostScroll = style({ + flex: '1 1 0', + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + overscrollBehavior: 'contain', +}); + +export const ForumPostList = style({ + display: 'grid', + gap: 0, + listStyle: 'none', + margin: 0, + padding: 0, + alignContent: 'start', +}); + +export const ForumPostItem = style({ + borderTop: `${config.borderWidth.B300} solid ${borderSubtle}`, + selectors: { + '&:first-child': { + borderTop: 0, + }, + }, +}); + +export const ForumPostLink = style([ + DefaultReset, + { + background: 'transparent', + border: 0, + borderRadius: config.radii.R300, + boxSizing: 'border-box', + color: 'inherit', + cursor: 'pointer', + display: 'grid', + font: 'inherit', + padding: `${toRem(11.2)} ${toRem(7.2)}`, + rowGap: toRem(4.8), + textAlign: 'left', + width: '100%', + selectors: { + '&:hover': { + background: color.Surface.ContainerHover, + }, + '&[data-active="true"]': { + background: color.Primary.Container, + color: color.Primary.OnContainer, + }, + '&[data-pinned="true"]': { + background: color.Warning.Container, + }, + }, + }, +]); + +export const ForumPostHead = style({ + alignItems: 'flex-start', + display: 'flex', + gap: config.space.S300, + justifyContent: 'space-between', + minWidth: 0, +}); + +export const ForumPostHeadMain = style({ + alignItems: 'flex-start', + display: 'flex', + flex: 1, + gap: config.space.S200, + minWidth: 0, +}); + +export const ForumPostTitle = style({ + flex: 1, + fontSize: 'inherit', + fontWeight: 600, + lineHeight: 1.3, + minWidth: 0, + wordBreak: 'break-word', + selectors: { + [`${ForumPostLink}:hover &`]: { + color: color.Primary.Main, + }, + [`${ForumPostLink}[data-active="true"] &`]: { + color: 'inherit', + }, + }, +}); + +export const ForumPostFoot = style({ + alignItems: 'center', + display: 'flex', + gap: config.space.S200, + justifyContent: 'space-between', + marginTop: config.space.S100, + minWidth: 0, +}); + +export const ForumPostAuthor = style({ + alignItems: 'center', + color: muted, + display: 'flex', + flex: 1, + flexWrap: 'wrap', + fontSize: '0.88rem', + gap: config.space.S200, + lineHeight: 1.3, + minWidth: 0, +}); + +export const ForumAuthorName = style({ + fontWeight: 600, + color: color.Background.OnContainer, +}); + +export const ForumPostListAuthorServer = style({ + color: muted, + fontSize: '0.8rem', +}); + +export const ForumPostMeta = style({ + alignItems: 'center', + color: muted, + display: 'flex', + flexShrink: 0, + fontSize: '0.8rem', + gap: config.space.S200, + whiteSpace: 'nowrap', +}); + +export const ForumCategoryLabel = style({ + textTransform: 'lowercase', +}); + +export const ForumReplyMeta = style({ + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + gap: config.space.S100, +}); + +export const ForumReplyCount = style({ + fontWeight: 600, + fontSize: '0.82rem', +}); + +export const ForumListFooter = style({ + borderTop: `${config.borderWidth.B300} solid ${borderSubtle}`, + flexShrink: 0, + margin: `0 calc(-1 * ${forumSidebarPad})`, + padding: `${toRem(10.4)} ${toRem(7.2)}`, + textAlign: 'center', +}); + +export const ForumListLoading = style({ + color: muted, + padding: config.space.S400, + margin: 0, +}); + +export const ForumDetailTitle = style({ + fontSize: toRem(22), + fontWeight: 700, + lineHeight: 1.2, + margin: `0 0 ${toRem(5.6)}`, + wordBreak: 'break-word', + color: color.Surface.OnContainer, +}); + +export const ForumEmptyDetail = style({ + alignItems: 'center', + color: muted, + display: 'flex', + flex: 1, + flexDirection: 'column', + gap: config.space.S300, + justifyContent: 'center', + minHeight: '200px', +}); + +export const ForumTopicDetailRoot = style({ + display: 'flex', + flexDirection: 'column', + minWidth: 0, + width: '100%', +}); + +export const ForumTopicDetailHead = style({ + alignItems: 'flex-start', + background: color.Surface.Container, + borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + display: 'flex', + gap: toRem(10.4), + justifyContent: 'space-between', + margin: `0 calc(-1 * ${forumDetailPad}) ${toRem(8)}`, + padding: `${forumDetailPad} ${forumDetailPadRight} ${toRem(8)} ${forumDetailPad}`, + position: 'sticky', + top: 0, + zIndex: 12, +}); + +export const ForumTopicDetailHeadMain = style({ + minWidth: 0, +}); + +export const ForumTopicDetailAuthor = style({ + alignItems: 'center', + color: muted, + display: 'flex', + flexWrap: 'wrap', + fontSize: '0.88rem', + gap: config.space.S200, +}); + +export const ForumTopicDetailOp = style({ + marginRight: 0, + maxWidth: '100%', + minWidth: 0, + paddingBottom: toRem(36), + position: 'relative', +}); + +export const ForumMessageBody = style({ + lineHeight: 1.55, + margin: `${toRem(5.6)} 0 ${toRem(12)}`, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + color: color.Surface.OnContainer, +}); + +export const ForumMessageBodyRich = style({ + color: color.Surface.OnContainer, + lineHeight: 1.55, + margin: `${toRem(6)} 0 ${toRem(12)}`, + whiteSpace: 'normal', + wordBreak: 'break-word', +}); + +globalStyle(`${ForumMessageBodyRich} p`, { + margin: '0 0 0.65em', +}); + +globalStyle(`${ForumMessageBodyRich} p:last-child`, { + marginBottom: 0, +}); + +globalStyle(`${ForumMessageBodyRich} blockquote`, { + borderLeft: `3px solid ${color.SurfaceVariant.ContainerLine}`, + margin: '0.5em 0', + paddingLeft: config.space.S300, + color: muted, +}); + +globalStyle(`${ForumMessageBodyRich} pre`, { + background: color.SurfaceVariant.Container, + borderRadius: config.radii.R300, + overflow: 'auto', + padding: config.space.S300, +}); + +globalStyle(`${ForumMessageBodyRich} code`, { + fontFamily: 'monospace', + fontSize: '0.9em', +}); + +globalStyle(`${ForumMessageBodyRich} ul, ${ForumMessageBodyRich} ol`, { + margin: '0.35em 0', + paddingLeft: '1.25em', +}); + +globalStyle(`${ForumMessageBodyRich} a`, { + color: color.Primary.Main, +}); + +globalStyle(`${ForumMessageBodyRich} img`, { + maxWidth: '100%', + height: 'auto', + borderRadius: config.radii.R300, +}); + +export const ForumReplyActionsFooter = style({ + bottom: toRem(6), + display: 'flex', + justifyContent: 'flex-end', + margin: 0, + pointerEvents: 'none', + position: 'absolute', + right: toRem(10), + zIndex: 2, + maxWidth: `calc(100% - ${toRem(12)})`, +}); + +export const ForumMessageActions = style({ + alignItems: 'center', + display: 'flex', + flexShrink: 0, + gap: toRem(3), + marginLeft: 'auto', + opacity: 0, + pointerEvents: 'none', + transition: 'opacity 0.16s ease', +}); + +export const ForumMessageActionBtn = style({ + alignItems: 'center', + background: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R300, + color: muted, + cursor: 'pointer', + display: 'inline-flex', + height: toRem(28), + justifyContent: 'center', + padding: 0, + width: toRem(28), + selectors: { + '&:hover': { + background: color.SurfaceVariant.ContainerHover, + color: color.Primary.Main, + }, + }, +}); + +export const ForumMessageActionBtnActive = style({ + background: color.Primary.Container, + borderColor: color.Primary.Main, + color: color.Primary.Main, +}); + +export const ForumMessageActionIcon = style({ + display: 'block', +}); + +export const ForumMessageActionPanels = style({ + marginTop: toRem(9), +}); + +export const ForumMessageActionPanel = style({ + marginTop: toRem(7), +}); + +export const ForumVisuallyHidden = style({ + border: 0, + clip: 'rect(0 0 0 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: 0, + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', +}); + +export const ForumFeedReplyForm = style({ + display: 'grid', + gap: toRem(7), + marginTop: 0, +}); + +export const ForumReplyToDeleted = style({ + borderLeft: `4px solid ${color.SurfaceVariant.ContainerLine}`, + color: muted, + fontSize: toRem(14), + fontStyle: 'italic', + margin: `0 0 ${toRem(8)}`, + padding: `${toRem(2)} 0 ${toRem(2)} ${toRem(10)}`, +}); + +export const ForumThread = style({ + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + gap: threadLineGap, + marginLeft: 0, + marginTop: threadLineGap, + minWidth: 0, + paddingLeft: 0, + position: 'relative', + width: '100%', + maxWidth: '100%', + selectors: { + '&::before': { + ...threadLineBefore, + background: threadLineBase, + left: 0, + }, + }, +}); + +export const ForumThreadReply = style({ + alignSelf: 'stretch', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + gap: 0, + margin: 0, + maxWidth: 'none', + minWidth: 0, + padding: 0, + position: 'relative', + width: '100%', +}); + +export const ForumThreadReplyInner = style({ + background: color.Background.Container, + border: `${config.borderWidth.B300} solid ${threadLineBase}`, + borderRadius: toRem(6), + boxSizing: 'border-box', + marginLeft: threadRailGap, + marginRight: 0, + maxWidth: '100%', + minWidth: 0, + overflow: 'hidden', + padding: `${toRem(12)} ${toRem(13.6)}`, + position: 'relative', + width: `calc(100% - ${threadRailGap})`, +}); + +export const ForumPostHeader = style({ + alignItems: 'flex-start', + display: 'flex', + justifyContent: 'space-between', +}); + +export const ForumPostHeaderMain = style({ + alignItems: 'center', + display: 'flex', + flex: 1, + flexWrap: 'wrap', + gap: toRem(10), + minWidth: 0, + paddingRight: toRem(40), +}); + +export const ForumPostTime = style({ + color: muted, + fontSize: toRem(13), +}); + +export const ForumThreadChildren = style({ + alignSelf: 'stretch', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + gap: threadLineGap, + marginLeft: threadLevelIndent, + marginRight: 0, + marginTop: threadLineGap, + minWidth: 0, + paddingLeft: 0, + position: 'relative', + width: `calc(100% - ${threadLevelIndent})`, + selectors: { + '&::before': { + ...threadLineBefore, + background: threadLineChild, + left: 0, + }, + }, +}); + +/** @deprecated nested lines use ForumThreadChildren + globalStyle below */ +export const ForumThreadChildrenNested = ForumThreadChildren; + +export const ForumAuthorIdentity = style({ + alignItems: 'center', + display: 'inline-flex', + gap: toRem(7), + minWidth: 0, +}); + +export const ForumAuthorIdentityCompact = style({ + gap: toRem(6), +}); + +export const ForumAuthorAvatar = style({ + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + height: toRem(24), + justifyContent: 'center', + overflow: 'hidden', + width: toRem(24), +}); + +export const ForumAuthorAvatarCompact = style([ + ForumAuthorAvatar, + { + height: toRem(18), + width: toRem(18), + }, +]); + +export const ForumAuthorMeta = style({ + alignItems: 'center', + display: 'inline-flex', + flexWrap: 'wrap', + gap: toRem(6), + minWidth: 0, +}); + +export const ForumSenderLabel = style({ + borderBottom: `1px dotted ${color.SurfaceVariant.ContainerLine}`, + color: color.Surface.OnContainer, + cursor: 'help', + fontWeight: 600, + textDecoration: 'none', +}); + +export const ForumAuthorServer = style({ + background: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R200, + color: muted, + fontSize: toRem(12), + fontWeight: 600, + lineHeight: 1.2, + padding: `${toRem(2)} ${toRem(6)}`, +}); + +export const ForumReplyForm = style({ + display: 'grid', + gap: config.space.S200, +}); + +export const ForumReplyFormActions = style({ + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + gap: config.space.S200, + justifyContent: 'flex-end', +}); + +export const ForumReplySubmitBtn = style({ + fontWeight: 700, +}); + +export const ForumReplyInput = style([ + DefaultReset, + { + background: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R300, + color: color.SurfaceVariant.OnContainer, + font: 'inherit', + minHeight: toRem(72), + padding: config.space.S300, + resize: 'vertical', + width: '100%', + selectors: { + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: 1, + }, + }, + }, +]); + +export const ForumReplyActions = style({ + display: 'flex', + justifyContent: 'flex-end', +}); + +export const ForumPostModalForm = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S400, + height: '100%', + minHeight: '100%', + minWidth: 0, +}); + +export const ForumPostModalField = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, + minWidth: 0, +}); + +export const ForumPostModalFieldLabel = style({ + color: color.Background.OnContainer, + fontSize: toRem(14), + fontWeight: 600, +}); + +export const ForumInlineReplyEditorWrap = style({ + minWidth: 0, + width: '100%', +}); + +/** Full-width inline reply editor. */ +globalStyle(`${ForumInlineReplyEditorWrap} .${editorCss.Editor}`, { + width: '100%', +}); + +globalStyle(`${ForumInlineReplyEditorWrap} .${editorCss.Editor} > *:first-child`, { + width: '100%', +}); + +globalStyle(`${ForumInlineReplyEditorWrap} .${editorCss.EditorTextareaScroll}`, { + minHeight: toRem(120), + width: '100%', +}); + +globalStyle(`${ForumInlineReplyEditorWrap} .${editorCss.EditorTextarea}`, { + minHeight: toRem(100), +}); + +export const ForumPostModalBodyField = style({ + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', + gap: config.space.S200, + minHeight: toRem(280), + minWidth: 0, +}); + +export const ForumPostModalEditorWrap = style({ + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', + minHeight: toRem(220), + minWidth: 0, + width: '100%', +}); + +export const ForumPostModalActions = style({ + display: 'flex', + flexShrink: 0, + justifyContent: 'flex-end', + gap: config.space.S300, + flexWrap: 'wrap', +}); + +/** Full-width post body editor (matrixsso .post-quill-mount min-height ~220px). */ +globalStyle(`${ForumPostModalEditorWrap} .${editorCss.Editor}`, { + flex: '1 1 auto', + height: '100%', + minHeight: 0, + width: '100%', +}); + +globalStyle(`${ForumPostModalEditorWrap} .${editorCss.EditorTextareaScroll}`, { + height: '100%', + width: '100%', +}); + +globalStyle(`${ForumPostModalEditorWrap} .${editorCss.EditorTextarea}`, { + height: '100%', + minHeight: toRem(180), +}); + +globalStyle(`${ForumThreadReplyInner}:hover ${ForumReplyActionsFooter} ${ForumMessageActions}`, { + opacity: 1, + pointerEvents: 'auto', +}); + +globalStyle(`${ForumThreadReplyInner}:focus-within ${ForumReplyActionsFooter} ${ForumMessageActions}`, { + opacity: 1, + pointerEvents: 'auto', +}); + +globalStyle(`${ForumThreadReplyInner}:has(${ForumMessageActionPanels}) ${ForumReplyActionsFooter}`, { + display: 'none', +}); + +globalStyle(`${ForumTopicDetailOp}:has(${ForumMessageActionPanels}) ${ForumReplyActionsFooter}`, { + display: 'none', +}); + +globalStyle(`${ForumTopicDetailOp}:hover ${ForumReplyActionsFooter} ${ForumMessageActions}`, { + opacity: 1, + pointerEvents: 'auto', +}); + +globalStyle(`${ForumTopicDetailOp}:focus-within ${ForumReplyActionsFooter} ${ForumMessageActions}`, { + opacity: 1, + pointerEvents: 'auto', +}); + +globalStyle( + `${ForumThread}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:hover)::before, ${ForumThread}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:focus-within)::before`, + { + background: threadLineActive, + } +); + +globalStyle( + `${ForumThreadChildren}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:hover)::before, ${ForumThreadChildren}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:focus-within)::before`, + { + background: threadLineActive, + } +); + +globalStyle(`${ForumThreadChildren} ${ForumThreadChildren}::before`, { + background: threadLineNested, +}); + +globalStyle(`${ForumAuthorAvatar} > *`, { + height: '100%', + width: '100%', +}); + +globalStyle(`${ForumThreadReplyInner} ${ForumMessageBody}, ${ForumThreadReplyInner} ${ForumMessageBodyRich}`, { + marginLeft: 0, + marginRight: 0, +}); + +globalStyle(`${ForumTopicDetailRoot} ${ForumMessageBody}, ${ForumTopicDetailRoot} ${ForumMessageBodyRich}`, { + marginTop: toRem(5.6), +}); + +globalStyle(`${ForumTopicDetailOp} ${ForumMessageBody}, ${ForumTopicDetailOp} ${ForumMessageBodyRich}`, { + marginTop: toRem(5.6), +}); + +// Legacy aliases +export const ForumApp = ForumTopicLiveApp; +export const ForumDetailScroll = ForumTopicPostDetail; +export const ForumDetailComposerBar = ForumComposerBar; +export const ForumChrome = style({ display: 'none' }); +export const ForumBrand = style({ display: 'none' }); +export const ForumSpaceTitle = style({ display: 'none' }); +export const ForumNewPostBtn = style({ display: 'none' }); +export const ForumLoadMoreBtn = style({ display: 'none' }); +export const ForumReplySubmit = style({ display: 'none' }); +export const ForumDetailTitleText = style({}); diff --git a/src/app/features/forum/forumTime.ts b/src/app/features/forum/forumTime.ts new file mode 100644 index 0000000..a8cecaa --- /dev/null +++ b/src/app/features/forum/forumTime.ts @@ -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`; +} diff --git a/src/app/features/forum/forumTopicHelpers.ts b/src/app/features/forum/forumTopicHelpers.ts new file mode 100644 index 0000000..2948da2 --- /dev/null +++ b/src/app/features/forum/forumTopicHelpers.ts @@ -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; + } +} diff --git a/src/app/features/forum/index.ts b/src/app/features/forum/index.ts new file mode 100644 index 0000000..65a6e58 --- /dev/null +++ b/src/app/features/forum/index.ts @@ -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'; diff --git a/src/app/features/forum/topicSearch.ts b/src/app/features/forum/topicSearch.ts new file mode 100644 index 0000000..0e729c4 --- /dev/null +++ b/src/app/features/forum/topicSearch.ts @@ -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([ + '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(); + + 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(); + 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(); + + 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( + 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, '$1'); + } + + return result; +} diff --git a/src/app/features/forum/types.ts b/src/app/features/forum/types.ts new file mode 100644 index 0000000..89f61cf --- /dev/null +++ b/src/app/features/forum/types.ts @@ -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'; +}; diff --git a/src/app/features/forum/useForumBoard.ts b/src/app/features/forum/useForumBoard.ts new file mode 100644 index 0000000..04c3ce5 --- /dev/null +++ b/src/app/features/forum/useForumBoard.ts @@ -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([]); + const [threads, setThreads] = useState([]); + const [loadingSections, setLoadingSections] = useState(true); + const [loadingThreads, setLoadingThreads] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [postsNextBatch, setPostsNextBatch] = useState(null); + const [error, setError] = useState(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, + }; +} diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 028cd56..4b1e03c 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -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; } diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index e078ed3..5154f8b 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -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 ; +} + export function Lobby() { + const space = useSpace(); + if (shouldShowForumLobby(space)) { + return ; + } + return ; +} + +function SpaceCardLobby() { const navigate = useNavigate(); const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index a0c4d3a..bd00fe3 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -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( 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(); + const [addExisting, setAddExisting] = useState(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(null); + const menuTriggerElRef = useRef(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 = (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 ( + <> + + + + {nameInitials(name)}} + /> + + + {name} + + + + More Options + + } + > + {(triggerRef) => ( + + + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > +
+ + setMenuAnchor(undefined)} + onOpenAddExisting={(options) => { + setAddExisting(options); + setMenuAnchor(undefined); + }} + forumAdd={ + forumBoard && canEditChildren + ? { + item: addParentItem, + showAddSpace: addParent.showAddSpace, + } + : undefined + } + /> + +
+ + } + /> +
+
+ {addExisting && ( + setAddExisting(null)} + /> + )} + + ); + } + return ( diff --git a/src/app/features/lobby/SpaceAddControls.tsx b/src/app/features/lobby/SpaceAddControls.tsx new file mode 100644 index 0000000..2eaa425 --- /dev/null +++ b/src/app/features/lobby/SpaceAddControls.tsx @@ -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(); + const openCreateRoomModal = useOpenCreateRoomModal(); + const [addExisting, setAddExisting] = useState(false); + + const handleAddRoom: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleCreateRoom = () => { + openCreateRoomModal(item.roomId); + setCords(undefined); + }; + + const handleAddExisting = () => { + setAddExisting(true); + setCords(undefined); + }; + + return ( + <> + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + New Room + + + Existing Room + + + + } + > + } + onClick={handleAddRoom} + aria-pressed={!!cords} + > + {roomLabel ?? 'Add Room'} + + + {addExisting && ( + setAddExisting(false)} /> + )} + + ); +} + +function AddSpaceButton({ item, spaceLabel }: { item: HierarchyItem; spaceLabel?: string }) { + const [cords, setCords] = useState(); + const openCreateSpaceModal = useOpenCreateSpaceModal(); + const [addExisting, setAddExisting] = useState(false); + + const handleAddSpace: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleCreateSpace = () => { + openCreateSpaceModal(item.roomId as string); + setCords(undefined); + }; + + const handleAddExisting = () => { + setAddExisting(true); + setCords(undefined); + }; + + return ( + <> + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + New Space + + + Existing Space + + + + } + > + } + onClick={handleAddSpace} + aria-pressed={!!cords} + > + {spaceLabel ?? 'Add Space'} + + + {addExisting && ( + 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 ( + + + {showAddSpace && } + + ); +} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 6667647..4fe0f0e 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -73,7 +73,7 @@ export const SpaceHierarchy = forwardRef( const subspaces = useMemo(() => { const s: Map = 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); } }); diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 64a9790..1f39236 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -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(); - const openCreateRoomModal = useOpenCreateRoomModal(); - const [addExisting, setAddExisting] = useState(false); - - const handleAddRoom: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleCreateRoom = () => { - openCreateRoomModal(item.roomId); - setCords(undefined); - }; - - const handleAddExisting = () => { - setAddExisting(true); - setCords(undefined); - }; - - return ( - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - New Room - - - Existing Room - - - - } - > - } - onClick={handleAddRoom} - aria-pressed={!!cords} - > - Add Room - - {addExisting && ( - setAddExisting(false)} /> - )} - - ); -} - -function AddSpaceButton({ item }: { item: HierarchyItem }) { - const [cords, setCords] = useState(); - const openCreateSpaceModal = useOpenCreateSpaceModal(); - const [addExisting, setAddExisting] = useState(false); - - const handleAddSpace: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleCreateSpace = () => { - openCreateSpaceModal(item.roomId as any); - setCords(undefined); - }; - - const handleAddExisting = () => { - setAddExisting(true); - setCords(undefined); - }; - return ( - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - New Space - - - Existing Space - - - - } - > - } - onClick={handleAddSpace} - aria-pressed={!!cords} - > - Add Space - - {addExisting && ( - setAddExisting(false)} /> - )} - - ); -} - type SpaceItemCardProps = { summary: IHierarchyRoom | undefined; loading?: boolean; @@ -484,8 +347,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( {space && canEditChild && ( - - {item.parentId === undefined && } + )} diff --git a/src/app/features/room/ForumRoomView.css.ts b/src/app/features/room/ForumRoomView.css.ts new file mode 100644 index 0000000..519013e --- /dev/null +++ b/src/app/features/room/ForumRoomView.css.ts @@ -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, +}); diff --git a/src/app/features/room/ForumRoomView.tsx b/src/app/features/room/ForumRoomView.tsx new file mode 100644 index 0000000..47784a2 --- /dev/null +++ b/src/app/features/room/ForumRoomView.tsx @@ -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 ; + } + + return ; +} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index e4f438f..20b09dd 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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 ( - + {forumRoom ? : } {screenSize === ScreenSize.Desktop && isDrawer && ( <> diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 8242850..4df0af9 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( - ({ 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( 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) => { diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 8d962ad..b85c1ec 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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={ - - {/* Speech bubble arrow */} -
- {displayName} - {member.userId} - + + + + {displayName} + + + {member.userId} + + + } > {(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() { )} - - {name} - + + + {name} + + {forumLayout && ( + + Forum + + )} + {topic && ( {(viewTopic, setViewTopic) => ( diff --git a/src/app/features/space/SpaceOptionsMenu.tsx b/src/app/features/space/SpaceOptionsMenu.tsx new file mode 100644 index 0000000..a9b092a --- /dev/null +++ b/src/app/features/space/SpaceOptionsMenu.tsx @@ -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( + ({ 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 ( + <> + + {forumAdd && ( + <> + + } + radii="300" + > + + Add Topic + + + + + Add Existing Topic + + + {forumAdd.showAddSpace && ( + <> + } + radii="300" + fill="None" + > + + Add Section + + + + + Add Existing Section + + + + )} + + + + )} + + {invitePrompt && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + } + radii="300" + disabled={!unread} + > + + Mark as Read + + + + + + } + radii="300" + aria-pressed={invitePrompt} + disabled={!canInvite} + > + + Invite + + + } + radii="300" + > + + Copy Link + + + } + radii="300" + > + + Space Settings + + + {developerTools && ( + } + radii="300" + > + + Event Timeline + + + )} + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + > + + Leave Space + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + {forumAdd && addExistingRoom && ( + setAddExistingRoom(false)} /> + )} + {forumAdd && addExistingSpace && ( + setAddExistingSpace(false)} + /> + )} + + ); + } +); diff --git a/src/app/hooks/useFileDrop.ts b/src/app/hooks/useFileDrop.ts index bead203..f6aa29f 100644 --- a/src/app/hooks/useFileDrop.ts +++ b/src/app/hooks/useFileDrop.ts @@ -11,14 +11,15 @@ export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHan ); export const useFileDropZone = ( - zoneRef: RefObject, + zoneRef: RefObject | 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'; diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index b2d7a91..025661b 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -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; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f..7219e0d 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -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(), @@ -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; }); diff --git a/src/app/paarrot-api.ts b/src/app/paarrot-api.ts index 363bfd7..2344ba5 100644 --- a/src/app/paarrot-api.ts +++ b/src/app/paarrot-api.ts @@ -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(/:(.+)$/); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 8619364..260980b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -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={ - - - - } - > - - + + + } > diff --git a/src/app/pages/client/home/useHomeRooms.ts b/src/app/pages/client/home/useHomeRooms.ts index 30166d2..3459224 100644 --- a/src/app/pages/client/home/useHomeRooms.ts +++ b/src/app/pages/client/home/useHomeRooms.ts @@ -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(); 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; diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index 0fd52ab..61f5a75 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -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 ( diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index a85038c..5ab3f2e 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -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(({ 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 ( - - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - {developerTools && ( - } - radii="300" - > - - Event Timeline - - - )} - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - Leave Space - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); -}); +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, }} > - setMenuAnchor(undefined)} /> + setMenuAnchor(undefined)} /> } /> @@ -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 ( @@ -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 ( diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e6add72..a864cde 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -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 { diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index f45511a..142b7a8 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -45,6 +45,9 @@ export enum StateEvent { /** Sub-rooms: child rooms nested under this room */ PaarrotSubRooms = 'im.paarrot.sub_rooms', + /** Paarrot room classification marker for custom room behaviors */ + PaarrotRoomKind = 'im.paarrot.room.kind', + /** Matrix RTC call membership (MSC3401) */ CallMember = 'org.matrix.msc3401.call.member', } @@ -59,6 +62,7 @@ export enum MessageEvent { export enum RoomType { Space = 'm.space', + Forum = 'm.forum', } export type MSpaceChildContent = { diff --git a/vite.config.js b/vite.config.js index 95d9911..a395289 100644 --- a/vite.config.js +++ b/vite.config.js @@ -221,6 +221,7 @@ export default defineConfig({ port: 38347, host: '0.0.0.0', cors: true, // Enable CORS for dev server + open: false, fs: { // Allow serving files from one level up to the project root allow: ['..'],