feat(archive): implement comment.archived and add archive page (#1074)

* feat(archive): implement comment.archived and add archive page

Add isCommentArchived() utility, /board/:boardIdentifier/archive route,
archive view with filtered feed, and UI integration (board buttons, edit
menu, catalog row, post). Archived indicator on catalog and posts.

* fix(archive): address review feedback from Bugbot and CodeRabbit

Add missing i18n keys (archived, thread_archived, view, loading_archive),
add /archive/settings route, replace hardcoded English in archive.tsx, and
fix mobile test state.
This commit is contained in:
Tommaso Casaburi
2026-03-13 16:06:36 +08:00
committed by GitHub
parent d7703953fb
commit aedee9fb83
65 changed files with 1384 additions and 62 deletions
+36 -3
View File
@@ -3,8 +3,6 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../app';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -63,6 +61,8 @@ vi.mock('../hooks/use-account-subplebbit-addresses', () => ({
vi.mock('../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string) =>
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
}));
vi.mock('../hooks/use-is-mobile', () => ({
@@ -180,6 +180,10 @@ vi.mock('../views/account-data-editor', () => ({
default: makeNamedComponent('account-data-editor-view'),
}));
vi.mock('../views/archive/archive', () => ({
default: makeNamedComponent('archive-view'),
}));
vi.mock('../components/boards-bar-edit-modal', () => ({
default: makeNamedComponent('boards-bar-edit-modal'),
}));
@@ -211,6 +215,7 @@ vi.mock('../components/reply-modal', () => ({
let latestLocation = '';
let container: HTMLDivElement;
let root: Root;
let App: typeof import('../app').default | null = null;
const LocationProbe = () => {
const location = useLocation();
@@ -230,9 +235,13 @@ const flushEffects = async (count = 8) => {
};
const renderApp = async (initialEntry: string) => {
if (!App) {
App = (await import('../app')).default;
}
latestLocation = initialEntry;
await act(async () => {
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App), createElement(LocationProbe)));
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App!), createElement(LocationProbe)));
});
await flushEffects();
};
@@ -336,6 +345,30 @@ describe('App', () => {
expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeTruthy();
});
it('renders board archive routes and hides board form/buttons on that dedicated page', async () => {
await renderApp('/mu/archive');
expect(latestLocation).toBe('/mu/archive');
expect(container.querySelector('[data-testid="post-form"]')).toBeNull();
expect(container.querySelector('[data-testid="board-blotter"]')).toBeNull();
expect(container.querySelector('[data-testid="desktop-board-buttons"]')).toBeNull();
expect(container.querySelector('[data-testid="boards-bar"]')).toBeTruthy();
});
it('renders archive settings route as archive view', async () => {
await renderApp('/mu/archive/settings');
expect(latestLocation).toBe('/mu/archive/settings');
expect(container.querySelector('[data-testid="archive-view"]')).toBeTruthy();
});
it('does not route /all/archive to a board archive page', async () => {
await renderApp('/all/archive');
expect(container.querySelector('[data-testid="archive-view"]')).toBeNull();
expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy();
});
it('enforces board-scoped mod queue access by account role', async () => {
testState.resolvedSubplebbitAddress = 'music-posting.eth';
testState.subplebbits = {
+15 -3
View File
@@ -17,6 +17,7 @@ import {
getSubplebbitAddress,
isBoardModRoute,
isDirectoryBoard,
isArchiveRoute,
isLegacyBoardModQueueRoute,
isPostRoute,
isPendingPostRoute,
@@ -31,6 +32,7 @@ import Blotter from './views/blotter';
import Catalog from './views/catalog';
import FAQ from './views/faq';
import Home from './views/home';
import Archive from './views/archive/archive';
import ModQueueView from './views/mod-queue';
import NotAllowed from './views/not-allowed';
import NotFound from './views/not-found';
@@ -76,9 +78,9 @@ const BoardLayout = () => {
const isOnPostRoute = isPostRoute(location.pathname);
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
const isOnModQueueRoute = isModQueueRoute(location.pathname);
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute;
const isOnArchiveRoute = isArchiveRoute(location.pathname);
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute;
const isInCatalogView = isCatalogView(location.pathname, params);
// Christmas theme
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
useEffect(() => {
@@ -145,6 +147,7 @@ const BoardLayout = () => {
<BoardHeader />
{isMobile
? (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) &&
!isOnArchiveRoute &&
(isInCatalogView ? (
<>
<PostForm key={key} />
@@ -157,7 +160,8 @@ const BoardLayout = () => {
{isInAllView && <MobileAllFeedFilter />}
</>
))
: (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) && (
: (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) &&
!isOnArchiveRoute && (
<>
<PostForm key={key} />
{!(isInAllView || isInSubscriptionsView || isInModView) && !isOnModQueueRoute && <BoardBlotter />}
@@ -307,6 +311,12 @@ const App = () => {
<Route path='/mod/queue' element={<ModQueueRoute />} />
<Route path='/mod/queue/settings' element={<ModQueueRoute />} />
<Route path='/all/archive' element={<Navigate to='/not-found' replace />} />
<Route path='/all/archive/settings' element={<Navigate to='/not-found' replace />} />
<Route path='/subs/archive' element={<Navigate to='/not-found' replace />} />
<Route path='/subs/archive/settings' element={<Navigate to='/not-found' replace />} />
<Route path='/mod/archive' element={<Navigate to='/not-found' replace />} />
<Route path='/mod/archive/settings' element={<Navigate to='/not-found' replace />} />
{/* Invalid subpaths: old URLs and unknown paths -> not-found */}
<Route path='/mod/modqueue' element={<Navigate to='/not-found' replace />} />
@@ -321,6 +331,8 @@ const App = () => {
<Route path='/:boardIdentifier/settings' element={boardFeedElement} />
<Route path='/:boardIdentifier/catalog' element={catalogFeedElement} />
<Route path='/:boardIdentifier/catalog/settings' element={catalogFeedElement} />
<Route path='/:boardIdentifier/archive' element={<Archive />} />
<Route path='/:boardIdentifier/archive/settings' element={<Archive />} />
<Route path='/:boardIdentifier/mod/queue' element={<ModQueueRoute />} />
<Route path='/:boardIdentifier/mod/queue/settings' element={<ModQueueRoute />} />
@@ -185,6 +185,7 @@ const renderWithRoute = async (element: React.ReactElement, initialEntry: string
createElement(Route, { path: '/all/catalog', element }),
createElement(Route, { path: '/mod/queue', element }),
createElement(Route, { path: '/:boardIdentifier/catalog', element }),
createElement(Route, { path: '/:boardIdentifier/archive', element }),
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid', element }),
createElement(Route, { path: '/:boardIdentifier', element }),
),
@@ -288,8 +289,9 @@ describe('BoardButtons', () => {
expect(testState.resetMock).toHaveBeenCalledTimes(1);
expect(testState.subscribeMock).toHaveBeenCalledTimes(1);
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/archive');
expect(globalThis.alert).toHaveBeenNthCalledWith(1, 'vote_button_unavailable_intro\n\nvote_button_unavailable_outro');
expect(globalThis.alert).toHaveBeenNthCalledWith(2, 'Work in progress');
expect(globalThis.alert).toHaveBeenCalledTimes(1);
});
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
@@ -299,6 +301,7 @@ describe('BoardButtons', () => {
expect(container.textContent).toContain('filtered_threads');
expect(container.textContent).toContain('4');
expect(container.textContent).not.toContain('archive');
expect(container.querySelector('[data-testid="catalog-filters"]')?.textContent).toBe('catalog-filters');
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
@@ -322,6 +325,7 @@ describe('BoardButtons', () => {
testState.commentsByCid = {
'comment-1': {
cid: 'comment-1',
archived: true,
closed: true,
number: 99,
pinned: true,
@@ -335,6 +339,7 @@ describe('BoardButtons', () => {
const tooltips = Array.from(container.querySelectorAll<HTMLElement>('[data-testid="tooltip"]'));
expect(tooltips.map((tooltip) => tooltip.dataset.content)).toEqual(['Replies', 'Links', 'pagination.pageLabel']);
expect(tooltips.map((tooltip) => tooltip.textContent)).toEqual(['9', '3', '7']);
expect(container.textContent).toContain('Archived /');
expect(container.textContent).toContain('Sticky /');
expect(container.textContent).toContain('Closed /');
+16 -5
View File
@@ -19,6 +19,7 @@ import CatalogFilters from '../catalog-filters';
import CatalogSearch from '../catalog-search';
import Tooltip from '../tooltip';
import { ModQueueButton } from '../../views/mod-queue/mod-queue';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import styles from './board-buttons.module.css';
import capitalize from 'lodash/capitalize';
@@ -59,13 +60,21 @@ export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isI
export const ArchiveButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams();
const directories = useDirectories();
const handleClick = () => {
window.alert('Work in progress');
};
const isInvalidArchiveContext = isInAllView || isInSubscriptionsView || isInModView;
const boardIdentifier = params.boardIdentifier || params.subplebbitAddress;
const archiveBoardIdentifier = address ? getBoardPath(address, directories) : boardIdentifier ? getBoardPath(boardIdentifier, directories) : '';
const archivePath = archiveBoardIdentifier ? `/${archiveBoardIdentifier}/archive` : '';
if (isInvalidArchiveContext || !archivePath) {
return null;
}
return (
<button className='button' onClick={handleClick}>
<button className='button' onClick={() => navigate(archivePath)}>
{t('archive')}
</button>
);
@@ -183,7 +192,7 @@ export const AutoButton = () => {
);
};
const BottomButton = () => {
export const BottomButton = () => {
const { t } = useTranslation();
const handleClick = () => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
@@ -495,6 +504,7 @@ export const PostPageStats = () => {
const postCid = comment?.postCid ?? commentCid;
const post = useComment({ commentCid: postCid });
const archived = isCommentArchived(post);
const { closed, pinned, replyCount } = post || {};
const linkCount = useCountLinksInReplies(post);
const directoryEntry = useDirectoryByAddress(communityAddress);
@@ -513,6 +523,7 @@ export const PostPageStats = () => {
return (
<span>
{pinned && `${capitalize(t('sticky'))} / `}
{archived && `${capitalize(t('archived'))} / `}
{closed && `${capitalize(t('closed'))} / `}
<Tooltip content={replyCountTooltip}>{displayReplyCount}</Tooltip> /{' '}
<Tooltip content={capitalize(requirePostLinkIsMedia ? t('images') : t('links'))}>{linkCount?.toString()}</Tooltip>
+3 -1
View File
@@ -7,6 +7,7 @@ import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores
import getShortAddress from '../../lib/get-short-address';
import { useStableCommunity } from '../../hooks/use-stable-community';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import { isArchiveRoute } from '../../lib/utils/route-utils';
import styles from './board-header.module.css';
import { useDirectoriesMetadata, useDirectories } from '../../hooks/use-directories';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
@@ -51,6 +52,7 @@ const BoardHeader = () => {
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
const isInModView = isModView(location.pathname);
const isInArchiveView = isArchiveRoute(location.pathname);
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || accountComment?.communityAddress;
@@ -120,7 +122,7 @@ const BoardHeader = () => {
subtitle
)}
</div>
<hr />
{!isInArchiveView && <hr />}
</div>
);
};
@@ -15,6 +15,9 @@ type TestComment = {
};
cid: string;
content?: string;
commentModeration?: {
archived?: boolean;
};
link?: string;
linkHeight?: number;
linkWidth?: number;
@@ -276,6 +279,25 @@ describe('CatalogRow', () => {
expect(container.querySelector<HTMLImageElement>('img[src="assets/filedeleted-res.gif"]')).toBeTruthy();
});
it('renders the archived icon for archived threads', async () => {
const post: TestComment = {
author: { address: 'author-1', displayName: 'Alice' },
cid: 'post-archived',
commentModeration: {
archived: true,
},
content: 'Archived thread',
replyCount: 3,
subplebbitAddress: 'music-posting.eth',
title: 'Old thread',
};
testState.mediaInfoByLink['https://example.com/media.png'] = { type: 'image', url: 'https://example.com/media.png' };
await renderWithRouter(createElement(CatalogRow, { row: [post] }));
expect(container.querySelector('[title="archived"]')).toBeTruthy();
});
it('renders audio players and video first-frame fallbacks for media without thumbnails', async () => {
await act(async () => {
root.render(
@@ -66,6 +66,10 @@
background-image: url("/assets/icons/closed.gif");
}
.threadIcons .archivedIcon {
background-image: url("/assets/icons/archived.gif");
}
.mediaWrapper {
background-color: var(--media-thumbnail-background-color);
width: var(--width);
@@ -18,6 +18,7 @@ import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useHide from '../../hooks/use-hide';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { removeMarkdown } from '../../lib/utils/post-utils';
import PostMenuDesktop from '../post-desktop/post-menu-desktop';
import styles from './catalog-row.module.css';
@@ -121,6 +122,7 @@ const CatalogPost = memo(
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } =
resolvedPost || {};
const archived = isCommentArchived(resolvedPost);
const linkCount = useCountLinksInReplies(resolvedPost);
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
@@ -144,6 +146,7 @@ const CatalogPost = memo(
<div className={styles.threadIcons}>
{pinned && <span className={styles.stickyIcon} title={t('sticky')} />}
{locked && <span className={styles.closedIcon} title={t('closed')} />}
{archived && <span className={styles.archivedIcon} title={t('archived')} />}
</div>
);
@@ -324,6 +327,7 @@ const CatalogPost = memo(
prev?.replyCount === next?.replyCount &&
prev?.locked === next?.locked &&
prev?.pinned === next?.pinned &&
isCommentArchived(prev) === isCommentArchived(next) &&
prev?.title === next?.title &&
prev?.content === next?.content &&
prev?.spoiler === next?.spoiler &&
@@ -127,6 +127,9 @@ const basePost = {
removed: false,
spoiler: false,
communityAddress: 'music-posting.eth',
commentModeration: {
archived: false,
},
} as Record<string, any>;
const renderMenu = async (post = basePost) => {
@@ -276,6 +279,7 @@ describe('EditMenu', () => {
await click(getCheckbox('removed'));
await click(getCheckbox('purged'));
await click(getCheckbox('locked'));
await click(getCheckbox('archived'));
await click(getCheckbox('spoiler'));
await click(getCheckbox('pinned'));
await click(getCheckbox('banUser'));
@@ -302,6 +306,7 @@ describe('EditMenu', () => {
});
expect(testState.modOptions?.commentModeration).toMatchObject({
reason: 'rule violation',
archived: true,
locked: true,
pinned: true,
purged: true,
@@ -329,6 +334,26 @@ describe('EditMenu', () => {
expect(testState.modOptions?.commentModeration?.purged).toBe(false);
});
it('shows archive control only for top-level comments', async () => {
testState.privileges = {
isAccountCommentAuthor: false,
isAccountMod: true,
isCommentAuthorMod: false,
};
await renderMenu(basePost);
await openMenu();
expect(getCheckbox('archived')).not.toBeNull();
await renderMenu({
...basePost,
cid: 'reply-comment',
parentCid: 'parent-cid',
});
await openMenu();
expect(getCheckbox('archived')).toBeNull();
});
it('runs both the author edit and moderation publication paths when the user has both privileges', async () => {
testState.privileges = {
isAccountCommentAuthor: true,
+31 -1
View File
@@ -11,6 +11,7 @@ import {
} from '@bitsocialnet/bitsocial-react-hooks';
import styles from './edit-menu.module.css';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import useChallengesStore from '../../stores/use-challenges-store';
import capitalize from 'lodash/capitalize';
import useIsMobile from '../../hooks/use-is-mobile';
@@ -36,6 +37,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {};
const communityAddress = getCommentCommunityAddress(resolvedPost);
const archived = isCommentArchived(resolvedPost);
const authorDisplayName = resolvedPost?.author?.displayName;
const modBanExpiresAt = resolvedPost?.commentModeration?.author?.banExpiresAt;
const purged = resolvedPost?.commentModeration?.purged ?? false;
@@ -67,6 +69,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
commentModeration: isAccountMod
? {
locked: locked ?? false,
archived: parentCid === undefined ? (archived ?? false) : undefined,
pinned: pinned ?? false,
removed: removed ?? false,
purged: purged ?? false,
@@ -82,7 +85,23 @@ const EditMenu = ({ post }: { post: Comment }) => {
alert('Comment edit failed. ' + error.message);
},
};
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, communityAddress, modBanExpiresAt, onChallenge]);
}, [
isAccountMod,
isAccountCommentAuthor,
archived,
cid,
content,
deleted,
locked,
pinned,
reason,
removed,
purged,
spoiler,
communityAddress,
modBanExpiresAt,
onChallenge,
]);
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
@@ -112,6 +131,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
communityAddress,
commentModeration: {
locked: parentCid === undefined ? publishCommentEditOptions.commentModeration?.locked : undefined,
archived: parentCid === undefined ? publishCommentEditOptions.commentModeration?.archived : undefined,
pinned: publishCommentEditOptions.commentModeration?.pinned,
removed: publishCommentEditOptions.commentModeration?.removed,
purged: publishCommentEditOptions.commentModeration?.purged,
@@ -337,6 +357,16 @@ const EditMenu = ({ post }: { post: Comment }) => {
</label>
]
</div>
{!parentCid && (
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.archived ?? false} type='checkbox' id='archived' />
{capitalize(t('archived'))}?
</label>
]
</div>
)}
<div className={styles.menuItem}>
[
<label>
+12 -1
View File
@@ -50,6 +50,7 @@ import useProgressiveRender from '../../hooks/use-progressive-render';
import useFreshReplies from '../../hooks/use-fresh-replies';
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../../lib/utils/replies-preview-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
@@ -99,6 +100,7 @@ const PostInfo = ({
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
const { t } = useTranslation();
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, communityAddress, timestamp } = post || {};
const archived = isCommentArchived(post);
const purged = post?.commentModeration?.purged;
const title = post?.title?.trim();
const { address, shortAddress } = author || {};
@@ -247,7 +249,9 @@ const PostInfo = ({
? isReply
? alert(t('this_reply_was_removed'))
: alert(t('this_thread_was_removed'))
: openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress);
: archived && !isReply
? alert(t('thread_archived'))
: openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress);
};
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
@@ -397,6 +401,13 @@ const PostInfo = ({
<img src='assets/icons/closed.gif' alt='' className={styles.closedIcon} title={t('closed')} />
</span>
)}
{archived && (
<span
className={`${styles.closedIconWrapper} ${!locked && !pinned ? styles.addPaddingBeforeReply : ''} ${pinned || locked ? styles.addPaddingInBetween : ''}`}
>
<img src='assets/icons/archived.gif' alt='' className={styles.closedIcon} title={t('archived')} />
</span>
)}
{!isInPostPageView && !isReply && !isHidden && !isModQueue && (
<span className={styles.replyButton}>
[
@@ -15,12 +15,12 @@ const testState = vi.hoisted(() => ({
},
accountComment: undefined as { communityAddress?: string } | undefined,
accountCommunityAddresses: ['mod.eth'] as string[],
comments: {} as Record<string, { deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
comments: {} as Record<string, { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
directories: [
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
] as Array<{ address: string; features?: Record<string, unknown>; title?: string }>,
editedComment: undefined as { deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
gifFrameStatus: 'idle' as 'idle' | 'ready',
handleUploadMock: vi.fn(),
isOffline: false,
@@ -332,6 +332,22 @@ describe('PostForm', () => {
expect(container.textContent).toContain('may_not_reply');
});
it('shows the closed-thread notice for archived threads', async () => {
testState.comments = {
'thread-cid': {
postCid: 'thread-cid',
commentModeration: {
archived: true,
},
},
};
await renderPostForm('/mu/thread/thread-cid');
expect(container.textContent).toContain('thread_archived');
expect(container.textContent).toContain('may_not_reply');
});
it('opens the new-thread form, validates all-view requirements, and publishes a board post', async () => {
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
+6 -3
View File
@@ -16,6 +16,7 @@ import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload';
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import styles from './post-form.module.css';
@@ -525,7 +526,9 @@ const PostForm = () => {
}
const { deleted, locked, removed, postCid } = comment || {};
const isThreadClosed = deleted || locked || removed;
const archived = isCommentArchived(comment);
const isThreadClosed = deleted || locked || removed || archived;
const threadStateKey = archived ? 'thread_archived' : 'thread_closed';
const [showForm, setShowForm] = useState(false);
@@ -543,7 +546,7 @@ const PostForm = () => {
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? (
<div className={styles.closed}>
{t('thread_closed')}
{t(threadStateKey)}
<br />
{t('may_not_reply')}
</div>
@@ -567,7 +570,7 @@ const PostForm = () => {
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? (
<div className={styles.closed}>
{t('thread_closed')}
{t(threadStateKey)}
<br />
{t('may_not_reply')}
</div>
+12 -1
View File
@@ -43,6 +43,7 @@ import useQuotedByMap from '../../hooks/use-quoted-by-map';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useFreshReplies from '../../hooks/use-fresh-replies';
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { filterRepliesForDisplay, getPreviewDisplayReplies } from '../../lib/utils/replies-preview-utils';
import { getRenderableMobileBacklinks } from '../../lib/utils/reply-backlink-utils';
import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
@@ -67,6 +68,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, deleted, link, linkHeight, linkWidth, locked, parentCid, pinned, postCid, reason, removed, state, communityAddress, timestamp, thumbnailUrl } =
resolvedPost || {};
const archived = isCommentArchived(resolvedPost);
const purged = resolvedPost?.commentModeration?.purged;
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const displayBoardPath =
@@ -229,7 +231,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
? isReply
? alert(t('this_reply_was_removed'))
: alert(t('this_thread_was_removed'))
: openReplyModal && openReplyModal(cid, resolvedPost?.number, postCid, threadNumber, communityAddress);
: archived && !isReply
? alert(t('thread_archived'))
: openReplyModal && openReplyModal(cid, resolvedPost?.number, postCid, threadNumber, communityAddress);
};
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
@@ -332,6 +336,13 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
<img src='assets/icons/closed.gif' alt='' className={styles.closedIcon} title={t('closed')} />
</span>
)}
{archived && (
<span
className={`${styles.closedIconWrapper} ${!locked && !pinned ? styles.addPaddingBeforeReply : ''} ${pinned || locked ? styles.addPaddingInBetween : ''}`}
>
<img src='assets/icons/archived.gif' alt='' className={styles.closedIcon} title={t('archived')} />
</span>
)}
{title && (
<span className={styles.subjectWrapper}>
{title.length <= 30 ? (
@@ -7,6 +7,7 @@ import {
getFeedType,
getPageFromFeedPath,
getSubplebbitAddress,
isArchiveRoute,
isBoardModRoute,
isDirectoryBoard,
isFeedRoute,
@@ -98,6 +99,13 @@ describe('isFeedRoute', () => {
expect(isFeedRoute('/biz/mod/queue')).toBe(false);
});
it('returns false for board archive paths', () => {
expect(isFeedRoute('/biz/archive')).toBe(false);
expect(isFeedRoute('/biz/archive/settings')).toBe(false);
expect(isArchiveRoute('/biz/archive')).toBe(true);
expect(isArchiveRoute('/biz/archive/settings')).toBe(true);
});
it('returns false for posts and pending items', () => {
expect(isFeedRoute('/biz/thread/abc')).toBe(false);
expect(isFeedRoute('/pending/4')).toBe(false);
@@ -175,6 +183,7 @@ describe('feed cache helpers', () => {
expect(getFeedCacheKey('/biz/3/settings')).toBe('/biz');
expect(getFeedCacheKey('/biz/catalog/4')).toBe('/biz/catalog');
expect(getFeedCacheKey('/biz/thread/abc')).toBe('/biz');
expect(getFeedCacheKey('/biz/archive')).toBeNull();
});
it('returns null cache keys for non-feed routes', () => {
@@ -187,5 +196,6 @@ describe('feed cache helpers', () => {
expect(getFeedType('/biz/thread/abc')).toBe('board');
expect(getFeedType('/biz/catalog/settings')).toBe('catalog');
expect(getFeedType('/pending/3')).toBeNull();
expect(getFeedType('/biz/archive')).toBeNull();
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
isAllView,
isArchiveView,
isBoardView,
isCatalogView,
isHomeView,
@@ -48,5 +49,8 @@ describe('view-utils', () => {
expect(isPostPageView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(true);
expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true);
expect(isNotFoundView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(false);
expect(isArchiveView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(true);
expect(isBoardView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false);
expect(isNotFoundView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false);
});
});
+15
View File
@@ -0,0 +1,15 @@
type MaybeArchivedComment = {
archived?: boolean;
commentModeration?: {
archived?: boolean;
};
};
export const isCommentArchived = (comment: unknown): boolean => {
if (!comment || typeof comment !== 'object') {
return false;
}
const archivedComment = comment as MaybeArchivedComment;
return Boolean(archivedComment.archived || archivedComment.commentModeration?.archived);
};
+7 -1
View File
@@ -120,11 +120,17 @@ export const isDirectoryBoard = (identifier: string, communities: DirectoryCommu
return directoryToAddress.has(identifier);
};
export const isArchiveRoute = (pathname: string): boolean => {
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
return /\/archive$/.test(normalizedPath);
};
export const isFeedRoute = (pathname: string): boolean => {
const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
if (normalizedPath.includes('/thread/')) return false;
if (normalizedPath.startsWith('/pending/')) return false;
if (isArchiveRoute(normalizedPath)) return false;
if (isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) return false;
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
@@ -262,7 +268,7 @@ export const getFeedCacheKey = (pathname: string): string | null => {
return null;
}
if (isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
if (isArchiveRoute(normalizedPath) || isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
return null;
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { isBoardModRoute, isModQueueRoute } from './route-utils';
import { isArchiveRoute, isBoardModRoute, isModQueueRoute } from './route-utils';
type ParamsType = {
accountCommentIndex?: string;
@@ -19,6 +19,7 @@ export const isBoardView = (pathname: string, params: ParamsType): boolean => {
pathname.startsWith('/all') ||
pathname.startsWith('/subs') ||
pathname.startsWith('/mod') ||
isArchiveRoute(pathname) ||
isBoardModRoute(pathname) ||
pathname.startsWith('/pending') ||
pathname === '/' ||
@@ -82,10 +83,19 @@ export const isSubscriptionsView = (pathname: string, params: ParamsType): boole
return pathname === '/subs' || pathname === '/subs/settings' || pathname === '/subs/catalog' || pathname === '/subs/catalog/settings';
};
export const isArchiveView = (pathname: string, params: ParamsType): boolean => {
const { boardIdentifier, subplebbitAddress } = params;
const identifier = boardIdentifier || subplebbitAddress;
const decodedPathname = decodeURIComponent(pathname);
return Boolean(identifier && isArchiveRoute(decodedPathname) && decodedPathname === `/${identifier}/archive`);
};
export const isNotFoundView = (pathname: string, params: ParamsType): boolean => {
return (
!isAllView(pathname) &&
!isBoardView(pathname, params) &&
!isArchiveView(pathname, params) &&
!isCatalogView(pathname, params) &&
!isHomeView(pathname) &&
!isPendingPostView(pathname, params) &&
@@ -0,0 +1,222 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Archive from '../archive';
import { renderArchiveRoute } from './helpers';
type TestComment = {
cid: string;
archived?: boolean;
commentModeration?: {
archived?: boolean;
};
title?: string;
content?: string;
timestamp?: number;
threadCid?: string;
};
const testState = vi.hoisted(() => ({
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }],
feed: [] as TestComment[],
hasMore: false,
isMobile: false,
loadMoreMock: vi.fn(),
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
subplebbit: {
error: undefined as Error | undefined,
title: '/mu/ - Music',
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'displaying_x_archived_threads') {
return `Displaying ${options?.count ?? 0} archived threads`;
}
if (key === 'displaying_x_archived_threads_from_past_x_days') {
return `Displaying ${options?.count ?? 0} archived threads from the past ${options?.days ?? 0} days`;
}
return key;
},
}),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useFeed: (options: { filter?: { filter?: (comment: TestComment) => boolean } }) => ({
feed: options.filter?.filter ? testState.feed.filter((comment) => options.filter?.filter?.(comment)) : testState.feed,
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: vi.fn(),
}),
useCommunity: () => testState.subplebbit,
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
}));
vi.mock('../../../hooks/use-state-string', () => ({
useFeedStateString: () => 'loading_feed',
}));
vi.mock('../../../hooks/use-stable-community', () => ({
useCommunityField: (_address: string | undefined, selector: (value: typeof testState.subplebbit) => unknown) => selector(testState.subplebbit),
}));
vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../../components/board-buttons/board-buttons', () => ({
BottomButton: () => createElement('button', { type: 'button' }, 'bottom'),
CatalogButton: () => createElement('button', { type: 'button' }, 'catalog'),
ReturnButton: () => createElement('button', { type: 'button' }, 'return'),
TopButton: () => createElement('button', { type: 'button' }, 'top'),
}));
vi.mock('../../../components/footer', () => ({
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
createElement('div', { 'data-testid': 'footer-desktop' }, firstRow, styleRow),
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children),
ThreadFooterStyleRow: () =>
createElement(
'div',
{ 'data-testid': 'thread-footer-style-row' },
createElement('span', {}, 'style'),
createElement('span', { 'data-testid': 'style-selector' }, 'style-selector'),
),
}));
vi.mock('../../../components/style-selector/style-selector', () => ({
default: () => createElement('span', { 'data-testid': 'style-selector' }),
}));
vi.mock('../../../components/error-display/error-display', () => ({
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
}));
vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../lib/snow', () => ({
shouldShowSnow: () => false,
}));
let container: HTMLDivElement;
let root: Root;
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
describe('Archive', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
testState.feed = [];
testState.hasMore = false;
testState.isMobile = false;
testState.subplebbit = {
error: undefined,
title: '/mu/ - Music',
};
testState.loadMoreMock = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it('filters the feed by truthy archived state and shows archive links', async () => {
testState.feed = [
{ cid: 'a', archived: true, threadCid: '111', title: 'Archived one', content: 'first excerpt' },
{ cid: 'b', archived: false, threadCid: '222', title: 'Not archived', content: 'not shown' },
{ cid: 'c', commentModeration: { archived: true }, threadCid: '333', title: 'Second archived' },
];
await renderArchiveRoute({ root, element: createElement(Archive), initialEntry: '/mu/archive', routePath: '/:boardIdentifier/archive' });
const rows = container.querySelectorAll('#arc-list tbody tr');
expect(rows.length).toBe(2);
expect(rows[0]!.textContent).toContain('111');
expect(rows[1]!.textContent).toContain('333');
expect(rows[0]!.textContent).not.toContain('222');
});
it('shows the archived summary window using the oldest archived timestamp', async () => {
vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-03-13T00:00:00Z').getTime());
testState.feed = [
{ cid: 'a', archived: true, threadCid: '111', title: 'Archived one', timestamp: Math.floor(new Date('2026-03-12T12:00:00Z').getTime() / 1000) },
{ cid: 'b', archived: true, threadCid: '222', title: 'Archived two', timestamp: Math.floor(new Date('2026-03-10T00:00:00Z').getTime() / 1000) },
];
await renderArchiveRoute({ root, element: createElement(Archive), initialEntry: '/mu/archive', routePath: '/:boardIdentifier/archive' });
expect(container.textContent).toContain('Displaying 2 archived threads from the past 3 days');
});
it('renders desktop controls and footer style selector', async () => {
testState.feed = [{ cid: 'a', archived: true, threadCid: '111', title: 'Archived one', content: 'excerpt' }];
await renderArchiveRoute({ root, element: createElement(Archive), initialEntry: '/mu/archive', routePath: '/:boardIdentifier/archive' });
expect(container.querySelector('[data-testid="footer-desktop"]')).toBeTruthy();
expect(container.textContent).toContain('return');
expect(container.textContent).toContain('catalog');
expect(container.textContent).toContain('bottom');
expect(container.textContent).toContain('top');
expect(container.textContent).toContain('style');
expect(container.querySelector('[data-testid="style-selector"]')).toBeTruthy();
expect(container.querySelector('tbody tr td')?.textContent).toContain('111');
expect(container.querySelector('thead tr td:last-child')?.textContent).toBe('');
});
it('shows load more action and forwards user interaction', async () => {
testState.feed = [
{ cid: 'a', archived: true, threadCid: '111', title: 'Archived one' },
{ cid: 'b', archived: true, threadCid: '222', title: 'Archived two' },
];
testState.hasMore = true;
await renderArchiveRoute({ root, element: createElement(Archive), initialEntry: '/mu/archive', routePath: '/:boardIdentifier/archive' });
const loadMoreButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more');
expect(loadMoreButton).toBeTruthy();
act(() => {
loadMoreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
});
it('renders the shared archive table and mobile nav actions when mobile hook is set', async () => {
testState.isMobile = true;
testState.feed = [
{ cid: 'a', archived: true, threadCid: '111', title: 'Archived one', content: 'mobile excerpt' },
{ cid: 'b', archived: true, threadCid: '222', title: 'Archived two', content: 'other excerpt' },
];
await renderArchiveRoute({ root, element: createElement(Archive), initialEntry: '/mu/archive', routePath: '/:boardIdentifier/archive' });
expect(container.querySelectorAll('#arc-list tbody tr').length).toBe(2);
expect(container.querySelector('[data-testid="footer-mobile"]')).toBeTruthy();
expect(container.textContent).toContain('return');
expect(container.textContent).toContain('catalog');
expect(container.textContent).toContain('bottom');
expect(container.textContent).toContain('top');
});
});
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
export type ArchiveRouteRenderOptions = {
root: Root;
element: React.ReactNode;
initialEntry: string;
routePath?: string;
};
export const renderArchiveRoute = async ({ root, element, initialEntry, routePath = '/:boardIdentifier/archive' }: ArchiveRouteRenderOptions) => {
let latestLocation = '';
const LocationProbe = () => {
const location = useLocation();
React.useLayoutEffect(() => {
latestLocation = location.pathname;
}, [location.pathname]);
return null;
};
await act(async () => {
root.render(
createElement(
MemoryRouter,
{ initialEntries: [initialEntry] },
createElement(Routes, {}, createElement(Route, { path: routePath, element }), createElement(Route, { path: '*', element })),
createElement(LocationProbe),
),
);
});
for (let i = 0; i < 6; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
return latestLocation;
};
+224
View File
@@ -0,0 +1,224 @@
.page {
max-width: none;
margin: 0;
padding: 0 0 24px;
color: var(--body-font-color);
font-family: var(--body-font-family);
font-size: var(--body-font-size);
}
.desktopDivider {
display: block;
}
.divider {
display: block;
}
.desktopNavLinks {
display: flex;
align-items: center;
gap: 4px;
text-align: left;
font-size: 13px;
text-transform: capitalize;
}
.desktopNavLinks a,
.desktopFooterButtons a {
all: unset;
}
.mobileNavLinks {
display: none;
text-align: center;
text-transform: capitalize;
margin-top: 5px;
}
.mobileNavLinks button {
text-transform: capitalize;
margin: 5px 2px;
}
.mobileNavLinks a {
all: unset;
}
.desktopFooterButtons {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
text-transform: capitalize;
}
.mobileFooterButtons {
text-align: center;
text-transform: capitalize;
}
.mobileFooterButtons button {
text-transform: capitalize;
margin: 5px 2px;
}
.mobileFooterButtons a {
all: unset;
}
.archiveSummary {
margin: 0 0 12px;
text-align: center;
font-size: 10pt;
font-weight: 700;
}
.error {
margin-bottom: 8px;
}
.flashListing {
width: auto;
max-width: 80%;
margin: 10px auto 0;
border-collapse: separate;
border-spacing: 1px;
table-layout: auto;
}
.flashListing td {
padding: 2px;
font-size: 12px;
text-align: center;
}
.flashListing thead td {
background: #98e;
border: 1px solid #000;
font-weight: 700;
}
.arcRow {
background: transparent;
}
.numberCell,
.viewCell {
white-space: nowrap;
}
.postblock {
padding: 5px !important;
text-align: center;
}
.teaserCol {
text-align: left !important;
word-break: break-all;
}
.archiveLink,
.viewLink {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.archiveLink:hover,
.viewLink:hover {
color: var(--post-link-text-color-hover);
}
.footerState {
margin: 8px 0;
text-align: center;
}
.loadMoreButton {
margin-right: 10px;
background: transparent;
border: none;
padding: 0;
text-transform: lowercase;
color: var(--button-desktop-text-color);
text-decoration: var(--button-text-decoration);
cursor: pointer;
}
.loadMoreButton:hover {
color: var(--button-desktop-text-color-hover);
}
.garland {
border-image-slice: 50 0 50 0;
border-image-width: 40px 0px 0px 0px;
border-image-outset: 0px 0px 0px 0px;
border-image-repeat: repeat repeat;
border-image-source: url('/assets/garland.png');
border-style: solid;
padding-top: 50px;
}
@media (max-width: 640px) {
.desktopDivider,
.desktopNavLinks {
display: none;
}
.mobileNavLinks {
display: block;
}
.flashListing {
max-width: calc(100% - 10px);
margin: 10px auto 0 auto;
}
.viewCell {
white-space: nowrap;
}
}
:global(body.yotsuba) .rowOdd td {
background: #ede2d4;
}
:global(body.yotsuba) .flashListing thead td {
background: #ea8;
}
:global(body.yotsuba-b) .rowOdd td {
background: #e0e5f6;
}
:global(body.futaba) .rowOdd td {
background: #ede2d4;
}
:global(body.futaba) .flashListing thead td {
background: #f0e0d6;
}
:global(body.burichan) .rowOdd td {
background: #e0e5f6;
}
:global(body.burichan) .flashListing thead td {
background: #c3c9e9;
}
:global(body.tomorrow) .rowOdd td {
background: rgba(255, 255, 255, 0.1);
}
:global(body.tomorrow) .flashListing thead td {
background: #b294bb;
}
:global(body.photon) .rowOdd td {
background: #888;
}
:global(body.photon) .flashListing thead td {
background: #ddd;
}
+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useFeed, useCommunity } from '@bitsocialnet/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { shouldShowSnow } from '../../lib/snow';
import { BottomButton, CatalogButton, ReturnButton, TopButton } from '../../components/board-buttons/board-buttons';
import ErrorDisplay from '../../components/error-display/error-display';
import { PageFooterDesktop, PageFooterMobile, ThreadFooterStyleRow } from '../../components/footer';
import LoadingEllipsis from '../../components/loading-ellipsis';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
import { useCommunityField } from '../../hooks/use-stable-community';
import { useFeedStateString } from '../../hooks/use-state-string';
import { getSubplebbitAddress, getBoardPath } from '../../lib/utils/route-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { removeMarkdown } from '../../lib/utils/post-utils';
import { useDirectories } from '../../hooks/use-directories';
import styles from './archive.module.css';
type BoardFeedComment = {
cid?: string;
[key: string]: unknown;
threadCid?: string;
link?: string;
content?: string;
title?: string;
timestamp?: number;
number?: number | string;
archived?: boolean;
commentModeration?: {
archived?: boolean;
};
};
const BOARD_SORT_TYPE = 'active';
const ARCHIVE_FILTER_KEY = 'archived-only';
const SECONDS_PER_DAY = 60 * 60 * 24;
const getThreadLink = (boardPath: string | undefined, comment: BoardFeedComment): string | null => {
const threadCid = comment.threadCid || comment.cid;
if (!boardPath || !threadCid) {
return null;
}
return `/${boardPath}/thread/${threadCid}`;
};
const getArchiveExcerptText = ({ content, title, link }: Pick<BoardFeedComment, 'content' | 'title' | 'link'>, t: (key: string) => string) => {
const cleanTitle = typeof title === 'string' ? removeMarkdown(title).trim() : '';
const cleanContent = typeof content === 'string' ? removeMarkdown(content).trim() : '';
const cleanLink = typeof link === 'string' ? link.trim() : '';
return cleanTitle || cleanContent || cleanLink || t('no_content');
};
const normalizeTimestamp = (timestamp: BoardFeedComment['timestamp']) => {
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) {
return null;
}
return timestamp > 1_000_000_000_000 ? Math.floor(timestamp / 1000) : Math.floor(timestamp);
};
const getArchiveWindowInDays = (comments: BoardFeedComment[]) => {
const oldestTimestamp = comments.reduce<number | null>((oldest, comment) => {
const normalizedTimestamp = normalizeTimestamp(comment.timestamp);
if (normalizedTimestamp === null) {
return oldest;
}
if (oldest === null) {
return normalizedTimestamp;
}
return Math.min(oldest, normalizedTimestamp);
}, null);
if (oldestTimestamp === null) {
return null;
}
const currentTimestamp = Math.floor(Date.now() / 1000);
const elapsedSeconds = Math.max(0, currentTimestamp - oldestTimestamp);
return Math.max(1, Math.ceil(elapsedSeconds / SECONDS_PER_DAY));
};
const ArchiveFooter = ({ hasMore, loadingState, onLoadMore }: { hasMore: boolean; loadingState: string; onLoadMore: () => void }) => {
const { t } = useTranslation();
if (!hasMore) {
return null;
}
return (
<div className={styles.footerState}>
<button type='button' className={styles.loadMoreButton} onClick={onLoadMore}>
{t('load_more')}
</button>
<LoadingEllipsis string={loadingState} />
</div>
);
};
const ArchiveDesktopTopControls = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => (
<div className={styles.desktopNavLinks}>
<span>
[<ReturnButton address={subplebbitAddress} />]
</span>
<span>
[<CatalogButton address={subplebbitAddress} />]
</span>
<span>
[<BottomButton />]
</span>
</div>
);
const ArchiveDesktopFooterControls = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => (
<div className={styles.desktopFooterButtons}>
<span>
[<ReturnButton address={subplebbitAddress} />]
</span>
<span>
[<CatalogButton address={subplebbitAddress} />]
</span>
<span>
[<TopButton />]
</span>
</div>
);
const ArchiveMobileTopControls = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => (
<div className={styles.mobileNavLinks}>
<ReturnButton address={subplebbitAddress} />
<CatalogButton address={subplebbitAddress} />
<BottomButton />
</div>
);
const ArchiveMobileFooterControls = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => (
<div className={styles.mobileFooterButtons}>
<ReturnButton address={subplebbitAddress} />
<CatalogButton address={subplebbitAddress} />
<TopButton />
</div>
);
const Archive = () => {
const { t } = useTranslation();
const params = useParams();
const boardIdentifier = params.boardIdentifier;
const directories = useDirectories();
const resolvedAddressFromUrl = useResolvedSubplebbitAddress();
const subplebbitAddress = useMemo(() => {
if (boardIdentifier) {
return getSubplebbitAddress(boardIdentifier, directories);
}
return resolvedAddressFromUrl;
}, [boardIdentifier, directories, resolvedAddressFromUrl]);
const boardPath = useMemo(() => {
if (!subplebbitAddress) {
return boardIdentifier;
}
return getBoardPath(subplebbitAddress, directories);
}, [boardIdentifier, directories, subplebbitAddress]);
const boardTitle = useCommunityField(subplebbitAddress, (community) => community?.title) || `/${boardIdentifier || subplebbitAddress || t('archive')}/`;
const archiveFilter = useMemo(
() => ({
filter: (comment: BoardFeedComment) => isCommentArchived(comment),
key: ARCHIVE_FILTER_KEY,
}),
[],
);
const communityAddresses = useMemo(() => (subplebbitAddress ? [subplebbitAddress] : []), [subplebbitAddress]);
const feedOptions = useMemo(
() => ({
communityAddresses,
sortType: BOARD_SORT_TYPE,
filter: archiveFilter,
}),
[communityAddresses, archiveFilter],
);
const { feed, hasMore, loadMore } = useFeed(feedOptions);
const loadingState = useFeedStateString(communityAddresses) || (hasMore ? t('loading_feed') : t('no_threads'));
const community = useCommunity({ communityAddress: subplebbitAddress });
const { error: communityError } = community || {};
const archiveWindowInDays = useMemo(() => getArchiveWindowInDays(feed), [feed]);
const isLoading = feed.length === 0 && hasMore;
const isEmpty = feed.length === 0 && !hasMore;
const summaryText = isEmpty
? t('no_archived_threads')
: archiveWindowInDays === null
? t('displaying_x_archived_threads', { count: feed.length })
: t('displaying_x_archived_threads_from_past_x_days', {
count: feed.length,
days: archiveWindowInDays,
});
useEffect(() => {
document.title = `${boardTitle} / ${t('archive')} - 5chan`;
}, [boardTitle, t]);
if (isLoading) {
return (
<div id='top' className={`${styles.page} ${shouldShowSnow() ? styles.garland : ''}`}>
<ArchiveMobileTopControls subplebbitAddress={subplebbitAddress} />
<hr className={styles.desktopDivider} />
<ArchiveDesktopTopControls subplebbitAddress={subplebbitAddress} />
<hr className={styles.divider} />
<h4 className={styles.archiveSummary}>{t('loading_archive')}</h4>
<PageFooterDesktop firstRow={<ArchiveDesktopFooterControls subplebbitAddress={subplebbitAddress} />} styleRow={<ThreadFooterStyleRow />} />
<PageFooterMobile>
<ArchiveMobileFooterControls subplebbitAddress={subplebbitAddress} />
</PageFooterMobile>
</div>
);
}
return (
<div id='top' className={`${styles.page} ${shouldShowSnow() ? styles.garland : ''}`}>
<ArchiveMobileTopControls subplebbitAddress={subplebbitAddress} />
<hr className={styles.desktopDivider} />
<ArchiveDesktopTopControls subplebbitAddress={subplebbitAddress} />
<hr className={styles.divider} />
<h4 className={styles.archiveSummary}>{summaryText}</h4>
{communityError && (
<div className={styles.error}>
<ErrorDisplay error={communityError} />
</div>
)}
{!isEmpty && (
<table id='arc-list' className={styles.flashListing}>
<thead>
<tr>
<td className={styles.postblock}>No.</td>
<td className={styles.postblock}>Excerpt</td>
<td className={styles.postblock}></td>
</tr>
</thead>
<tbody>
{feed.map((comment, index) => {
const threadLink = getThreadLink(boardPath, comment);
const threadNumber = comment.threadCid || comment.number || comment.cid;
const cleanTitle = typeof comment.title === 'string' ? removeMarkdown(comment.title).trim() : '';
const cleanContent = typeof comment.content === 'string' ? removeMarkdown(comment.content).trim() : '';
const excerptText = getArchiveExcerptText(comment, t);
return (
<tr key={comment.cid || `archive-${index}`} className={`${styles.arcRow} ${index % 2 === 0 ? styles.rowOdd : ''}`}>
<td className={styles.numberCell}>{threadNumber || '—'}</td>
<td className={styles.teaserCol} title={excerptText}>
{cleanTitle ? (
<>
<b>{cleanTitle}</b>
{cleanContent ? ': ' : ''}
{cleanContent || null}
</>
) : (
excerptText
)}
</td>
<td className={styles.viewCell}>
{threadLink ? (
<>
[
<Link to={threadLink} className={styles.viewLink}>
{t('view')}
</Link>
]
</>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
)}
<ArchiveFooter hasMore={hasMore} loadingState={loadingState} onLoadMore={loadMore} />
<PageFooterDesktop firstRow={<ArchiveDesktopFooterControls subplebbitAddress={subplebbitAddress} />} styleRow={<ThreadFooterStyleRow />} />
<PageFooterMobile>
<ArchiveMobileFooterControls subplebbitAddress={subplebbitAddress} />
</PageFooterMobile>
</div>
);
};
export default Archive;
+1
View File
@@ -0,0 +1 @@
export { default } from './archive';
+11 -1
View File
@@ -18,6 +18,7 @@ import usePostNumberStore from '../../stores/use-post-number-store';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import ErrorDisplay from '../../components/error-display/error-display';
import LoadingEllipsis from '../../components/loading-ellipsis';
import BoardPagination from '../../components/board-pagination';
@@ -140,13 +141,22 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
const excludeArchivedFilter = useMemo(
() => ({
filter: (comment: Comment) => !isCommentArchived(comment),
key: 'exclude-archived',
}),
[],
);
const feedOptions = useMemo(
() => ({
communityAddresses,
sortType: BOARD_SORT_TYPE,
postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
filter: excludeArchivedFilter,
}),
[communityAddresses, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage],
[communityAddresses, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter],
);
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
+3 -1
View File
@@ -23,6 +23,7 @@ import LoadingEllipsis from '../../components/loading-ellipsis';
import ErrorDisplay from '../../components/error-display/error-display';
import styles from './catalog.module.css';
import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
@@ -179,12 +180,13 @@ const createCombinedFilter = (
return {
filter: (comment: Comment) => {
if (isCommentArchived(comment)) return false;
if (!contentFilter.filter(comment)) return false;
if (!searchFilter.filter(comment)) return false;
return true;
},
key: `${contentFilter.key}-${searchFilter.key}`,
key: `${contentFilter.key}-${searchFilter.key}-exclude-archived`,
};
};
+24
View File
@@ -12,6 +12,9 @@ type TestComment = {
cid?: string;
content?: string;
error?: Error;
commentModeration?: {
archived?: boolean;
};
locked?: boolean;
number?: number;
parentCid?: string;
@@ -302,6 +305,27 @@ describe('Post', () => {
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
});
it('passes archived OP state through to thread footers as closed', async () => {
testState.commentsByCid = {
'archived-thread': {
cid: 'archived-thread',
content: 'thread',
commentModeration: {
archived: true,
},
number: 777,
replyCount: 0,
subplebbitAddress: 'music-posting.eth',
title: 'Archived thread',
},
};
await renderPostPage('/mu/thread/archived-thread');
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('archived-thread:777:music-posting.eth:true');
expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('archived-thread:777:music-posting.eth:true');
});
it('only aligns the OP container when navigation explicitly requests it', async () => {
testState.commentsByCid = {
'thread-cid': {
+16 -2
View File
@@ -7,6 +7,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { isAllView } from '../../lib/utils/view-utils';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useDirectories } from '../../hooks/use-directories';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import useIsMobile from '../../hooks/use-is-mobile';
@@ -111,6 +112,7 @@ export const Post = memo(
prev?.updatedAt === next?.updatedAt &&
prev?.locked === next?.locked &&
prev?.pinned === next?.pinned &&
isCommentArchived(prev) === isCommentArchived(next) &&
prev?.removed === next?.removed &&
prev?.deleted === next?.deleted &&
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
@@ -231,10 +233,22 @@ const PostPage = () => {
{post?.cid && communityAddress ? (
<>
<PageFooterDesktop
firstRow={<ThreadFooterFirstRow postCid={post.cid} threadNumber={post?.number} communityAddress={communityAddress} isThreadClosed={!!post?.locked} />}
firstRow={
<ThreadFooterFirstRow
postCid={post.cid}
threadNumber={post?.number}
communityAddress={communityAddress}
isThreadClosed={!!(post?.locked || isCommentArchived(post))}
/>
}
styleRow={<ThreadFooterStyleRow />}
/>
<ThreadFooterMobile postCid={post.cid} threadNumber={post?.number} communityAddress={communityAddress} isThreadClosed={!!post?.locked} />
<ThreadFooterMobile
postCid={post.cid}
threadNumber={post?.number}
communityAddress={communityAddress}
isThreadClosed={!!(post?.locked || isCommentArchived(post))}
/>
</>
) : null}
</div>