Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9821b16968 | ||
| a887cd39f1 | |||
|
|
ac863b3c1a | ||
| a889b67c2e | |||
|
|
4618d59605 | ||
| 697f03bc51 | |||
|
|
670aaf6a20 | ||
| c4a6b4b175 | |||
| a5efb1f337 | |||
|
|
8cacf555e5 | ||
| 5a1579dd01 | |||
| 792c735ffe |
2
cinny
2
cinny
Submodule cinny updated: 9c7f71896c...61d41900cc
@@ -220,6 +220,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
||||
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
||||
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
|
||||
|
||||
const isInsideSwipeHost = (target: EventTarget | null): boolean => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !(target instanceof Node)) return false;
|
||||
return root.contains(target);
|
||||
};
|
||||
|
||||
const handleTouchStart = (evt: TouchEvent) => {
|
||||
if (gestureRef.current.phase !== 'idle') return;
|
||||
|
||||
@@ -232,7 +238,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
||||
const messageEl = findMessageElement(target);
|
||||
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
||||
|
||||
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target);
|
||||
// Only steal touches that start inside the detail/swipe host. Document-level
|
||||
// listeners otherwise fight the always-visible sidebar (space icons sit in the
|
||||
// former screen-left EDGE_WIDTH zone and lost clicks to preventDefault).
|
||||
const insideHost = isInsideSwipeHost(target);
|
||||
const canStartBack =
|
||||
backEnabled && insideHost && !shouldIgnoreBackTarget(target);
|
||||
const canStartReply =
|
||||
replyEnabled &&
|
||||
messageEl &&
|
||||
@@ -242,23 +253,11 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
||||
|
||||
if (!canStartBack && !canStartReply) return;
|
||||
|
||||
if (touch.clientX <= EDGE_WIDTH && canStartBack) {
|
||||
evt.preventDefault();
|
||||
gestureRef.current = {
|
||||
phase: 'back',
|
||||
touchId: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
messageEl: null,
|
||||
transformEl: null,
|
||||
messageId: null,
|
||||
edgeBack: true,
|
||||
offset: 0,
|
||||
};
|
||||
document.documentElement.classList.add('mobile-gesture-lock');
|
||||
return;
|
||||
}
|
||||
const contentLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const nearContentLeftEdge =
|
||||
touch.clientX >= contentLeft && touch.clientX <= contentLeft + EDGE_WIDTH;
|
||||
|
||||
// Prefer pending over immediate back+preventDefault so a tap still clicks.
|
||||
gestureRef.current = {
|
||||
phase: 'pending',
|
||||
touchId: touch.identifier,
|
||||
@@ -267,7 +266,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
||||
messageEl: canStartReply ? messageEl : null,
|
||||
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
|
||||
messageId: canStartReply ? messageId : null,
|
||||
edgeBack: false,
|
||||
edgeBack: nearContentLeftEdge && canStartBack,
|
||||
offset: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as swipeCss from '../mobile/mobile-gestures.css';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type PageRootProps = {
|
||||
nav: ReactNode;
|
||||
@@ -74,6 +75,7 @@ export function PageNav({
|
||||
grow={isMobile ? 'Yes' : undefined}
|
||||
className={classNames(css.PageNav({ size }), className)}
|
||||
shrink={isMobile ? 'Yes' : 'No'}
|
||||
data-page-nav=""
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{children}
|
||||
@@ -89,6 +91,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||
variant="Background"
|
||||
size="600"
|
||||
data-folder-tab="nav"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -104,13 +107,16 @@ export function PageNavContent({
|
||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hideScrollbar = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
size={hideScrollbar ? '0' : '300'}
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
{...scrollProps}
|
||||
@@ -126,6 +132,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||
data-page=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -137,6 +144,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||
as="header"
|
||||
size="600"
|
||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||
data-folder-tab-bar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
136
overlay/src/app/features/forum/ForumBoardDetail.tsx
Normal file
136
overlay/src/app/features/forum/ForumBoardDetail.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Scroll, Text, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { Page, PageContent } from '../../components/page';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { getSpacePath } from '../../pages/pathUtils';
|
||||
import { useForumBoardContext } from './ForumBoardContext';
|
||||
import { ForumThreadDetail } from './ForumThreadDetail';
|
||||
import type { ForumPost } from './types';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
|
||||
export function ForumBoardDetail() {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const compact = useCompactNav();
|
||||
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
|
||||
|
||||
const {
|
||||
forumSpace,
|
||||
query,
|
||||
error,
|
||||
selectedPostId,
|
||||
activeTopicRoomId,
|
||||
threads,
|
||||
recordThreadReply,
|
||||
} = useForumBoardContext();
|
||||
|
||||
// Compact: /feed/ without a selected post has no detail content — return to the list
|
||||
// so an empty detail layer cannot cover the forum sidebar.
|
||||
useEffect(() => {
|
||||
if (!compact || selectedPostId) return;
|
||||
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId)), { replace: true });
|
||||
}, [compact, selectedPostId, navigate, mx, forumSpace.roomId]);
|
||||
|
||||
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 handleReplySent = useCallback(
|
||||
(_reply: ForumPost) => {
|
||||
if (selectedPostId) {
|
||||
recordThreadReply(selectedPostId);
|
||||
}
|
||||
},
|
||||
[recordThreadReply, selectedPostId]
|
||||
);
|
||||
|
||||
const showThread = Boolean(selectedPostId && activeTopicRoom);
|
||||
const threadInitialPost =
|
||||
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
|
||||
const detailDropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
|
||||
<PageContent className={theme.ForumPageContent}>
|
||||
{error && (
|
||||
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<aside className={theme.ForumDetailPane}>
|
||||
{showThread && activeTopicRoom ? (
|
||||
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
|
||||
<ForumThreadDetail
|
||||
roomId={activeTopicRoom.roomId}
|
||||
rootEventId={selectedPostId!}
|
||||
titleHint={selectedThread?.title}
|
||||
initialPost={threadInitialPost}
|
||||
fileDropContainerRef={detailDropRef}
|
||||
onReplySent={handleReplySent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Scroll
|
||||
id="topicPostDetail"
|
||||
className={theme.ForumTopicPostDetailScroll}
|
||||
variant="Surface"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
>
|
||||
<div className={theme.ForumTopicPostDetailInner}>
|
||||
<Box
|
||||
className={theme.ForumEmptyDetail}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
>
|
||||
<Icon src={Icons.Thread} size="600" />
|
||||
<Text size="T300" priority="300">
|
||||
{topicSelected
|
||||
? 'Select a post to read the thread'
|
||||
: 'Pick a category and topic, then choose a post'}
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
</Scroll>
|
||||
)}
|
||||
</aside>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
431
overlay/src/app/features/forum/useForumBoard.ts
Normal file
431
overlay/src/app/features/forum/useForumBoard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
|
||||
gap="300"
|
||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||
justifyContent="SpaceBetween"
|
||||
alignItems="Baseline"
|
||||
alignItems="Center"
|
||||
grow="Yes"
|
||||
data-message-header=""
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username
|
||||
|
||||
@@ -7,6 +7,11 @@ export const isCapacitorAndroid = Boolean(
|
||||
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
|
||||
);
|
||||
|
||||
// Stationery signatures use Caveat on all platforms (including Android WebView).
|
||||
await import('@fontsource/caveat/500.css');
|
||||
await import('@fontsource/caveat/600.css');
|
||||
await import('@fontsource/caveat/700.css');
|
||||
|
||||
if (isCapacitorAndroid) {
|
||||
document.documentElement.classList.add('android-capacitor');
|
||||
document.documentElement.style.setProperty(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.125",
|
||||
"version": "4.11.130",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user