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
@@ -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>