mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf(mod queue): stabilize empty loading state
Keep the mod queue empty state visible while background P2P loading continues, reduce footer-driven rerenders, and preserve footer errors when retained queue history rows are rendered.
This commit is contained in:
@@ -9,6 +9,7 @@ import ModQueueView from '../mod-queue';
|
|||||||
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
|
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
type TestComment = {
|
type TestComment = {
|
||||||
|
approved?: boolean;
|
||||||
cid: string;
|
cid: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
communityAddress?: string;
|
communityAddress?: string;
|
||||||
@@ -21,6 +22,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
account: { author: { address: '0x123' }, id: 'account' },
|
account: { author: { address: '0x123' }, id: 'account' },
|
||||||
accountCommunityAddresses: ['music-posting.eth'],
|
accountCommunityAddresses: ['music-posting.eth'],
|
||||||
addChallengeMock: vi.fn(),
|
addChallengeMock: vi.fn(),
|
||||||
|
communityError: null as Error | null,
|
||||||
directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }],
|
directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }],
|
||||||
dismissedCommentCids: [] as string[],
|
dismissedCommentCids: [] as string[],
|
||||||
feed: [] as TestComment[],
|
feed: [] as TestComment[],
|
||||||
@@ -60,6 +62,7 @@ vi.mock('react-i18next', () => ({
|
|||||||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||||
useAccount: () => testState.account,
|
useAccount: () => testState.account,
|
||||||
useCommunity: () => ({
|
useCommunity: () => ({
|
||||||
|
error: testState.communityError,
|
||||||
roles: {
|
roles: {
|
||||||
'0x123': { role: 'moderator' },
|
'0x123': { role: 'moderator' },
|
||||||
},
|
},
|
||||||
@@ -115,10 +118,12 @@ vi.mock('@floating-ui/react', () => ({
|
|||||||
vi.mock('react-virtuoso', () => ({
|
vi.mock('react-virtuoso', () => ({
|
||||||
Virtuoso: ({
|
Virtuoso: ({
|
||||||
components,
|
components,
|
||||||
|
context,
|
||||||
data = [],
|
data = [],
|
||||||
itemContent,
|
itemContent,
|
||||||
}: {
|
}: {
|
||||||
components?: { Footer?: React.ComponentType };
|
components?: { Footer?: React.ComponentType<{ context?: unknown }> };
|
||||||
|
context?: unknown;
|
||||||
data?: TestComment[];
|
data?: TestComment[];
|
||||||
itemContent: (index: number, item: TestComment) => React.ReactNode;
|
itemContent: (index: number, item: TestComment) => React.ReactNode;
|
||||||
}) =>
|
}) =>
|
||||||
@@ -126,7 +131,7 @@ vi.mock('react-virtuoso', () => ({
|
|||||||
'div',
|
'div',
|
||||||
{ 'data-testid': 'virtuoso' },
|
{ 'data-testid': 'virtuoso' },
|
||||||
data.map((item, index) => createElement(React.Fragment, { key: item.cid }, itemContent(index, item))),
|
data.map((item, index) => createElement(React.Fragment, { key: item.cid }, itemContent(index, item))),
|
||||||
components?.Footer ? createElement(components.Footer) : null,
|
components?.Footer ? createElement(components.Footer, { context }) : null,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -172,10 +177,6 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
|
|||||||
default: () => testState.isMobile,
|
default: () => testState.isMobile,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-state-string', () => ({
|
|
||||||
useFeedStateString: () => 'loading_mod_queue',
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../components/error-display/error-display', () => ({
|
vi.mock('../../../components/error-display/error-display', () => ({
|
||||||
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'error'),
|
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'error'),
|
||||||
}));
|
}));
|
||||||
@@ -258,6 +259,7 @@ describe('ModQueueView', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
testState.accountCommunityAddresses = ['music-posting.eth'];
|
testState.accountCommunityAddresses = ['music-posting.eth'];
|
||||||
|
testState.communityError = null;
|
||||||
testState.directories = [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }];
|
testState.directories = [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }];
|
||||||
testState.dismissedCommentCids = [];
|
testState.dismissedCommentCids = [];
|
||||||
testState.feed = [];
|
testState.feed = [];
|
||||||
@@ -278,14 +280,83 @@ describe('ModQueueView', () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the compact table hidden while an empty mod queue is still loading', async () => {
|
it('keeps the compact table visible with the empty state while an empty mod queue continues loading', async () => {
|
||||||
testState.hasMore = true;
|
testState.hasMore = true;
|
||||||
|
|
||||||
await renderModQueue();
|
await renderModQueue();
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_mod_queue');
|
const text = container.textContent ?? '';
|
||||||
expect(container.textContent).not.toContain('No.');
|
expect(text).toContain('No.');
|
||||||
expect(container.textContent).not.toContain('queue_is_empty');
|
expect(text).toContain('excerpt');
|
||||||
|
expect(text).toContain('queue_is_empty');
|
||||||
|
expect(text.indexOf('No.')).toBeLessThan(text.indexOf('queue_is_empty'));
|
||||||
|
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a loading footer for an empty all-boards mod queue', async () => {
|
||||||
|
testState.accountCommunityAddresses = ['music-posting.eth', 'tech-posting.eth'];
|
||||||
|
testState.directories = [
|
||||||
|
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
|
||||||
|
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
|
||||||
|
];
|
||||||
|
testState.hasMore = true;
|
||||||
|
|
||||||
|
await renderModQueue();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('queue_is_empty');
|
||||||
|
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the empty queue state quiet when background community metadata fails', async () => {
|
||||||
|
testState.communityError = new Error('community unavailable');
|
||||||
|
testState.hasMore = true;
|
||||||
|
|
||||||
|
await renderModQueue();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('queue_is_empty');
|
||||||
|
expect(container.querySelector('[data-testid="error-display"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a generic continuing load state after a queue item appears', async () => {
|
||||||
|
testState.hasMore = true;
|
||||||
|
testState.feed = [
|
||||||
|
{
|
||||||
|
cid: 'pending-reply',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
content: 'pending reply body',
|
||||||
|
pendingApproval: true,
|
||||||
|
timestamp: 90_000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderModQueue();
|
||||||
|
|
||||||
|
const loadingTexts = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]')).map((element) => element.textContent);
|
||||||
|
expect(container.textContent).toContain('pending reply body');
|
||||||
|
expect(loadingTexts).toContain('looking_for_more_posts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the footer error visible when local queue history is shown while the live feed is empty', async () => {
|
||||||
|
testState.communityError = new Error('community unavailable');
|
||||||
|
testState.hasMore = true;
|
||||||
|
testState.queuedCommentHistory = [
|
||||||
|
{
|
||||||
|
approved: true,
|
||||||
|
cid: 'approved-history',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
content: 'recently approved body',
|
||||||
|
pendingApproval: false,
|
||||||
|
timestamp: 90_000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderModQueue();
|
||||||
|
|
||||||
|
const loadingTexts = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]')).map((element) => element.textContent);
|
||||||
|
expect(container.textContent).toContain('recently approved body');
|
||||||
|
expect(container.querySelector('[data-testid="error-display"]')?.textContent).toBe('community unavailable');
|
||||||
|
expect(loadingTexts).toContain('looking_for_more_posts');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the compact table visible and renders the empty state under its header after loading', async () => {
|
it('keeps the compact table visible and renders the empty state under its header after loading', async () => {
|
||||||
|
|||||||
+320
-201
@@ -5,13 +5,12 @@ import { useParams, Link } from 'react-router-dom';
|
|||||||
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
|
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 } from 'react-virtuoso';
|
import { Virtuoso, type Components } from 'react-virtuoso';
|
||||||
import styles from './mod-queue.module.css';
|
import styles from './mod-queue.module.css';
|
||||||
import postStyles from '../post/post.module.css';
|
import postStyles from '../post/post.module.css';
|
||||||
import useModQueueStore from '../../stores/use-mod-queue-store';
|
import useModQueueStore from '../../stores/use-mod-queue-store';
|
||||||
import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis';
|
import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis';
|
||||||
import ErrorDisplay from '../../components/error-display/error-display';
|
import ErrorDisplay from '../../components/error-display/error-display';
|
||||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
|
||||||
import { getCommunityAddress, getBoardPath, areSameBoardAddress } from '../../lib/utils/route-utils';
|
import { getCommunityAddress, getBoardPath, areSameBoardAddress } from '../../lib/utils/route-utils';
|
||||||
import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
|
import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
import getShortAddress from '../../lib/get-short-address';
|
||||||
@@ -119,25 +118,85 @@ 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 MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 };
|
||||||
|
const NOOP_LOAD_MORE = () => undefined;
|
||||||
|
|
||||||
interface ModQueueFooterProps {
|
interface ModQueueFooterProps {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
communityAddresses: string[];
|
loadingStateString: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization)
|
// Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization)
|
||||||
// The useFeedStateString hook is called here instead of in ModQueueView to isolate re-renders
|
const ModQueueFooter = memo(({ hasMore, loadingStateString }: ModQueueFooterProps) => {
|
||||||
// caused by backend IPFS state changes to just this footer component
|
|
||||||
const ModQueueFooter = ({ hasMore, communityAddresses }: ModQueueFooterProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
|
|
||||||
|
|
||||||
return hasMore ? (
|
return hasMore ? (
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<LoadingEllipsis string={loadingStateString} />
|
<LoadingEllipsis string={loadingStateString} />
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
});
|
||||||
|
ModQueueFooter.displayName = 'ModQueueFooter';
|
||||||
|
|
||||||
|
const ModQueueContinuingFooter = memo(({ hasMore }: { hasMore: boolean }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return <ModQueueFooter hasMore={hasMore} loadingStateString={t('looking_for_more_posts')} />;
|
||||||
|
});
|
||||||
|
ModQueueContinuingFooter.displayName = 'ModQueueContinuingFooter';
|
||||||
|
|
||||||
|
interface ModQueueVirtuosoFooterContext {
|
||||||
|
error: Error | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModQueueVirtuosoFooter = memo(({ context }: { context?: ModQueueVirtuosoFooterContext }) => {
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{context.error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<ErrorDisplay error={context.error} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ModQueueContinuingFooter hasMore={context.hasMore} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ModQueueVirtuosoFooter.displayName = 'ModQueueVirtuosoFooter';
|
||||||
|
|
||||||
|
const MOD_QUEUE_VIRTUOSO_COMPONENTS: Components<Comment, ModQueueVirtuosoFooterContext> = {
|
||||||
|
Footer: ModQueueVirtuosoFooter,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ModQueuePageFooter = memo(() => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const reset = useFeedResetStore((state) => state.reset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageFooterDesktop firstRow={<StyleOnlyFooterFirstRow />} />
|
||||||
|
<PageFooterMobile>
|
||||||
|
<div>
|
||||||
|
<div className={footerStyles.mobileFooterButtons}>
|
||||||
|
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
|
||||||
|
{t('top')}
|
||||||
|
</button>
|
||||||
|
<button type='button' className='button' onClick={() => reset?.()}>
|
||||||
|
{t('refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageFooterMobile>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ModQueuePageFooter.displayName = 'ModQueuePageFooter';
|
||||||
|
|
||||||
interface ModQueueRowProps {
|
interface ModQueueRowProps {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
isOdd?: boolean;
|
isOdd?: boolean;
|
||||||
@@ -654,7 +713,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
|||||||
});
|
});
|
||||||
ModQueueCard.displayName = 'ModQueueCard';
|
ModQueueCard.displayName = 'ModQueueCard';
|
||||||
|
|
||||||
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
const ModQueueFeedPost = memo(({ comment }: { comment: Comment }) => {
|
||||||
const { editedComment } = useEditedComment({ comment });
|
const { editedComment } = useEditedComment({ comment });
|
||||||
const displayComment = editedComment || comment;
|
const displayComment = editedComment || comment;
|
||||||
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
|
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
|
||||||
@@ -673,7 +732,8 @@ const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
|||||||
onRemoveFromModQueue={handleRemove}
|
onRemoveFromModQueue={handleRemove}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
ModQueueFeedPost.displayName = 'ModQueueFeedPost';
|
||||||
|
|
||||||
interface ModQueueBoardSummaryProps {
|
interface ModQueueBoardSummaryProps {
|
||||||
feed: Comment[];
|
feed: Comment[];
|
||||||
@@ -704,7 +764,7 @@ const ModQueueBoardCount = ({ normal, urgent }: { normal: number; urgent: number
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses, selectedBoardFilter, setSelectedBoardFilter }: ModQueueBoardSummaryProps) => {
|
const ModQueueBoardSummary = memo(({ feed, directories, accountCommunityAddresses, selectedBoardFilter, setSelectedBoardFilter }: ModQueueBoardSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
|
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
|
||||||
const currentTime = useCurrentTime();
|
const currentTime = useCurrentTime();
|
||||||
@@ -795,7 +855,206 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses, se
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
ModQueueBoardSummary.displayName = 'ModQueueBoardSummary';
|
||||||
|
|
||||||
|
interface ModQueueContentProps {
|
||||||
|
accountCommunityAddresses: string[];
|
||||||
|
addressToPathMap: Map<string, string>;
|
||||||
|
boardSummaryFeed: Comment[];
|
||||||
|
compactCardItemContent: (index: number, comment: Comment) => React.ReactNode;
|
||||||
|
compactRowItemContent: (index: number, comment: Comment) => React.ReactNode;
|
||||||
|
communityError: Error | null | undefined;
|
||||||
|
directories: DirectoryCommunity[];
|
||||||
|
feedLength: number;
|
||||||
|
feedPostItemContent: (index: number, comment: Comment) => React.ReactNode;
|
||||||
|
filteredFeed: Comment[];
|
||||||
|
hasMore: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
isQueueEmpty: boolean;
|
||||||
|
loadMore: () => void;
|
||||||
|
resolvedAddress: string | undefined;
|
||||||
|
selectedBoardFilter: string | null;
|
||||||
|
setSelectedBoardFilter: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
showBoardColumn: boolean;
|
||||||
|
viewMode: 'compact' | 'feed';
|
||||||
|
virtuosoFooterContext: ModQueueVirtuosoFooterContext | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModQueueContent = memo(
|
||||||
|
({
|
||||||
|
accountCommunityAddresses,
|
||||||
|
addressToPathMap,
|
||||||
|
boardSummaryFeed,
|
||||||
|
compactCardItemContent,
|
||||||
|
compactRowItemContent,
|
||||||
|
communityError,
|
||||||
|
directories,
|
||||||
|
feedLength,
|
||||||
|
feedPostItemContent,
|
||||||
|
filteredFeed,
|
||||||
|
hasMore,
|
||||||
|
isMobile,
|
||||||
|
isQueueEmpty,
|
||||||
|
loadMore,
|
||||||
|
resolvedAddress,
|
||||||
|
selectedBoardFilter,
|
||||||
|
setSelectedBoardFilter,
|
||||||
|
showBoardColumn,
|
||||||
|
viewMode,
|
||||||
|
virtuosoFooterContext,
|
||||||
|
}: ModQueueContentProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{!resolvedAddress && (
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<div className={styles.controlsLeft}>
|
||||||
|
<ModQueueBoardSummary
|
||||||
|
feed={boardSummaryFeed}
|
||||||
|
directories={directories}
|
||||||
|
accountCommunityAddresses={accountCommunityAddresses}
|
||||||
|
selectedBoardFilter={selectedBoardFilter}
|
||||||
|
setSelectedBoardFilter={setSelectedBoardFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'compact' && !isMobile && (
|
||||||
|
<>
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<div className={styles.numberHeader}>No.</div>
|
||||||
|
{!resolvedAddress && <div className={styles.boardHeader}>{t('board')}</div>}
|
||||||
|
<div className={styles.excerptHeader}>{t('excerpt')}</div>
|
||||||
|
<div className={styles.timeHeader}>{t('submitted')}</div>
|
||||||
|
<div className={styles.typeHeader}>{t('type')}</div>
|
||||||
|
<div className={styles.imageHeader}>{t('image')}</div>
|
||||||
|
<div className={styles.actionsHeader}>{t('actions')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isQueueEmpty ? (
|
||||||
|
<div className={`${styles.empty} ${styles.emptyTableRow}`}>{t('queue_is_empty')}</div>
|
||||||
|
) : hasMore ? (
|
||||||
|
<Virtuoso
|
||||||
|
useWindowScroll
|
||||||
|
data={filteredFeed}
|
||||||
|
totalCount={filteredFeed.length}
|
||||||
|
endReached={loadMore}
|
||||||
|
increaseViewportBy={MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY}
|
||||||
|
itemContent={compactRowItemContent}
|
||||||
|
components={MOD_QUEUE_VIRTUOSO_COMPONENTS}
|
||||||
|
context={virtuosoFooterContext ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredFeed.map((comment, index) => {
|
||||||
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||||
|
const path =
|
||||||
|
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
||||||
|
return (
|
||||||
|
<ModQueueRow
|
||||||
|
key={comment.cid}
|
||||||
|
comment={comment}
|
||||||
|
isOdd={index % 2 === 0}
|
||||||
|
showBoard={showBoardColumn}
|
||||||
|
boardPath={path}
|
||||||
|
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{communityError?.message && feedLength === 0 && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<ErrorDisplay error={communityError} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ModQueueContinuingFooter hasMore={hasMore} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'compact' && isMobile && (
|
||||||
|
<>
|
||||||
|
{isQueueEmpty ? (
|
||||||
|
<div className={styles.empty}>{t('queue_is_empty')}</div>
|
||||||
|
) : hasMore ? (
|
||||||
|
<Virtuoso
|
||||||
|
useWindowScroll
|
||||||
|
data={filteredFeed}
|
||||||
|
totalCount={filteredFeed.length}
|
||||||
|
endReached={loadMore}
|
||||||
|
increaseViewportBy={MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY}
|
||||||
|
itemContent={compactCardItemContent}
|
||||||
|
components={MOD_QUEUE_VIRTUOSO_COMPONENTS}
|
||||||
|
context={virtuosoFooterContext ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredFeed.map((comment) => {
|
||||||
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||||
|
const path =
|
||||||
|
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
||||||
|
return (
|
||||||
|
<ModQueueCard
|
||||||
|
key={comment.cid}
|
||||||
|
comment={comment}
|
||||||
|
showBoard={showBoardColumn}
|
||||||
|
boardPath={path}
|
||||||
|
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{communityError?.message && feedLength === 0 && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<ErrorDisplay error={communityError} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ModQueueContinuingFooter hasMore={hasMore} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'feed' && (
|
||||||
|
<>
|
||||||
|
{isQueueEmpty ? (
|
||||||
|
<div className={styles.empty}>{t('queue_is_empty')}</div>
|
||||||
|
) : hasMore ? (
|
||||||
|
<Virtuoso
|
||||||
|
useWindowScroll
|
||||||
|
data={filteredFeed}
|
||||||
|
totalCount={filteredFeed.length}
|
||||||
|
endReached={loadMore}
|
||||||
|
increaseViewportBy={MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY}
|
||||||
|
itemContent={feedPostItemContent}
|
||||||
|
components={MOD_QUEUE_VIRTUOSO_COMPONENTS}
|
||||||
|
context={virtuosoFooterContext ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredFeed.map((comment) => (
|
||||||
|
<ModQueueFeedPost key={comment.cid} comment={comment} />
|
||||||
|
))}
|
||||||
|
{communityError?.message && feedLength === 0 && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<ErrorDisplay error={communityError} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ModQueueContinuingFooter hasMore={hasMore} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModQueuePageFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ModQueueContent.displayName = 'ModQueueContent';
|
||||||
|
|
||||||
interface ModQueueButtonProps {
|
interface ModQueueButtonProps {
|
||||||
boardIdentifier?: string;
|
boardIdentifier?: string;
|
||||||
@@ -862,7 +1121,9 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
|
|||||||
|
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const accountAddress = account?.author?.address;
|
const accountAddress = account?.author?.address;
|
||||||
const accountCommunityAddresses = useModeratedCommunityAddresses();
|
const rawAccountCommunityAddresses = useModeratedCommunityAddresses();
|
||||||
|
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
|
||||||
|
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
|
||||||
|
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
|
|
||||||
@@ -926,7 +1187,6 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [selectedBoardFilter, setSelectedBoardFilter] = useState<string | null>(null);
|
const [selectedBoardFilter, setSelectedBoardFilter] = useState<string | null>(null);
|
||||||
const viewMode = useModQueueStore((state) => state.viewMode);
|
const viewMode = useModQueueStore((state) => state.viewMode);
|
||||||
@@ -935,7 +1195,9 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
|||||||
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
|
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const accountCommunityAddresses = useModeratedCommunityAddresses();
|
const rawAccountCommunityAddresses = useModeratedCommunityAddresses();
|
||||||
|
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
|
||||||
|
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
|
||||||
|
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
|
|
||||||
@@ -948,10 +1210,11 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [boardIdentifier, directories]);
|
}, [boardIdentifier, directories]);
|
||||||
|
|
||||||
const communityAddresses = useMemo(() => {
|
const communityAddressesKey = resolvedAddress ?? accountCommunityAddressesKey;
|
||||||
if (resolvedAddress) return [resolvedAddress];
|
const communityAddresses = useMemo(
|
||||||
return accountCommunityAddresses;
|
() => (resolvedAddress ? [resolvedAddress] : getAddressListFromKey(communityAddressesKey)),
|
||||||
}, [resolvedAddress, accountCommunityAddresses]);
|
[resolvedAddress, communityAddressesKey],
|
||||||
|
);
|
||||||
const communities = useCommunityIdentifiers(communityAddresses);
|
const communities = useCommunityIdentifiers(communityAddresses);
|
||||||
|
|
||||||
const communityAddress = communityAddresses[0];
|
const communityAddress = communityAddresses[0];
|
||||||
@@ -997,6 +1260,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
|||||||
() => filterVisibleModQueueFeed(feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses),
|
() => filterVisibleModQueueFeed(feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses),
|
||||||
[feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses],
|
[feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses],
|
||||||
);
|
);
|
||||||
|
const hasVisibleComments = filteredFeed.length > 0;
|
||||||
|
|
||||||
const addressToPathMap = useMemo(() => {
|
const addressToPathMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -1040,198 +1304,53 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
|||||||
},
|
},
|
||||||
[addressToPathMap, showBoardColumn, directories],
|
[addressToPathMap, showBoardColumn, directories],
|
||||||
);
|
);
|
||||||
|
const feedPostItemContent = useCallback((_index: number, comment: Comment) => <ModQueueFeedPost key={comment.cid} comment={comment} />, []);
|
||||||
|
|
||||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResetFunction(reset);
|
setResetFunction(reset);
|
||||||
}, [reset, setResetFunction]);
|
}, [reset, setResetFunction]);
|
||||||
|
|
||||||
// Memoize footer components object to preserve identity across renders (Virtuoso optimization)
|
const footerError = feed.length === 0 && communityError?.message ? communityError : null;
|
||||||
// Note: useFeedStateString is called inside ModQueueFooter to isolate re-renders from backend state changes
|
const virtuosoFooterContext = useMemo(
|
||||||
const footerComponents = useMemo(
|
|
||||||
() => ({
|
() => ({
|
||||||
Footer: () => (
|
error: footerError,
|
||||||
<>
|
hasMore,
|
||||||
{communityError?.message && feed.length === 0 && (
|
|
||||||
<div className={styles.error}>
|
|
||||||
<ErrorDisplay error={communityError} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
[hasMore, communityAddresses, communityError, feed.length],
|
[footerError, hasMore],
|
||||||
);
|
);
|
||||||
|
const isQueueEmpty = !hasVisibleComments;
|
||||||
const pageFooter = (
|
const boardSummaryFeed = feed.length > 0 ? feed : EMPTY_COMMENTS;
|
||||||
<>
|
const visibleFilteredFeed = isQueueEmpty ? EMPTY_COMMENTS : filteredFeed;
|
||||||
<PageFooterDesktop firstRow={<StyleOnlyFooterFirstRow />} />
|
const visibleHasMore = isQueueEmpty ? false : hasMore;
|
||||||
<PageFooterMobile>
|
const visibleLoadMore = isQueueEmpty ? NOOP_LOAD_MORE : loadMore;
|
||||||
<div>
|
const visibleCommunityError = isQueueEmpty ? null : communityError;
|
||||||
<div className={footerStyles.mobileFooterButtons}>
|
const visibleFeedLength = isQueueEmpty ? 0 : feed.length;
|
||||||
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
|
const visibleVirtuosoFooterContext = isQueueEmpty ? null : virtuosoFooterContext;
|
||||||
{t('top')}
|
|
||||||
</button>
|
|
||||||
<button type='button' className='button' onClick={() => reset?.()}>
|
|
||||||
{t('refresh')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageFooterMobile>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const isInitialFeedLoading = filteredFeed.length === 0 && hasMore;
|
|
||||||
const isQueueEmpty = filteredFeed.length === 0 && !hasMore;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ModQueueContent
|
||||||
<div className={styles.container}>
|
accountCommunityAddresses={accountCommunityAddresses}
|
||||||
{!resolvedAddress && (
|
addressToPathMap={addressToPathMap}
|
||||||
<div className={styles.controls}>
|
boardSummaryFeed={boardSummaryFeed}
|
||||||
<div className={styles.controlsLeft}>
|
compactCardItemContent={compactCardItemContent}
|
||||||
<ModQueueBoardSummary
|
compactRowItemContent={compactRowItemContent}
|
||||||
feed={feed}
|
communityError={visibleCommunityError}
|
||||||
directories={directories}
|
directories={directories}
|
||||||
accountCommunityAddresses={accountCommunityAddresses}
|
feedLength={visibleFeedLength}
|
||||||
selectedBoardFilter={selectedBoardFilter}
|
feedPostItemContent={feedPostItemContent}
|
||||||
setSelectedBoardFilter={setSelectedBoardFilter}
|
filteredFeed={visibleFilteredFeed}
|
||||||
/>
|
hasMore={visibleHasMore}
|
||||||
</div>
|
isMobile={isMobile}
|
||||||
</div>
|
isQueueEmpty={isQueueEmpty}
|
||||||
)}
|
loadMore={visibleLoadMore}
|
||||||
|
resolvedAddress={resolvedAddress}
|
||||||
{isInitialFeedLoading ? (
|
selectedBoardFilter={selectedBoardFilter}
|
||||||
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
setSelectedBoardFilter={setSelectedBoardFilter}
|
||||||
) : (
|
showBoardColumn={showBoardColumn}
|
||||||
<>
|
viewMode={viewMode}
|
||||||
{viewMode === 'compact' && !isMobile && (
|
virtuosoFooterContext={visibleVirtuosoFooterContext}
|
||||||
<>
|
/>
|
||||||
<div className={styles.tableHeader}>
|
|
||||||
<div className={styles.numberHeader}>No.</div>
|
|
||||||
{!resolvedAddress && <div className={styles.boardHeader}>{t('board')}</div>}
|
|
||||||
<div className={styles.excerptHeader}>{t('excerpt')}</div>
|
|
||||||
<div className={styles.timeHeader}>{t('submitted')}</div>
|
|
||||||
<div className={styles.typeHeader}>{t('type')}</div>
|
|
||||||
<div className={styles.imageHeader}>{t('image')}</div>
|
|
||||||
<div className={styles.actionsHeader}>{t('actions')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isQueueEmpty ? (
|
|
||||||
<div className={`${styles.empty} ${styles.emptyTableRow}`}>{t('queue_is_empty')}</div>
|
|
||||||
) : hasMore ? (
|
|
||||||
<Virtuoso
|
|
||||||
useWindowScroll
|
|
||||||
data={filteredFeed}
|
|
||||||
totalCount={filteredFeed.length}
|
|
||||||
endReached={loadMore}
|
|
||||||
increaseViewportBy={{ bottom: 600, top: 600 }}
|
|
||||||
itemContent={compactRowItemContent}
|
|
||||||
components={footerComponents}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{filteredFeed.map((comment, index) => {
|
|
||||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
||||||
const path =
|
|
||||||
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
|
||||||
return (
|
|
||||||
<ModQueueRow
|
|
||||||
key={comment.cid}
|
|
||||||
comment={comment}
|
|
||||||
isOdd={index % 2 === 0}
|
|
||||||
showBoard={showBoardColumn}
|
|
||||||
boardPath={path}
|
|
||||||
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{communityError?.message && feed.length === 0 && (
|
|
||||||
<div className={styles.error}>
|
|
||||||
<ErrorDisplay error={communityError} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'compact' && isMobile && (
|
|
||||||
<>
|
|
||||||
{isQueueEmpty ? (
|
|
||||||
<div className={styles.empty}>{t('queue_is_empty')}</div>
|
|
||||||
) : hasMore ? (
|
|
||||||
<Virtuoso
|
|
||||||
useWindowScroll
|
|
||||||
data={filteredFeed}
|
|
||||||
totalCount={filteredFeed.length}
|
|
||||||
endReached={loadMore}
|
|
||||||
increaseViewportBy={{ bottom: 600, top: 600 }}
|
|
||||||
itemContent={compactCardItemContent}
|
|
||||||
components={footerComponents}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{filteredFeed.map((comment) => {
|
|
||||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
||||||
const path =
|
|
||||||
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
|
||||||
return (
|
|
||||||
<ModQueueCard
|
|
||||||
key={comment.cid}
|
|
||||||
comment={comment}
|
|
||||||
showBoard={showBoardColumn}
|
|
||||||
boardPath={path}
|
|
||||||
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{communityError?.message && feed.length === 0 && (
|
|
||||||
<div className={styles.error}>
|
|
||||||
<ErrorDisplay error={communityError} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'feed' && (
|
|
||||||
<>
|
|
||||||
{isQueueEmpty ? (
|
|
||||||
<div className={styles.empty}>{t('queue_is_empty')}</div>
|
|
||||||
) : hasMore ? (
|
|
||||||
<Virtuoso
|
|
||||||
useWindowScroll
|
|
||||||
data={filteredFeed}
|
|
||||||
totalCount={filteredFeed.length}
|
|
||||||
endReached={loadMore}
|
|
||||||
increaseViewportBy={{ bottom: 600, top: 600 }}
|
|
||||||
itemContent={(_index, comment) => <ModQueueFeedPost key={comment.cid} comment={comment} />}
|
|
||||||
components={footerComponents}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{filteredFeed.map((comment) => (
|
|
||||||
<ModQueueFeedPost key={comment.cid} comment={comment} />
|
|
||||||
))}
|
|
||||||
{communityError?.message && feed.length === 0 && (
|
|
||||||
<div className={styles.error}>
|
|
||||||
<ErrorDisplay error={communityError} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{pageFooter}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user