refactor: migrate 5chan to the community hooks API (#1073)

* refactor(community-api): migrate 5chan to community hooks

* fix(review): address PR feedback

* fix(review): preserve legacy board context fallbacks

* fix(review): address latest bot feedback

* fix(review): use communityAddress in edit menu privileges
This commit is contained in:
Tommaso Casaburi
2026-03-13 13:25:10 +08:00
committed by GitHub
parent 9dc4d96d27
commit d7703953fb
105 changed files with 2659 additions and 1491 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ const testState = vi.hoisted(() => ({
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useAccountSubplebbits: () => ({ accountSubplebbits: testState.accountSubplebbits }),
useAccountCommunities: () => ({ accountCommunities: testState.accountSubplebbits }),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/utils', () => ({
@@ -37,8 +37,8 @@ vi.mock('../use-directories', () => ({
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryLookup[address] : undefined),
}));
vi.mock('../use-stable-subplebbit', () => ({
useSubplebbitField: (_address: string | undefined, selector: (subplebbit: unknown) => unknown) => selector(testState.subplebbitSnapshot),
vi.mock('../use-stable-community', () => ({
useCommunityField: (_address: string | undefined, selector: (community: unknown) => unknown) => selector(testState.subplebbitSnapshot),
}));
let latestValue: unknown;
@@ -26,15 +26,15 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('../../stores/use-subplebbit-offline-store', () => ({
vi.mock('../../stores/use-community-offline-store', () => ({
default: () => ({
initializesubplebbitOfflineState: testState.initializeMock,
setSubplebbitOfflineState: testState.setOfflineStateMock,
subplebbitOfflineState: testState.subplebbitOfflineState,
initializeCommunityOfflineState: testState.initializeMock,
setCommunityOfflineState: testState.setOfflineStateMock,
communityOfflineState: testState.subplebbitOfflineState,
}),
}));
vi.mock('../../stores/use-subplebbits-loading-start-timestamps-store', () => ({
vi.mock('../../stores/use-communities-loading-start-timestamps-store', () => ({
default: (addresses?: string[]) => {
testState.requestedAddresses = addresses;
return testState.loadingTimestamps;
@@ -17,7 +17,7 @@ vi.mock('../use-current-time', () => ({
useCurrentTime: () => testState.currentTime,
}));
vi.mock('../../stores/use-subplebbits-loading-start-timestamps-store', () => ({
vi.mock('../../stores/use-communities-loading-start-timestamps-store', () => ({
default: (addresses?: string[]) => {
testState.requestedAddresses = addresses;
return testState.loadingTimestamps;
@@ -86,7 +86,7 @@ describe('usePostPageNumber', () => {
testState.feedsOptions = {
boardFeed: {
sortType: 'active',
subplebbitAddresses: ['music.eth'],
communityAddresses: ['music.eth'],
},
};
testState.loadedFeeds = {
@@ -97,7 +97,7 @@ describe('usePostPageNumber', () => {
expect(testState.preloadOptions).toEqual({
postsPerPage: 20,
sortType: 'active',
subplebbitAddresses: ['music.eth'],
communityAddresses: ['music.eth'],
});
});
@@ -108,7 +108,7 @@ describe('usePostPageNumber', () => {
expect(testState.preloadOptions).toEqual({
postsPerPage: 20,
sortType: 'active',
subplebbitAddresses: ['music.eth'],
communityAddresses: ['music.eth'],
});
});
@@ -8,13 +8,13 @@ import { useStableSubplebbit, useSubplebbitField } from '../use-stable-subplebbi
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
subplebbits: {} as Record<string, unknown>,
communities: {} as Record<string, unknown>,
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => unknown) =>
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities', () => ({
default: (selector: (state: { communities: typeof testState.communities }) => unknown) =>
selector({
subplebbits: testState.subplebbits,
communities: testState.communities,
}),
}));
@@ -40,7 +40,7 @@ describe('use-stable-subplebbit', () => {
beforeEach(() => {
latestValue = undefined;
renderCount = 0;
testState.subplebbits = {};
testState.communities = {};
container = document.createElement('div');
document.body.appendChild(container);
@@ -53,7 +53,7 @@ describe('use-stable-subplebbit', () => {
});
it('resolves alias board addresses when the store key uses a different suffix', () => {
testState.subplebbits = {
testState.communities = {
'international-sfw.bso': {
address: 'international-sfw.bso',
roles: {
@@ -74,7 +74,7 @@ describe('use-stable-subplebbit', () => {
});
it('prefers an exact key match when both exact and alias variants are present', () => {
testState.subplebbits = {
testState.communities = {
'business.eth': {
address: 'business.eth',
title: '/biz/ - Exact',
+13 -13
View File
@@ -9,23 +9,23 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
const testState = vi.hoisted(() => ({
clientsStates: {} as Record<string, string[]>,
subplebbit: undefined as
community: undefined as
| {
publishingState?: string;
state?: string;
updatingState?: string;
}
| undefined,
subplebbitsStates: {} as Record<string, { clientUrls: string[]; subplebbitAddresses: string[] }>,
communitiesStates: {} as Record<string, { clientUrls: string[]; communityAddresses: string[] }>,
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useClientsStates: () => ({
states: testState.clientsStates,
}),
useSubplebbit: () => testState.subplebbit,
useSubplebbitsStates: () => ({
states: testState.subplebbitsStates,
useCommunity: () => testState.community,
useCommunitiesStates: () => ({
states: testState.communitiesStates,
}),
}));
@@ -63,8 +63,8 @@ describe('use-state-string', () => {
beforeEach(() => {
latestValue = undefined;
testState.clientsStates = {};
testState.subplebbit = undefined;
testState.subplebbitsStates = {};
testState.community = undefined;
testState.communitiesStates = {};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@@ -101,7 +101,7 @@ describe('use-state-string', () => {
});
it('sanitizes single-board feed state strings to board wording', () => {
testState.subplebbit = {
testState.community = {
state: 'updating',
updatingState: 'fetching-ipfs',
};
@@ -114,22 +114,22 @@ describe('use-state-string', () => {
});
it('aggregates multi-board feed states across address resolution, threads, and pages', () => {
testState.subplebbitsStates = {
testState.communitiesStates = {
'fetching-ipfs': {
clientUrls: ['https://ipfs.io'],
subplebbitAddresses: ['music-posting.eth'],
communityAddresses: ['music-posting.eth'],
},
'fetching-ipns': {
clientUrls: ['https://gateway.example.com'],
subplebbitAddresses: ['music-posting.eth', 'tech-posting.eth'],
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
},
'page-1': {
clientUrls: ['https://gateway.example.com', 'https://ipfs.io'],
subplebbitAddresses: ['music-posting.eth'],
communityAddresses: ['music-posting.eth'],
},
'resolving-address': {
clientUrls: ['https://ens.example.com'],
subplebbitAddresses: ['music-posting.eth', 'tech-posting.eth'],
communityAddresses: ['music-posting.eth', 'tech-posting.eth'],
},
};
@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { useAccountCommunities } from '@bitsocialnet/bitsocial-react-hooks';
import type { DirectoryCommunity } from './use-directories';
export const useAccountCommunitiesWithMetadata = (): DirectoryCommunity[] => {
const { accountCommunities } = useAccountCommunities({ onlyIfCached: true });
return useMemo(
() =>
Object.values(accountCommunities).map((community) => ({
address: (community as any).address,
title: (community as any).title,
})),
[accountCommunities],
);
};
@@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { useAccountCommunities } from '@bitsocialnet/bitsocial-react-hooks';
export const useAccountCommunityAddresses = (): string[] => {
const { accountCommunities } = useAccountCommunities({ onlyIfCached: true });
return useMemo(() => Object.keys(accountCommunities), [accountCommunities]);
};
@@ -1,8 +1 @@
import { useMemo } from 'react';
import { useAccountSubplebbits } from '@bitsocialnet/bitsocial-react-hooks';
export const useAccountSubplebbitAddresses = (): string[] => {
const { accountSubplebbits } = useAccountSubplebbits({ onlyIfCached: true });
return useMemo(() => Object.keys(accountSubplebbits), [accountSubplebbits]);
};
export { useAccountCommunityAddresses as useAccountSubplebbitAddresses } from './use-account-community-addresses';
@@ -1,16 +1 @@
import { useMemo } from 'react';
import { useAccountSubplebbits } from '@bitsocialnet/bitsocial-react-hooks';
import { DirectoryCommunity } from './use-directories';
export const useAccountSubplebbitsWithMetadata = (): DirectoryCommunity[] => {
const { accountSubplebbits } = useAccountSubplebbits({ onlyIfCached: true });
return useMemo(
() =>
Object.values(accountSubplebbits).map((sub) => ({
address: (sub as any).address,
title: (sub as any).title,
})),
[accountSubplebbits],
);
};
export { useAccountCommunitiesWithMetadata as useAccountSubplebbitsWithMetadata } from './use-account-communities-with-metadata';
+6 -4
View File
@@ -1,18 +1,20 @@
import { useMemo } from 'react';
import { useAccount } from '@bitsocialnet/bitsocial-react-hooks';
import { useSubplebbitField } from './use-stable-subplebbit';
import { useCommunityField } from './use-stable-community';
interface AuthorPrivilegesProps {
commentAuthorAddress: string;
subplebbitAddress: string;
subplebbitAddress?: string;
communityAddress?: string;
postCid?: string;
}
const useAuthorPrivileges = ({ commentAuthorAddress, subplebbitAddress }: AuthorPrivilegesProps) => {
const useAuthorPrivileges = ({ commentAuthorAddress, subplebbitAddress, communityAddress }: AuthorPrivilegesProps) => {
const account = useAccount();
const targetAddress = communityAddress ?? subplebbitAddress;
const accountAuthorAddress = account?.author?.address;
// Only subscribe to roles field to avoid rerenders from updatingState changes
const roles = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.roles);
const roles = useCommunityField(targetAddress, (community) => community?.roles);
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor, commentAuthorRole, accountAuthorRole } = useMemo(() => {
const commentAuthorRole = roles?.[commentAuthorAddress]?.role;
const isCommentAuthorMod = commentAuthorRole === 'admin' || commentAuthorRole === 'owner' || commentAuthorRole === 'moderator';
+4 -4
View File
@@ -1,13 +1,13 @@
import { useDirectoryByAddress } from './use-directories';
import { useSubplebbitField } from './use-stable-subplebbit';
import { useCommunityField } from './use-stable-community';
/**
* Prefer authoritative live board metadata when available, but fall back to the
* bundled directory entry so known boards can render IDs immediately on first load.
*/
export const useBoardPseudonymityMode = (subplebbitAddress: string | undefined): string | undefined => {
const directory = useDirectoryByAddress(subplebbitAddress);
const livePseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
export const useBoardPseudonymityMode = (communityAddress: string | undefined): string | undefined => {
const directory = useDirectoryByAddress(communityAddress);
const livePseudonymityMode = useCommunityField(communityAddress, (community) => community?.features?.pseudonymityMode);
return livePseudonymityMode ?? directory?.features?.pseudonymityMode;
};
+8 -5
View File
@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { useAccountComments, Subplebbit } from '@bitsocialnet/bitsocial-react-hooks';
const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, subplebbit: Subplebbit) => {
const { address } = subplebbit || {};
import { useAccountComments, type Community } from '@bitsocialnet/bitsocial-react-hooks';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, community: Community) => {
const { address } = community || {};
const { accountComments } = useAccountComments();
@@ -14,7 +16,8 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
const filteredComments = accountComments.filter((comment) => {
const { cid, deleted, postCid, removed, state, subplebbitAddress, timestamp } = comment || {};
const { cid, deleted, postCid, removed, state, timestamp } = comment || {};
const communityAddress = getCommentCommunityAddress(comment);
return (
!deleted &&
@@ -23,7 +26,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
state === 'succeeded' &&
cid &&
cid === postCid &&
subplebbitAddress === address &&
communityAddress === address &&
!_feed.some((feedItem) => feedItem.cid === cid)
);
});
+35
View File
@@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { create } from 'zustand';
import { useCommunityStats } from '@bitsocialnet/bitsocial-react-hooks';
type CommunityStatsState = {
communityStats: { [communityAddress: string]: any };
setCommunityStats: (communityAddress: string, stats: any) => void;
};
export const useCommunitiesStatsStore = create<CommunityStatsState>((set) => ({
communityStats: {},
setCommunityStats: (communityAddress, stats) =>
set((state) => ({
communityStats: { ...state.communityStats, [communityAddress]: stats },
})),
}));
export const CommunityStatsCollector = ({ communityAddress }: { communityAddress: string }) => {
const stats = useCommunityStats({ communityAddress });
const setCommunityStats = useCommunitiesStatsStore((state) => state.setCommunityStats);
useEffect(() => {
if (stats && stats.allPostCount !== undefined) {
setCommunityStats(communityAddress, stats);
}
}, [stats, communityAddress, setCommunityStats]);
return null;
};
/**
* Back-compat exports for old naming.
*/
export const useSubplebbitsStatsStore = useCommunitiesStatsStore;
export const SubplebbitStatsCollector = CommunityStatsCollector;
+45
View File
@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
import { Community } from '@bitsocialnet/bitsocial-react-hooks';
import { getFormattedTimeAgo } from '../lib/utils/time-utils';
import useCommunityOfflineStore from '../stores/use-community-offline-store';
import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store';
const useIsCommunityOffline = (community?: Community | undefined) => {
const { t } = useTranslation();
const { address, state, updatedAt, updatingState } = community || {};
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps([address]);
useEffect(() => {
if (address && !communityOfflineState[address]) {
initializeCommunityOfflineState(address);
}
}, [address, communityOfflineState, initializeCommunityOfflineState]);
useEffect(() => {
if (address) {
setCommunityOfflineState(address, { state, updatedAt, updatingState });
}
}, [address, state, updatedAt, updatingState, setCommunityOfflineState]);
const offlineState = communityOfflineState[address] || { initialLoad: true };
const loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
const isLoading = offlineState.initialLoad && (!updatedAt || Date.now() / 1000 - updatedAt >= 120 * 120) && Date.now() / 1000 - loadingStartTimestamp < 30;
const isOffline = !isLoading && ((updatedAt && updatedAt < Date.now() / 1000 - 120 * 120) || (!updatedAt && Date.now() / 1000 - loadingStartTimestamp >= 30));
const isOnline = updatedAt && Date.now() / 1000 - updatedAt < 120 * 120;
const offlineIconClass = isLoading ? 'yellowOfflineIcon' : isOffline ? 'redOfflineIcon' : '';
const offlineTitle = isLoading
? 'downloading board...'
: updatedAt
? isOffline && t('posts_last_synced_info', { time: getFormattedTimeAgo(updatedAt), interpolation: { escapeValue: false } })
: t('subplebbit_offline_info');
return { isOffline: !isOnline && isOffline, isOnlineStatusLoading: !isOnline && isLoading, offlineIconClass, offlineTitle };
};
export const useIsSubplebbitOffline = useIsCommunityOffline;
export default useIsCommunityOffline;
+3 -44
View File
@@ -1,45 +1,4 @@
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
import { Subplebbit } from '@bitsocialnet/bitsocial-react-hooks';
import { getFormattedTimeAgo } from '../lib/utils/time-utils';
import useSubplebbitOfflineStore from '../stores/use-subplebbit-offline-store';
import useSubplebbitsLoadingStartTimestamps from '../stores/use-subplebbits-loading-start-timestamps-store';
import useIsCommunityOffline from './use-is-community-offline';
const useIsSubplebbitOffline = (subplebbit: Subplebbit | undefined) => {
const { t } = useTranslation();
const { address, state, updatedAt, updatingState } = subplebbit || {};
const { subplebbitOfflineState, setSubplebbitOfflineState, initializesubplebbitOfflineState } = useSubplebbitOfflineStore();
const subplebbitsLoadingStartTimestamps = useSubplebbitsLoadingStartTimestamps([address]);
useEffect(() => {
if (address && !subplebbitOfflineState[address]) {
initializesubplebbitOfflineState(address);
}
}, [address, subplebbitOfflineState, initializesubplebbitOfflineState]);
useEffect(() => {
if (address) {
setSubplebbitOfflineState(address, { state, updatedAt, updatingState });
}
}, [address, state, updatedAt, updatingState, setSubplebbitOfflineState]);
const subplebbitOfflineStore = subplebbitOfflineState[address] || { initialLoad: true };
const loadingStartTimestamp = subplebbitsLoadingStartTimestamps[0] || 0;
const isLoading = subplebbitOfflineStore.initialLoad && (!updatedAt || Date.now() / 1000 - updatedAt >= 120 * 120) && Date.now() / 1000 - loadingStartTimestamp < 30;
const isOffline = !isLoading && ((updatedAt && updatedAt < Date.now() / 1000 - 120 * 120) || (!updatedAt && Date.now() / 1000 - loadingStartTimestamp >= 30));
const isOnline = updatedAt && Date.now() / 1000 - updatedAt < 120 * 120;
const offlineIconClass = isLoading ? 'yellowOfflineIcon' : isOffline ? 'redOfflineIcon' : '';
const offlineTitle = isLoading
? 'downloading board...'
: updatedAt
? isOffline && t('posts_last_synced_info', { time: getFormattedTimeAgo(updatedAt), interpolation: { escapeValue: false } })
: t('subplebbit_offline_info');
return { isOffline: !isOnline && isOffline, isOnlineStatusLoading: !isOnline && isLoading, offlineIconClass, offlineTitle };
};
export default useIsSubplebbitOffline;
export { useIsCommunityOffline as useIsSubplebbitOffline };
export default useIsCommunityOffline;
+14 -14
View File
@@ -1,7 +1,7 @@
import { useMemo, useRef } from 'react';
import { Comment, Subplebbit } from '@bitsocialnet/bitsocial-react-hooks';
import { Comment, type Community } from '@bitsocialnet/bitsocial-react-hooks';
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
import useSubplebbitsLoadingStartTimestamps from '../stores/use-subplebbits-loading-start-timestamps-store';
import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store';
import { useCurrentTime } from './use-current-time';
const MAX_POSTS = 8;
@@ -32,8 +32,8 @@ function popularityScore(post: Comment, nowSeconds: number): number {
return Math.max(replies, 0.1) / (1 + ageSeconds / HALF_LIFE_SECONDS);
}
function isBoardStillLoading(subplebbit: Subplebbit | undefined, loadingStartTimestamp: number | undefined, nowSeconds: number): boolean {
if (subplebbit?.updatedAt) {
function isBoardStillLoading(community: Community | undefined, loadingStartTimestamp: number | undefined, nowSeconds: number): boolean {
if (community?.updatedAt) {
return false;
}
@@ -62,8 +62,8 @@ function shuffleBoardAddresses(boardAddresses: string[]): string[] {
* The first revealed set is frozen until the user refreshes or changes
* the board filter, so threads never disappear during background loads.
*/
const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => {
const inputKey = [...subplebbitAddresses].sort().join(',');
const usePopularPosts = (communities: Array<Community | undefined>, communityAddresses: string[]) => {
const inputKey = [...communityAddresses].sort().join(',');
const committedRef = useRef<CommittedPopularPosts>({
posts: [],
revealed: false,
@@ -74,7 +74,7 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
// Reset committed and reshuffle when the requested board set changes (e.g. NSFW filter toggle).
if (prevInputKeyRef.current !== inputKey) {
prevInputKeyRef.current = inputKey;
randomizedBoardAddressesRef.current = shuffleBoardAddresses(subplebbitAddresses);
randomizedBoardAddressesRef.current = shuffleBoardAddresses(communityAddresses);
committedRef.current = {
posts: [],
revealed: false,
@@ -83,7 +83,7 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
const currentTime = useCurrentTime(committedRef.current.revealed ? 300 : 5);
const nowSeconds = Math.floor(currentTime);
const loadingStartTimestamps = useSubplebbitsLoadingStartTimestamps(subplebbitAddresses);
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses);
const candidates = useMemo<PopularPostCandidate[]>(() => {
if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) {
@@ -93,16 +93,16 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
try {
const selectedLinks = new Set<string>();
const allPosts: PopularPostCandidate[] = [];
const subplebbitsByAddress = new Map(subplebbitAddresses.map((boardAddress, index) => [boardAddress, subplebbits[index]]));
const communitiesByAddress = new Map(communityAddresses.map((boardAddress, index) => [boardAddress, communities[index]]));
randomizedBoardAddressesRef.current.forEach((boardAddress) => {
const subplebbit = subplebbitsByAddress.get(boardAddress);
if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) {
const community = communitiesByAddress.get(boardAddress);
if (!boardAddress || !community?.posts?.pages?.hot?.comments) {
return;
}
const subPosts: Comment[] = [];
for (const post of Object.values(subplebbit.posts.pages.hot.comments as Record<string, Comment>)) {
for (const post of Object.values(community.posts.pages.hot.comments as Record<string, Comment>)) {
const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, thumbnailUrl } = post;
try {
@@ -131,9 +131,9 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
console.error('Error in usePopularPosts:', err);
return [];
}
}, [nowSeconds, subplebbits, subplebbitAddresses]);
}, [nowSeconds, communities, communityAddresses]);
const hasPendingBoards = subplebbitAddresses.some((_, index) => isBoardStillLoading(subplebbits[index], loadingStartTimestamps[index], nowSeconds));
const hasPendingBoards = communityAddresses.some((_, index) => isBoardStillLoading(communities[index], loadingStartTimestamps[index], nowSeconds));
if (!committedRef.current.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) {
committedRef.current.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post);
+23 -7
View File
@@ -6,7 +6,10 @@ import { useBoardFeedPageSize } from './use-board-feed-page-size';
import { findPostPageInFeed, findPostPageInLoadedBoardFeeds, type FeedsOptionsLike, type LoadedFeedsLike } from '../lib/utils/post-page-resolution';
interface UsePostPageNumberOptions {
subplebbitAddress: string | undefined;
/** Canonical name. Kept for backward compatibility with older call sites. */
communityAddress?: string;
/** Legacy name kept for backwards compatibility. */
subplebbitAddress?: string;
postCid: string | undefined;
/** When false, page segment is excluded (e.g. pending-post view). When true, resolve and show page. */
enabled?: boolean;
@@ -19,16 +22,29 @@ interface UsePostPageNumberOptions {
*
* @returns 1-based page number, or undefined when unresolved (render as "?")
*/
export function usePostPageNumber({ subplebbitAddress, postCid, enabled = true }: UsePostPageNumberOptions): number | undefined {
const community = useDirectoryByAddress(subplebbitAddress);
export function usePostPageNumber({
communityAddress: requestedCommunityAddress,
subplebbitAddress: legacyCommunityAddress,
postCid,
enabled = true,
}: UsePostPageNumberOptions): number | undefined {
const communityAddress = requestedCommunityAddress ?? legacyCommunityAddress;
const community = useDirectoryByAddress(communityAddress);
const { guiPostsPerPage, paginationFeedPostsPerPage } = useBoardFeedPageSize(community);
const canResolve = Boolean(enabled && subplebbitAddress && postCid && guiPostsPerPage > 0);
const canResolve = Boolean(enabled && communityAddress && postCid && guiPostsPerPage > 0);
// Cache-first: selector returns only computed page to minimize rerenders
const cachedPage = useFeedsStore((state) => {
if (!canResolve) return undefined;
return findPostPageInLoadedBoardFeeds(state.feedsOptions as FeedsOptionsLike, state.loadedFeeds as LoadedFeedsLike, subplebbitAddress!, postCid!, guiPostsPerPage);
return findPostPageInLoadedBoardFeeds(
state.feedsOptions as unknown as FeedsOptionsLike,
state.loadedFeeds as unknown as LoadedFeedsLike,
communityAddress!,
postCid!,
guiPostsPerPage,
);
});
// Preload when cache miss and enabled (10 GUI pages)
@@ -36,12 +52,12 @@ export function usePostPageNumber({ subplebbitAddress, postCid, enabled = true }
() =>
canResolve
? {
subplebbitAddresses: [subplebbitAddress!],
communityAddresses: [communityAddress!],
sortType: 'active' as const,
postsPerPage: paginationFeedPostsPerPage,
}
: undefined,
[canResolve, subplebbitAddress, paginationFeedPostsPerPage],
[canResolve, communityAddress, paginationFeedPostsPerPage],
);
const { feed: preloadFeed } = useFeed(preloadOptions);
+12 -3
View File
@@ -3,7 +3,13 @@ import { Comment, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks'
import usePublishPostStore from '../stores/use-publish-post-store';
import useChallengesStore from '../stores/use-challenges-store';
const usePublishPost = ({ subplebbitAddress }: { subplebbitAddress?: string }) => {
type UsePublishPostOptions = {
communityAddress?: string;
/** legacy compatibility */
subplebbitAddress?: string;
};
const usePublishPost = ({ communityAddress: requestedCommunityAddress, subplebbitAddress }: UsePublishPostOptions) => {
const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore((state) => ({
author: state.author,
title: state.title || undefined,
@@ -21,9 +27,12 @@ const usePublishPost = ({ subplebbitAddress }: { subplebbitAddress?: string }) =
await abandonPublishRef.current?.();
}, []);
const communityAddress = requestedCommunityAddress ?? subplebbitAddress;
const createBaseOptions = useCallback(() => {
const baseOptions: Comment = {
subplebbitAddress,
communityAddress,
subplebbitAddress: communityAddress,
title,
content,
link,
@@ -36,7 +45,7 @@ const usePublishPost = ({ subplebbitAddress }: { subplebbitAddress?: string }) =
}
return baseOptions;
}, [author, content, link, spoiler, subplebbitAddress, title]);
}, [author, content, link, spoiler, communityAddress, title]);
const setPublishPostOptions = useCallback(
(options: Partial<Comment>) => {
+18 -7
View File
@@ -9,7 +9,17 @@ import { extractUnresolvedExternalQuoteReferences, getExternalQuoteStatusMessage
import { resolveExternalQuoteTarget } from '../lib/utils/external-quote-resolver';
import useChallengesStore from '../stores/use-challenges-store';
const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; subplebbitAddress: string; postCid?: string }) => {
type UsePublishReplyOptions = {
cid: string;
communityAddress?: string;
/** legacy compatibility */
subplebbitAddress?: string;
postCid?: string;
};
const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, subplebbitAddress, postCid }: UsePublishReplyOptions) => {
const communityAddress = requestedCommunityAddress ?? subplebbitAddress;
const { t } = useTranslation();
const parentCid = cid;
const account = useAccount();
@@ -39,7 +49,8 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
const createBaseOptions = useCallback(() => {
const baseOptions: Comment = {
subplebbitAddress,
communityAddress,
subplebbitAddress: communityAddress,
parentCid,
postCid: postCid ?? parentCid,
content,
@@ -53,7 +64,7 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
}
return baseOptions;
}, [author, content, link, parentCid, postCid, spoiler, subplebbitAddress]);
}, [author, content, link, parentCid, postCid, spoiler, communityAddress]);
const setPublishReplyOptions = useCallback(
(options: Partial<Comment>) => {
@@ -74,16 +85,16 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
const resetPublishReplyOptions = useCallback(() => resetPublishReplyStore(parentCid), [parentCid, resetPublishReplyStore]);
const scopedNumberToCid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress] : undefined));
const scopedNumberToCid = usePostNumberStore((state) => (communityAddress ? state.numberToCid[communityAddress] : undefined));
const quotedCids = useMemo(() => getQuotedCidsFromContent(content, scopedNumberToCid), [content, scopedNumberToCid]);
const unresolvedExternalQuoteReferences = useMemo(
() =>
extractUnresolvedExternalQuoteReferences({
content,
scopedNumberToCid,
subplebbitAddress,
communityAddress,
}),
[content, scopedNumberToCid, subplebbitAddress],
[content, scopedNumberToCid, communityAddress],
);
const publishResolvableQuoteReferences = useMemo(
() => unresolvedExternalQuoteReferences.filter((reference) => reference.kind === 'same-board'),
@@ -123,7 +134,7 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
setPublishReplyError(null);
setPublishReplyStateMessage(null);
setIsResolvingExternalQuotes(false);
}, [content, subplebbitAddress]);
}, [content, communityAddress]);
useEffect(() => {
if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) {
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import usePostNumberStore from '../stores/use-post-number-store';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
/**
* Registers post and fresh replies with the post-number store so backlinks
@@ -18,7 +19,7 @@ const useRegisterFreshReplies = (post: Comment | undefined, freshRepliesForRende
const cidsKey = all
.map((comment) => {
const commentKey = comment?.cid ?? (typeof comment?.index === 'number' ? `index:${comment.index}` : `timestamp:${comment?.timestamp ?? ''}`);
return `${comment?.subplebbitAddress ?? ''}:${commentKey}:${typeof comment?.number === 'number' ? comment.number : ''}`;
return `${getCommentCommunityAddress(comment) ?? ''}:${commentKey}:${typeof comment?.number === 'number' ? comment.number : ''}`;
})
.sort()
.join(',');
@@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useDirectories } from './use-directories';
import { getCommunityAddress, getBoardPath } from '../lib/utils/route-utils';
/**
* Resolve a board identifier from URL params to canonical community address.
* Supports both current route params (`boardIdentifier`) and legacy
* compatibility params (`subplebbitAddress`).
*/
export const useResolvedCommunityAddress = (): string | undefined => {
const params = useParams<{ boardIdentifier?: string; subplebbitAddress?: string }>();
const directories = useDirectories();
const boardIdentifier = params.boardIdentifier || params.subplebbitAddress;
return useMemo(() => {
if (!boardIdentifier) {
return undefined;
}
return getCommunityAddress(boardIdentifier, directories);
}, [boardIdentifier, directories]);
};
/**
* Back-compat export kept for callers still importing the legacy hook name.
*/
export const useResolvedSubplebbitAddress = useResolvedCommunityAddress;
/**
* Resolve a community address to board path (directory code or address) for links.
*/
export const useBoardPath = (communityAddress: string | undefined): string | undefined => {
const directories = useDirectories();
return useMemo(() => {
if (!communityAddress) {
return undefined;
}
return getBoardPath(communityAddress, directories);
}, [communityAddress, directories]);
};
+1 -48
View File
@@ -1,48 +1 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useDirectories } from './use-directories';
import { getSubplebbitAddress, getBoardPath } from '../lib/utils/route-utils';
/**
* Hook to resolve boardIdentifier from URL params to subplebbitAddress
* Handles both directory codes (e.g., "biz") and full addresses (e.g., "someboard.eth")
*
* Performance: Uses useMemo to avoid recalculating when params/directories haven't changed.
* The directories reference is stable (from cache) after initial load, so memoization works effectively.
*/
export const useResolvedSubplebbitAddress = (): string | undefined => {
const params = useParams();
const directories = useDirectories();
// Try boardIdentifier first (new format), then subplebbitAddress (old format for backward compatibility)
const boardIdentifier = params.boardIdentifier || params.subplebbitAddress;
return useMemo(() => {
if (!boardIdentifier) {
return undefined;
}
// Resolve directory code to address if needed
// getSubplebbitAddress uses internal caching, so this is efficient
return getSubplebbitAddress(boardIdentifier, directories);
}, [boardIdentifier, directories]);
};
/**
* Hook to get the board path (directory code or address) for use in links
*
* Performance: Uses useMemo to avoid recalculating when subplebbitAddress/directories haven't changed.
* The directories reference is stable (from cache) after initial load, so memoization works effectively.
*/
export const useBoardPath = (subplebbitAddress: string | undefined): string | undefined => {
const directories = useDirectories();
return useMemo(() => {
if (!subplebbitAddress) {
return undefined;
}
// getBoardPath uses internal caching, so this is efficient
return getBoardPath(subplebbitAddress, directories);
}, [subplebbitAddress, directories]);
};
export { useBoardPath, useResolvedSubplebbitAddress } from './use-resolved-community-address';
+80
View File
@@ -0,0 +1,80 @@
import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities';
import type { Community } from '@bitsocialnet/bitsocial-react-hooks';
import { normalizeBoardAddress } from './use-directories';
type CommunityLike = Record<string, unknown> | Community | undefined;
const getCommunityByAddress = (communities: Record<string, unknown> | undefined, communityAddress: string | undefined) => {
if (!communities || !communityAddress) {
return undefined;
}
const exactMatch = communities[communityAddress];
if (exactMatch) {
return exactMatch;
}
const normalizedAddress = normalizeBoardAddress(communityAddress);
return Object.entries(communities).find(([key, community]) => {
const candidateAddress = typeof (community as CommunityLike)?.address === 'string' ? (community as CommunityLike)?.address : key;
return normalizeBoardAddress(candidateAddress) === normalizedAddress;
})?.[1];
};
const shallowEqual = (obj1: Record<string, any> | undefined, obj2: Record<string, any> | undefined): boolean => {
if (obj1 === obj2) return true;
if (!obj1 || !obj2) return obj1 === obj2;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (obj1[key] !== obj2[key]) return false;
}
return true;
};
/**
* Ignore transient lifecycle props when deciding whether to update hook consumers.
*/
const isCommunityEqual = (prev: any, next: any): boolean => {
if (prev === next) return true;
if (!prev || !next) return prev === next;
return (
prev.address === next.address &&
prev.title === next.title &&
prev.shortAddress === next.shortAddress &&
prev.createdAt === next.createdAt &&
prev.updatedAt === next.updatedAt &&
prev.description === next.description &&
shallowEqual(prev.roles, next.roles)
);
};
export const useStableCommunity = (communityAddress: string | undefined) => {
const community = useCommunitiesStore((state) => {
return getCommunityByAddress(state.communities, communityAddress) as Community | undefined;
}, isCommunityEqual);
return community;
};
export const useCommunityField = <T>(communityAddress: string | undefined, selector: (community: any) => T): T | undefined => {
const field = useCommunitiesStore(
(state) => {
const community = getCommunityByAddress(state.communities, communityAddress);
return community ? selector(community) : undefined;
},
(prev, next) => prev === next,
);
return field;
};
/**
* Back-compat exports for old hook names.
*/
export const useStableSubplebbit = useStableCommunity;
export const useSubplebbitField = useCommunityField;
+1 -89
View File
@@ -1,89 +1 @@
import useSubplebbitsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits';
import { normalizeBoardAddress } from './use-directories';
const getSubplebbitByAddress = (subplebbits: Record<string, any> | undefined, subplebbitAddress: string | undefined) => {
if (!subplebbits || !subplebbitAddress) {
return undefined;
}
const exactMatch = subplebbits[subplebbitAddress];
if (exactMatch) {
return exactMatch;
}
const normalizedAddress = normalizeBoardAddress(subplebbitAddress);
return Object.entries(subplebbits).find(([key, subplebbit]) => {
const candidateAddress = typeof subplebbit?.address === 'string' ? subplebbit.address : key;
return normalizeBoardAddress(candidateAddress) === normalizedAddress;
})?.[1];
};
/**
* Shallow compare two objects by keys and values.
*/
const shallowEqual = (obj1: Record<string, any> | undefined, obj2: Record<string, any> | undefined): boolean => {
if (obj1 === obj2) return true;
if (!obj1 || !obj2) return obj1 === obj2;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (obj1[key] !== obj2[key]) return false;
}
return true;
};
/**
* Custom equality function that ignores transient state properties
* like updatingState, state, errors, etc. Only compares stable content fields.
*/
const isSubplebbitEqual = (prev: any, next: any): boolean => {
if (prev === next) return true;
if (!prev || !next) return prev === next;
// Compare only stable fields, ignore transient state
// Use shallow comparison for roles object to handle new object instances with same content
return (
prev.address === next.address &&
prev.title === next.title &&
prev.shortAddress === next.shortAddress &&
shallowEqual(prev.roles, next.roles) &&
prev.updatedAt === next.updatedAt &&
prev.createdAt === next.createdAt &&
prev.description === next.description
);
};
/**
* Hook to get a subplebbit with stable reference that ignores updatingState changes.
* Use this when you only need content fields and don't care about loading states.
*
* @param subplebbitAddress - The address of the subplebbit to retrieve
* @returns The subplebbit object, or undefined if not found
*/
export const useStableSubplebbit = (subplebbitAddress: string | undefined) => {
// Use selector with custom equality to ignore transient state
const subplebbit = useSubplebbitsStore((state) => getSubplebbitByAddress(state.subplebbits, subplebbitAddress), isSubplebbitEqual);
return subplebbit;
};
/**
* Hook to get only specific fields from a subplebbit, ignoring updatingState.
* This is more efficient when you only need a few fields.
*
* @param subplebbitAddress - The address of the subplebbit
* @param selector - Function to extract the needed fields
* @returns The selected fields
*/
export const useSubplebbitField = <T>(subplebbitAddress: string | undefined, selector: (subplebbit: any) => T): T | undefined => {
const field = useSubplebbitsStore(
(state) => {
const subplebbit = getSubplebbitByAddress(state.subplebbits, subplebbitAddress);
return subplebbit ? selector(subplebbit) : undefined;
},
(prev, next) => prev === next,
);
return field;
};
export { useStableSubplebbit, useSubplebbitField } from './use-stable-community';
+70 -44
View File
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useClientsStates, useSubplebbit, useSubplebbitsStates } from '@bitsocialnet/bitsocial-react-hooks';
import { useClientsStates, useCommunity, useCommunitiesStates } from '@bitsocialnet/bitsocial-react-hooks';
import debounce from 'lodash/debounce';
import getShortAddress from '../lib/get-short-address';
interface CommentOrSubplebbit {
interface CommentOrCommunity {
state?: string;
publishingState?: string;
updatingState?: string;
@@ -13,13 +13,24 @@ interface States {
[key: string]: string[];
}
type CommunityLoadingState = {
communityAddresses: string[];
clientUrls: string[];
};
const isCommunityLoadingState = (state: string[] | CommunityLoadingState | undefined): state is CommunityLoadingState =>
Boolean(state && !Array.isArray(state) && 'communityAddresses' in state && 'clientUrls' in state);
const friendlyStateNames: Record<string, string> = {
'fetching-ipns': 'downloading board',
'fetching-ipfs': 'downloading thread',
'fetching-community-ipns': 'downloading board',
'fetching-community-ipfs': 'downloading board',
'fetching-subplebbit-ipns': 'downloading board',
'fetching-subplebbit-ipfs': 'downloading board',
'fetching-update-ipfs': 'downloading update',
'resolving-address': 'resolving address',
'resolving-community-address': 'resolving board address',
'resolving-subplebbit-address': 'resolving board address',
'resolving-author-address': 'resolving author address',
};
@@ -38,8 +49,8 @@ const sanitizeSingleFeedLoadingState = (stateString?: string): string | undefine
.replace(/\bloading thread\b/g, 'loading board');
};
const useStateString = (commentOrSubplebbit: CommentOrSubplebbit): string | undefined => {
const { states: rawStates } = useClientsStates({ comment: commentOrSubplebbit }) as { states: States };
const useStateString = (commentOrCommunity: CommentOrCommunity): string | undefined => {
const { states: rawStates } = useClientsStates({ comment: commentOrCommunity }) as { states: States };
const debouncedStates = useMemo(() => {
const debouncedValue = debounce((value: States) => value, 300);
@@ -69,21 +80,21 @@ const useStateString = (commentOrSubplebbit: CommentOrSubplebbit): string | unde
stateString += downloadingParts.join(', ') + ' via IPFS';
}
if (!stateString && commentOrSubplebbit?.state !== 'succeeded') {
if (commentOrSubplebbit?.publishingState && commentOrSubplebbit?.publishingState !== 'stopped' && commentOrSubplebbit?.publishingState !== 'succeeded') {
stateString = commentOrSubplebbit.publishingState;
} else if (commentOrSubplebbit?.updatingState !== 'stopped' && commentOrSubplebbit?.updatingState !== 'succeeded') {
stateString = commentOrSubplebbit?.updatingState;
if (!stateString && commentOrCommunity?.state !== 'succeeded') {
if (commentOrCommunity?.publishingState && commentOrCommunity?.publishingState !== 'stopped' && commentOrCommunity?.publishingState !== 'succeeded') {
stateString = commentOrCommunity.publishingState;
} else if (commentOrCommunity?.updatingState !== 'stopped' && commentOrCommunity?.updatingState !== 'succeeded') {
stateString = commentOrCommunity?.updatingState;
}
if (stateString) {
const isIpfsRelated = stateString.includes('ipfs') || stateString.includes('ipns');
stateString = stateString
.replaceAll('-', ' ')
.replace('ipfs', 'thread')
.replace('ipns', 'subplebbit')
.replace('ipns', 'community')
.replace('fetching', 'downloading')
.replace('subplebbit subplebbit', 'board')
.replace('downloading subplebbit', 'downloading board');
.replace('community community', 'board')
.replace('downloading community', 'downloading board');
if (isIpfsRelated) {
stateString += ' via IPFS';
}
@@ -95,68 +106,83 @@ const useStateString = (commentOrSubplebbit: CommentOrSubplebbit): string | unde
}
return stateString === '' ? undefined : stateString;
}, [debouncedStates, commentOrSubplebbit]);
}, [debouncedStates, commentOrCommunity]);
};
export const useFeedStateString = (subplebbitAddresses?: string[]): string | undefined => {
// single subplebbit feed state string
const subplebbitAddress = subplebbitAddresses?.length === 1 ? subplebbitAddresses[0] : undefined;
const subplebbit = useSubplebbit({ subplebbitAddress });
const singleSubplebbitFeedStateString = sanitizeSingleFeedLoadingState(useStateString(subplebbit));
export const useFeedStateString = (communityAddresses?: string[]): string | undefined => {
// single community feed state string
const communityAddress = communityAddresses?.length === 1 ? communityAddresses[0] : undefined;
const community = useCommunity(communityAddress ? { communityAddress } : undefined);
const singleCommunityFeedStateString = sanitizeSingleFeedLoadingState(useStateString(community));
// multiple subplebbit feed state string
const { states } = useSubplebbitsStates({ subplebbitAddresses });
// multiple community feed state string
const { states } = useCommunitiesStates({ communityAddresses });
const multipleSubplebbitsFeedStateString = useMemo(() => {
if (subplebbitAddress) {
const multipleCommunitiesFeedStateString = useMemo(() => {
if (communityAddress) {
return;
}
let stateString = '';
if (states['resolving-address']) {
const { subplebbitAddresses, clientUrls } = states['resolving-address'];
if (subplebbitAddresses.length && clientUrls.length) {
const count = subplebbitAddresses.length;
const resolvingState = states['resolving-address'];
if (isCommunityLoadingState(resolvingState)) {
const { communityAddresses } = resolvingState;
const count = communityAddresses.length;
stateString += `resolving ${count} board ${count === 1 ? 'address' : 'addresses'}`;
}
}
const pagesStatesSubplebbitAddresses = new Set<string>();
const pagesStatesCommunityAddresses = new Set<string>();
for (const state in states) {
if (state.match('page')) {
states[state].subplebbitAddresses.forEach((subplebbitAddress: string) => pagesStatesSubplebbitAddresses.add(subplebbitAddress));
const communityState = states[state];
if (isCommunityLoadingState(communityState)) {
communityState.communityAddresses.forEach((address: string) => pagesStatesCommunityAddresses.add(address));
}
}
}
if (states['fetching-ipns'] || states['fetching-ipfs'] || pagesStatesSubplebbitAddresses.size) {
if (states['fetching-ipns'] || states['fetching-ipfs'] || pagesStatesCommunityAddresses.size) {
if (stateString) stateString += ', ';
stateString += 'downloading ';
if (states['fetching-ipns']) {
const count = states['fetching-ipns'].subplebbitAddresses.length;
stateString += `${count} ${count === 1 ? 'board' : 'boards'}`;
if (count <= 5) {
stateString += ` (${states['fetching-ipns'].subplebbitAddresses.map((a: string) => getShortAddress(a) || a).join(', ')})`;
const fetchingIpnsState = states['fetching-ipns'];
if (isCommunityLoadingState(fetchingIpnsState)) {
const count = fetchingIpnsState.communityAddresses.length;
stateString += `${count} ${count === 1 ? 'board' : 'boards'}`;
if (count <= 5) {
stateString += ` (${fetchingIpnsState.communityAddresses.map((a: string) => getShortAddress(a) || a).join(', ')})`;
}
}
}
if (states['fetching-ipfs']) {
if (states['fetching-ipns']) stateString += ', ';
const count = states['fetching-ipfs'].subplebbitAddresses.length;
stateString += `${count} ${count === 1 ? 'thread' : 'threads'}`;
const fetchingIpfsState = states['fetching-ipfs'];
if (isCommunityLoadingState(fetchingIpfsState)) {
if (stateString[stateString.length - 1] !== ' ') {
stateString += ', ';
}
const count = fetchingIpfsState.communityAddresses.length;
stateString += `${count} ${count === 1 ? 'thread' : 'threads'}`;
}
}
if (pagesStatesSubplebbitAddresses.size) {
if (pagesStatesCommunityAddresses.size) {
if (states['fetching-ipns'] || states['fetching-ipfs']) stateString += ', ';
const count = pagesStatesSubplebbitAddresses.size;
const count = pagesStatesCommunityAddresses.size;
stateString += `${count} ${count === 1 ? 'page' : 'pages'}`;
}
stateString += ' via IPFS';
}
if (!stateString && subplebbitAddresses?.length) {
const count = subplebbitAddresses.length;
if (!stateString && communityAddresses?.length) {
const count = communityAddresses.length;
stateString = `downloading ${count} ${count === 1 ? 'board' : 'boards'}`;
if (count <= 5) {
stateString += ` (${subplebbitAddresses.map((a) => getShortAddress(a) || a).join(', ')})`;
stateString += ` (${communityAddresses.map((a) => getShortAddress(a) || a).join(', ')})`;
}
}
@@ -165,12 +191,12 @@ export const useFeedStateString = (subplebbitAddresses?: string[]): string | und
// if string is empty, return undefined instead
return stateString === '' ? undefined : stateString;
}, [states, subplebbitAddress, subplebbitAddresses]);
}, [states, communityAddress, communityAddresses]);
if (singleSubplebbitFeedStateString) {
return singleSubplebbitFeedStateString;
if (singleCommunityFeedStateString) {
return singleCommunityFeedStateString;
}
return multipleSubplebbitsFeedStateString;
return multipleCommunitiesFeedStateString;
};
export default useStateString;
+1 -34
View File
@@ -1,34 +1 @@
import { useEffect } from 'react';
import { useSubplebbitStats } from '@bitsocialnet/bitsocial-react-hooks';
import { create } from 'zustand';
type SubplebbitsStatsState = {
subplebbitsStats: { [subplebbitAddress: string]: any };
setSubplebbitStats: (subplebbitAddress: string, stats: any) => void;
};
export const useSubplebbitsStatsStore = create<SubplebbitsStatsState>((set) => ({
subplebbitsStats: {},
setSubplebbitStats: (subplebbitAddress: string, subplebbitStats: any) =>
set((state) => ({
subplebbitsStats: { ...state.subplebbitsStats, [subplebbitAddress]: subplebbitStats },
})),
}));
/**
* Component that fetches stats for a single subplebbit and stores them.
* Render one of these for each subplebbit you want to track stats for.
*/
export const SubplebbitStatsCollector = ({ subplebbitAddress }: { subplebbitAddress: string }) => {
const stats = useSubplebbitStats({ subplebbitAddress });
const setSubplebbitStats = useSubplebbitsStatsStore((state) => state.setSubplebbitStats);
useEffect(() => {
// Only update store when we have actual stats (not just loading state)
if (stats && stats.allPostCount !== undefined) {
setSubplebbitStats(subplebbitAddress, stats);
}
}, [stats, subplebbitAddress, setSubplebbitStats]);
return null; // This is a data-fetching component, renders nothing
};
export { SubplebbitStatsCollector, useSubplebbitsStatsStore } from './use-communities-stats';
+27 -30
View File
@@ -1,13 +1,13 @@
import { useEffect, useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { isAllView, isSubscriptionsView, isModView } from '../lib/utils/view-utils';
import { isAllView, isModView, isSubscriptionsView } from '../lib/utils/view-utils';
import useThemeStore from '../stores/use-theme-store';
import { useDirectories } from './use-directories';
import { useResolvedSubplebbitAddress } from './use-resolved-subplebbit-address';
import { useResolvedCommunityAddress } from './use-resolved-community-address';
import { useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
import useSpecialThemeStore from '../stores/use-special-theme-store';
import { isChristmas } from '../lib/utils/time-utils';
import { updateFavicon, isSfwBoard } from '../lib/update-favicon';
import { isSfwBoard, updateFavicon } from '../lib/update-favicon';
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon'];
@@ -20,48 +20,47 @@ const updateThemeClass = (newTheme: string) => {
const useTheme = (): [string, (theme: string) => void] => {
const location = useLocation();
const params = useParams<{ subplebbitAddress: string }>();
const params = useParams<{ boardIdentifier?: string; subplebbitAddress?: string }>();
const pendingPostParams = useParams<{ accountCommentIndex?: string }>();
const pendingPostCommentIndex = pendingPostParams?.accountCommentIndex ? parseInt(pendingPostParams.accountCommentIndex) : undefined;
const pendingPostCommentIndex = pendingPostParams?.accountCommentIndex ? parseInt(pendingPostParams.accountCommentIndex, 10) : undefined;
const pendingPost = useAccountComment({ commentIndex: pendingPostCommentIndex });
const pendingPostSubplebbitAddress = pendingPost?.subplebbitAddress;
const pendingPostCommunityAddress =
(pendingPost as { communityAddress?: string }).communityAddress ||
// compatibility fallback for legacy inbound/persisted comment payloads
(pendingPost as { subplebbitAddress?: string }).subplebbitAddress;
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
const setThemeStore = useThemeStore((state) => state.setTheme);
// Subscribe to the actual themes data, not just the getter function
const themes = useThemeStore((state) => state.themes);
const directories = useDirectories();
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInModView = isModView(location.pathname);
const resolvedAddress = useResolvedSubplebbitAddress();
const subplebbitAddress = resolvedAddress || pendingPostSubplebbitAddress;
const routeIdentifier = params.boardIdentifier || params.subplebbitAddress;
const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier;
// Check for Christmas and initialize special theme if needed
useEffect(() => {
const isChristmasTime = isChristmas();
if (isChristmasTime && isEnabled === null && subplebbitAddress && !isInAllView && !isInSubscriptionsView && !isInModView) {
if (isChristmasTime && isEnabled === null && communityAddress && !isInAllView && !isInSubscriptionsView && !isInModView) {
setIsEnabled(true);
} else if (!isChristmasTime && isEnabled) {
setIsEnabled(false);
}
}, [isEnabled, setIsEnabled, subplebbitAddress, isInAllView, isInSubscriptionsView, isInModView]);
}, [isEnabled, setIsEnabled, communityAddress, isInAllView, isInSubscriptionsView, isInModView]);
// Calculate current theme during render - no effects needed
const currentTheme = useMemo(() => {
// Always use yotsuba for home page
if (location.pathname === '/') {
return 'yotsuba';
}
// Always use yotsuba for rules page (boardIdentifier in URL is for loading rules, not theming)
if (location.pathname.startsWith('/rules')) {
return 'yotsuba';
}
// If special theme is enabled, use tomorrow
if (isEnabled) {
return 'tomorrow';
}
@@ -69,9 +68,9 @@ const useTheme = (): [string, (theme: string) => void] => {
let storedTheme = null;
if (isInAllView || isInSubscriptionsView || isInModView) {
storedTheme = themes.nsfw;
} else if (subplebbitAddress) {
const subplebbit = directories.find((s) => s.address === subplebbitAddress);
if (subplebbit?.nsfw) {
} else if (communityAddress) {
const community = directories.find((entry) => entry.address === communityAddress);
if (community?.nsfw) {
storedTheme = themes.nsfw;
} else {
storedTheme = themes.sfw;
@@ -79,7 +78,7 @@ const useTheme = (): [string, (theme: string) => void] => {
}
return storedTheme || 'yotsuba';
}, [location.pathname, isEnabled, isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directories, themes]);
}, [location.pathname, isEnabled, isInAllView, isInSubscriptionsView, isInModView, communityAddress, directories, themes]);
const sfw = isSfwBoard({
pathname: location.pathname,
@@ -87,37 +86,35 @@ const useTheme = (): [string, (theme: string) => void] => {
isInAllView,
isInSubscriptionsView,
isInModView,
subplebbitAddress,
subplebbitAddress: communityAddress,
directories,
});
// Update DOM class when theme changes
useEffect(() => {
updateThemeClass(currentTheme);
}, [currentTheme]);
// Update favicon when SFW status changes (separate effect for independent lifecycle)
useEffect(() => {
updateFavicon(sfw);
}, [sfw]);
const setSubplebbitTheme = useCallback(
const setCommunityTheme = useCallback(
async (newTheme: string) => {
if (isInAllView || isInSubscriptionsView || isInModView) {
await setThemeStore('nsfw', newTheme);
} else if (subplebbitAddress) {
const subplebbit = directories.find((s) => s.address === subplebbitAddress);
if (subplebbit?.nsfw) {
} else if (communityAddress) {
const community = directories.find((entry) => entry.address === communityAddress);
if (community?.nsfw) {
await setThemeStore('nsfw', newTheme);
} else {
await setThemeStore('sfw', newTheme);
}
}
},
[isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directories, setThemeStore],
[isInAllView, isInSubscriptionsView, isInModView, communityAddress, directories, setThemeStore],
);
return [currentTheme, setSubplebbitTheme];
return [currentTheme, setCommunityTheme];
};
export default useTheme;