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
|
## 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.).
|
- [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/translations.md",
|
||||||
"docs/agent-playbooks/skills-and-tools.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,
|
default: () => testState.isMobile,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../catalog-filters', () => ({
|
vi.mock('../../catalog-filters/catalog-filters', () => ({
|
||||||
default: () => createElement('div', { 'data-testid': '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'),
|
default: () => createElement('div', { 'data-testid': 'catalog-search' }, 'catalog-search'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../tooltip', () => ({
|
vi.mock('../../tooltip/tooltip', () => ({
|
||||||
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
|
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
|
||||||
createElement('span', { 'data-content': content, 'data-testid': 'tooltip' }, children),
|
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 }) =>
|
ModQueueButton: ({ boardIdentifier, isMobile }: { boardIdentifier?: string; isMobile?: boolean }) =>
|
||||||
createElement('div', { 'data-mobile': String(!!isMobile), 'data-testid': 'mod-queue-button' }, boardIdentifier || 'global-mod-queue'),
|
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 useOptimisticReplyCount from '../../hooks/use-optimistic-reply-count';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import useTimeFilter from '../../hooks/use-time-filter';
|
import useTimeFilter from '../../hooks/use-time-filter';
|
||||||
import CatalogFilters from '../catalog-filters';
|
import CatalogFilters from '../catalog-filters/catalog-filters';
|
||||||
import CatalogSearch from '../catalog-search';
|
import CatalogSearch from '../catalog-search/catalog-search';
|
||||||
import Tooltip from '../tooltip';
|
import Tooltip from '../tooltip/tooltip';
|
||||||
import { ModQueueButton } from '../../views/mod-queue/mod-queue';
|
import { ModQueueButton } from '../mod-queue-button/mod-queue-button';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
|
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
|
||||||
import { shouldShowCatalogButton } from './catalog-button-utils';
|
import { shouldShowCatalogButton } from './catalog-button-utils';
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ vi.mock('../../../lib/snow', () => ({
|
|||||||
shouldShowSnow: () => testState.shouldShowSnow,
|
shouldShowSnow: () => testState.shouldShowSnow,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../tooltip', () => ({
|
vi.mock('../../tooltip/tooltip', () => ({
|
||||||
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
|
default: ({ content, children }: { content: string; children: React.ReactNode }) =>
|
||||||
createElement('span', { 'data-testid': 'tooltip', 'data-content': content }, children),
|
createElement('span', { 'data-testid': 'tooltip', 'data-content': content }, children),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
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 getShortAddress from '../../lib/get-short-address';
|
||||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||||
import { useStableCommunity } from '../../hooks/use-stable-community';
|
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 useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
||||||
import { shouldShowSnow } from '../../lib/snow';
|
import { shouldShowSnow } from '../../lib/snow';
|
||||||
import Tooltip from '../tooltip';
|
import Tooltip from '../tooltip/tooltip';
|
||||||
import { BANNERS } from '../../generated/asset-manifest';
|
import { BANNERS } from '../../generated/asset-manifest';
|
||||||
|
|
||||||
const ImageBanner = () => {
|
const ImageBanner = () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
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 { isAllView, isCatalogView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||||
import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
|
import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ vi.mock('../../../hooks/use-state-string', () => ({
|
|||||||
default: () => testState.stateString,
|
default: () => testState.stateString,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../loading-ellipsis', () => ({
|
vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({
|
||||||
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
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: ({
|
default: ({
|
||||||
isOP,
|
isOP,
|
||||||
isQuotelinkReply,
|
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),
|
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),
|
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]']);
|
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 () => {
|
it('ignores unsupported BBCode styling values for moderator authors', async () => {
|
||||||
await renderContent(
|
await renderContent(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type ReactNode, useMemo, useState } from 'react';
|
|||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { Comment, useComment } from '@bitsocial/bitsocial-react-hooks';
|
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 usePostNumberStore from '../../stores/use-post-number-store';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
import getShortAddress from '../../lib/get-short-address';
|
||||||
import { getFormattedDate } from '../../lib/utils/time-utils';
|
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 { isPostPageView } from '../../lib/utils/view-utils';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import useStateString from '../../hooks/use-state-string';
|
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 BbcodeContent from '../../components/bbcode-content/bbcode-content';
|
||||||
import ErrorDisplay from '../../components/error-display/error-display';
|
import ErrorDisplay from '../../components/error-display/error-display';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview';
|
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
|
||||||
import Markdown from '../markdown';
|
import Markdown from '../markdown/markdown';
|
||||||
import Tooltip from '../tooltip';
|
import Tooltip from '../tooltip/tooltip';
|
||||||
import styles from '../../views/post/post.module.css';
|
import styles from '../../views/post/post.module.css';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
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;
|
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 = ({
|
const CommentContent = ({
|
||||||
appendContent,
|
appendContent,
|
||||||
comment: post,
|
comment: post,
|
||||||
@@ -106,6 +110,7 @@ const CommentContent = ({
|
|||||||
const authorRole = getRoleByAddress(roles, authorAddress);
|
const authorRole = getRoleByAddress(roles, authorAddress);
|
||||||
const isPrivilegedAuthor = hasModQueueAccessRole(authorRole);
|
const isPrivilegedAuthor = hasModQueueAccessRole(authorRole);
|
||||||
const shouldRenderBbcode = isPrivilegedAuthor;
|
const shouldRenderBbcode = isPrivilegedAuthor;
|
||||||
|
const shouldWaitForRoleSensitiveBbcode = roles === undefined && Boolean(authorAddress && communityAddress) && containsRoleSensitiveBbcode(visibleContent);
|
||||||
const purged = resolvedPost?.commentModeration?.purged;
|
const purged = resolvedPost?.commentModeration?.purged;
|
||||||
const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt;
|
const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt;
|
||||||
const banned = !!banExpiresAt;
|
const banned = !!banExpiresAt;
|
||||||
@@ -179,7 +184,11 @@ const CommentContent = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const renderContent = (value: string | undefined) =>
|
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} />
|
<BbcodeContent content={value || ''} postCid={postCid} communityAddress={communityAddress} />
|
||||||
) : (
|
) : (
|
||||||
<Markdown content={value || ''} postCid={postCid} communityAddress={communityAddress} />
|
<Markdown content={value || ''} postCid={postCid} communityAddress={communityAddress} />
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ vi.mock('@floating-ui/react', () => ({
|
|||||||
autoUpdate: () => undefined,
|
autoUpdate: () => undefined,
|
||||||
offset: () => ({}),
|
offset: () => ({}),
|
||||||
shift: () => ({}),
|
shift: () => ({}),
|
||||||
useClick: () => ({}),
|
useClick: ({ onOpenChange, open }: { onOpenChange: (open: boolean) => void; open: boolean }) => ({
|
||||||
|
reference: {
|
||||||
|
onClick: () => onOpenChange(!open),
|
||||||
|
},
|
||||||
|
}),
|
||||||
useDismiss: () => ({}),
|
useDismiss: () => ({}),
|
||||||
useFloating: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ({
|
useFloating: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ({
|
||||||
context: {
|
context: {
|
||||||
@@ -66,9 +70,16 @@ vi.mock('@floating-ui/react', () => ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useId: () => 'edit-menu-heading',
|
useId: () => 'edit-menu-heading',
|
||||||
useInteractions: () => ({
|
useInteractions: (interactions: Array<{ reference?: { onClick?: () => void } }>) => ({
|
||||||
getFloatingProps: (props?: Record<string, unknown>) => props || {},
|
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: () => ({}),
|
useRole: () => ({}),
|
||||||
}));
|
}));
|
||||||
@@ -227,7 +238,21 @@ describe('EditMenu', () => {
|
|||||||
expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply');
|
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 = {
|
testState.privileges = {
|
||||||
isAccountCommentAuthor: true,
|
isAccountCommentAuthor: true,
|
||||||
isAccountMod: false,
|
isAccountMod: false,
|
||||||
@@ -238,12 +263,8 @@ describe('EditMenu', () => {
|
|||||||
await openMenu();
|
await openMenu();
|
||||||
|
|
||||||
await click(getCheckbox('deleted'));
|
await click(getCheckbox('deleted'));
|
||||||
await click(getLabelCheckbox('Edit?'));
|
expect(getLabelCheckbox('Edit?')).toBeNull();
|
||||||
|
expect(container.querySelector('textarea')).toBeNull();
|
||||||
const textarea = container.querySelector('textarea');
|
|
||||||
expect(textarea).not.toBeNull();
|
|
||||||
|
|
||||||
await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body');
|
|
||||||
await clickButton('save');
|
await clickButton('save');
|
||||||
|
|
||||||
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
|
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
|
||||||
@@ -254,11 +275,11 @@ describe('EditMenu', () => {
|
|||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
},
|
},
|
||||||
commentCid: 'comment-1',
|
commentCid: 'comment-1',
|
||||||
content: 'Updated body',
|
|
||||||
deleted: true,
|
deleted: true,
|
||||||
spoiler: false,
|
|
||||||
communityAddress: 'music-posting.eth',
|
communityAddress: 'music-posting.eth',
|
||||||
});
|
});
|
||||||
|
expect(testState.authorOptions?.content).toBeUndefined();
|
||||||
|
expect(testState.authorOptions?.spoiler).toBeUndefined();
|
||||||
expect(testState.authorPrivilegesOptions).toMatchObject({
|
expect(testState.authorPrivilegesOptions).toMatchObject({
|
||||||
commentAuthorAddress: '0xauthor',
|
commentAuthorAddress: '0xauthor',
|
||||||
communityAddress: 'music-posting.eth',
|
communityAddress: 'music-posting.eth',
|
||||||
@@ -463,7 +484,7 @@ describe('EditMenu', () => {
|
|||||||
expect(getCheckbox('archived')).toBeNull();
|
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 = {
|
testState.privileges = {
|
||||||
isAccountCommentAuthor: true,
|
isAccountCommentAuthor: true,
|
||||||
isAccountMod: true,
|
isAccountMod: true,
|
||||||
@@ -474,7 +495,7 @@ describe('EditMenu', () => {
|
|||||||
await openMenu();
|
await openMenu();
|
||||||
await clickButton('save');
|
await clickButton('save');
|
||||||
|
|
||||||
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
|
expect(testState.publishAuthorEditMock).not.toHaveBeenCalled();
|
||||||
expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce();
|
expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.menuItem input[type="text"], .editTextarea {
|
.menuItem input[type="text"] {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
box-shadow: var(--box-shadow-input);
|
box-shadow: var(--box-shadow-input);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -50,12 +50,12 @@
|
|||||||
margin-bottom: 2px;
|
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);
|
border: var(--post-form-field-input-border);
|
||||||
outline: none;
|
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);
|
border: var(--post-form-field-input-focus-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +64,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editTextarea {
|
|
||||||
width: calc(100% - 12px);
|
|
||||||
height: 100px;
|
|
||||||
margin-left: 3px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom button {
|
.bottom button {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
@@ -102,4 +95,4 @@
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
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 {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
PublishCommentEditOptions,
|
PublishCommentEditOptions,
|
||||||
@@ -34,14 +34,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const resolvedPost = withResolvedCommentCommunityAddress(post);
|
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 communityAddress = getCommentCommunityAddress(resolvedPost);
|
||||||
const archived = isCommentArchived(resolvedPost);
|
const archived = isCommentArchived(resolvedPost);
|
||||||
const authorDisplayName = resolvedPost?.author?.displayName;
|
const authorDisplayName = resolvedPost?.author?.displayName;
|
||||||
const modBanExpiresAt = resolvedPost?.author?.community?.banExpiresAt ?? resolvedPost?.commentModeration?.author?.banExpiresAt;
|
const modBanExpiresAt = resolvedPost?.author?.community?.banExpiresAt ?? resolvedPost?.commentModeration?.author?.banExpiresAt;
|
||||||
const purged = resolvedPost?.commentModeration?.purged ?? false;
|
const purged = resolvedPost?.commentModeration?.purged ?? false;
|
||||||
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
||||||
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
|
|
||||||
|
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
|
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
|
||||||
@@ -65,9 +64,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
commentCid: cid,
|
commentCid: cid,
|
||||||
communityAddress,
|
communityAddress,
|
||||||
// Author edit properties
|
// Author edit properties
|
||||||
content: isAccountCommentAuthor ? content : undefined,
|
|
||||||
deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined,
|
deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined,
|
||||||
spoiler: isAccountCommentAuthor ? (spoiler ?? false) : undefined,
|
|
||||||
// Mod edit properties
|
// Mod edit properties
|
||||||
commentModeration: isAccountMod
|
commentModeration: isAccountMod
|
||||||
? {
|
? {
|
||||||
@@ -90,11 +87,9 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
isAccountMod,
|
isAccountMod,
|
||||||
isAccountCommentAuthor,
|
|
||||||
canAttemptAuthorDelete,
|
canAttemptAuthorDelete,
|
||||||
archived,
|
archived,
|
||||||
cid,
|
cid,
|
||||||
content,
|
|
||||||
deleted,
|
deleted,
|
||||||
locked,
|
locked,
|
||||||
pinned,
|
pinned,
|
||||||
@@ -114,9 +109,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
const options: PublishCommentEditOptions = {
|
const options: PublishCommentEditOptions = {
|
||||||
commentCid: cid,
|
commentCid: cid,
|
||||||
communityAddress,
|
communityAddress,
|
||||||
content: publishCommentEditOptions.content,
|
|
||||||
deleted: publishCommentEditOptions.deleted,
|
deleted: publishCommentEditOptions.deleted,
|
||||||
spoiler: publishCommentEditOptions.spoiler,
|
|
||||||
onChallenge,
|
onChallenge,
|
||||||
onChallengeVerification: alertChallengeVerificationFailed,
|
onChallengeVerification: alertChallengeVerificationFailed,
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
@@ -173,7 +166,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
setBanDuration(
|
setBanDuration(
|
||||||
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
|
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
|
||||||
);
|
);
|
||||||
setIsContentEditorOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -199,8 +191,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
|
|
||||||
if (id === 'deleted' && canAttemptAuthorDelete) {
|
if (id === 'deleted' && canAttemptAuthorDelete) {
|
||||||
newState.deleted = checked;
|
newState.deleted = checked;
|
||||||
} else if (isAccountCommentAuthor) {
|
|
||||||
newState[id] = checked;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newState;
|
return newState;
|
||||||
@@ -253,17 +243,16 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
whileElementsMounted: autoUpdate,
|
whileElementsMounted: autoUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = useClick(context);
|
|
||||||
const dismiss = useDismiss(context);
|
const dismiss = useDismiss(context);
|
||||||
const role = useRole(context);
|
const role = useRole(context);
|
||||||
|
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, role]);
|
||||||
|
|
||||||
const headingId = useId();
|
const headingId = useId();
|
||||||
const hasDeleteStateChanged = (deleted ?? false) !== (publishCommentEditOptions.deleted ?? false);
|
const hasDeleteStateChanged = (deleted ?? false) !== (publishCommentEditOptions.deleted ?? false);
|
||||||
|
|
||||||
const _publishCommentEdit = async () => {
|
const _publishCommentEdit = async () => {
|
||||||
const shouldPublishAuthorEdit = isAccountCommentAuthor || (canAttemptAuthorDelete && hasDeleteStateChanged);
|
const shouldPublishAuthorEdit = canAttemptAuthorDelete && hasDeleteStateChanged;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (shouldPublishAuthorEdit && isAccountMod) {
|
if (shouldPublishAuthorEdit && isAccountMod) {
|
||||||
@@ -327,35 +316,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
</div>
|
</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 && (
|
{isAccountMod && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.menuItem}>
|
<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 { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
|
||||||
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
||||||
import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store';
|
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 { useComment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
|
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
|
||||||
import ExternalNumberQuoteLink from './external-number-quote-link';
|
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 { CommentMediaInfo, getHasThumbnail, getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink } from '../../lib/utils/media-utils';
|
||||||
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
|
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
|
||||||
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-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 { isValidURL, parseHttpUrl } from '../../lib/utils/url-utils';
|
||||||
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
|
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
|
||||||
@@ -44,13 +44,11 @@ import lowerCase from 'lodash/lowerCase';
|
|||||||
import { shouldShowSnow } from '../../lib/snow';
|
import { shouldShowSnow } from '../../lib/snow';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||||
import useChallengesStore from '../../stores/use-challenges-store';
|
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
|
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
|
||||||
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
|
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
|
||||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
|
||||||
import { usePublishCommentModeration } from '@bitsocial/bitsocial-react-hooks';
|
|
||||||
import useQuotedByMap from '../../hooks/use-quoted-by-map';
|
import useQuotedByMap from '../../hooks/use-quoted-by-map';
|
||||||
import useProgressiveRender from '../../hooks/use-progressive-render';
|
import useProgressiveRender from '../../hooks/use-progressive-render';
|
||||||
import useFreshReplies from '../../hooks/use-fresh-replies';
|
import useFreshReplies from '../../hooks/use-fresh-replies';
|
||||||
@@ -105,78 +103,12 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
publishCommentModeration: approvePending,
|
handleApprove: handlePendingApprove,
|
||||||
state: approvePendingState,
|
handleReject: handlePendingReject,
|
||||||
error: approvePendingError,
|
isPublishing: isPublishingPending,
|
||||||
} = usePublishCommentModeration({
|
status: pendingStatus,
|
||||||
commentCid: cid,
|
errorMessage: pendingErrorMessage,
|
||||||
communityAddress,
|
} = usePendingCommentModerationActions({ comment: post, 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.modQueueActions}>
|
<span className={styles.modQueueActions}>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { TFunction } from 'i18next';
|
|||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
|
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
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 { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
|
||||||
import {
|
import {
|
||||||
getExpiringMediaLinkAlert,
|
getExpiringMediaLinkAlert,
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
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 getShortAddress from '../../lib/get-short-address';
|
||||||
import styles from '../../views/post/post.module.css';
|
import styles from '../../views/post/post.module.css';
|
||||||
import { shouldShowSnow } from '../../lib/snow';
|
import { shouldShowSnow } from '../../lib/snow';
|
||||||
import { getHasThumbnail } from '../../lib/utils/media-utils';
|
import { getHasThumbnail } from '../../lib/utils/media-utils';
|
||||||
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
|
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
|
||||||
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-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 { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
|
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
|
||||||
import useModQueueStore from '../../stores/use-mod-queue-store';
|
import useModQueueStore from '../../stores/use-mod-queue-store';
|
||||||
@@ -39,12 +39,11 @@ import capitalize from 'lodash/capitalize';
|
|||||||
import lowerCase from 'lodash/lowerCase';
|
import lowerCase from 'lodash/lowerCase';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||||
import useChallengesStore from '../../stores/use-challenges-store';
|
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
|
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
|
||||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
|
||||||
import useQuotedByMap from '../../hooks/use-quoted-by-map';
|
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 useProgressiveRender from '../../hooks/use-progressive-render';
|
||||||
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
|
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
|
||||||
import useFreshReplies from '../../hooks/use-fresh-replies';
|
import useFreshReplies from '../../hooks/use-fresh-replies';
|
||||||
@@ -128,86 +127,18 @@ const PostInfoAndMedia = ({
|
|||||||
|
|
||||||
// Moderation actions for pending approval posts
|
// Moderation actions for pending approval posts
|
||||||
const {
|
const {
|
||||||
publishCommentModeration: approvePending,
|
handleApprove: handlePendingApprove,
|
||||||
state: approvePendingState,
|
handleReject: handlePendingReject,
|
||||||
error: approvePendingError,
|
isPublishing: isPublishingPending,
|
||||||
} = usePublishCommentModeration({
|
status: pendingStatus,
|
||||||
|
errorMessage: pendingErrorMessage,
|
||||||
|
} = usePendingCommentModerationActions({
|
||||||
|
comment: resolvedPost,
|
||||||
commentCid: cid,
|
commentCid: cid,
|
||||||
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
|
communityAddress,
|
||||||
commentModeration: approvePendingCommentModeration,
|
enabled: !!shouldShowPendingApprovalButtons,
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
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 { accountsStore as useAccountsStore } from '../lib/bitsocial-internals/stores';
|
||||||
import { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js';
|
import { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '../lib/bitsocial-internals/utils';
|
||||||
|
|
||||||
type AccountWithCommunities = {
|
type AccountWithCommunities = {
|
||||||
communities?: Record<string, unknown>;
|
communities?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Comment } from '@bitsocial/bitsocial-react-hooks';
|
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) => {
|
const useCountLinksInReplies = (comment: Comment | undefined, firstXReplies?: number) => {
|
||||||
let linkCount = 0;
|
let linkCount = 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 gifFrameDb = localForageLru.createInstance({ name: '5chanGifFrames', size: 500 });
|
||||||
const failedUrls = new Set<string>();
|
const failedUrls = new Set<string>();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useAccount, useBlock } from '@bitsocial/bitsocial-react-hooks';
|
import { useAccount, useBlock } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import type { Comment } 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';
|
import useHiddenCatalogThreadsStore from '../stores/use-hidden-catalog-threads-store';
|
||||||
|
|
||||||
export type HiddenCidLookup = { [cid: string]: boolean | undefined };
|
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 { useMemo } from 'react';
|
||||||
import type { Community } from '@bitsocial/bitsocial-react-hooks';
|
import type { Community } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
|
import { accountsStore as useAccountsStore, communitiesStore as useCommunitiesStore } from '../lib/bitsocial-internals/stores';
|
||||||
import useCommunitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
|
|
||||||
import { useDirectories } from './use-directories';
|
import { useDirectories } from './use-directories';
|
||||||
import { areStringArraysEqual, useAccountCommunityAddresses } from './use-account-community-addresses';
|
import { areStringArraysEqual, useAccountCommunityAddresses } from './use-account-community-addresses';
|
||||||
import { getModeratedCommunityAddresses } from '../lib/utils/mod-queue-utils';
|
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 { useMemo } from 'react';
|
||||||
import { useFeed } from '@bitsocial/bitsocial-react-hooks';
|
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 { useDirectoryByAddress } from './use-directories';
|
||||||
import { useBoardFeedPageSize } from './use-board-feed-page-size';
|
import { useBoardFeedPageSize } from './use-board-feed-page-size';
|
||||||
import { useCommunityIdentifier } from './use-community-identifiers';
|
import { useCommunityIdentifier } from './use-community-identifiers';
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { useAccount, type Comment } from '@bitsocial/bitsocial-react-hooks';
|
import { useAccount, type Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
|
import { accountsStore, communitiesStore, communitiesPagesStore } from '../lib/bitsocial-internals/stores';
|
||||||
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
|
|
||||||
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
|
||||||
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
|
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
|
||||||
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
|
||||||
import { getRawBoardThreadState } from '../lib/utils/raw-board-thread-state';
|
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 type { Community } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { normalizeBoardAddress } from './use-directories';
|
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',
|
sortType: 'active',
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
isFullyLoaded: true,
|
isFullyLoaded: false,
|
||||||
rootThreadCids: new Set<string>(),
|
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 = {
|
const community = {
|
||||||
posts: {
|
posts: {
|
||||||
pages: {
|
pages: {
|
||||||
@@ -51,6 +51,28 @@ describe('getRawBoardThreadState', () => {
|
|||||||
},
|
},
|
||||||
} as Community;
|
} 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(
|
expect(
|
||||||
getRawBoardThreadState({
|
getRawBoardThreadState({
|
||||||
accountId: undefined,
|
accountId: undefined,
|
||||||
@@ -61,6 +83,30 @@ describe('getRawBoardThreadState', () => {
|
|||||||
).toBe(true);
|
).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', () => {
|
it('treats explicit empty page CIDs as a fully loaded empty board', () => {
|
||||||
const community = {
|
const community = {
|
||||||
posts: {
|
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 type { Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import feedsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/feeds';
|
import { feedsStore, repliesStore, feedOptionsToFeedName, communitiesPagesStore } from '../bitsocial-internals/stores';
|
||||||
import repliesStore, { feedOptionsToFeedName } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies';
|
|
||||||
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
|
||||||
import type { DirectoryCommunity } from '../../hooks/use-directories';
|
import type { DirectoryCommunity } from '../../hooks/use-directories';
|
||||||
import usePostNumberStore from '../../stores/use-post-number-store';
|
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||||
import type { ExternalQuoteReference, ExternalQuoteSearchStatus } from './external-quote-utils';
|
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 { canEmbed, getYouTubeVideoId, youtubeHosts } from '../../components/embed/embed-utils';
|
||||||
import memoize from 'memoizee';
|
import memoize from 'memoizee';
|
||||||
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
|
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { isPendingApprovalRejected } from './pending-approval-moderation';
|
|||||||
import { areSameBoardAddress, getBoardPath } from './route-utils';
|
import { areSameBoardAddress, getBoardPath } from './route-utils';
|
||||||
import { getThreadTopNavigationState } from './thread-scroll-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 = {
|
type ModQueueCommentLike = {
|
||||||
approved?: boolean;
|
approved?: boolean;
|
||||||
author?: Comment['author'];
|
author?: Comment['author'];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
|
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';
|
import { getCommentCommunityAddress } from './comment-utils';
|
||||||
|
|
||||||
type CommunityLike = {
|
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 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 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) {
|
for (const page of preloadedPages) {
|
||||||
addRootThreadCids(rootThreadCids, page?.comments);
|
addRootThreadCids(rootThreadCids, page?.comments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasCompletePreloadedPage = hasCompletePreloadedChain && (rootThreadCids.size > 0 || hasFetchedCommunityUpdate);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasExplicitEmptyPageCids,
|
hasExplicitEmptyPageCids,
|
||||||
isFullyLoaded: hasCompletePreloadedPage || hasExplicitEmptyPageCids,
|
isFullyLoaded: hasCompletePreloadedPage || hasExplicitEmptyPageCids,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Comment, RepliesPages } from '@bitsocial/bitsocial-react-hooks';
|
import type { Comment, RepliesPages } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import repliesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages';
|
import { repliesPagesStore } from '../bitsocial-internals/stores';
|
||||||
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru';
|
import { localForageLru } from '../bitsocial-internals/utils';
|
||||||
|
|
||||||
const commentsDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-comments' });
|
const commentsDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-comments' });
|
||||||
const repliesPagesDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-repliesPages' });
|
const repliesPagesDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-repliesPages' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create, StoreApi } from 'zustand';
|
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 {
|
interface ThemeState {
|
||||||
themes: {
|
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>;
|
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
type TestComment = {
|
type TestComment = {
|
||||||
|
accountId?: string;
|
||||||
author?: {
|
author?: {
|
||||||
|
address?: string;
|
||||||
|
avatar?: unknown;
|
||||||
|
community?: unknown;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
flair?: unknown;
|
||||||
|
shortAddress?: string;
|
||||||
};
|
};
|
||||||
cid?: string;
|
cid?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
flairs?: Array<{ text?: string }>;
|
flairs?: Array<{ text?: string }>;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
lastReplyTimestamp?: number;
|
||||||
link?: string;
|
link?: string;
|
||||||
number?: number | string;
|
number?: number | string;
|
||||||
parentCid?: string;
|
parentCid?: string;
|
||||||
@@ -31,6 +38,19 @@ type TestComment = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
upvoteCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestAccount = {
|
||||||
|
author?: {
|
||||||
|
address?: string;
|
||||||
|
avatar?: unknown;
|
||||||
|
displayName?: string;
|
||||||
|
flair?: unknown;
|
||||||
|
shortAddress?: string;
|
||||||
|
};
|
||||||
|
id?: string;
|
||||||
|
subscriptions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestCommunity = {
|
type TestCommunity = {
|
||||||
@@ -47,7 +67,7 @@ type TestCommunity = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
account: { subscriptions: [] as string[] },
|
account: { subscriptions: [] as string[] } as TestAccount,
|
||||||
accountComments: [] as Array<TestComment | undefined>,
|
accountComments: [] as Array<TestComment | undefined>,
|
||||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||||
accountCommunityAddresses: [] as string[],
|
accountCommunityAddresses: [] as string[],
|
||||||
@@ -290,7 +310,16 @@ vi.mock('../../../components/footer/footer', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../post/post', () => ({
|
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', () => ({
|
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 = {
|
||||||
...testState.community,
|
...testState.community,
|
||||||
|
...(options.hasCommunityUpdate ? { updatedAt: 1781773422 } : {}),
|
||||||
posts: {
|
posts: {
|
||||||
|
...testState.community?.posts,
|
||||||
pages: {
|
pages: {
|
||||||
|
...testState.community?.posts?.pages,
|
||||||
active: {
|
active: {
|
||||||
comments,
|
comments,
|
||||||
},
|
},
|
||||||
@@ -542,7 +574,7 @@ describe('Board', () => {
|
|||||||
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
|
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
|
||||||
|
|
||||||
expect(document.title).toBe('/mu/ - 5chan');
|
expect(document.title).toBe('/mu/ - 5chan');
|
||||||
expect(testState.setResetFunctionMock).toHaveBeenCalledWith(testState.resetMock);
|
expect(testState.setResetFunctionMock).toHaveBeenCalledWith(expect.any(Function));
|
||||||
expect(testState.accountCommentsCalls).toContainEqual({
|
expect(testState.accountCommentsCalls).toContainEqual({
|
||||||
communityAddress: 'music-posting.eth',
|
communityAddress: 'music-posting.eth',
|
||||||
newerThan: 3600,
|
newerThan: 3600,
|
||||||
@@ -569,6 +601,279 @@ describe('Board', () => {
|
|||||||
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
|
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 () => {
|
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.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
|
||||||
testState.directoryByAddress = {
|
testState.directoryByAddress = {
|
||||||
@@ -643,7 +948,7 @@ describe('Board', () => {
|
|||||||
shortAddress: 'flash-posting.bso',
|
shortAddress: 'flash-posting.bso',
|
||||||
title: '/f/ - Flash',
|
title: '/f/ - Flash',
|
||||||
};
|
};
|
||||||
markRawBoardThreadsFullyLoaded();
|
markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true });
|
||||||
|
|
||||||
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
||||||
|
|
||||||
@@ -677,7 +982,7 @@ describe('Board', () => {
|
|||||||
title: '/f/ - Flash',
|
title: '/f/ - Flash',
|
||||||
};
|
};
|
||||||
testState.hasMore = true;
|
testState.hasMore = true;
|
||||||
markRawBoardThreadsFullyLoaded();
|
markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true });
|
||||||
|
|
||||||
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
||||||
|
|
||||||
@@ -699,8 +1004,9 @@ describe('Board', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
||||||
testState.feedState = 'fetching-ipns';
|
testState.feedState = 'succeeded';
|
||||||
testState.hasMore = true;
|
testState.feedStateString = undefined;
|
||||||
|
testState.hasMore = false;
|
||||||
testState.community = {
|
testState.community = {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
posts: {
|
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']);
|
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 () => {
|
it('redirects oversized board pages back to the last available page', async () => {
|
||||||
testState.feed = [
|
testState.feed = [
|
||||||
{ cid: 'first-post', communityAddress: 'music-posting.eth' },
|
{ cid: 'first-post', communityAddress: 'music-posting.eth' },
|
||||||
@@ -1073,7 +1401,7 @@ describe('Board', () => {
|
|||||||
state: 'succeeded',
|
state: 'succeeded',
|
||||||
title: '/mu/ - Music',
|
title: '/mu/ - Music',
|
||||||
};
|
};
|
||||||
markRawBoardThreadsFullyLoaded();
|
markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true });
|
||||||
|
|
||||||
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
|
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
|
||||||
|
|
||||||
@@ -1100,7 +1428,7 @@ describe('Board', () => {
|
|||||||
expect(container.textContent).toContain('load_more');
|
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.feedStateString = 'Downloading board from peers';
|
||||||
testState.feedState = 'fetching-ipns';
|
testState.feedState = 'fetching-ipns';
|
||||||
testState.hasMore = true;
|
testState.hasMore = true;
|
||||||
@@ -1122,6 +1450,33 @@ describe('Board', () => {
|
|||||||
|
|
||||||
await renderBoard({ initialEntry: '/blog.bitsocial.bso', routePath: '/:boardIdentifier/*' });
|
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.textContent).toContain('no_threads');
|
||||||
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
|
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
|
||||||
expect(container.textContent).not.toContain('load_more');
|
expect(container.textContent).not.toContain('load_more');
|
||||||
@@ -1146,6 +1501,24 @@ describe('Board', () => {
|
|||||||
expect(container.textContent).toContain('load_more');
|
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 () => {
|
it('does not show no threads after board metadata loads but raw thread pages are still missing', async () => {
|
||||||
testState.feedStateString = undefined;
|
testState.feedStateString = undefined;
|
||||||
testState.feedState = 'succeeded';
|
testState.feedState = 'succeeded';
|
||||||
|
|||||||
+148
-56
@@ -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 { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
|
||||||
import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocial/bitsocial-react-hooks';
|
import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
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 { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import styles from './board.module.css';
|
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 { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-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 { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
|
||||||
import { getRawBoardThreadState } from '../../lib/utils/raw-board-thread-state';
|
import { getRawBoardThreadState } from '../../lib/utils/raw-board-thread-state';
|
||||||
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
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. */
|
/** Board feed always uses 'active' sort; catalog dropdown does not affect board ordering. */
|
||||||
const BOARD_SORT_TYPE = 'active' as const;
|
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 {
|
interface BoardFooterProps {
|
||||||
communityAddresses: string[];
|
communityAddresses: string[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
feedState: string | undefined;
|
feedState: string | undefined;
|
||||||
combinedFeedLength: number;
|
combinedFeedLength: number;
|
||||||
isSingleCommunityBoard: boolean;
|
isSingleCommunityBoard: boolean;
|
||||||
isRawBoardThreadStateFullyLoaded: boolean;
|
|
||||||
isKnownEmptySingleCommunityBoard: boolean;
|
isKnownEmptySingleCommunityBoard: boolean;
|
||||||
isInSubscriptionsView: boolean;
|
isInSubscriptionsView: boolean;
|
||||||
isInModView: boolean;
|
isInModView: boolean;
|
||||||
|
isManualRefreshPending: boolean;
|
||||||
currentTimeFilterName: string;
|
currentTimeFilterName: string;
|
||||||
moreThreadsSuggestion: TimeFilterSuggestion | null;
|
moreThreadsSuggestion: TimeFilterSuggestion | null;
|
||||||
moreThreadsSuggestionPathname: string | null;
|
moreThreadsSuggestionPathname: string | null;
|
||||||
@@ -84,10 +121,10 @@ const BoardFooter = ({
|
|||||||
feedState,
|
feedState,
|
||||||
combinedFeedLength,
|
combinedFeedLength,
|
||||||
isSingleCommunityBoard,
|
isSingleCommunityBoard,
|
||||||
isRawBoardThreadStateFullyLoaded,
|
|
||||||
isKnownEmptySingleCommunityBoard,
|
isKnownEmptySingleCommunityBoard,
|
||||||
isInSubscriptionsView,
|
isInSubscriptionsView,
|
||||||
isInModView,
|
isInModView,
|
||||||
|
isManualRefreshPending,
|
||||||
currentTimeFilterName,
|
currentTimeFilterName,
|
||||||
moreThreadsSuggestion,
|
moreThreadsSuggestion,
|
||||||
moreThreadsSuggestionPathname,
|
moreThreadsSuggestionPathname,
|
||||||
@@ -100,13 +137,13 @@ const BoardFooter = ({
|
|||||||
}: BoardFooterProps) => {
|
}: BoardFooterProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('downloading_board') : t('looking_for_more_posts'));
|
const feedStateString = useFeedStateString(communityAddresses);
|
||||||
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
|
const loadingStateString = isManualRefreshPending
|
||||||
|
? t('loading_feed')
|
||||||
|
: feedStateString || (combinedFeedLength === 0 ? t('downloading_board') : t('looking_for_more_posts'));
|
||||||
const isFeedSucceeded = feedState === 'succeeded';
|
const isFeedSucceeded = feedState === 'succeeded';
|
||||||
const isFeedFailed = feedState === 'failed';
|
const isFeedFailed = feedState === 'failed';
|
||||||
const canShowNoThreads =
|
const canShowNoThreads = !isManualRefreshPending && (isSingleCommunityBoard ? isKnownEmptySingleCommunityBoard : isFeedSucceeded && !hasMore);
|
||||||
isKnownEmptySingleCommunityBoard ||
|
|
||||||
(isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore);
|
|
||||||
const isEmptyFeedLoading = combinedFeedLength === 0 && !canShowNoThreads && (isSingleCommunityBoard ? communityState !== 'failed' : !isFeedFailed);
|
const isEmptyFeedLoading = combinedFeedLength === 0 && !canShowNoThreads && (isSingleCommunityBoard ? communityState !== 'failed' : !isFeedFailed);
|
||||||
const showFooterLoading = showLoadingEllipsis && (hasMore || isEmptyFeedLoading);
|
const showFooterLoading = showLoadingEllipsis && (hasMore || isEmptyFeedLoading);
|
||||||
|
|
||||||
@@ -334,15 +371,85 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
|
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
|
||||||
|
|
||||||
const resetTriggeredRef = useRef(false);
|
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);
|
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
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 feedCids = useMemo(() => new Set(feed.map((f) => f.cid)), [feed]);
|
||||||
const nonokoPendingAccountComment = useMemo(() => {
|
const nonokoPendingAccountComment = useMemo(() => {
|
||||||
const comment = nonokoPendingAccountComments.find(Boolean);
|
const comment = nonokoPendingAccountComments.find(Boolean);
|
||||||
@@ -375,21 +482,32 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
[recentAccountComments, communityAddress, feedCids, nowSeconds],
|
[recentAccountComments, communityAddress, feedCids, nowSeconds],
|
||||||
);
|
);
|
||||||
const localAccountComments = useMemo(() => {
|
const localAccountComments = useMemo(() => {
|
||||||
if (!nonokoPendingAccountComment) return filteredComments;
|
const comments = (() => {
|
||||||
if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments];
|
if (!nonokoPendingAccountComment) return filteredComments;
|
||||||
|
if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments];
|
||||||
|
|
||||||
return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)];
|
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
|
return comments.map((comment) => restoreActiveAccountAuthor(comment, account));
|
||||||
const combinedFeed = useMemo(() => {
|
}, [nonokoPendingAccountComment, filteredComments, account]);
|
||||||
const newFeed = [...feed];
|
|
||||||
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
|
const sortedFeed = useMemo(() => sortBoardActiveFeed(feed), [feed]);
|
||||||
if (localAccountComments.length > 0) {
|
const canShowRecentLocalAccountComments = !isSingleCommunityBoard || sortedFeed.length > 0 || isRawBoardThreadStateFullyLoaded;
|
||||||
newFeed.splice(lastPinnedIndex + 1, 0, ...localAccountComments);
|
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(
|
const cappedFeed = useMemo(
|
||||||
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
|
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
|
||||||
@@ -456,33 +574,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
}
|
}
|
||||||
}, [combinedFeed, registerComments]);
|
}, [combinedFeed, registerComments]);
|
||||||
|
|
||||||
// Use stable community fields to avoid rerenders from updatingState
|
const isKnownEmptySingleCommunityBoard = isSingleCommunityBoard && combinedFeed.length === 0 && isLoadedCommunityState && isRawBoardThreadStateEmpty && isFeedSucceeded;
|
||||||
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 effectiveHasMore = isKnownEmptySingleCommunityBoard ? false : hasMore;
|
const effectiveHasMore = isKnownEmptySingleCommunityBoard ? false : hasMore;
|
||||||
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle;
|
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}
|
feedState={feedState}
|
||||||
combinedFeedLength={combinedFeed.length}
|
combinedFeedLength={combinedFeed.length}
|
||||||
isSingleCommunityBoard={isSingleCommunityBoard}
|
isSingleCommunityBoard={isSingleCommunityBoard}
|
||||||
isRawBoardThreadStateFullyLoaded={isRawBoardThreadStateFullyLoaded}
|
|
||||||
isKnownEmptySingleCommunityBoard={isKnownEmptySingleCommunityBoard}
|
isKnownEmptySingleCommunityBoard={isKnownEmptySingleCommunityBoard}
|
||||||
isInSubscriptionsView={isInSubscriptionsView}
|
isInSubscriptionsView={isInSubscriptionsView}
|
||||||
isInModView={isInModView}
|
isInModView={isInModView}
|
||||||
|
isManualRefreshPending={isBoardRefreshPending}
|
||||||
currentTimeFilterName={currentTimeFilterName}
|
currentTimeFilterName={currentTimeFilterName}
|
||||||
moreThreadsSuggestion={moreThreadsSuggestion}
|
moreThreadsSuggestion={moreThreadsSuggestion}
|
||||||
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
|
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' })}>
|
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
|
||||||
{t('top')}
|
{t('top')}
|
||||||
</button>
|
</button>
|
||||||
<button type='button' className='button' onClick={() => reset && reset()}>
|
<button type='button' className='button' onClick={refreshBoardFeed}>
|
||||||
{t('refresh')}
|
{t('refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,12 +673,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
communityAddresses,
|
communityAddresses,
|
||||||
effectiveHasMore,
|
effectiveHasMore,
|
||||||
combinedFeed.length,
|
combinedFeed.length,
|
||||||
isRawBoardThreadStateFullyLoaded,
|
|
||||||
isKnownEmptySingleCommunityBoard,
|
isKnownEmptySingleCommunityBoard,
|
||||||
isSingleCommunityBoard,
|
isSingleCommunityBoard,
|
||||||
isInAllView,
|
isInAllView,
|
||||||
isInSubscriptionsView,
|
isInSubscriptionsView,
|
||||||
isInModView,
|
isInModView,
|
||||||
|
isBoardRefreshPending,
|
||||||
currentTimeFilterName,
|
currentTimeFilterName,
|
||||||
moreThreadsSuggestion,
|
moreThreadsSuggestion,
|
||||||
moreThreadsSuggestionPathname,
|
moreThreadsSuggestionPathname,
|
||||||
@@ -603,7 +695,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
setEnableInfiniteScroll,
|
setEnableInfiniteScroll,
|
||||||
reset,
|
refreshBoardFeed,
|
||||||
routerLocation.search,
|
routerLocation.search,
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
@@ -680,7 +772,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
communityIdentifier.publicKey.length > 0 &&
|
communityIdentifier.publicKey.length > 0 &&
|
||||||
communityData?.nameResolved === false;
|
communityData?.nameResolved === false;
|
||||||
const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed;
|
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';
|
const shouldShowFlashTableLoading = shouldUseFlashTable && displayFeed.length === 0 && !canShowEmptyFlashTable && communityState !== 'failed' && feedState !== 'failed';
|
||||||
|
|
||||||
return (
|
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 {
|
.modQueueButtonCount {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useCommunities } from '@bitsocial/bitsocial-react-hooks';
|
import { useFeed, Comment, useEditedComment, useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
|
|
||||||
import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react';
|
import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react';
|
||||||
import { Virtuoso, type Components } from 'react-virtuoso';
|
import { Virtuoso, type Components } from 'react-virtuoso';
|
||||||
import styles from './mod-queue.module.css';
|
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 getShortAddress from '../../lib/get-short-address';
|
||||||
import { BOARD_CODE_GROUPS } from '../../constants/board-codes';
|
import { BOARD_CODE_GROUPS } from '../../constants/board-codes';
|
||||||
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
|
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
|
||||||
import {
|
import { isPendingApprovalAwaiting, isPendingApprovalRejected } from '../../lib/utils/pending-approval-moderation';
|
||||||
approvePendingCommentModeration,
|
|
||||||
isPendingApprovalAwaiting,
|
|
||||||
isPendingApprovalRejected,
|
|
||||||
rejectPendingCommentModeration,
|
|
||||||
} from '../../lib/utils/pending-approval-moderation';
|
|
||||||
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
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 { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||||
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
|
import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
|
||||||
import {
|
import {
|
||||||
filterVisibleModQueueFeed,
|
filterVisibleModQueueFeed,
|
||||||
|
getAddressListFromKey,
|
||||||
|
getAddressListKey,
|
||||||
getModQueueBoardFilterGroups,
|
getModQueueBoardFilterGroups,
|
||||||
getModQueueBoardFilterKey,
|
getModQueueBoardFilterKey,
|
||||||
getModQueueCommentRoute,
|
getModQueueCommentRoute,
|
||||||
@@ -44,7 +38,8 @@ import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use
|
|||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { useCurrentTime } from '../../hooks/use-current-time';
|
import { useCurrentTime } from '../../hooks/use-current-time';
|
||||||
import { Post } from '../post/post';
|
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 capitalize from 'lodash/capitalize';
|
||||||
import lowerCase from 'lodash/lowerCase';
|
import lowerCase from 'lodash/lowerCase';
|
||||||
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer';
|
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer';
|
||||||
@@ -58,78 +53,14 @@ const getBoardDisplayPath = (address: string, path: string): string => {
|
|||||||
return getShortAddress(address) || address;
|
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 {
|
interface ModQueueViewProps {
|
||||||
boardIdentifier?: string; // If provided, shows queue for single board
|
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 EMPTY_COMMENTS: Comment[] = [];
|
||||||
const MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 };
|
const MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 };
|
||||||
const NOOP_LOAD_MORE = () => undefined;
|
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 {
|
interface ModQueueFooterProps {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loadingStateString: string;
|
loadingStateString: string;
|
||||||
@@ -213,9 +144,6 @@ interface ModQueueRowProps {
|
|||||||
boardDisplayPath: string | undefined;
|
boardDisplayPath: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which action was initiated to show appropriate completion message
|
|
||||||
type ModerationAction = 'approve' | 'reject' | null;
|
|
||||||
|
|
||||||
interface ModQueueActionState {
|
interface ModQueueActionState {
|
||||||
status: 'approved' | 'rejected' | 'failed' | null;
|
status: 'approved' | 'rejected' | 'failed' | null;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
@@ -433,89 +361,38 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cid, approved, removed, pendingApproval } = comment || {};
|
const { cid, approved, removed, pendingApproval } = comment || {};
|
||||||
const communityAddress = getCommentCommunityAddress(comment);
|
const communityAddress = getCommentCommunityAddress(comment);
|
||||||
const dismissCommentFromQueue = useModQueueStore((state) => state.dismissCommentFromQueue);
|
const dismissCommentFromQueue = useModQueueStore((state) => state.dismissCommentFromQueue);
|
||||||
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
|
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
|
||||||
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
|
|
||||||
|
|
||||||
const alreadyApproved = approved === true;
|
const alreadyApproved = approved === true;
|
||||||
const alreadyRejected = isPendingApprovalRejected({ approved, removed, pendingApproval });
|
const alreadyRejected = isPendingApprovalRejected({ approved, removed, pendingApproval });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
publishCommentModeration: approve,
|
status: actionStatus,
|
||||||
state: approveState,
|
error,
|
||||||
error: approveError,
|
errorMessage,
|
||||||
} = usePublishCommentModeration({
|
isPublishing,
|
||||||
|
handleApprove,
|
||||||
|
handleReject,
|
||||||
|
} = usePendingCommentModerationActions({
|
||||||
|
comment,
|
||||||
commentCid: cid,
|
commentCid: cid,
|
||||||
communityAddress,
|
communityAddress,
|
||||||
commentModeration: approvePendingCommentModeration,
|
onApproveSuccess: () => {
|
||||||
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();
|
|
||||||
const approvedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: true, pendingApproval: false });
|
const approvedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: true, pendingApproval: false });
|
||||||
if (approvedSnapshot) {
|
if (approvedSnapshot) {
|
||||||
rememberCommentsInQueue([approvedSnapshot]);
|
rememberCommentsInQueue([approvedSnapshot]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
console.error(e);
|
onRejectSuccess: () => {
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReject = async () => {
|
|
||||||
const confirm = window.confirm(t('double_confirm'));
|
|
||||||
if (!confirm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitiatedAction('reject');
|
|
||||||
try {
|
|
||||||
await reject();
|
|
||||||
const rejectedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: false, pendingApproval: false });
|
const rejectedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: false, pendingApproval: false });
|
||||||
if (rejectedSnapshot) {
|
if (rejectedSnapshot) {
|
||||||
rememberCommentsInQueue([rejectedSnapshot]);
|
rememberCommentsInQueue([rejectedSnapshot]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
console.error(e);
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleRemove = () => {
|
||||||
if (cid) {
|
if (cid) {
|
||||||
@@ -523,19 +400,9 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isApproving = initiatedAction === 'approve' && approveState !== 'initializing' && approveState !== 'succeeded' && approveState !== 'failed';
|
// Mod queue rows also reflect a comment that was already moderated before this
|
||||||
const isRejecting = initiatedAction === 'reject' && rejectState !== 'initializing' && rejectState !== 'succeeded' && rejectState !== 'failed';
|
// session, so combine that baseline with the live action-derived status.
|
||||||
const isPublishing = isApproving || isRejecting;
|
const status = alreadyApproved || actionStatus === 'approved' ? 'approved' : alreadyRejected || actionStatus === 'rejected' ? 'rejected' : actionStatus;
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove: status ? handleRemove : undefined };
|
return { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove: status ? handleRemove : undefined };
|
||||||
};
|
};
|
||||||
@@ -1062,142 +929,6 @@ const ModQueueContent = memo(
|
|||||||
);
|
);
|
||||||
ModQueueContent.displayName = 'ModQueueContent';
|
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 ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [selectedBoardFilter, setSelectedBoardFilter] = useState<string | null>(null);
|
const [selectedBoardFilter, setSelectedBoardFilter] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
roles: {
|
roles: {
|
||||||
'0xmod': { role: 'admin' },
|
'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 } }>,
|
useCommentCalls: [] as Array<{ commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }>,
|
||||||
evictThreadRefreshCachesMock: vi.fn(),
|
evictThreadRefreshCachesMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -116,7 +116,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages', () =>
|
|||||||
vi.mock('../../../hooks/use-stable-community', () => ({
|
vi.mock('../../../hooks/use-stable-community', () => ({
|
||||||
useCommunityField: (address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => {
|
useCommunityField: (address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => {
|
||||||
testState.communityFieldAddress = address;
|
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-number': post?.number === undefined ? '' : String(post.number),
|
||||||
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
|
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
|
||||||
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
|
'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-thread-container-cid': post?.cid }),
|
||||||
createElement('div', { 'data-post-info-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-number': post?.number === undefined ? '' : String(post.number),
|
||||||
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
|
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
|
||||||
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
|
'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-thread-container-cid': post?.cid }),
|
||||||
createElement('div', { 'data-post-info-cid': post?.cid }),
|
createElement('div', { 'data-post-info-cid': post?.cid }),
|
||||||
@@ -370,6 +372,44 @@ describe('Post', () => {
|
|||||||
expect(testState.communityFieldAddress).toBe('music-posting.eth');
|
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 () => {
|
it('rerenders posts when pending approval turns into an approved numbered post', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
|
|||||||
+9
-45
@@ -1,17 +1,7 @@
|
|||||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { type Comment, type CommunityIdentifier, type Role, useAccount, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocial/bitsocial-react-hooks';
|
||||||
type Account,
|
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
|
||||||
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 { useCommunityField } from '../../hooks/use-stable-community';
|
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { isAllView } from '../../lib/utils/view-utils';
|
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 { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
|
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-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 useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import ErrorDisplay from '../../components/error-display/error-display';
|
import ErrorDisplay from '../../components/error-display/error-display';
|
||||||
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer/footer';
|
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 type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||||
import styles from './post.module.css';
|
import styles from './post.module.css';
|
||||||
|
|
||||||
|
const EMPTY_ROLE_MAP = {};
|
||||||
|
|
||||||
export type CommentWithRefresh = Comment & {
|
export type CommentWithRefresh = Comment & {
|
||||||
approved?: boolean;
|
approved?: boolean;
|
||||||
communityAddress?: string;
|
communityAddress?: string;
|
||||||
@@ -47,19 +40,6 @@ export type CommentWithRefresh = Comment & {
|
|||||||
removed?: boolean;
|
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 => {
|
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
||||||
if (!state || typeof state !== 'object') return undefined;
|
if (!state || typeof state !== 'object') return undefined;
|
||||||
if ('queuedComment' in state || 'scrollThreadContainerCid' in state) {
|
if ('queuedComment' in state || 'scrollThreadContainerCid' in state) {
|
||||||
@@ -144,25 +124,6 @@ const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accou
|
|||||||
return mergeLocalCommentAuthor(mergedComment, accountComment);
|
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.
|
// 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
|
// 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.
|
// local account comment so author controls keep working after publish navigation.
|
||||||
@@ -249,7 +210,10 @@ export const Post = memo(
|
|||||||
}: PostProps) => {
|
}: PostProps) => {
|
||||||
// Only subscribe to roles field to avoid rerenders from updatingState changes
|
// Only subscribe to roles field to avoid rerenders from updatingState changes
|
||||||
const communityAddress = getCommentCommunityAddress(post);
|
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();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
let comment = post;
|
let comment = post;
|
||||||
|
|||||||
Reference in New Issue
Block a user