mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(feed): support strict community refs and safer domain publishing
This commit is contained in:
@@ -13,6 +13,7 @@ const testState = vi.hoisted(() => ({
|
||||
abandonPublishMock: vi.fn(async () => undefined),
|
||||
index: 12,
|
||||
lastPublishOptions: undefined as Record<string, any> | undefined,
|
||||
publishAuthorBlockedReason: undefined as 'resolving' | 'unresolved' | 'mismatch' | undefined,
|
||||
publishCommentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -27,6 +28,14 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../use-publish-author-domain-guard', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
blockedReason: testState.publishAuthorBlockedReason,
|
||||
}),
|
||||
getPublishAuthorDomainErrorMessage: (reason: string) => `blocked:${reason}`,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let latestValue: ReturnType<typeof usePublishPost>;
|
||||
let root: Root;
|
||||
@@ -47,6 +56,7 @@ describe('usePublishPost', () => {
|
||||
vi.clearAllMocks();
|
||||
testState.index = 12;
|
||||
testState.lastPublishOptions = undefined;
|
||||
testState.publishAuthorBlockedReason = undefined;
|
||||
useChallengesStore.setState({ challenges: [] });
|
||||
usePublishPostStore.getState().resetPublishPostStore();
|
||||
|
||||
@@ -73,7 +83,7 @@ describe('usePublishPost', () => {
|
||||
});
|
||||
|
||||
expect(latestValue.postIndex).toBe(12);
|
||||
expect(latestValue.publishPost).toBe(testState.publishCommentMock);
|
||||
expect(typeof latestValue.publishPost).toBe('function');
|
||||
expect(latestValue.publishPostOptions).toMatchObject({
|
||||
author: { displayName: 'Alice' },
|
||||
communityAddress: 'music.eth',
|
||||
@@ -116,4 +126,16 @@ describe('usePublishPost', () => {
|
||||
|
||||
expect(latestValue.publishPostOptions).toEqual({});
|
||||
});
|
||||
|
||||
it('blocks publish when the active account address is a domain that is not verified yet', async () => {
|
||||
testState.publishAuthorBlockedReason = 'unresolved';
|
||||
renderHook();
|
||||
|
||||
await act(async () => {
|
||||
latestValue.publishPost();
|
||||
});
|
||||
|
||||
expect(latestValue.publishPostError).toBe('blocked:unresolved');
|
||||
expect(testState.publishCommentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
|
||||
directories: [] as Array<Record<string, unknown>>,
|
||||
index: 7,
|
||||
lastPublishOptions: undefined as Record<string, any> | undefined,
|
||||
publishAuthorBlockedReason: undefined as 'resolving' | 'unresolved' | 'mismatch' | undefined,
|
||||
publishCommentMock: vi.fn(),
|
||||
resolveExternalQuoteTargetMock: vi.fn(),
|
||||
}));
|
||||
@@ -46,6 +47,14 @@ vi.mock('../../lib/utils/external-quote-resolver', () => ({
|
||||
resolveExternalQuoteTarget: (...args: any[]) => testState.resolveExternalQuoteTargetMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../use-publish-author-domain-guard', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
blockedReason: testState.publishAuthorBlockedReason,
|
||||
}),
|
||||
getPublishAuthorDomainErrorMessage: (reason: string) => `blocked:${reason}`,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let latestValue: ReturnType<typeof usePublishReply>;
|
||||
let root: Root;
|
||||
@@ -68,6 +77,7 @@ describe('usePublishReply', () => {
|
||||
testState.directories = [];
|
||||
testState.index = 7;
|
||||
testState.lastPublishOptions = undefined;
|
||||
testState.publishAuthorBlockedReason = undefined;
|
||||
useChallengesStore.setState({ challenges: [] });
|
||||
usePostNumberStore.setState({ cidToNumber: {}, numberToCid: { 'music.eth': { 12: 'quoted-cid' } } });
|
||||
usePublishReplyStore.setState({
|
||||
@@ -175,6 +185,18 @@ describe('usePublishReply', () => {
|
||||
expect(testState.publishCommentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks publish when the active account address is a domain that is not verified yet', async () => {
|
||||
testState.publishAuthorBlockedReason = 'unresolved';
|
||||
renderHook();
|
||||
|
||||
await act(async () => {
|
||||
await latestValue.publishReply();
|
||||
});
|
||||
|
||||
expect(latestValue.publishReplyError).toBe('blocked:unresolved');
|
||||
expect(testState.publishCommentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues reply challenges and clears the scoped reply store on reset', async () => {
|
||||
await act(async () => {
|
||||
latestValue.setPublishReplyOptions({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useCommunityStats } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useCommunityIdentifier } from './use-community-identifiers';
|
||||
|
||||
type CommunityStatsState = {
|
||||
communityStats: { [communityAddress: string]: any };
|
||||
@@ -16,7 +17,8 @@ export const useCommunitiesStatsStore = create<CommunityStatsState>((set) => ({
|
||||
}));
|
||||
|
||||
export const CommunityStatsCollector = ({ communityAddress }: { communityAddress: string }) => {
|
||||
const stats = useCommunityStats({ communityAddress });
|
||||
const community = useCommunityIdentifier(communityAddress);
|
||||
const stats = useCommunityStats(community ? { community } : undefined);
|
||||
const setCommunityStats = useCommunitiesStatsStore((state) => state.setCommunityStats);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { CommunityIdentifier } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { findDirectoryByAddress, type DirectoryCommunity, useDirectories } from './use-directories';
|
||||
|
||||
const isLikelyCommunityName = (value: string) => value.includes('.');
|
||||
|
||||
export const getCommunityIdentifier = (communityAddress: string | undefined, directories: DirectoryCommunity[]): CommunityIdentifier | undefined => {
|
||||
if (!communityAddress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const directory = findDirectoryByAddress(directories, communityAddress);
|
||||
if (directory?.name && directory.publicKey) {
|
||||
return {
|
||||
name: directory.name,
|
||||
publicKey: directory.publicKey,
|
||||
};
|
||||
}
|
||||
if (directory?.publicKey) {
|
||||
return {
|
||||
publicKey: directory.publicKey,
|
||||
};
|
||||
}
|
||||
if (directory?.name) {
|
||||
return {
|
||||
name: directory.name,
|
||||
};
|
||||
}
|
||||
|
||||
return isLikelyCommunityName(communityAddress)
|
||||
? {
|
||||
name: communityAddress,
|
||||
}
|
||||
: {
|
||||
publicKey: communityAddress,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCommunityIdentifiers = (communityAddresses: Array<string | undefined>, directories: DirectoryCommunity[]): CommunityIdentifier[] =>
|
||||
communityAddresses.flatMap((communityAddress) => {
|
||||
const community = getCommunityIdentifier(communityAddress, directories);
|
||||
return community ? [community] : [];
|
||||
});
|
||||
|
||||
export const useCommunityIdentifier = (communityAddress: string | undefined): CommunityIdentifier | undefined => {
|
||||
const directories = useDirectories();
|
||||
|
||||
return useMemo(() => getCommunityIdentifier(communityAddress, directories), [communityAddress, directories]);
|
||||
};
|
||||
|
||||
export const useCommunityIdentifiers = (communityAddresses?: Array<string | undefined>): CommunityIdentifier[] => {
|
||||
const directories = useDirectories();
|
||||
|
||||
return useMemo(() => getCommunityIdentifiers(communityAddresses ?? [], directories), [communityAddresses, directories]);
|
||||
};
|
||||
@@ -53,7 +53,6 @@ let cacheCommunities: DirectoryCommunity[] | null = null;
|
||||
let cacheMetadata: DirectoriesMetadata | null = null;
|
||||
let inFlightGitHubFetch: Promise<DirectoriesData> | null = null;
|
||||
const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const;
|
||||
const MULTIBOARD_PUBLIC_KEY_FALLBACK_NAMES = new Set(['business-and-finance.bso', 'politically-incorrect.bso']);
|
||||
|
||||
// Exposed for deterministic unit tests around module-level cache state.
|
||||
export const __resetDirectoriesModuleStateForTests = () => {
|
||||
@@ -97,14 +96,6 @@ const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
|
||||
...new Set([community.address, community.name, community.publicKey].filter((value): value is string => typeof value === 'string' && value.length > 0)),
|
||||
];
|
||||
|
||||
export const getDirectoryFetchAddress = (community: DirectoryCommunity): string => {
|
||||
if (community.name && MULTIBOARD_PUBLIC_KEY_FALLBACK_NAMES.has(community.name) && community.publicKey) {
|
||||
return community.publicKey;
|
||||
}
|
||||
|
||||
return community.address;
|
||||
};
|
||||
|
||||
const toCanonicalCommunity = (value: {
|
||||
address?: unknown;
|
||||
communityAddress?: unknown;
|
||||
@@ -473,7 +464,7 @@ export const useDirectoriesState = () => {
|
||||
|
||||
export const useDirectoryAddresses = () => {
|
||||
const directories = useDirectories();
|
||||
const directoryAddresses = useMemo(() => (Array.isArray(directories) ? directories.map((community) => getDirectoryFetchAddress(community)) : []), [directories]);
|
||||
const directoryAddresses = useMemo(() => (Array.isArray(directories) ? directories.map((community) => community.address) : []), [directories]);
|
||||
|
||||
return directoryAddresses;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { getDirectoryFetchAddress, useDirectories } from './use-directories';
|
||||
import { useDirectories } from './use-directories';
|
||||
import useAllFeedFilterStore from '../stores/use-all-feed-filter-store';
|
||||
|
||||
export const useFilteredDirectoryAddresses = () => {
|
||||
@@ -8,13 +8,13 @@ export const useFilteredDirectoryAddresses = () => {
|
||||
|
||||
const filteredAddresses = useMemo(() => {
|
||||
if (filter === 'all') {
|
||||
return directories.map((community) => getDirectoryFetchAddress(community));
|
||||
return directories.map((community) => community.address);
|
||||
}
|
||||
if (filter === 'nsfw') {
|
||||
return directories.filter((community) => community.nsfw === true).map((community) => getDirectoryFetchAddress(community));
|
||||
return directories.filter((community) => community.nsfw === true).map((community) => community.address);
|
||||
}
|
||||
// filter === 'sfw'
|
||||
return directories.filter((community) => community.nsfw !== true).map((community) => getDirectoryFetchAddress(community));
|
||||
return directories.filter((community) => community.nsfw !== true).map((community) => community.address);
|
||||
}, [directories, filter]);
|
||||
|
||||
return filteredAddresses;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useFeed } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import useFeedsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/feeds';
|
||||
import { useDirectoryByAddress } from './use-directories';
|
||||
import { useBoardFeedPageSize } from './use-board-feed-page-size';
|
||||
import { useCommunityIdentifier } from './use-community-identifiers';
|
||||
import { findPostPageInFeed, findPostPageInLoadedBoardFeeds, type FeedsOptionsLike, type LoadedFeedsLike } from '../lib/utils/post-page-resolution';
|
||||
|
||||
interface UsePostPageNumberOptions {
|
||||
@@ -29,6 +30,7 @@ export function usePostPageNumber({
|
||||
enabled = true,
|
||||
}: UsePostPageNumberOptions): number | undefined {
|
||||
const communityAddress = requestedCommunityAddress ?? legacyCommunityAddress;
|
||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||
|
||||
const community = useDirectoryByAddress(communityAddress);
|
||||
const { guiPostsPerPage, paginationFeedPostsPerPage } = useBoardFeedPageSize(community);
|
||||
@@ -52,12 +54,12 @@ export function usePostPageNumber({
|
||||
() =>
|
||||
canResolve
|
||||
? {
|
||||
communityAddresses: [communityAddress!],
|
||||
communities: communityIdentifier ? [communityIdentifier] : [],
|
||||
sortType: 'active' as const,
|
||||
postsPerPage: paginationFeedPostsPerPage,
|
||||
}
|
||||
: undefined,
|
||||
[canResolve, communityAddress, paginationFeedPostsPerPage],
|
||||
[canResolve, communityIdentifier, paginationFeedPostsPerPage],
|
||||
);
|
||||
|
||||
const { feed: preloadFeed } = useFeed(preloadOptions);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAccount, useResolvedAuthorAddress } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
|
||||
type PublishAuthorDomainBlockReason = 'resolving' | 'unresolved' | 'mismatch';
|
||||
|
||||
const isDomainAddress = (address: unknown): address is string => typeof address === 'string' && address.includes('.');
|
||||
|
||||
export const getPublishAuthorDomainErrorMessage = (reason: PublishAuthorDomainBlockReason) => {
|
||||
if (reason === 'mismatch') {
|
||||
return 'Your Bitsocial Account address belongs to another account.';
|
||||
}
|
||||
if (reason === 'resolving') {
|
||||
return 'Your Bitsocial Account address is still being verified. Try again in a moment.';
|
||||
}
|
||||
return 'Your Bitsocial Account address is not resolved yet.';
|
||||
};
|
||||
|
||||
const usePublishAuthorDomainGuard = () => {
|
||||
const account = useAccount();
|
||||
const authorAddress = account?.author?.address;
|
||||
const hasDomainAuthor = isDomainAddress(authorAddress);
|
||||
const { resolvedAddress, state } = useResolvedAuthorAddress({
|
||||
author: hasDomainAuthor ? account?.author : undefined,
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const blockedReason = useMemo<PublishAuthorDomainBlockReason | undefined>(() => {
|
||||
if (!hasDomainAuthor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (state === 'succeeded') {
|
||||
if (!resolvedAddress) {
|
||||
return 'unresolved';
|
||||
}
|
||||
return resolvedAddress === account?.signer?.address ? undefined : 'mismatch';
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
return 'unresolved';
|
||||
}
|
||||
|
||||
return 'resolving';
|
||||
}, [account?.signer?.address, hasDomainAuthor, resolvedAddress, state]);
|
||||
|
||||
return {
|
||||
account,
|
||||
blockedReason,
|
||||
hasDomainAuthor,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePublishAuthorDomainGuard;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Comment, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import usePublishPostStore from '../stores/use-publish-post-store';
|
||||
import useChallengesStore from '../stores/use-challenges-store';
|
||||
import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard';
|
||||
|
||||
type UsePublishPostOptions = {
|
||||
communityAddress?: string;
|
||||
@@ -23,6 +24,8 @@ const usePublishPost = ({ communityAddress: requestedCommunityAddress, subplebbi
|
||||
const resetPublishPostStore = usePublishPostStore((state) => state.resetPublishPostStore);
|
||||
const addChallenge = useChallengesStore((state) => state.addChallenge);
|
||||
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
|
||||
const [publishPostError, setPublishPostError] = useState<string | null>(null);
|
||||
const { blockedReason } = usePublishAuthorDomainGuard();
|
||||
const abandonCurrentPublish = useCallback(async () => {
|
||||
await abandonPublishRef.current?.();
|
||||
}, []);
|
||||
@@ -88,11 +91,26 @@ const usePublishPost = ({ communityAddress: requestedCommunityAddress, subplebbi
|
||||
const { index, publishComment, abandonPublish } = usePublishComment(publishOptionsWithAbandon);
|
||||
abandonPublishRef.current = abandonPublish;
|
||||
|
||||
useEffect(() => {
|
||||
setPublishPostError(null);
|
||||
}, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]);
|
||||
|
||||
const publishPost = useCallback(() => {
|
||||
if (blockedReason) {
|
||||
setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason));
|
||||
return;
|
||||
}
|
||||
|
||||
setPublishPostError(null);
|
||||
return publishComment();
|
||||
}, [blockedReason, publishComment]);
|
||||
|
||||
return {
|
||||
setPublishPostOptions,
|
||||
resetPublishPostOptions,
|
||||
postIndex: index,
|
||||
publishPost: publishComment,
|
||||
publishPost,
|
||||
publishPostError,
|
||||
publishPostOptions: publishCommentOptions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getQuotedCidsFromContent, mergeQuotedCids } from '../lib/utils/reply-qu
|
||||
import { extractUnresolvedExternalQuoteReferences, getExternalQuoteStatusMessage } from '../lib/utils/external-quote-utils';
|
||||
import { resolveExternalQuoteTarget } from '../lib/utils/external-quote-resolver';
|
||||
import useChallengesStore from '../stores/use-challenges-store';
|
||||
import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard';
|
||||
|
||||
type UsePublishReplyOptions = {
|
||||
cid: string;
|
||||
@@ -36,6 +37,7 @@ const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, sub
|
||||
const setPublishReplyStore = usePublishReplyStore((state) => state.setPublishReplyStore);
|
||||
const resetPublishReplyStore = usePublishReplyStore((state) => state.resetPublishReplyStore);
|
||||
const addChallenge = useChallengesStore((state) => state.addChallenge);
|
||||
const { blockedReason } = usePublishAuthorDomainGuard();
|
||||
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
|
||||
const startedPublishRequestIdRef = useRef(0);
|
||||
const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState<string[] | undefined>();
|
||||
@@ -143,7 +145,7 @@ const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, sub
|
||||
setPublishReplyError(null);
|
||||
setPublishReplyStateMessage(null);
|
||||
setIsResolvingExternalQuotes(false);
|
||||
}, [content, communityAddress]);
|
||||
}, [blockedReason, content, communityAddress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) {
|
||||
@@ -157,6 +159,12 @@ const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, sub
|
||||
const publishReply = useCallback(async () => {
|
||||
setPublishReplyError(null);
|
||||
|
||||
if (blockedReason) {
|
||||
setPublishReplyStateMessage(null);
|
||||
setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason));
|
||||
return;
|
||||
}
|
||||
|
||||
if (publishResolvableQuoteReferences.length === 0) {
|
||||
setResolvedExternalQuotedCids(undefined);
|
||||
setPublishReplyStateMessage(null);
|
||||
@@ -205,7 +213,7 @@ const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, sub
|
||||
} finally {
|
||||
setIsResolvingExternalQuotes(false);
|
||||
}
|
||||
}, [account, directories, publishResolvableQuoteReferences, t]);
|
||||
}, [account, blockedReason, directories, publishResolvableQuoteReferences, t]);
|
||||
|
||||
return {
|
||||
isResolvingExternalQuotes,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useClientsStates, useCommunity, useCommunitiesStates } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import debounce from 'lodash/debounce';
|
||||
import getShortAddress from '../lib/get-short-address';
|
||||
import { useCommunityIdentifiers } from './use-community-identifiers';
|
||||
|
||||
interface CommentOrCommunity {
|
||||
state?: string;
|
||||
@@ -110,13 +111,16 @@ const useStateString = (commentOrCommunity: CommentOrCommunity): string | undefi
|
||||
};
|
||||
|
||||
export const useFeedStateString = (communityAddresses?: string[]): string | undefined => {
|
||||
const communities = useCommunityIdentifiers(communityAddresses);
|
||||
|
||||
// single community feed state string
|
||||
const communityAddress = communityAddresses?.length === 1 ? communityAddresses[0] : undefined;
|
||||
const community = useCommunity(communityAddress ? { communityAddress } : undefined);
|
||||
const communityIdentifier = communityAddress ? communities[0] : undefined;
|
||||
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||
const singleCommunityFeedStateString = sanitizeSingleFeedLoadingState(useStateString(community));
|
||||
|
||||
// multiple community feed state string
|
||||
const { states } = useCommunitiesStates({ communityAddresses });
|
||||
const { states } = useCommunitiesStates({ communities });
|
||||
|
||||
const multipleCommunitiesFeedStateString = useMemo(() => {
|
||||
if (communityAddress) {
|
||||
|
||||
Reference in New Issue
Block a user