mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './archive';
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user