Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-06-23 18:55:24 +07:00
54 changed files with 2690 additions and 3661 deletions
-1924
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -47,5 +47,4 @@ This file is generated by `scripts/generate-llms-files.mjs`. Do not hand-edit it
## Optional
- [Changelog](https://github.com/bitsocialnet/5chan/blob/master/CHANGELOG.md): * **ci:** keep raw board tests side-effect free ([75dd7a8](https://github.com/bitsocialnet/5chan/commit/75dd7a84967cb3c99da37780fb31bb6a947d7d57)) * **pubsub:** avoid false browser p2p provider failures ([10015de](htt...
- [Upload Automation Retest Checklist](https://github.com/bitsocialnet/5chan/blob/master/docs/upload-automation-retest-checklist.md): Retest checklist for Android and desktop after changes to media upload automation (`MediaUploadAutomationRunner`, `MediaUploadRecipes`, `upload-orchestrator`, etc.).
+1 -1
View File
@@ -37,5 +37,5 @@
"docs/agent-playbooks/translations.md",
"docs/agent-playbooks/skills-and-tools.md"
],
"optionalDocs": ["CHANGELOG.md", "docs/upload-automation-retest-checklist.md"]
"optionalDocs": ["docs/upload-automation-retest-checklist.md"]
}
@@ -193,20 +193,20 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../catalog-filters', () => ({
vi.mock('../../catalog-filters/catalog-filters', () => ({
default: () => createElement('div', { 'data-testid': 'catalog-filters' }, 'catalog-filters'),
}));
vi.mock('../../catalog-search', () => ({
vi.mock('../../catalog-search/catalog-search', () => ({
default: () => createElement('div', { 'data-testid': 'catalog-search' }, 'catalog-search'),
}));
vi.mock('../../tooltip', () => ({
vi.mock('../../tooltip/tooltip', () => ({
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
createElement('span', { 'data-content': content, 'data-testid': 'tooltip' }, children),
}));
vi.mock('../../../views/mod-queue/mod-queue', () => ({
vi.mock('../../mod-queue-button/mod-queue-button', () => ({
ModQueueButton: ({ boardIdentifier, isMobile }: { boardIdentifier?: string; isMobile?: boolean }) =>
createElement('div', { 'data-mobile': String(!!isMobile), 'data-testid': 'mod-queue-button' }, boardIdentifier || 'global-mod-queue'),
}));
@@ -24,10 +24,10 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useOptimisticReplyCount from '../../hooks/use-optimistic-reply-count';
import useIsMobile from '../../hooks/use-is-mobile';
import useTimeFilter from '../../hooks/use-time-filter';
import CatalogFilters from '../catalog-filters';
import CatalogSearch from '../catalog-search';
import Tooltip from '../tooltip';
import { ModQueueButton } from '../../views/mod-queue/mod-queue';
import CatalogFilters from '../catalog-filters/catalog-filters';
import CatalogSearch from '../catalog-search/catalog-search';
import Tooltip from '../tooltip/tooltip';
import { ModQueueButton } from '../mod-queue-button/mod-queue-button';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
import { shouldShowCatalogButton } from './catalog-button-utils';
@@ -103,7 +103,7 @@ vi.mock('../../../lib/snow', () => ({
shouldShowSnow: () => testState.shouldShowSnow,
}));
vi.mock('../../tooltip', () => ({
vi.mock('../../tooltip/tooltip', () => ({
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
createElement('span', { 'data-testid': 'tooltip', 'data-content': content }, children),
}));
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import { accountsStore as useAccountsStore } from '../../lib/bitsocial-internals/stores';
import getShortAddress from '../../lib/get-short-address';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { useStableCommunity } from '../../hooks/use-stable-community';
@@ -15,7 +15,7 @@ import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import useIsMobile from '../../hooks/use-is-mobile';
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
import { shouldShowSnow } from '../../lib/snow';
import Tooltip from '../tooltip';
import Tooltip from '../tooltip/tooltip';
import { BANNERS } from '../../generated/asset-manifest';
const ImageBanner = () => {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import getShortAddress from '../../lib/get-short-address';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import { accountsStore as useAccountsStore } from '../../lib/bitsocial-internals/stores';
import { isAllView, isCatalogView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
@@ -136,7 +136,7 @@ vi.mock('../../../hooks/use-state-string', () => ({
default: () => testState.stateString,
}));
vi.mock('../../loading-ellipsis', () => ({
vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
@@ -154,7 +154,7 @@ vi.mock('../../error-display/error-display', () => ({
),
}));
vi.mock('../../reply-quote-preview', () => ({
vi.mock('../../reply-quote-preview/reply-quote-preview', () => ({
default: ({
isOP,
isQuotelinkReply,
@@ -180,11 +180,11 @@ vi.mock('../../reply-quote-preview', () => ({
),
}));
vi.mock('../../markdown', () => ({
vi.mock('../../markdown/markdown', () => ({
default: ({ content }: { content?: string }) => createElement('div', { 'data-testid': 'markdown' }, content),
}));
vi.mock('../../tooltip', () => ({
vi.mock('../../tooltip/tooltip', () => ({
default: ({ children, content }: { children?: React.ReactNode; content: string }) => createElement('span', { 'data-testid': 'tooltip', title: content }, children),
}));
@@ -339,6 +339,27 @@ describe('CommentContent', () => {
expect(queryMarkdownText()).toEqual(['[b]plain[/b]']);
});
it('waits for role data before rendering role-sensitive BBCode content', async () => {
const comment = {
author: { address: '0xmod' },
cid: 'post-1',
communityAddress: 'music-posting.eth',
content: '[color=red]hello[/color]',
postCid: 'post-1',
};
await renderContent(comment);
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading');
expect(container.textContent).not.toContain('[color=red]');
expect(queryMarkdownText()).toEqual([]);
await renderContent(comment, {});
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
expect(queryMarkdownText()).toEqual(['[color=red]hello[/color]']);
});
it('ignores unsupported BBCode styling values for moderator authors', async () => {
await renderContent(
{
@@ -2,7 +2,7 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Comment, useComment } from '@bitsocial/bitsocial-react-hooks';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import usePostNumberStore from '../../stores/use-post-number-store';
import getShortAddress from '../../lib/get-short-address';
import { getFormattedDate } from '../../lib/utils/time-utils';
@@ -10,12 +10,12 @@ import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import { isPostPageView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import useStateString from '../../hooks/use-state-string';
import LoadingEllipsis from '../loading-ellipsis';
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import BbcodeContent from '../../components/bbcode-content/bbcode-content';
import ErrorDisplay from '../../components/error-display/error-display';
import ReplyQuotePreview from '../reply-quote-preview';
import Markdown from '../markdown';
import Tooltip from '../tooltip';
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import Markdown from '../markdown/markdown';
import Tooltip from '../tooltip/tooltip';
import styles from '../../views/post/post.module.css';
import capitalize from 'lodash/capitalize';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
@@ -81,6 +81,10 @@ const getRoleByAddress = (roles: unknown, address?: string): string | undefined
return typeof roleEntry?.role === 'string' ? roleEntry.role : undefined;
};
const ROLE_SENSITIVE_BBCODE_PATTERN = /\[(?:\/(?:b|i|u|s|color|size|quote|url)|(?:b|i|u|s|quote)|(?:color|size|url)(?:=[^\]\r\n]*)?)\]/i;
const containsRoleSensitiveBbcode = (content: string | undefined): boolean => Boolean(content && ROLE_SENSITIVE_BBCODE_PATTERN.test(content));
const CommentContent = ({
appendContent,
comment: post,
@@ -106,6 +110,7 @@ const CommentContent = ({
const authorRole = getRoleByAddress(roles, authorAddress);
const isPrivilegedAuthor = hasModQueueAccessRole(authorRole);
const shouldRenderBbcode = isPrivilegedAuthor;
const shouldWaitForRoleSensitiveBbcode = roles === undefined && Boolean(authorAddress && communityAddress) && containsRoleSensitiveBbcode(visibleContent);
const purged = resolvedPost?.commentModeration?.purged;
const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt;
const banned = !!banExpiresAt;
@@ -179,7 +184,11 @@ const CommentContent = ({
</div>
);
const renderContent = (value: string | undefined) =>
shouldRenderBbcode ? (
shouldWaitForRoleSensitiveBbcode && containsRoleSensitiveBbcode(value) ? (
<span className={styles.stateString}>
<LoadingEllipsis string={t('loading')} />
</span>
) : shouldRenderBbcode ? (
<BbcodeContent content={value || ''} postCid={postCid} communityAddress={communityAddress} />
) : (
<Markdown content={value || ''} postCid={postCid} communityAddress={communityAddress} />
@@ -52,7 +52,11 @@ vi.mock('@floating-ui/react', () => ({
autoUpdate: () => undefined,
offset: () => ({}),
shift: () => ({}),
useClick: () => ({}),
useClick: ({ onOpenChange, open }: { onOpenChange: (open: boolean) => void; open: boolean }) => ({
reference: {
onClick: () => onOpenChange(!open),
},
}),
useDismiss: () => ({}),
useFloating: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ({
context: {
@@ -66,9 +70,16 @@ vi.mock('@floating-ui/react', () => ({
},
}),
useId: () => 'edit-menu-heading',
useInteractions: () => ({
useInteractions: (interactions: Array<{ reference?: { onClick?: () => void } }>) => ({
getFloatingProps: (props?: Record<string, unknown>) => props || {},
getReferenceProps: (props?: Record<string, unknown>) => props || {},
getReferenceProps: (props?: Record<string, unknown>) => ({
...props,
onClick: () => {
for (const interaction of interactions) {
interaction.reference?.onClick?.();
}
},
}),
}),
useRole: () => ({}),
}));
@@ -227,7 +238,21 @@ describe('EditMenu', () => {
expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply');
});
it('lets comment authors update content and deletion', async () => {
it('keeps an unauthorized reply checkbox unchecked after showing the edit warning', async () => {
await renderMenu({
...basePost,
parentCid: 'parent-1',
});
const editCheckbox = container.querySelector<HTMLInputElement>('span input[type="checkbox"]');
await click(editCheckbox);
expect(alertSpy).toHaveBeenCalledWith('cannot_edit_reply');
expect(editCheckbox?.checked).toBe(false);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('lets comment authors delete without exposing content editing', async () => {
testState.privileges = {
isAccountCommentAuthor: true,
isAccountMod: false,
@@ -238,12 +263,8 @@ describe('EditMenu', () => {
await openMenu();
await click(getCheckbox('deleted'));
await click(getLabelCheckbox('Edit?'));
const textarea = container.querySelector('textarea');
expect(textarea).not.toBeNull();
await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body');
expect(getLabelCheckbox('Edit?')).toBeNull();
expect(container.querySelector('textarea')).toBeNull();
await clickButton('save');
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
@@ -254,11 +275,11 @@ describe('EditMenu', () => {
displayName: 'Alice',
},
commentCid: 'comment-1',
content: 'Updated body',
deleted: true,
spoiler: false,
communityAddress: 'music-posting.eth',
});
expect(testState.authorOptions?.content).toBeUndefined();
expect(testState.authorOptions?.spoiler).toBeUndefined();
expect(testState.authorPrivilegesOptions).toMatchObject({
commentAuthorAddress: '0xauthor',
communityAddress: 'music-posting.eth',
@@ -463,7 +484,7 @@ describe('EditMenu', () => {
expect(getCheckbox('archived')).toBeNull();
});
it('runs both the author edit and moderation publication paths when the user has both privileges', async () => {
it('does not publish author-side edits for author moderators when deletion did not change', async () => {
testState.privileges = {
isAccountCommentAuthor: true,
isAccountMod: true,
@@ -474,7 +495,7 @@ describe('EditMenu', () => {
await openMenu();
await clickButton('save');
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
expect(testState.publishAuthorEditMock).not.toHaveBeenCalled();
expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce();
});
});
+4 -11
View File
@@ -42,7 +42,7 @@
}
.menuItem input[type="text"], .editTextarea {
.menuItem input[type="text"] {
padding: 2px;
box-shadow: var(--box-shadow-input);
margin-top: 2px;
@@ -50,12 +50,12 @@
margin-bottom: 2px;
}
.menuItem input[type="text"], .menuItem input[type='number'], .editTextarea {
.menuItem input[type="text"], .menuItem input[type='number'] {
border: var(--post-form-field-input-border);
outline: none;
}
.menuItem input[type="text"]:focus, .menuItem input[type='number']:focus, .editTextarea:focus {
.menuItem input[type="text"]:focus, .menuItem input[type='number']:focus {
border: var(--post-form-field-input-focus-border);
}
@@ -64,13 +64,6 @@
display: block;
}
.editTextarea {
width: calc(100% - 12px);
height: 100px;
margin-left: 3px;
font-family: inherit;
}
.bottom button {
text-transform: capitalize;
margin-left: 2px;
@@ -102,4 +95,4 @@
padding-left: 0;
padding-bottom: 0;
}
}
}
+4 -44
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import {
Comment,
PublishCommentEditOptions,
@@ -34,14 +34,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {};
const { author, cid, 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?.author?.community?.banExpiresAt ?? resolvedPost?.commentModeration?.author?.banExpiresAt;
const purged = resolvedPost?.commentModeration?.purged ?? false;
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
const account = useAccount();
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
@@ -65,9 +64,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
commentCid: cid,
communityAddress,
// Author edit properties
content: isAccountCommentAuthor ? content : undefined,
deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined,
spoiler: isAccountCommentAuthor ? (spoiler ?? false) : undefined,
// Mod edit properties
commentModeration: isAccountMod
? {
@@ -90,11 +87,9 @@ const EditMenu = ({ post }: { post: Comment }) => {
};
}, [
isAccountMod,
isAccountCommentAuthor,
canAttemptAuthorDelete,
archived,
cid,
content,
deleted,
locked,
pinned,
@@ -114,9 +109,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const options: PublishCommentEditOptions = {
commentCid: cid,
communityAddress,
content: publishCommentEditOptions.content,
deleted: publishCommentEditOptions.deleted,
spoiler: publishCommentEditOptions.spoiler,
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
@@ -173,7 +166,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
setBanDuration(
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
);
setIsContentEditorOpen(false);
};
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -199,8 +191,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
if (id === 'deleted' && canAttemptAuthorDelete) {
newState.deleted = checked;
} else if (isAccountCommentAuthor) {
newState[id] = checked;
}
return newState;
@@ -253,17 +243,16 @@ const EditMenu = ({ post }: { post: Comment }) => {
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, role]);
const headingId = useId();
const hasDeleteStateChanged = (deleted ?? false) !== (publishCommentEditOptions.deleted ?? false);
const _publishCommentEdit = async () => {
const shouldPublishAuthorEdit = isAccountCommentAuthor || (canAttemptAuthorDelete && hasDeleteStateChanged);
const shouldPublishAuthorEdit = canAttemptAuthorDelete && hasDeleteStateChanged;
try {
if (shouldPublishAuthorEdit && isAccountMod) {
@@ -327,35 +316,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
</div>
</>
)}
{isAccountCommentAuthor && (
<>
<div className={styles.menuItem}>
<label>
[
<input
type='checkbox'
aria-label={capitalize(t('edit'))}
onChange={() => setIsContentEditorOpen(!isContentEditorOpen)}
checked={isContentEditorOpen}
/>
{capitalize(t('edit'))}?]
</label>
</div>
{isContentEditorOpen && (
<div>
<textarea
aria-label={capitalize(t('edit'))}
className={styles.editTextarea}
value={publishCommentEditOptions.content || ''}
onChange={(e) => {
const newContent = e.target.value;
setPublishCommentEditOptions((state) => ({ ...state, content: newContent }));
}}
/>
</div>
)}
</>
)}
{isAccountMod && (
<>
<div className={styles.menuItem}>
+1 -1
View File
@@ -15,7 +15,7 @@ import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } f
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { useComment } from '@bitsocial/bitsocial-react-hooks';
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link';
+1
View File
@@ -0,0 +1 @@
export { ModQueueButton } from './mod-queue-button';
@@ -0,0 +1,23 @@
/* Count badge styling for the board mod-queue button. Mirrors the route's
ModQueueBoardCount styling, which shares the same visual; kept here so the
button stays self-contained and does not import the route's stylesheet. */
.modQueueButtonCount {
font-weight: bold;
}
/* Number blinking, changing color to red */
.modQueueButtonCountAlert {
animation: blink-color 2s infinite;
}
@keyframes blink-color {
0% {
color: inherit;
}
50% {
color: var(--mod-queue-alert-color);
}
100% {
color: inherit;
}
}
@@ -0,0 +1,152 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Comment, useCommunity, useFeed } from '@bitsocial/bitsocial-react-hooks';
import useModQueueStore from '../../stores/use-mod-queue-store';
import { areSameBoardAddress, getCommunityAddress } from '../../lib/utils/route-utils';
import { useDirectories } from '../../hooks/use-directories';
import { isPendingApprovalAwaiting } from '../../lib/utils/pending-approval-moderation';
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
import { useCurrentTime } from '../../hooks/use-current-time';
import { canAccessBoardModQueue, hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../../hooks/use-moderated-community-addresses';
import { getAddressListFromKey, getAddressListKey } from '../../lib/utils/mod-queue-utils';
import { useLocallyModeratedModQueueFeed } from '../../hooks/use-locally-moderated-mod-queue-feed';
import ModQueueCommunityMetadataLoader from '../mod-queue-community-metadata-loader/mod-queue-community-metadata-loader';
import styles from './mod-queue-button.module.css';
interface ModQueueButtonProps {
boardIdentifier?: string;
isMobile?: boolean;
}
interface ModQueueButtonContentProps {
feed: Comment[];
alertThresholdSeconds: number;
boardIdentifier?: string;
isMobile?: boolean;
}
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
const { t } = useTranslation();
const currentTime = useCurrentTime();
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
const { normalCount, urgentCount } = useMemo(() => {
let normal = 0;
let urgent = 0;
for (const comment of locallyModeratedFeed) {
if (!isPendingApprovalAwaiting(comment)) continue;
const timeWaiting = currentTime - (comment.timestamp ?? 0);
if (timeWaiting > alertThresholdSeconds) urgent++;
else normal++;
}
return { normalCount: normal, urgentCount: urgent };
}, [alertThresholdSeconds, currentTime, locallyModeratedFeed]);
const totalCount = normalCount + urgentCount;
const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
const buttonContent = (
<Link className='button' to={to}>
{t('mod_queue')}
{totalCount > 0 && (
<strong>
(
{urgentCount > 0 && normalCount > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normalCount}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
{'+'}
{urgentCount}
</span>
</>
) : urgentCount > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
) : (
<span className={styles.modQueueButtonCount}>{totalCount}</span>
)}
)
</strong>
)}
</Link>
);
return isMobile ? buttonContent : <>[{buttonContent}]</>;
};
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
const accountAddress = moderatedCommunityAddressInputs.accountAddress;
const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
const directories = useDirectories();
const resolvedAddress = useMemo(() => {
if (boardIdentifier) {
return getCommunityAddress(boardIdentifier, directories);
}
return undefined;
}, [boardIdentifier, directories]);
const resolvedCommunity = useCommunityIdentifier(resolvedAddress);
const community = useCommunity(resolvedCommunity ? { community: resolvedCommunity } : undefined);
const communityAddresses = useMemo(() => {
if (resolvedAddress) {
return [resolvedAddress];
}
return accountCommunityAddresses;
}, [resolvedAddress, accountCommunityAddresses]);
const accountRole = accountAddress ? community?.roles?.[accountAddress]?.role : undefined;
const hasBoardAccessFromAccountCommunities = resolvedAddress
? accountCommunityAddresses.some((address) => areSameBoardAddress(address, resolvedAddress))
: accountCommunityAddresses.length > 0;
const hasBoardAccess = canAccessBoardModQueue({
boardAddress: resolvedAddress,
accountCommunityAddresses,
accountRole,
});
const isBoardAccessLoading =
Boolean(resolvedAddress) &&
Boolean(accountAddress) &&
!hasModQueueAccessRole(accountRole) &&
!hasBoardAccessFromAccountCommunities &&
community?.state !== 'succeeded' &&
community?.state !== 'failed';
// Only fetch if we have addresses to check and permissions
const shouldFetch = !isBoardAccessLoading && communityAddresses.length > 0 && hasBoardAccess;
const feedAddresses = shouldFetch ? communityAddresses : [];
const feedCommunities = useCommunityIdentifiers(feedAddresses);
const feedOptions = useMemo(
() => ({
communities: feedCommunities,
modQueue: ['pendingApproval'],
sortType: 'new' as const,
postsPerPage: 200,
}),
[feedCommunities],
);
const { feed } = useFeed(feedOptions);
const metadataLoader = <ModQueueCommunityMetadataLoader candidateCommunityAddresses={moderatedCommunityAddressInputs.candidateCommunityAddresses} />;
if (!shouldFetch || communityAddresses.length === 0) {
return metadataLoader;
}
const alertThresholdSeconds = getAlertThresholdSeconds();
// Remount when switching boards so memoized counts reset cleanly.
const contentKey = communityAddresses.join(',');
return (
<>
{metadataLoader}
<ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />
</>
);
};
@@ -0,0 +1 @@
export { default } from './mod-queue-community-metadata-loader';
@@ -0,0 +1,14 @@
import { memo } from 'react';
import { useCommunities } from '@bitsocial/bitsocial-react-hooks';
import { useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
// Warms community metadata for the candidate mod queue boards without rendering
// anything. Shared by the mod queue route and the board mod-queue button so
// neither has to reach into the other for it.
const ModQueueCommunityMetadataLoader = memo(({ candidateCommunityAddresses }: { candidateCommunityAddresses: string[] }) => {
const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses);
useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined);
return null;
});
export default ModQueueCommunityMetadataLoader;
+8 -76
View File
@@ -8,7 +8,7 @@ import styles from '../../views/post/post.module.css';
import { CommentMediaInfo, getHasThumbnail, getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink } from '../../lib/utils/media-utils';
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { approvePendingCommentModeration, isPendingApprovalAwaiting, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { isPendingApprovalAwaiting } from '../../lib/utils/pending-approval-moderation';
import { isValidURL, parseHttpUrl } from '../../lib/utils/url-utils';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
@@ -44,13 +44,11 @@ import lowerCase from 'lodash/lowerCase';
import { shouldShowSnow } from '../../lib/snow';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { usePublishCommentModeration } from '@bitsocial/bitsocial-react-hooks';
import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useFreshReplies from '../../hooks/use-fresh-replies';
@@ -105,78 +103,12 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
const { t } = useTranslation();
const {
publishCommentModeration: approvePending,
state: approvePendingState,
error: approvePendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
});
const {
publishCommentModeration: rejectPending,
state: rejectPendingState,
error: rejectPendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
const handlePendingApprove = useCallback(async () => {
if (!window.confirm(t('double_confirm'))) return;
setInitiatedPendingAction('approve');
try {
await approvePending();
} catch (e) {
console.error(e);
}
}, [approvePending, t]);
const handlePendingReject = useCallback(async () => {
if (!window.confirm(t('double_confirm'))) return;
setInitiatedPendingAction('reject');
try {
await rejectPending();
} catch (e) {
console.error(e);
}
}, [rejectPending, t]);
const isApprovingPending =
initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
const isRejectingPending =
initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
const isPublishingPending = isApprovingPending || isRejectingPending;
const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
const pendingError = approvePendingFailed ? approvePendingError : rejectPendingFailed ? rejectPendingError : undefined;
const pendingErrorMessage = formatErrorForDisplay(pendingError);
handleApprove: handlePendingApprove,
handleReject: handlePendingReject,
isPublishing: isPublishingPending,
status: pendingStatus,
errorMessage: pendingErrorMessage,
} = usePendingCommentModerationActions({ comment: post, commentCid: cid, communityAddress });
return (
<span className={styles.modQueueActions}>
+1 -1
View File
@@ -4,7 +4,7 @@ import type { TFunction } from 'i18next';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import {
getExpiringMediaLinkAlert,
+12 -81
View File
@@ -2,14 +2,14 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Comment, useEditedComment, useReplies, useAccount, usePublishCommentModeration } from '@bitsocial/bitsocial-react-hooks';
import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
import styles from '../../views/post/post.module.css';
import { shouldShowSnow } from '../../lib/snow';
import { getHasThumbnail } from '../../lib/utils/media-utils';
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { approvePendingCommentModeration, isPendingApprovalAwaiting, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { isPendingApprovalAwaiting } from '../../lib/utils/pending-approval-moderation';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -39,12 +39,11 @@ import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import useFreshReplies from '../../hooks/use-fresh-replies';
@@ -128,86 +127,18 @@ const PostInfoAndMedia = ({
// Moderation actions for pending approval posts
const {
publishCommentModeration: approvePending,
state: approvePendingState,
error: approvePendingError,
} = usePublishCommentModeration({
handleApprove: handlePendingApprove,
handleReject: handlePendingReject,
isPublishing: isPublishingPending,
status: pendingStatus,
errorMessage: pendingErrorMessage,
} = usePendingCommentModerationActions({
comment: resolvedPost,
commentCid: cid,
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, resolvedPost]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
communityAddress,
enabled: !!shouldShowPendingApprovalButtons,
});
const {
publishCommentModeration: rejectPending,
state: rejectPendingState,
error: rejectPendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, resolvedPost]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
const handlePendingApprove = useCallback(async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedPendingAction('approve');
try {
await approvePending();
} catch (e) {
console.error(e);
}
}, [approvePending, t]);
const handlePendingReject = useCallback(async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedPendingAction('reject');
try {
await rejectPending();
} catch (e) {
console.error(e);
}
}, [rejectPending, t]);
const isApprovingPending =
initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
const isRejectingPending =
initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
const isPublishingPending = isApprovingPending || isRejectingPending;
const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
const pendingError = approvePendingFailed ? approvePendingError : rejectPendingFailed ? rejectPendingError : undefined;
const pendingErrorMessage = formatErrorForDisplay(pendingError);
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
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 usePendingCommentModerationActions, { type UsePendingCommentModerationActionsOptions } from '../use-pending-comment-moderation-actions';
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
(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>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// One controllable fake per publisher. usePublishCommentModeration is called
// twice (approve + reject); we route by the commentModeration.approved flag.
const testState = vi.hoisted(() => ({
approve: { publish: vi.fn(async () => undefined), state: 'initializing' as string, error: undefined as unknown, options: undefined as Record<string, any> | undefined },
reject: { publish: vi.fn(async () => undefined), state: 'initializing' as string, error: undefined as unknown, options: undefined as Record<string, any> | undefined },
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
usePublishCommentModeration: (options: Record<string, any>) => {
const target = options?.commentModeration?.approved === true ? testState.approve : testState.reject;
target.options = options;
return {
publishCommentModeration: target.publish,
state: target.state,
error: target.error,
};
},
}));
let latestValue: ReturnType<typeof usePendingCommentModerationActions>;
let container: HTMLDivElement;
let root: Root;
let harnessProps: Partial<UsePendingCommentModerationActionsOptions>;
const baseComment = { cid: 'cid-1' } as unknown as UsePendingCommentModerationActionsOptions['comment'];
const HookHarness = () => {
latestValue = usePendingCommentModerationActions({
comment: baseComment,
commentCid: 'cid-1',
communityAddress: 'board.eth',
...harnessProps,
});
return null;
};
const createDeferred = () => {
let resolve: (() => void) | undefined;
const promise = new Promise<undefined>((promiseResolve) => {
resolve = () => promiseResolve(undefined);
});
return { promise, resolve: () => resolve?.() };
};
const renderHook = () => {
act(() => {
root.render(createElement(HookHarness));
});
};
describe('usePendingCommentModerationActions', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.approve.state = 'initializing';
testState.approve.error = undefined;
testState.approve.options = undefined;
testState.reject.state = 'initializing';
testState.reject.error = undefined;
testState.reject.options = undefined;
harnessProps = {};
window.confirm = vi.fn(() => true);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderHook();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('publishes the approve moderation after confirmation', async () => {
await act(async () => {
await latestValue.handleApprove();
});
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(testState.approve.publish).toHaveBeenCalledTimes(1);
expect(testState.reject.publish).not.toHaveBeenCalled();
expect(testState.approve.options?.commentModeration).toEqual({ approved: true });
});
it('ignores rapid duplicate approve clicks while the publish is in flight', async () => {
const pendingApprove = createDeferred();
testState.approve.publish.mockReturnValueOnce(pendingApprove.promise);
await act(async () => {
const firstPublish = latestValue.handleApprove();
const duplicatePublish = latestValue.handleApprove();
await Promise.resolve();
pendingApprove.resolve();
await Promise.all([firstPublish, duplicatePublish]);
});
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(testState.approve.publish).toHaveBeenCalledTimes(1);
expect(testState.reject.publish).not.toHaveBeenCalled();
});
it('publishes the reject moderation after confirmation', async () => {
await act(async () => {
await latestValue.handleReject();
});
expect(testState.reject.publish).toHaveBeenCalledTimes(1);
expect(testState.approve.publish).not.toHaveBeenCalled();
expect(testState.reject.options?.commentModeration).toEqual({ approved: false });
});
it('ignores rapid duplicate reject clicks while the publish is in flight', async () => {
const pendingReject = createDeferred();
testState.reject.publish.mockReturnValueOnce(pendingReject.promise);
await act(async () => {
const firstPublish = latestValue.handleReject();
const duplicatePublish = latestValue.handleReject();
await Promise.resolve();
pendingReject.resolve();
await Promise.all([firstPublish, duplicatePublish]);
});
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(testState.reject.publish).toHaveBeenCalledTimes(1);
expect(testState.approve.publish).not.toHaveBeenCalled();
});
it('does not publish when the confirmation is cancelled', async () => {
window.confirm = vi.fn(() => false);
await act(async () => {
await latestValue.handleApprove();
});
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(testState.approve.publish).not.toHaveBeenCalled();
});
it('runs only the success callback for the action that was taken', async () => {
const onApproveSuccess = vi.fn();
const onRejectSuccess = vi.fn();
harnessProps = { onApproveSuccess, onRejectSuccess };
renderHook();
await act(async () => {
await latestValue.handleApprove();
});
expect(onApproveSuccess).toHaveBeenCalledTimes(1);
expect(onRejectSuccess).not.toHaveBeenCalled();
});
it('withholds the community address from the publishers when disabled', () => {
harnessProps = { enabled: false };
renderHook();
expect(testState.approve.options?.communityAddress).toBeUndefined();
expect(testState.reject.options?.communityAddress).toBeUndefined();
});
it('exposes failed status and the formatted error message for the failed action', async () => {
await act(async () => {
await latestValue.handleApprove();
});
const error = new Error('boom');
testState.approve.state = 'failed';
testState.approve.error = error;
renderHook();
expect(latestValue.status).toBe('failed');
expect(latestValue.error).toBe(error);
expect(latestValue.errorMessage).toBeTruthy();
expect(latestValue.errorMessage).toBe(formatErrorForDisplay(error));
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js';
import { accountsStore as useAccountsStore } from '../lib/bitsocial-internals/stores';
import { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '../lib/bitsocial-internals/utils';
type AccountWithCommunities = {
communities?: Record<string, unknown>;
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Comment } from '@bitsocial/bitsocial-react-hooks';
import { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils';
import { flattenCommentsPages } from '../lib/bitsocial-internals/utils';
const useCountLinksInReplies = (comment: Comment | undefined, firstXReplies?: number) => {
let linkCount = 0;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { localForageLru } from '../lib/bitsocial-internals/utils';
const gifFrameDb = localForageLru.createInstance({ name: '5chanGifFrames', size: 500 });
const failedUrls = new Set<string>();
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { useAccount, useBlock } from '@bitsocial/bitsocial-react-hooks';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import { accountsStore } from '../lib/bitsocial-internals/stores';
import useHiddenCatalogThreadsStore from '../stores/use-hidden-catalog-threads-store';
export type HiddenCidLookup = { [cid: string]: boolean | undefined };
@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { Comment } from '@bitsocial/bitsocial-react-hooks';
import { accountsStore as useAccountsStore } from '../lib/bitsocial-internals/stores';
type LocalModerationEditSummary = Record<string, { timestamp?: number; value: unknown } | undefined>;
type LocalModerationEditSummaries = Record<string, LocalModerationEditSummary | undefined>;
const LOCAL_MODERATION_EDIT_FIELDS = ['approved', 'removed'] as const;
const LOCAL_EDIT_PENDING_SECONDS = 20 * 60;
const shouldApplyLocalModerationEdit = (
comment: Comment,
propertyName: (typeof LOCAL_MODERATION_EDIT_FIELDS)[number],
edit: { timestamp?: number; value: unknown },
now: number,
) => {
const editTimestamp = edit.timestamp ?? 0;
const updatedAt = (comment as { updatedAt?: number }).updatedAt;
const currentValue = (comment as Record<string, unknown>)[propertyName];
if (!updatedAt) {
return Object.is(currentValue, edit.value) || editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS;
}
if (updatedAt < editTimestamp || Object.is(currentValue, edit.value)) {
return true;
}
return editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS || updatedAt - editTimestamp < LOCAL_EDIT_PENDING_SECONDS;
};
const applyLocalModerationEdits = (comment: Comment, editSummary: LocalModerationEditSummary | undefined, now: number): Comment => {
if (!editSummary) {
return comment;
}
let editedComment: Comment | undefined;
for (const propertyName of LOCAL_MODERATION_EDIT_FIELDS) {
const edit = editSummary[propertyName];
if (!edit || edit.value === undefined || !shouldApplyLocalModerationEdit(comment, propertyName, edit, now)) {
continue;
}
editedComment = { ...(editedComment ?? comment), [propertyName]: edit.value } as Comment;
}
return editedComment ?? comment;
};
// Overlay the active account's local (optimistic) approve/removed edits onto a
// mod queue feed so a just-moderated comment reflects the pending state until
// the edit lands or expires (LOCAL_EDIT_PENDING_SECONDS). Shared by the mod
// queue route and the board mod-queue button.
export const useLocallyModeratedModQueueFeed = (feed: Comment[], currentTime: number) => {
const accountId = useAccountsStore((state) => state.activeAccountId);
const editSummaries = useAccountsStore((state) => (accountId ? state.accountsEditsSummaries[accountId] : undefined)) as LocalModerationEditSummaries | undefined;
return useMemo(() => {
if (!editSummaries) {
return feed;
}
return feed.map((comment) => (comment.cid ? applyLocalModerationEdits(comment, editSummaries[comment.cid], currentTime) : comment));
}, [currentTime, editSummaries, feed]);
};
export default useLocallyModeratedModQueueFeed;
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import type { Community } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import useCommunitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import { accountsStore as useAccountsStore, communitiesStore as useCommunitiesStore } from '../lib/bitsocial-internals/stores';
import { useDirectories } from './use-directories';
import { areStringArraysEqual, useAccountCommunityAddresses } from './use-account-community-addresses';
import { getModeratedCommunityAddresses } from '../lib/utils/mod-queue-utils';
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useReducer, useRef } from 'react';
import { getP2PStats, type AccountShape, type StatRow } from '../lib/p2p-stats';
import type { P2PRuntimeMode } from '../lib/p2p-runtime';
const STATS_REFRESH_MS = 5000;
type StatsState = {
error?: string;
loading: boolean;
rows: StatRow[];
updatedAt?: number;
};
type StatsAction =
| {
type: 'loading';
}
| {
rows: StatRow[];
timestamp: number;
type: 'loaded';
}
| {
error: string;
timestamp: number;
type: 'failed';
};
const statsReducer = (state: StatsState, action: StatsAction): StatsState => {
if (action.type === 'loading') return { ...state, error: undefined, loading: state.rows.length === 0 };
if (action.type === 'loaded') return { loading: false, rows: action.rows, updatedAt: action.timestamp };
return { ...state, error: action.error, loading: false, updatedAt: action.timestamp };
};
const getErrorMessage = (error: unknown, fallback = 'Error') => (error instanceof Error ? error.message : String(error || fallback));
export interface UseP2PStatsOptions {
account?: AccountShape;
mode?: P2PRuntimeMode | null;
rpcState?: string;
}
export interface P2PStatsResult {
error?: string;
loading: boolean;
rows: StatRow[];
updatedAt?: number;
}
// Polls the P2P stats engine (getP2PStats) every STATS_REFRESH_MS and exposes
// the reducer-managed result. Refreshes once immediately, then on interval,
// aborting in-flight work and skipping dispatches after unmount or a dependency
// change so stale results never overwrite fresh state.
export const useP2PStats = ({ account, mode, rpcState }: UseP2PStatsOptions): P2PStatsResult => {
const [statsState, dispatchStats] = useReducer(statsReducer, { loading: !!mode, rows: [] });
const accountRef = useRef(account);
accountRef.current = account;
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
const activeMode = mode;
if (!activeMode) return () => abortController.abort();
const refreshStats = async () => {
dispatchStats({ type: 'loading' });
try {
const rows = await getP2PStats(activeMode, accountRef.current, rpcState, signal);
if (!signal.aborted) dispatchStats({ rows, timestamp: Date.now(), type: 'loaded' });
} catch (error) {
if (!signal.aborted) {
dispatchStats({
error: getErrorMessage(error),
timestamp: Date.now(),
type: 'failed',
});
}
}
};
void refreshStats();
const intervalId = window.setInterval(refreshStats, STATS_REFRESH_MS);
return () => {
abortController.abort();
window.clearInterval(intervalId);
};
}, [mode, rpcState]);
return { error: statsState.error, loading: statsState.loading, rows: statsState.rows, updatedAt: statsState.updatedAt };
};
export default useP2PStats;
@@ -0,0 +1,143 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Comment, usePublishCommentModeration } from '@bitsocial/bitsocial-react-hooks';
import { approvePendingCommentModeration, rejectPendingCommentModeration } from '../lib/utils/pending-approval-moderation';
import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils';
import { formatErrorForDisplay } from '../lib/utils/error-utils';
import useChallengesStore from '../stores/use-challenges-store';
export type PendingModerationStatus = 'approved' | 'rejected' | 'failed' | null;
export interface UsePendingCommentModerationActionsOptions {
// The comment the moderation challenge is published against. Forwarded to the
// challenge store so the challenge UI can reference the target comment.
comment: Comment | undefined;
commentCid: string | undefined;
communityAddress: string | undefined;
// When false, the underlying publishers are disabled by withholding the
// community address (mirrors the mobile post page gating its actions).
enabled?: boolean;
// Run after a successful approve/reject publish, inside the same try block as
// the publish call so a throw here is logged exactly like the publish path.
onApproveSuccess?: () => void | Promise<void>;
onRejectSuccess?: () => void | Promise<void>;
}
export interface PendingCommentModerationActions {
handleApprove: () => Promise<void>;
handleReject: () => Promise<void>;
isPublishing: boolean;
// Action-derived status only ('approved'/'rejected' on success, 'failed' on
// error). Callers that also have a baseline (e.g. an already-moderated mod
// queue comment) combine that baseline with this value themselves.
status: PendingModerationStatus;
error: unknown;
errorMessage: string | undefined;
}
// Shared approve/reject state machine for pending-approval comment moderation.
// Extracted from the duplicated copies in desktop posts, mobile posts, and the
// mod queue so the riskiest moderation flow lives behind one tested boundary.
export const usePendingCommentModerationActions = ({
comment,
commentCid,
communityAddress,
enabled = true,
onApproveSuccess,
onRejectSuccess,
}: UsePendingCommentModerationActionsOptions): PendingCommentModerationActions => {
const { t } = useTranslation();
const effectiveCommunityAddress = enabled ? communityAddress : undefined;
const {
publishCommentModeration: approve,
state: approveState,
error: approveError,
} = usePublishCommentModeration({
commentCid,
communityAddress: effectiveCommunityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, challengedComment) => {
alertChallengeVerificationFailed(challengeVerification, challengedComment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
});
const {
publishCommentModeration: reject,
state: rejectState,
error: rejectError,
} = usePublishCommentModeration({
commentCid,
communityAddress: effectiveCommunityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, challengedComment) => {
alertChallengeVerificationFailed(challengeVerification, challengedComment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
const [initiatedAction, setInitiatedAction] = useState<'approve' | 'reject' | null>(null);
const pendingActionRef = useRef<'approve' | 'reject' | null>(null);
const handleApprove = useCallback(async () => {
if (pendingActionRef.current) return;
if (!window.confirm(t('double_confirm'))) {
return;
}
pendingActionRef.current = 'approve';
setInitiatedAction('approve');
try {
await approve();
await onApproveSuccess?.();
} catch (e) {
console.error(e);
} finally {
pendingActionRef.current = null;
}
}, [approve, onApproveSuccess, t]);
const handleReject = useCallback(async () => {
if (pendingActionRef.current) return;
if (!window.confirm(t('double_confirm'))) {
return;
}
pendingActionRef.current = 'reject';
setInitiatedAction('reject');
try {
await reject();
await onRejectSuccess?.();
} catch (e) {
console.error(e);
} finally {
pendingActionRef.current = null;
}
}, [reject, onRejectSuccess, t]);
const isApproving = initiatedAction === 'approve' && approveState !== 'succeeded' && approveState !== 'failed';
const isRejecting = initiatedAction === 'reject' && rejectState !== 'succeeded' && rejectState !== 'failed';
const isPublishing = isApproving || isRejecting;
const approveSucceeded = initiatedAction === 'approve' && approveState === 'succeeded';
const rejectSucceeded = initiatedAction === 'reject' && rejectState === 'succeeded';
const approveFailed = initiatedAction === 'approve' && approveState === 'failed';
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
const status: PendingModerationStatus = approveSucceeded ? 'approved' : rejectSucceeded ? 'rejected' : approveFailed || rejectFailed ? 'failed' : null;
const error = approveFailed ? approveError : rejectFailed ? rejectError : undefined;
const errorMessage = formatErrorForDisplay(error);
return { handleApprove, handleReject, isPublishing, status, error, errorMessage };
};
export default usePendingCommentModerationActions;
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useFeed } from '@bitsocial/bitsocial-react-hooks';
import useFeedsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/feeds';
import { feedsStore as useFeedsStore } from '../lib/bitsocial-internals/stores';
import { useDirectoryByAddress } from './use-directories';
import { useBoardFeedPageSize } from './use-board-feed-page-size';
import { useCommunityIdentifier } from './use-community-identifiers';
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import { useAccount, type Comment } from '@bitsocial/bitsocial-react-hooks';
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { accountsStore, communitiesStore, communitiesPagesStore } from '../lib/bitsocial-internals/stores';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
import { getRawBoardThreadState } from '../lib/utils/raw-board-thread-state';
+1 -1
View File
@@ -1,4 +1,4 @@
import useCommunitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import { communitiesStore as useCommunitiesStore } from '../lib/bitsocial-internals/stores';
import type { Community } from '@bitsocial/bitsocial-react-hooks';
import { normalizeBoardAddress } from './use-directories';
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import {
formatBytes,
getConnectedPeersRowFromRecords,
getTransferStatsFromMetricSnapshot,
getTransportLabel,
isLikelyPublicIp,
mergeTransferSnapshots,
} from '../p2p-stats';
describe('p2p-stats engine', () => {
describe('formatBytes', () => {
it('formats byte magnitudes with adaptive units and precision', () => {
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(1024)).toBe('1.00 KB');
expect(formatBytes(1536)).toBe('1.50 KB');
expect(formatBytes(10 * 1024 * 1024)).toBe('10.0 MB');
});
it('falls back to a readable string for non-numeric input', () => {
expect(formatBytes('not-a-number')).toBe('not-a-number');
expect(formatBytes(undefined)).toBe('unknown');
// Number(null) === 0, so null coerces to a real magnitude rather than the fallback.
expect(formatBytes(null)).toBe('0 B');
});
});
describe('mergeTransferSnapshots', () => {
it('prefers primary totals/peer stats and backfills from the fallback', () => {
const primary = {
peers: new Map([['peerA', { downloadedBytes: 10 }]]),
totals: { downloadedBytes: 100 },
};
const fallback = {
peers: new Map([
['peerA', { uploadedBytes: 5 }],
['peerB', { downloadedBytes: 7 }],
]),
totals: { downloadedBytes: 50, uploadedBytes: 30 },
};
const merged = mergeTransferSnapshots(primary, fallback);
expect(merged.totals).toEqual({ downloadedBytes: 100, uploadedBytes: 30 });
expect(merged.peers.get('peerA')).toEqual({ downloadedBytes: 10, uploadedBytes: 5 });
expect(merged.peers.get('peerB')).toEqual({ downloadedBytes: 7 });
});
});
describe('getConnectedPeersRowFromRecords', () => {
it('normalizes peer records from heterogeneous field names', () => {
const row = getConnectedPeersRowFromRecords({
peers: [
{ remotePeer: 'peerA', remoteAddr: '/ip4/1.2.3.4/tcp/4001/p2p/peerA', direction: 'inbound' },
{ peerId: 'peerB', multiaddr: '/ip4/5.6.7.8/udp/4001/quic' },
],
});
expect(row.type).toBe('connectedPeers');
expect(row.entries).toHaveLength(2);
expect(row.entries[0]).toMatchObject({ peerId: 'peerA', address: '/ip4/1.2.3.4/tcp/4001/p2p/peerA', direction: 'inbound', transport: 'TCP' });
expect(row.entries[1]).toMatchObject({ peerId: 'peerB', address: '/ip4/5.6.7.8/udp/4001/quic', transport: 'QUIC' });
expect(row.peerCount).toBe(2);
expect(row.connectionCount).toBe(2);
});
});
describe('getTransportLabel', () => {
it('detects transports from multiaddr fragments', () => {
expect(getTransportLabel('/ip4/1.2.3.4/tcp/4001')).toBe('TCP');
expect(getTransportLabel('/ip4/1.2.3.4/udp/4001/quic-v1')).toBe('QUIC');
expect(getTransportLabel('/dns4/example.com/tcp/443/wss')).toBe('Secure WebSocket');
expect(getTransportLabel('/ip4/1.2.3.4/udp/4001/webrtc-direct')).toBe('WebRTC direct');
expect(getTransportLabel('/ip4/1.2.3.4/tcp/4001/p2p-circuit/p2p/peer')).toBe('TCP through relay');
expect(getTransportLabel('/ip4/1.2.3.4/onion3/abc')).toBe('Unknown transport');
});
});
describe('isLikelyPublicIp', () => {
it('treats routable addresses as public and filters private/reserved ones', () => {
expect(isLikelyPublicIp('8.8.8.8')).toBe(true);
expect(isLikelyPublicIp('2001:4860:4860::8888')).toBe(true);
expect(isLikelyPublicIp('192.168.1.1')).toBe(false);
expect(isLikelyPublicIp('10.0.0.1')).toBe(false);
expect(isLikelyPublicIp('127.0.0.1')).toBe(false);
expect(isLikelyPublicIp('::1')).toBe(false);
expect(isLikelyPublicIp('fe80::1')).toBe(false);
expect(isLikelyPublicIp('')).toBe(false);
expect(isLikelyPublicIp('not-an-ip')).toBe(false);
});
});
describe('getTransferStatsFromMetricSnapshot', () => {
it('extracts download/upload totals from named counters', () => {
const snapshot = getTransferStatsFromMetricSnapshot({ bytesReceived: 1000, bytesSent: 500 });
expect(snapshot.totals).toEqual({ downloadedBytes: 1000, uploadedBytes: 500 });
});
it('never throws on malformed or non-record snapshots', () => {
expect(() => getTransferStatsFromMetricSnapshot(undefined)).not.toThrow();
expect(() => getTransferStatsFromMetricSnapshot(null)).not.toThrow();
expect(() => getTransferStatsFromMetricSnapshot('nonsense')).not.toThrow();
expect(() => getTransferStatsFromMetricSnapshot(42)).not.toThrow();
expect(getTransferStatsFromMetricSnapshot(undefined).totals).toEqual({});
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() => getTransferStatsFromMetricSnapshot(circular)).not.toThrow();
});
});
});
+14
View File
@@ -0,0 +1,14 @@
// Approved boundary for @bitsocial/bitsocial-react-hooks store internals.
//
// This directory is the ONLY place 5chan production code may import from
// `@bitsocial/bitsocial-react-hooks/dist/...`. Every other module must reach
// these stores through this adapter so that future upstream package-layout or
// API changes stay localized to one reviewable file instead of scattering
// across views, hooks, stores, components, and utils.
export { default as accountsStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
export { default as communitiesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
export { default as communitiesPagesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
export { default as feedsStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/feeds';
export { default as repliesStore, feedOptionsToFeedName } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies';
export { default as repliesPagesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages';
+8
View File
@@ -0,0 +1,8 @@
// Approved boundary for @bitsocial/bitsocial-react-hooks lib internals.
// See ./stores for the rationale: this is the single reviewable seam for the
// package's compiled `dist/lib/...` helpers. Do not import those paths directly
// elsewhere in production code.
export { default as localForageLru } from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru';
export { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils';
export { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js';
+1004
View File
File diff suppressed because it is too large Load Diff
@@ -35,12 +35,12 @@ describe('getRawBoardThreadState', () => {
sortType: 'active',
}),
).toMatchObject({
isFullyLoaded: true,
isFullyLoaded: false,
rootThreadCids: new Set<string>(),
});
});
it('treats an empty preloaded requested-sort page as a fully loaded empty board', () => {
it('does not treat an empty preloaded requested-sort page as loaded before community update evidence', () => {
const community = {
posts: {
pages: {
@@ -51,6 +51,28 @@ describe('getRawBoardThreadState', () => {
},
} as Community;
expect(
getRawBoardThreadState({
accountId: undefined,
communitiesPages: {} as CommunitiesPages,
community,
sortType: 'active',
}).isFullyLoaded,
).toBe(false);
});
it('treats an empty preloaded requested-sort page as loaded after community update evidence', () => {
const community = {
posts: {
pages: {
active: {
comments: [],
},
},
},
updatedAt: 1781773422,
} as Community;
expect(
getRawBoardThreadState({
accountId: undefined,
@@ -61,6 +83,30 @@ describe('getRawBoardThreadState', () => {
).toBe(true);
});
it('treats a non-empty complete preloaded requested-sort page as loaded', () => {
const community = {
posts: {
pages: {
active: {
comments: [rootThread('thread-1')],
},
},
},
} as Community;
expect(
getRawBoardThreadState({
accountId: undefined,
communitiesPages: {} as CommunitiesPages,
community,
sortType: 'active',
}),
).toMatchObject({
isFullyLoaded: true,
rootThreadCids: new Set(['thread-1']),
});
});
it('treats explicit empty page CIDs as a fully loaded empty board', () => {
const community = {
posts: {
@@ -0,0 +1,51 @@
import type { Account } from '@bitsocial/bitsocial-react-hooks';
type CommentAuthor = {
address?: string;
avatar?: unknown;
displayName?: string;
flair?: unknown;
shortAddress?: string;
[key: string]: unknown;
};
type AccountCommentWithAuthor = {
accountId?: string;
author?: CommentAuthor;
};
export const mergeDefinedFields = <T extends object>(base: T | undefined, override: T | undefined): T | undefined => {
if (!override) return base;
const merged = { ...base } as Record<string, unknown>;
for (const [key, value] of Object.entries(override)) {
if (value !== undefined) {
merged[key] = value;
}
}
return merged as T;
};
export function restoreActiveAccountAuthor<T extends object>(accountComment: T, account: Account | undefined): T;
export function restoreActiveAccountAuthor<T extends object>(accountComment: T | undefined, account: Account | undefined): T | undefined;
export function restoreActiveAccountAuthor<T extends object>(accountComment: T | undefined, account: Account | undefined): T | undefined {
const comment = accountComment as AccountCommentWithAuthor | undefined;
if (!comment || comment.author?.address || !account?.id || comment.accountId !== account.id || !account.author?.address) {
return accountComment;
}
const accountAuthor = {
address: account.author.address,
shortAddress: account.author.shortAddress,
displayName: account.author.displayName,
avatar: account.author.avatar,
flair: account.author.flair,
};
return {
...accountComment,
author: mergeDefinedFields(comment.author, accountAuthor),
} as T;
}
+1 -3
View File
@@ -1,7 +1,5 @@
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import feedsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/feeds';
import repliesStore, { feedOptionsToFeedName } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies';
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { feedsStore, repliesStore, feedOptionsToFeedName, communitiesPagesStore } from '../bitsocial-internals/stores';
import type { DirectoryCommunity } from '../../hooks/use-directories';
import usePostNumberStore from '../../stores/use-post-number-store';
import type { ExternalQuoteReference, ExternalQuoteSearchStatus } from './external-quote-utils';
+1 -1
View File
@@ -1,4 +1,4 @@
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { localForageLru } from '../bitsocial-internals/utils';
import { canEmbed, getYouTubeVideoId, youtubeHosts } from '../../components/embed/embed-utils';
import memoize from 'memoizee';
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
+6
View File
@@ -6,6 +6,12 @@ import { isPendingApprovalRejected } from './pending-approval-moderation';
import { areSameBoardAddress, getBoardPath } from './route-utils';
import { getThreadTopNavigationState } from './thread-scroll-utils';
// Serialize a list of board addresses into a single stable string key (and back)
// so memoization dependencies stay referentially stable across renders. Shared
// by the mod queue route and the board mod-queue button.
export const getAddressListKey = (addresses: string[]) => addresses.join('\0');
export const getAddressListFromKey = (key: string) => (key ? key.split('\0') : []);
type ModQueueCommentLike = {
approved?: boolean;
author?: Comment['author'];
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import { communitiesStore } from '../bitsocial-internals/stores';
import { getCommentCommunityAddress } from './comment-utils';
type CommunityLike = {
+6 -4
View File
@@ -88,18 +88,20 @@ export const getRawBoardThreadState = ({
};
}
const hasPageCid = Boolean(community.posts?.pageCids?.[sortType]);
const preloadedPages = (preloadedSortPage ? [preloadedSortPage] : []) as Array<{ comments?: Comment[]; nextCid?: string }>;
const hasCompletePreloadedPage = !hasPageCid && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid);
const hasFetchedCommunityUpdate = typeof community.updatedAt === 'number' || typeof community.updateCid === 'string';
const hasPageCid = Boolean(community.posts?.pageCids?.[sortType]);
const hasExplicitEmptyPageCids = hasFetchedCommunityUpdate && Boolean(community.posts?.pageCids && !hasPageCid);
const preloadedPages = (preloadedSortPage ? [preloadedSortPage] : []) as Array<{ comments?: Comment[]; nextCid?: string }>;
const hasCompletePreloadedChain = !hasPageCid && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid);
if (hasCompletePreloadedPage) {
if (hasCompletePreloadedChain) {
for (const page of preloadedPages) {
addRootThreadCids(rootThreadCids, page?.comments);
}
}
const hasCompletePreloadedPage = hasCompletePreloadedChain && (rootThreadCids.size > 0 || hasFetchedCommunityUpdate);
return {
hasExplicitEmptyPageCids,
isFullyLoaded: hasCompletePreloadedPage || hasExplicitEmptyPageCids,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Comment, RepliesPages } from '@bitsocial/bitsocial-react-hooks';
import repliesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages';
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru';
import { repliesPagesStore } from '../bitsocial-internals/stores';
import { localForageLru } from '../bitsocial-internals/utils';
const commentsDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-comments' });
const repliesPagesDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-repliesPages' });
+1 -1
View File
@@ -1,5 +1,5 @@
import { create, StoreApi } from 'zustand';
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { localForageLru } from '../lib/bitsocial-internals/utils';
interface ThemeState {
themes: {
+383 -10
View File
@@ -11,13 +11,20 @@ import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../.
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
type TestComment = {
accountId?: string;
author?: {
address?: string;
avatar?: unknown;
community?: unknown;
displayName?: string;
flair?: unknown;
shortAddress?: string;
};
cid?: string;
content?: string;
flairs?: Array<{ text?: string }>;
index?: number;
lastReplyTimestamp?: number;
link?: string;
number?: number | string;
parentCid?: string;
@@ -31,6 +38,19 @@ type TestComment = {
state?: string;
timestamp?: number;
title?: string;
upvoteCount?: number;
};
type TestAccount = {
author?: {
address?: string;
avatar?: unknown;
displayName?: string;
flair?: unknown;
shortAddress?: string;
};
id?: string;
subscriptions?: string[];
};
type TestCommunity = {
@@ -47,7 +67,7 @@ type TestCommunity = {
};
const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
account: { subscriptions: [] as string[] } as TestAccount,
accountComments: [] as Array<TestComment | undefined>,
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
accountCommunityAddresses: [] as string[],
@@ -290,7 +310,16 @@ vi.mock('../../../components/footer/footer', () => ({
}));
vi.mock('../../post/post', () => ({
Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || post?.content || 'missing-post'),
Post: ({ post }: { post?: TestComment }) =>
createElement(
'div',
{
'data-author-address': post?.author?.address || '',
'data-content': post?.content || '',
'data-testid': 'post',
},
post?.cid || post?.content || 'missing-post',
),
}));
vi.mock('../../../lib/snow', () => ({
@@ -318,11 +347,14 @@ const flushEffects = async (count = 5) => {
}
};
const markRawBoardThreadsFullyLoaded = (comments: TestComment[] = []) => {
const markRawBoardThreadsFullyLoaded = (comments: TestComment[] = [], options: { hasCommunityUpdate?: boolean } = {}) => {
testState.community = {
...testState.community,
...(options.hasCommunityUpdate ? { updatedAt: 1781773422 } : {}),
posts: {
...testState.community?.posts,
pages: {
...testState.community?.posts?.pages,
active: {
comments,
},
@@ -542,7 +574,7 @@ describe('Board', () => {
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(document.title).toBe('/mu/ - 5chan');
expect(testState.setResetFunctionMock).toHaveBeenCalledWith(testState.resetMock);
expect(testState.setResetFunctionMock).toHaveBeenCalledWith(expect.any(Function));
expect(testState.accountCommentsCalls).toContainEqual({
communityAddress: 'music-posting.eth',
newerThan: 3600,
@@ -569,6 +601,279 @@ describe('Board', () => {
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
});
it('does not render recent account comments as the whole board while the feed is still hydrating', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feedState = 'succeeded';
testState.feedStateString = undefined;
testState.accountComments = [
{
cid: 'fresh-post',
postCid: 'fresh-post',
state: 'succeeded',
communityAddress: 'music-posting.eth',
timestamp: currentTimestamp,
},
];
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(container.querySelector('[data-testid="post"]')).toBeNull();
expect(container.textContent).not.toContain('no_threads');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board');
});
it('hides local account comments while a manual refresh is pending', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }];
testState.accountComments = [
{
cid: 'fresh-post',
postCid: 'fresh-post',
state: 'succeeded',
communityAddress: 'music-posting.eth',
timestamp: currentTimestamp,
},
];
markRawBoardThreadsFullyLoaded();
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']);
let resolveManualRefresh: (() => void) | undefined;
testState.resetMock.mockReset();
testState.resetMock.mockReturnValue(
new Promise<void>((resolve) => {
resolveManualRefresh = resolve;
}),
);
await act(async () => {
const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh');
refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.resetMock).toHaveBeenCalledOnce();
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['older-post']);
await act(async () => {
resolveManualRefresh?.();
await Promise.resolve();
});
await flushEffects();
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']);
});
it('keeps local account comments hidden after refresh settles until the canonical feed repopulates', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
const canonicalFeed = [
{ cid: 'fresh-post', communityAddress: 'music-posting.eth', postCid: 'fresh-post', timestamp: currentTimestamp },
{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 },
];
testState.feed = [canonicalFeed[1]];
testState.accountComments = [
{
cid: 'fresh-post',
postCid: 'fresh-post',
state: 'succeeded',
communityAddress: 'music-posting.eth',
timestamp: currentTimestamp,
},
];
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']);
testState.resetMock.mockReset();
testState.resetMock.mockImplementation(() => {
testState.feed = [];
return Promise.resolve();
});
await act(async () => {
const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh');
refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
await flushEffects();
expect(testState.resetMock).toHaveBeenCalledOnce();
expect(container.querySelector('[data-testid="post"]')).toBeNull();
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_feed');
testState.feed = canonicalFeed;
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']);
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
});
it('releases the manual refresh hold when feed reset rejects', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }];
testState.accountComments = [
{
cid: 'fresh-post',
postCid: 'fresh-post',
state: 'succeeded',
communityAddress: 'music-posting.eth',
timestamp: currentTimestamp,
},
];
markRawBoardThreadsFullyLoaded();
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']);
const refreshError = new Error('reset failed');
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
testState.resetMock.mockReset();
testState.resetMock.mockRejectedValue(refreshError);
await act(async () => {
const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh');
refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
await flushEffects();
expect(testState.resetMock).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalledWith('Failed to refresh board feed:', refreshError);
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']);
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
consoleError.mockRestore();
});
it('shows loading copy instead of no threads when refresh hides the only local thread', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feedState = 'succeeded';
testState.feedStateString = undefined;
testState.community = {
error: undefined,
posts: {
pageCids: {},
pages: {},
},
shortAddress: 'music-posting.eth',
state: 'succeeded',
title: '/mu/ - Music',
updatedAt: currentTimestamp,
};
testState.accountComments = [
{
cid: 'fresh-post',
postCid: 'fresh-post',
state: 'succeeded',
communityAddress: 'music-posting.eth',
timestamp: currentTimestamp,
},
];
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post']);
expect(container.textContent).not.toContain('no_threads');
let resolveManualRefresh: (() => void) | undefined;
testState.resetMock.mockReset();
testState.resetMock.mockReturnValue(
new Promise<void>((resolve) => {
resolveManualRefresh = resolve;
}),
);
await act(async () => {
const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh');
refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.resetMock).toHaveBeenCalledOnce();
expect(container.querySelector('[data-testid="post"]')).toBeNull();
expect(container.textContent).not.toContain('no_threads');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_feed');
await act(async () => {
resolveManualRefresh?.();
await Promise.resolve();
});
await flushEffects();
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post']);
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
});
it('restores active account author data on local board posts before rendering', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.account = {
id: 'active-account',
author: {
address: 'plebeius.bso',
shortAddress: 'plebeius',
},
subscriptions: [],
};
testState.feed = [{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }];
testState.accountComments = [
{
accountId: 'active-account',
author: {
community: { displayName: 'Anonymous' },
},
cid: 'fresh-dev-post',
communityAddress: 'music-posting.eth',
content: '[color=red]hello[/color]',
postCid: 'fresh-dev-post',
state: 'succeeded',
timestamp: currentTimestamp,
},
];
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
const firstPost = container.querySelector('[data-testid="post"]');
expect(firstPost?.textContent).toBe('fresh-dev-post');
expect(firstPost?.getAttribute('data-author-address')).toBe('plebeius.bso');
expect(firstPost?.getAttribute('data-content')).toBe('[color=red]hello[/color]');
expect(testState.registerCommentsMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
author: expect.objectContaining({ address: 'plebeius.bso' }),
cid: 'fresh-dev-post',
content: '[color=red]hello[/color]',
}),
]),
);
});
it('keeps propagated board posts in active order when hook updates are appended', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.pageSizes = {
guiPostsPerPage: 6,
infiniteFeedPostsPerPage: 6,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.feed = [
{ cid: 'pinned-post', pinned: true, communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 300 },
{ cid: 'older-post', communityAddress: 'music-posting.eth', lastReplyTimestamp: currentTimestamp - 200, timestamp: currentTimestamp - 200 },
{ cid: 'newly-propagated-post', communityAddress: 'music-posting.eth', postCid: 'newly-propagated-post', timestamp: currentTimestamp },
{ cid: 'middle-post', communityAddress: 'music-posting.eth', lastReplyTimestamp: currentTimestamp - 100, timestamp: currentTimestamp - 100 },
];
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual([
'pinned-post',
'newly-propagated-post',
'middle-post',
'older-post',
]);
});
it('renders flash board posts as table rows instead of the normal feed', async () => {
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
testState.directoryByAddress = {
@@ -643,7 +948,7 @@ describe('Board', () => {
shortAddress: 'flash-posting.bso',
title: '/f/ - Flash',
};
markRawBoardThreadsFullyLoaded();
markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true });
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
@@ -677,7 +982,7 @@ describe('Board', () => {
title: '/f/ - Flash',
};
testState.hasMore = true;
markRawBoardThreadsFullyLoaded();
markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true });
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
@@ -699,8 +1004,9 @@ describe('Board', () => {
},
};
testState.resolvedCommunityAddress = 'flash-posting.bso';
testState.feedState = 'fetching-ipns';
testState.hasMore = true;
testState.feedState = 'succeeded';
testState.feedStateString = undefined;
testState.hasMore = false;
testState.community = {
error: undefined,
posts: {
@@ -753,6 +1059,28 @@ describe('Board', () => {
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'pending thread body']);
});
it('keeps a nonoko pending account comment visible before the canonical board feed hydrates', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.accountComments[7] = {
content: 'pending thread body',
communityAddress: 'music-posting.eth',
index: 7,
state: 'publishing-challenge',
timestamp: currentTimestamp,
};
await renderBoard({
initialEntry: '/mu',
initialState: { nonokoPendingAccountCommentIndex: 7 },
routePath: '/:boardIdentifier/*',
});
expect(testState.accountCommentsCalls).toContainEqual({
commentIndices: [7],
});
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pending thread body']);
});
it('redirects oversized board pages back to the last available page', async () => {
testState.feed = [
{ cid: 'first-post', communityAddress: 'music-posting.eth' },
@@ -1073,7 +1401,7 @@ describe('Board', () => {
state: 'succeeded',
title: '/mu/ - Music',
};
markRawBoardThreadsFullyLoaded();
markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true });
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
@@ -1100,7 +1428,7 @@ describe('Board', () => {
expect(container.textContent).toContain('load_more');
});
it('shows no threads when a loaded board reports explicit empty page cids', async () => {
it('keeps loading when explicit empty page cids arrive before the feed finishes', async () => {
testState.feedStateString = 'Downloading board from peers';
testState.feedState = 'fetching-ipns';
testState.hasMore = true;
@@ -1122,6 +1450,33 @@ describe('Board', () => {
await renderBoard({ initialEntry: '/blog.bitsocial.bso', routePath: '/:boardIdentifier/*' });
expect(container.textContent).not.toContain('no_threads');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Downloading board from peers');
expect(container.textContent).toContain('load_more');
});
it('shows no threads when a loaded empty board feed finishes', async () => {
testState.feedStateString = undefined;
testState.feedState = 'succeeded';
testState.hasMore = false;
testState.community = {
error: undefined,
posts: {
pageCids: {},
pages: {},
},
shortAddress: 'blog.bitsocial.bso',
state: 'succeeded',
title: 'Bitsocial Updates',
updatedAt: 1781773422,
};
testState.communitySnapshot = {
shortAddress: 'blog.bitsocial.bso',
title: 'Bitsocial Updates',
};
await renderBoard({ initialEntry: '/blog.bitsocial.bso', routePath: '/:boardIdentifier/*' });
expect(container.textContent).toContain('no_threads');
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
expect(container.textContent).not.toContain('load_more');
@@ -1146,6 +1501,24 @@ describe('Board', () => {
expect(container.textContent).toContain('load_more');
});
it('does not show no threads when a succeeded feed has not caught up to loaded raw thread pages', async () => {
testState.feedStateString = undefined;
testState.feedState = 'succeeded';
testState.hasMore = false;
testState.community = {
error: undefined,
shortAddress: 'music-posting.eth',
state: 'succeeded',
title: '/mu/ - Music',
};
markRawBoardThreadsFullyLoaded([{ cid: 'post-1' }]);
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
expect(container.textContent).not.toContain('no_threads');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board');
});
it('does not show no threads after board metadata loads but raw thread pages are still missing', async () => {
testState.feedStateString = undefined;
testState.feedState = 'succeeded';
+148 -56
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react';
import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocial/bitsocial-react-hooks';
import { useCommunityField } from '../../hooks/use-stable-community';
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { communitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Trans, useTranslation } from 'react-i18next';
import styles from './board.module.css';
@@ -27,6 +27,7 @@ import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { restoreActiveAccountAuthor } from '../../lib/utils/account-comment-author-utils';
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
import { getRawBoardThreadState } from '../../lib/utils/raw-board-thread-state';
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
@@ -53,16 +54,52 @@ const EMPTY_COMMUNITIES_PAGES = {};
/** Board feed always uses 'active' sort; catalog dropdown does not affect board ordering. */
const BOARD_SORT_TYPE = 'active' as const;
const toFiniteNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
const compareBoardActivePosts = (firstPost: Comment, secondPost: Comment) => {
const activeDifference =
toFiniteNumber(secondPost?.lastReplyTimestamp ?? secondPost?.timestamp) - toFiniteNumber(firstPost?.lastReplyTimestamp ?? firstPost?.timestamp);
if (activeDifference !== 0) return activeDifference;
const upvoteDifference = toFiniteNumber(secondPost?.upvoteCount) - toFiniteNumber(firstPost?.upvoteCount);
if (upvoteDifference !== 0) return upvoteDifference;
return toFiniteNumber(secondPost?.timestamp) - toFiniteNumber(firstPost?.timestamp);
};
const sortBoardActiveFeed = (posts: Comment[]) => {
const pinnedPosts: Comment[] = [];
const regularPosts: Comment[] = [];
for (const post of posts) {
if (post?.pinned) {
pinnedPosts.push(post);
} else {
regularPosts.push(post);
}
}
return [...pinnedPosts, ...regularPosts.toSorted(compareBoardActivePosts)];
};
type RefreshHoldState = {
hasSettledResetCall: boolean;
hasSeenEmptyFeed: boolean;
isReleased: boolean;
startedFeedLength: number;
startedAt: number;
};
interface BoardFooterProps {
communityAddresses: string[];
hasMore: boolean;
feedState: string | undefined;
combinedFeedLength: number;
isSingleCommunityBoard: boolean;
isRawBoardThreadStateFullyLoaded: boolean;
isKnownEmptySingleCommunityBoard: boolean;
isInSubscriptionsView: boolean;
isInModView: boolean;
isManualRefreshPending: boolean;
currentTimeFilterName: string;
moreThreadsSuggestion: TimeFilterSuggestion | null;
moreThreadsSuggestionPathname: string | null;
@@ -84,10 +121,10 @@ const BoardFooter = ({
feedState,
combinedFeedLength,
isSingleCommunityBoard,
isRawBoardThreadStateFullyLoaded,
isKnownEmptySingleCommunityBoard,
isInSubscriptionsView,
isInModView,
isManualRefreshPending,
currentTimeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
@@ -100,13 +137,13 @@ const BoardFooter = ({
}: BoardFooterProps) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('downloading_board') : t('looking_for_more_posts'));
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const feedStateString = useFeedStateString(communityAddresses);
const loadingStateString = isManualRefreshPending
? t('loading_feed')
: feedStateString || (combinedFeedLength === 0 ? t('downloading_board') : t('looking_for_more_posts'));
const isFeedSucceeded = feedState === 'succeeded';
const isFeedFailed = feedState === 'failed';
const canShowNoThreads =
isKnownEmptySingleCommunityBoard ||
(isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore);
const canShowNoThreads = !isManualRefreshPending && (isSingleCommunityBoard ? isKnownEmptySingleCommunityBoard : isFeedSucceeded && !hasMore);
const isEmptyFeedLoading = combinedFeedLength === 0 && !canShowNoThreads && (isSingleCommunityBoard ? communityState !== 'failed' : !isFeedFailed);
const showFooterLoading = showLoadingEllipsis && (hasMore || isEmptyFeedLoading);
@@ -334,15 +371,85 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
const resetTriggeredRef = useRef(false);
const refreshHoldRef = useRef<RefreshHoldState | null>(null);
const feedLengthRef = useRef(feed.length);
feedLengthRef.current = feed.length;
const [, bumpRefreshHoldVersion] = useState(0);
const [isManualRefreshPending, startManualRefreshTransition] = useTransition();
const refreshBoardFeed = useCallback(() => {
const startedAt = Date.now();
refreshHoldRef.current = { hasSettledResetCall: false, hasSeenEmptyFeed: false, isReleased: false, startedFeedLength: feedLengthRef.current, startedAt };
bumpRefreshHoldVersion((version) => version + 1);
startManualRefreshTransition(async () => {
try {
await reset();
} catch (error) {
console.error('Failed to refresh board feed:', error);
} finally {
const currentRefreshHold = refreshHoldRef.current;
if (currentRefreshHold?.startedAt === startedAt && !currentRefreshHold.isReleased) {
currentRefreshHold.hasSettledResetCall = true;
bumpRefreshHoldVersion((version) => version + 1);
}
}
});
}, [reset, startManualRefreshTransition]);
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
useEffect(() => {
if (isVisible) {
setResetFunction(reset);
setResetFunction(refreshBoardFeed);
}
}, [reset, setResetFunction, feed, isVisible]);
}, [refreshBoardFeed, setResetFunction, isVisible]);
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
// Use stable community fields to avoid rerenders from updatingState
const communityTitle = useCommunityField(communityAddress, (community) => community?.title);
const shortAddress = useCommunityField(communityAddress, (community) => community?.shortAddress);
// useCommunityField only reads from store, doesn't trigger fetching
const communityData = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { error: communityError, state: communityState } = communityData || {};
const communitiesPages = communitiesPagesStore((state) => (isMultiboardView ? EMPTY_COMMUNITIES_PAGES : state.communitiesPages));
const rawBoardThreadState = useMemo(
() =>
isMultiboardView
? undefined
: getRawBoardThreadState({
accountId: account?.id,
communitiesPages,
community: communityData,
sortType: BOARD_SORT_TYPE,
}),
[account?.id, communitiesPages, communityData, isMultiboardView],
);
const isRawBoardThreadStateFullyLoaded = rawBoardThreadState?.isFullyLoaded ?? false;
const isRawBoardThreadStateEmpty = isRawBoardThreadStateFullyLoaded && (rawBoardThreadState?.rootThreadCids.size ?? 0) === 0;
const isSingleCommunityBoard = !isInAllView && !isInSubscriptionsView && !isInModView;
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const isFeedSucceeded = feedState === 'succeeded';
const refreshHold = refreshHoldRef.current;
if (refreshHold && !refreshHold.isReleased && !refreshHold.hasSeenEmptyFeed && feed.length === 0) {
refreshHold.hasSeenEmptyFeed = true;
}
const canReleaseRefreshHoldToEmptyBoard =
refreshHold?.startedFeedLength === 0 && isSingleCommunityBoard && isLoadedCommunityState && isRawBoardThreadStateEmpty && isFeedSucceeded;
const refreshHoldHasSeenEmptyFeed = Boolean(refreshHold?.hasSeenEmptyFeed || (refreshHold && feed.length === 0));
const canReleaseRefreshHold = Boolean(
refreshHold &&
!refreshHold.isReleased &&
(feed.length > 0 ||
communityState === 'failed' ||
feedState === 'failed' ||
canReleaseRefreshHoldToEmptyBoard ||
(!refreshHoldHasSeenEmptyFeed && refreshHold.hasSettledResetCall)) &&
(refreshHoldHasSeenEmptyFeed || refreshHold.hasSettledResetCall),
);
if (refreshHold && canReleaseRefreshHold) {
refreshHold.isReleased = true;
}
const isRefreshHoldPending = Boolean(refreshHold && !refreshHold.isReleased);
const isBoardRefreshPending = isManualRefreshPending || isRefreshHoldPending;
// Show local posts without letting them become the whole board during feed hydration.
const feedCids = useMemo(() => new Set(feed.map((f) => f.cid)), [feed]);
const nonokoPendingAccountComment = useMemo(() => {
const comment = nonokoPendingAccountComments.find(Boolean);
@@ -375,21 +482,32 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
[recentAccountComments, communityAddress, feedCids, nowSeconds],
);
const localAccountComments = useMemo(() => {
if (!nonokoPendingAccountComment) return filteredComments;
if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments];
const comments = (() => {
if (!nonokoPendingAccountComment) return filteredComments;
if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments];
return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)];
}, [nonokoPendingAccountComment, filteredComments]);
return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)];
})();
// show newest account comment at the top of the feed but after pinned posts
const combinedFeed = useMemo(() => {
const newFeed = [...feed];
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
if (localAccountComments.length > 0) {
newFeed.splice(lastPinnedIndex + 1, 0, ...localAccountComments);
return comments.map((comment) => restoreActiveAccountAuthor(comment, account));
}, [nonokoPendingAccountComment, filteredComments, account]);
const sortedFeed = useMemo(() => sortBoardActiveFeed(feed), [feed]);
const canShowRecentLocalAccountComments = !isSingleCommunityBoard || sortedFeed.length > 0 || isRawBoardThreadStateFullyLoaded;
const feedWithLocalAccountComments = useMemo(() => {
if (isBoardRefreshPending) {
return sortedFeed;
}
return newFeed;
}, [feed, localAccountComments]);
const visibleLocalAccountComments = canShowRecentLocalAccountComments ? localAccountComments : nonokoPendingAccountComment ? localAccountComments.slice(0, 1) : [];
if (visibleLocalAccountComments.length === 0) {
return sortedFeed;
}
return sortBoardActiveFeed([...feed, ...visibleLocalAccountComments]);
}, [canShowRecentLocalAccountComments, feed, isBoardRefreshPending, localAccountComments, nonokoPendingAccountComment, sortedFeed]);
const combinedFeed = feedWithLocalAccountComments;
const cappedFeed = useMemo(
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
@@ -456,33 +574,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
}
}, [combinedFeed, registerComments]);
// Use stable community fields to avoid rerenders from updatingState
const communityTitle = useCommunityField(communityAddress, (community) => community?.title);
const shortAddress = useCommunityField(communityAddress, (community) => community?.shortAddress);
// useCommunityField only reads from store, doesn't trigger fetching
const communityData = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { error: communityError, state: communityState } = communityData || {};
const communitiesPages = communitiesPagesStore((state) => (isMultiboardView ? EMPTY_COMMUNITIES_PAGES : state.communitiesPages));
const rawBoardThreadState = useMemo(
() =>
isMultiboardView
? undefined
: getRawBoardThreadState({
accountId: account?.id,
communitiesPages,
community: communityData,
sortType: BOARD_SORT_TYPE,
}),
[account?.id, communitiesPages, communityData, isMultiboardView],
);
const isRawBoardThreadStateFullyLoaded = rawBoardThreadState?.isFullyLoaded ?? false;
const hasExplicitEmptyPageCids = rawBoardThreadState?.hasExplicitEmptyPageCids ?? false;
const isRawBoardThreadStateEmpty = isRawBoardThreadStateFullyLoaded && (rawBoardThreadState?.rootThreadCids.size ?? 0) === 0;
const isSingleCommunityBoard = !isInAllView && !isInSubscriptionsView && !isInModView;
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const isFeedSucceeded = feedState === 'succeeded';
const isKnownEmptySingleCommunityBoard =
isSingleCommunityBoard && combinedFeed.length === 0 && isLoadedCommunityState && isRawBoardThreadStateEmpty && (hasExplicitEmptyPageCids || isFeedSucceeded);
const isKnownEmptySingleCommunityBoard = isSingleCommunityBoard && combinedFeed.length === 0 && isLoadedCommunityState && isRawBoardThreadStateEmpty && isFeedSucceeded;
const effectiveHasMore = isKnownEmptySingleCommunityBoard ? false : hasMore;
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle;
@@ -499,10 +591,10 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
feedState={feedState}
combinedFeedLength={combinedFeed.length}
isSingleCommunityBoard={isSingleCommunityBoard}
isRawBoardThreadStateFullyLoaded={isRawBoardThreadStateFullyLoaded}
isKnownEmptySingleCommunityBoard={isKnownEmptySingleCommunityBoard}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
isManualRefreshPending={isBoardRefreshPending}
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
@@ -539,7 +631,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
{t('top')}
</button>
<button type='button' className='button' onClick={() => reset && reset()}>
<button type='button' className='button' onClick={refreshBoardFeed}>
{t('refresh')}
</button>
</div>
@@ -581,12 +673,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
communityAddresses,
effectiveHasMore,
combinedFeed.length,
isRawBoardThreadStateFullyLoaded,
isKnownEmptySingleCommunityBoard,
isSingleCommunityBoard,
isInAllView,
isInSubscriptionsView,
isInModView,
isBoardRefreshPending,
currentTimeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
@@ -603,7 +695,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
currentPage,
totalPages,
setEnableInfiniteScroll,
reset,
refreshBoardFeed,
routerLocation.search,
t,
],
@@ -680,7 +772,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
communityIdentifier.publicKey.length > 0 &&
communityData?.nameResolved === false;
const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed;
const canShowEmptyFlashTable = hasExplicitEmptyPageCids || (isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded);
const canShowEmptyFlashTable = isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateEmpty;
const shouldShowFlashTableLoading = shouldUseFlashTable && displayFeed.length === 0 && !canShowEmptyFlashTable && communityState !== 'failed' && feedState !== 'failed';
return (
+2 -1
View File
@@ -526,7 +526,8 @@ span.reject:hover {
}
/* ModQueueButton styles */
/* Mod queue count badge styles (ModQueueBoardCount); the board mod-queue button
keeps its own copy in components/mod-queue-button/mod-queue-button.module.css */
.modQueueButtonCount {
font-weight: bold;
}
+23 -292
View File
@@ -2,8 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useCommunities } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { useFeed, Comment, useEditedComment, useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react';
import { Virtuoso, type Components } from 'react-virtuoso';
import styles from './mod-queue.module.css';
@@ -16,20 +15,15 @@ import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'
import getShortAddress from '../../lib/get-short-address';
import { BOARD_CODE_GROUPS } from '../../constants/board-codes';
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
import {
approvePendingCommentModeration,
isPendingApprovalAwaiting,
isPendingApprovalRejected,
rejectPendingCommentModeration,
} from '../../lib/utils/pending-approval-moderation';
import { isPendingApprovalAwaiting, isPendingApprovalRejected } from '../../lib/utils/pending-approval-moderation';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useChallengesStore from '../../stores/use-challenges-store';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
import {
filterVisibleModQueueFeed,
getAddressListFromKey,
getAddressListKey,
getModQueueBoardFilterGroups,
getModQueueBoardFilterKey,
getModQueueCommentRoute,
@@ -44,7 +38,8 @@ import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use
import useIsMobile from '../../hooks/use-is-mobile';
import { useCurrentTime } from '../../hooks/use-current-time';
import { Post } from '../post/post';
import { canAccessBoardModQueue, hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { useLocallyModeratedModQueueFeed } from '../../hooks/use-locally-moderated-mod-queue-feed';
import ModQueueCommunityMetadataLoader from '../../components/mod-queue-community-metadata-loader/mod-queue-community-metadata-loader';
import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer';
@@ -58,78 +53,14 @@ const getBoardDisplayPath = (address: string, path: string): string => {
return getShortAddress(address) || address;
};
type LocalModerationEditSummary = Record<string, { timestamp?: number; value: unknown } | undefined>;
type LocalModerationEditSummaries = Record<string, LocalModerationEditSummary | undefined>;
const LOCAL_MODERATION_EDIT_FIELDS = ['approved', 'removed'] as const;
const LOCAL_EDIT_PENDING_SECONDS = 20 * 60;
const shouldApplyLocalModerationEdit = (
comment: Comment,
propertyName: (typeof LOCAL_MODERATION_EDIT_FIELDS)[number],
edit: { timestamp?: number; value: unknown },
now: number,
) => {
const editTimestamp = edit.timestamp ?? 0;
const updatedAt = (comment as { updatedAt?: number }).updatedAt;
const currentValue = (comment as Record<string, unknown>)[propertyName];
if (!updatedAt) {
return Object.is(currentValue, edit.value) || editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS;
}
if (updatedAt < editTimestamp || Object.is(currentValue, edit.value)) {
return true;
}
return editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS || updatedAt - editTimestamp < LOCAL_EDIT_PENDING_SECONDS;
};
const applyLocalModerationEdits = (comment: Comment, editSummary: LocalModerationEditSummary | undefined, now: number): Comment => {
if (!editSummary) {
return comment;
}
let editedComment: Comment | undefined;
for (const propertyName of LOCAL_MODERATION_EDIT_FIELDS) {
const edit = editSummary[propertyName];
if (!edit || edit.value === undefined || !shouldApplyLocalModerationEdit(comment, propertyName, edit, now)) {
continue;
}
editedComment = { ...(editedComment ?? comment), [propertyName]: edit.value } as Comment;
}
return editedComment ?? comment;
};
const useLocallyModeratedModQueueFeed = (feed: Comment[], currentTime: number) => {
const accountId = useAccountsStore((state) => state.activeAccountId);
const editSummaries = useAccountsStore((state) => (accountId ? state.accountsEditsSummaries[accountId] : undefined)) as LocalModerationEditSummaries | undefined;
return useMemo(() => {
if (!editSummaries) {
return feed;
}
return feed.map((comment) => (comment.cid ? applyLocalModerationEdits(comment, editSummaries[comment.cid], currentTime) : comment));
}, [currentTime, editSummaries, feed]);
};
interface ModQueueViewProps {
boardIdentifier?: string; // If provided, shows queue for single board
}
const getAddressListKey = (addresses: string[]) => addresses.join('\0');
const getAddressListFromKey = (key: string) => (key ? key.split('\0') : []);
const EMPTY_COMMENTS: Comment[] = [];
const MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 };
const NOOP_LOAD_MORE = () => undefined;
const ModQueueCommunityMetadataLoader = memo(({ candidateCommunityAddresses }: { candidateCommunityAddresses: string[] }) => {
const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses);
useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined);
return null;
});
interface ModQueueFooterProps {
hasMore: boolean;
loadingStateString: string;
@@ -213,9 +144,6 @@ interface ModQueueRowProps {
boardDisplayPath: string | undefined;
}
// Track which action was initiated to show appropriate completion message
type ModerationAction = 'approve' | 'reject' | null;
interface ModQueueActionState {
status: 'approved' | 'rejected' | 'failed' | null;
error?: unknown;
@@ -433,89 +361,38 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
};
const useModQueueActions = (comment: Comment): ModQueueActionState => {
const { t } = useTranslation();
const { cid, approved, removed, pendingApproval } = comment || {};
const communityAddress = getCommentCommunityAddress(comment);
const dismissCommentFromQueue = useModQueueStore((state) => state.dismissCommentFromQueue);
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
const alreadyApproved = approved === true;
const alreadyRejected = isPendingApprovalRejected({ approved, removed, pendingApproval });
const {
publishCommentModeration: approve,
state: approveState,
error: approveError,
} = usePublishCommentModeration({
status: actionStatus,
error,
errorMessage,
isPublishing,
handleApprove,
handleReject,
} = usePendingCommentModerationActions({
comment,
commentCid: cid,
communityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
});
const {
publishCommentModeration: reject,
state: rejectState,
error: rejectError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
const handleApprove = async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedAction('approve');
try {
await approve();
onApproveSuccess: () => {
const approvedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: true, pendingApproval: false });
if (approvedSnapshot) {
rememberCommentsInQueue([approvedSnapshot]);
}
} catch (e) {
console.error(e);
}
};
const handleReject = async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedAction('reject');
try {
await reject();
},
onRejectSuccess: () => {
const rejectedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: false, pendingApproval: false });
if (rejectedSnapshot) {
rememberCommentsInQueue([rejectedSnapshot]);
}
} catch (e) {
console.error(e);
}
};
},
});
const handleRemove = () => {
if (cid) {
@@ -523,19 +400,9 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
}
};
const isApproving = initiatedAction === 'approve' && approveState !== 'initializing' && approveState !== 'succeeded' && approveState !== 'failed';
const isRejecting = initiatedAction === 'reject' && rejectState !== 'initializing' && rejectState !== 'succeeded' && rejectState !== 'failed';
const isPublishing = isApproving || isRejecting;
const approveSucceeded = initiatedAction === 'approve' && approveState === 'succeeded';
const rejectSucceeded = initiatedAction === 'reject' && rejectState === 'succeeded';
const approveFailed = initiatedAction === 'approve' && approveState === 'failed';
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
const status = alreadyApproved || approveSucceeded ? 'approved' : alreadyRejected || rejectSucceeded ? 'rejected' : approveFailed || rejectFailed ? 'failed' : null;
const error = approveFailed ? approveError : rejectFailed ? rejectError : undefined;
const errorMessage = formatErrorForDisplay(error);
// Mod queue rows also reflect a comment that was already moderated before this
// session, so combine that baseline with the live action-derived status.
const status = alreadyApproved || actionStatus === 'approved' ? 'approved' : alreadyRejected || actionStatus === 'rejected' ? 'rejected' : actionStatus;
return { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove: status ? handleRemove : undefined };
};
@@ -1062,142 +929,6 @@ const ModQueueContent = memo(
);
ModQueueContent.displayName = 'ModQueueContent';
interface ModQueueButtonProps {
boardIdentifier?: string;
isMobile?: boolean;
}
interface ModQueueButtonContentProps {
feed: Comment[];
alertThresholdSeconds: number;
boardIdentifier?: string;
isMobile?: boolean;
}
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
const { t } = useTranslation();
const currentTime = useCurrentTime();
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
const { normalCount, urgentCount } = useMemo(() => {
let normal = 0;
let urgent = 0;
for (const comment of locallyModeratedFeed) {
if (!isPendingApprovalAwaiting(comment)) continue;
const timeWaiting = currentTime - (comment.timestamp ?? 0);
if (timeWaiting > alertThresholdSeconds) urgent++;
else normal++;
}
return { normalCount: normal, urgentCount: urgent };
}, [alertThresholdSeconds, currentTime, locallyModeratedFeed]);
const totalCount = normalCount + urgentCount;
const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
const buttonContent = (
<Link className='button' to={to}>
{t('mod_queue')}
{totalCount > 0 && (
<strong>
(
{urgentCount > 0 && normalCount > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normalCount}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
{'+'}
{urgentCount}
</span>
</>
) : urgentCount > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
) : (
<span className={styles.modQueueButtonCount}>{totalCount}</span>
)}
)
</strong>
)}
</Link>
);
return isMobile ? buttonContent : <>[{buttonContent}]</>;
};
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
const accountAddress = moderatedCommunityAddressInputs.accountAddress;
const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
const directories = useDirectories();
const resolvedAddress = useMemo(() => {
if (boardIdentifier) {
return getCommunityAddress(boardIdentifier, directories);
}
return undefined;
}, [boardIdentifier, directories]);
const resolvedCommunity = useCommunityIdentifier(resolvedAddress);
const community = useCommunity(resolvedCommunity ? { community: resolvedCommunity } : undefined);
const communityAddresses = useMemo(() => {
if (resolvedAddress) {
return [resolvedAddress];
}
return accountCommunityAddresses;
}, [resolvedAddress, accountCommunityAddresses]);
const accountRole = accountAddress ? community?.roles?.[accountAddress]?.role : undefined;
const hasBoardAccessFromAccountCommunities = resolvedAddress
? accountCommunityAddresses.some((address) => areSameBoardAddress(address, resolvedAddress))
: accountCommunityAddresses.length > 0;
const hasBoardAccess = canAccessBoardModQueue({
boardAddress: resolvedAddress,
accountCommunityAddresses,
accountRole,
});
const isBoardAccessLoading =
Boolean(resolvedAddress) &&
Boolean(accountAddress) &&
!hasModQueueAccessRole(accountRole) &&
!hasBoardAccessFromAccountCommunities &&
community?.state !== 'succeeded' &&
community?.state !== 'failed';
// Only fetch if we have addresses to check and permissions
const shouldFetch = !isBoardAccessLoading && communityAddresses.length > 0 && hasBoardAccess;
const feedAddresses = shouldFetch ? communityAddresses : [];
const feedCommunities = useCommunityIdentifiers(feedAddresses);
const feedOptions = useMemo(
() => ({
communities: feedCommunities,
modQueue: ['pendingApproval'],
sortType: 'new' as const,
postsPerPage: 200,
}),
[feedCommunities],
);
const { feed } = useFeed(feedOptions);
const metadataLoader = <ModQueueCommunityMetadataLoader candidateCommunityAddresses={moderatedCommunityAddressInputs.candidateCommunityAddresses} />;
if (!shouldFetch || communityAddresses.length === 0) {
return metadataLoader;
}
const alertThresholdSeconds = getAlertThresholdSeconds();
// Remount when switching boards so memoized counts reset cleanly.
const contentKey = communityAddresses.join(',');
return (
<>
{metadataLoader}
<ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />
</>
);
};
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
const params = useParams();
const [selectedBoardFilter, setSelectedBoardFilter] = useState<string | null>(null);
+42 -2
View File
@@ -64,7 +64,7 @@ const testState = vi.hoisted(() => ({
roles: {
'0xmod': { role: 'admin' },
},
} as { roles?: Record<string, unknown> },
} as { roles?: Record<string, unknown> } | undefined,
useCommentCalls: [] as Array<{ commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }>,
evictThreadRefreshCachesMock: vi.fn(),
}));
@@ -116,7 +116,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages', () =>
vi.mock('../../../hooks/use-stable-community', () => ({
useCommunityField: (address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => {
testState.communityFieldAddress = address;
return selector(testState.communitySnapshot);
return testState.communitySnapshot ? selector(testState.communitySnapshot) : undefined;
},
}));
@@ -189,6 +189,7 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({
'data-number': post?.number === undefined ? '' : String(post.number),
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
'data-roles-present': String(roles !== undefined),
},
createElement('div', { 'data-thread-container-cid': post?.cid }),
createElement('div', { 'data-post-info-cid': post?.cid }),
@@ -217,6 +218,7 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({
'data-number': post?.number === undefined ? '' : String(post.number),
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
'data-roles-present': String(roles !== undefined),
},
createElement('div', { 'data-thread-container-cid': post?.cid }),
createElement('div', { 'data-post-info-cid': post?.cid }),
@@ -370,6 +372,44 @@ describe('Post', () => {
expect(testState.communityFieldAddress).toBe('music-posting.eth');
});
it('passes an empty role map after a community with no roles is loaded', async () => {
testState.communitySnapshot = {};
await act(async () => {
root.render(createElement(Post, { post: { cid: 'post-no-roles', communityAddress: 'music-posting.eth', content: '[b]raw[/b]' } }));
});
const postDesktop = container.querySelector('[data-testid="post-desktop"]');
expect(postDesktop?.getAttribute('data-roles-present')).toBe('true');
expect(postDesktop?.textContent).toBe('post-no-roles:none:0');
});
it('keeps roles pending for matching board routes until the community loads', async () => {
testState.communitySnapshot = undefined;
testState.resolvedCommunityAddress = 'music-posting.eth';
await act(async () => {
root.render(createElement(Post, { post: { cid: 'post-pending-roles', communityAddress: 'music-posting.eth', content: '[color=red]raw[/color]' } }));
});
const postDesktop = container.querySelector('[data-testid="post-desktop"]');
expect(postDesktop?.getAttribute('data-roles-present')).toBe('false');
expect(postDesktop?.textContent).toBe('post-pending-roles:none:0');
});
it('uses an empty role map for posts outside a resolved board route when the community is unavailable', async () => {
testState.communitySnapshot = undefined;
testState.resolvedCommunityAddress = undefined;
await act(async () => {
root.render(createElement(Post, { post: { cid: 'post-multiboard', communityAddress: 'other-board.eth', content: '[color=red]raw[/color]' } }));
});
const postDesktop = container.querySelector('[data-testid="post-desktop"]');
expect(postDesktop?.getAttribute('data-roles-present')).toBe('true');
expect(postDesktop?.textContent).toBe('post-multiboard:none:0');
});
it('rerenders posts when pending approval turns into an approved numbered post', async () => {
await act(async () => {
root.render(
+9 -45
View File
@@ -1,17 +1,7 @@
import { memo, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
type Account,
type Comment,
type CommunityIdentifier,
type Role,
useAccount,
useComment,
useEditedComment,
useCommunity,
useReplies,
} from '@bitsocial/bitsocial-react-hooks';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { type Comment, type CommunityIdentifier, type Role, useAccount, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocial/bitsocial-react-hooks';
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { useCommunityField } from '../../hooks/use-stable-community';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { isAllView } from '../../lib/utils/view-utils';
@@ -21,6 +11,7 @@ import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { mergeDefinedFields, restoreActiveAccountAuthor } from '../../lib/utils/account-comment-author-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import ErrorDisplay from '../../components/error-display/error-display';
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer/footer';
@@ -35,6 +26,8 @@ import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import styles from './post.module.css';
const EMPTY_ROLE_MAP = {};
export type CommentWithRefresh = Comment & {
approved?: boolean;
communityAddress?: string;
@@ -47,19 +40,6 @@ export type CommentWithRefresh = Comment & {
removed?: boolean;
};
const mergeDefinedFields = <T extends object>(base: T | undefined, override: T | undefined): T | undefined => {
if (!override) return base;
const merged = { ...base } as Record<string, unknown>;
for (const [key, value] of Object.entries(override)) {
if (value !== undefined) {
merged[key] = value;
}
}
return merged as T;
};
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
if (!state || typeof state !== 'object') return undefined;
if ('queuedComment' in state || 'scrollThreadContainerCid' in state) {
@@ -144,25 +124,6 @@ const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accou
return mergeLocalCommentAuthor(mergedComment, accountComment);
};
const restoreActiveAccountAuthor = (accountComment: CommentWithRefresh | undefined, account: Account | undefined): CommentWithRefresh | undefined => {
if (!accountComment || accountComment.author?.address || !account?.id || accountComment.accountId !== account.id || !account.author?.address) {
return accountComment;
}
const accountAuthor = {
address: account.author.address,
shortAddress: account.author.shortAddress,
displayName: account.author.displayName,
avatar: account.author.avatar,
flair: account.author.flair,
};
return {
...accountComment,
author: mergeDefinedFields(accountComment.author, accountAuthor),
};
};
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
// This hook falls back to the communities pages store and then overlays a matching
// local account comment so author controls keep working after publish navigation.
@@ -249,7 +210,10 @@ export const Post = memo(
}: PostProps) => {
// Only subscribe to roles field to avoid rerenders from updatingState changes
const communityAddress = getCommentCommunityAddress(post);
const roles = useCommunityField(communityAddress, (community) => community?.roles);
const routeCommunityAddress = useResolvedCommunityAddress();
const rawRoles = useCommunityField(communityAddress, (community) => community?.roles ?? EMPTY_ROLE_MAP);
const shouldWaitForRoles = Boolean(routeCommunityAddress && communityAddress && areSameBoardAddress(routeCommunityAddress, communityAddress));
const roles = rawRoles ?? (shouldWaitForRoles ? undefined : EMPTY_ROLE_MAP);
const isMobile = useIsMobile();
let comment = post;