chore: update subproject status to dirty

This commit is contained in:
2026-07-11 22:45:48 +10:00
parent 5a1579dd01
commit a5efb1f337
2 changed files with 567 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useCompactNav } from '../../hooks/useCompactNav';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceFeedPath, getSpaceLobbyPath, getSpacePath } from '../../pages/pathUtils';
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,
ForumPost,
ForumPublishedPost,
ForumSection,
ForumThreadSummary,
} from './types';
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
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 location = useLocation();
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
const compact = useCompactNav();
const searchParams = useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
const filterQuery: ForumBoardQuery = useMemo(
() => ({
category: searchParams.get('category') || undefined,
topic: 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]
);
const query: ForumBoardQuery = useMemo(
() => ({
...filterQuery,
post: searchParams.get('post') || undefined,
postRoom: searchParams.get('postRoom') || undefined,
}),
[filterQuery, searchParams]
);
const [sections, setSections] = useState<ForumSection[]>([]);
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
const [loadingSections, setLoadingSections] = useState(true);
const [loadingThreads, setLoadingThreads] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const applyForumSearchParams = useCallback(
(mutate: (next: URLSearchParams) => void) => {
const next = new URLSearchParams(location.search);
mutate(next);
const search = next.toString();
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, forumSpace.roomId);
const onTopicRoomRoute =
Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
// Compact master view only mounts the post list on /:spaceId/. Opening a post
// must switch to /feed/ so ForumFeedPage mounts as the detail pane.
let pathname = location.pathname;
if (onTopicRoomRoute) {
pathname = getSpaceLobbyPath(spaceIdOrAlias);
} else if (next.get('post')) {
pathname = getSpaceFeedPath(spaceIdOrAlias);
} else if (compact) {
pathname = getSpacePath(spaceIdOrAlias);
}
navigate(
{
pathname,
search: search ? `?${search}` : '',
},
{ replace: true }
);
},
[
location.pathname,
location.search,
navigate,
mx,
forumSpace.roomId,
selectedRoomId,
compact,
]
);
const setQueryParam = useCallback(
(key: string, value: string | null) => {
applyForumSearchParams((next) => {
if (!value) next.delete(key);
else next.set(key, value);
if (key !== 'post') {
next.delete('post');
next.delete('postRoom');
}
});
},
[applyForumSearchParams]
);
const setCategory = useCallback(
(category: string | null) => {
applyForumSearchParams((next) => {
if (!category) next.delete('category');
else next.set('category', category);
next.delete('topic');
next.delete('post');
next.delete('postRoom');
});
},
[applyForumSearchParams]
);
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 (options?: { silent?: boolean }) => {
if (!options?.silent) 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 {
if (!options?.silent) 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) return;
void refreshThreads();
}, [
loadingSections,
refreshThreads,
filterQuery.category,
filterQuery.topic,
filterQuery.q,
filterQuery.status,
filterQuery.sort,
]);
const scopedRooms = useMemo(() => {
if (loadingSections) return [];
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
return roomScopes
.map((scope) => mx.getRoom(scope.roomId))
.filter((room): room is Room => room !== null);
}, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
useForumRoomLiveUpdates(scopedRooms, () => {
void refreshThreads({ silent: true });
});
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) => {
applyForumSearchParams((next) => {
if (!eventId) {
next.delete('post');
next.delete('postRoom');
} else {
next.set('post', eventId);
if (topicRoomId) next.set('postRoom', topicRoomId);
else next.delete('postRoom');
}
});
},
[applyForumSearchParams]
);
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,
};
}