mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'master' of github.com:bitsocialnet/5chan
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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.).
|
||||
|
||||
@@ -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,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 = () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
+14
@@ -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,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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,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,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,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';
|
||||
|
||||
@@ -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,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,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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
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,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,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,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,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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+145
-53
@@ -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(() => {
|
||||
const comments = (() => {
|
||||
if (!nonokoPendingAccountComment) return filteredComments;
|
||||
if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments];
|
||||
|
||||
return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)];
|
||||
}, [nonokoPendingAccountComment, filteredComments]);
|
||||
})();
|
||||
|
||||
// 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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user