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:
@@ -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
@@ -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 /');
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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