diff --git a/package.json b/package.json index 7398e447..4b040a05 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "GPL-2.0-only", "private": true, "dependencies": { - "@bitsocialnet/bitsocial-react-hooks": "https://github.com/bitsocialnet/bitsocial-react-hooks.git#0f8c9061cd08a0725a7b9fbd85b922d65e44d677", + "@bitsocialnet/bitsocial-react-hooks": "https://github.com/bitsocialnet/bitsocial-react-hooks.git#0e1d9ccd9c158cd0f161a62471cbf91d4928d317", "@capacitor/app": "7.0.1", "@capacitor/status-bar": "7.0.1", "@capawesome/capacitor-android-edge-to-edge-support": "7.2.2", diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 24345f5f..546c73f4 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -50,6 +50,10 @@ const testState = vi.hoisted(() => ({ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, useAccountComment: ({ commentIndex }: { commentIndex?: number }) => (typeof commentIndex === 'number' ? testState.accountComments[commentIndex] : undefined), + useCommunity: ({ communityAddress }: { communityAddress?: string }) => (communityAddress ? testState.subplebbits[communityAddress] : undefined), + useAccountCommunities: () => ({ + accountCommunities: Object.fromEntries(testState.accountSubplebbitAddresses.map((address) => [address, { address }])), + }), useSubplebbit: ({ subplebbitAddress }: { subplebbitAddress?: string }) => (subplebbitAddress ? testState.subplebbits[subplebbitAddress] : undefined), })); diff --git a/src/app.tsx b/src/app.tsx index 70403a67..3e2de54b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useEffect } from 'react'; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom'; -import { useAccount, useAccountComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks'; +import { useAccount, useAccountComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; import { initSnow, removeSnow } from './lib/snow'; import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils'; import { preloadReplyModal, preloadThemeAssets } from './lib/utils/preload-utils'; @@ -8,10 +8,10 @@ import useReplyModalStore from './stores/use-reply-modal-store'; import useCreateBoardModalStore from './stores/use-create-board-modal-store'; import useSpecialThemeStore from './stores/use-special-theme-store'; import useIsMobile from './hooks/use-is-mobile'; -import { useAccountSubplebbitAddresses } from './hooks/use-account-subplebbit-addresses'; +import { useAccountCommunityAddresses } from './hooks/use-account-community-addresses'; import useTheme from './hooks/use-theme'; import { useDirectories } from './hooks/use-directories'; -import { useResolvedSubplebbitAddress } from './hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from './hooks/use-resolved-community-address'; import { getBoardPath, getSubplebbitAddress, @@ -69,8 +69,9 @@ const BoardLayout = () => { const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const isInModView = isModView(location.pathname); const directories = useDirectories(); - const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined; + const communityAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined; const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined }); + const pendingPostCommunityAddress = pendingPost?.communityAddress || pendingPost?.subplebbitAddress; const { closeCreateBoardModal } = useCreateBoardModalStore(); const isOnPostRoute = isPostRoute(location.pathname); const isOnPendingPostRoute = isPendingPostRoute(location.pathname); @@ -96,8 +97,8 @@ const BoardLayout = () => { // force rerender of post form when navigating between pages, except when opening settings modal in current view const key = location.pathname.endsWith('/settings') - ? `${subplebbitAddress}-${location.pathname.replace(/\/settings$/, '')}` - : `${subplebbitAddress}-${location.pathname}`; + ? `${communityAddress}-${location.pathname.replace(/\/settings$/, '')}` + : `${communityAddress}-${location.pathname}`; if (pageNumber === '1') { return ; @@ -143,7 +144,7 @@ const BoardLayout = () => { {isMobile - ? (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && + ? (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) && (isInCatalogView ? ( <> @@ -156,7 +157,7 @@ const BoardLayout = () => { {isInAllView && } > )) - : (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && ( + : (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) && ( <> {!(isInAllView || isInSubscriptionsView || isInModView) && !isOnModQueueRoute && } @@ -172,7 +173,7 @@ const BoardLayout = () => { const GlobalLayout = () => { useTheme(); - const { activeCid, parentNumber, threadNumber, threadCid, subplebbitAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore(); + const { activeCid, parentNumber, threadNumber, threadCid, subplebbitAddress: activeCommunityAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore(); const location = useLocation(); const isInSettingsView = location.pathname.endsWith('/settings'); @@ -183,7 +184,7 @@ const GlobalLayout = () => { - {activeCid && threadCid && subplebbitAddress && ( + {activeCid && threadCid && activeCommunityAddress && ( { postCid={threadCid} scrollY={scrollY} showReplyModal={showReplyModal} - subplebbitAddress={subplebbitAddress} + communityAddress={activeCommunityAddress} /> )} @@ -239,9 +240,9 @@ const ModQueueRoute = () => { const { boardIdentifier } = useParams(); const account = useAccount(); const accountAddress = account?.author?.address; - const subplebbitAddress = useResolvedSubplebbitAddress(); - const subplebbit = useSubplebbit({ subplebbitAddress }); - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const communityAddress = useResolvedCommunityAddress(); + const community = useCommunity({ communityAddress }); + const accountCommunityAddresses = useAccountCommunityAddresses(); if (!account) { return null; @@ -252,17 +253,17 @@ const ModQueueRoute = () => { } if (!boardIdentifier) { - return accountSubplebbitAddresses.length > 0 ? : ; + return accountCommunityAddresses.length > 0 ? : ; } // Wait for board role metadata before enforcing access to avoid false redirects during initial load. - const boardState = subplebbit?.state; - const isBoardLoading = !subplebbit || !boardState || (boardState !== 'succeeded' && boardState !== 'failed'); + const boardState = community?.state; + const isBoardLoading = !community || !boardState || (boardState !== 'succeeded' && boardState !== 'failed'); if (isBoardLoading) { return null; } - const accountRole = subplebbit?.roles?.[accountAddress]?.role; + const accountRole = community?.roles?.[accountAddress]?.role; return hasModQueueAccessRole(accountRole) ? : ; }; diff --git a/src/components/__tests__/post-community-address-compat.test.tsx b/src/components/__tests__/post-community-address-compat.test.tsx new file mode 100644 index 00000000..fde22b74 --- /dev/null +++ b/src/components/__tests__/post-community-address-compat.test.tsx @@ -0,0 +1,445 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import PostDesktop from '../post-desktop'; +import PostMobile from '../post-mobile'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +type TestComment = { + author?: { + address?: string; + shortAddress?: string; + }; + cid?: string; + communityAddress?: string; + content?: string; + deleted?: boolean; + index?: number; + link?: string; + linkHeight?: number; + linkWidth?: number; + number?: number; + parentCid?: string; + pinned?: boolean; + postCid?: string; + removed?: boolean; + replyCount?: number; + replies?: { + pages?: Record< + string, + { + comments?: TestComment[]; + } + >; + }; + state?: string; + subplebbitAddress?: string; + thumbnailUrl?: string; + timestamp?: number; + updatedAt?: number; +}; + +const testState = vi.hoisted(() => ({ + addChallengeMock: vi.fn(), + openReplyModalMock: vi.fn(), + replyComments: [] as Array, + setResetFunctionMock: vi.fn(), +})); + +const getMockPreloadedReplies = (comment?: TestComment, sortType?: string) => { + if (!comment) { + return []; + } + + const preloadedReplies = + comment.replies?.pages?.[sortType || 'best']?.comments ?? Object.values(comment.replies?.pages ?? {}).find((page) => page?.comments?.length)?.comments ?? []; + + const compatibleReplies: TestComment[] = []; + for (const reply of preloadedReplies) { + if (!reply?.communityAddress || reply.communityAddress !== comment.communityAddress) { + break; + } + compatibleReplies.push(reply); + } + + return compatibleReplies; +}; + +vi.mock('react-i18next', () => ({ + Trans: ({ i18nKey, values }: { i18nKey?: string; values?: Record }) => + createElement('span', {}, `${i18nKey ?? 'trans'}:${JSON.stringify(values ?? {})}`), + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ + useAccount: () => ({ author: { address: '0xviewer' } }), + useAccountComment: () => undefined, + useEditedComment: () => ({ editedComment: undefined }), + usePublishCommentModeration: () => ({ + error: undefined, + publishCommentModeration: vi.fn(), + state: 'initializing', + }), + useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => { + testState.replyComments.push(comment); + return { + hasMore: false, + loadMore: vi.fn(), + replies: getMockPreloadedReplies(comment, sortType), + }; + }, +})); + +vi.mock('react-virtuoso', () => ({ + Virtuoso: React.forwardRef( + ( + { + components, + data = [], + itemContent, + }: { + components?: { Footer?: React.ComponentType }; + data?: TestComment[]; + itemContent: (index: number, item: TestComment) => React.ReactNode; + }, + ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>, + ) => { + React.useImperativeHandle(ref, () => ({ + getState: (cb) => cb({ ranges: [0], scrollTop: 0 }), + })); + + return createElement( + 'div', + { 'data-testid': 'virtuoso' }, + data.map((item, index) => createElement('div', { key: item.cid ?? index }, itemContent(index, item))), + components?.Footer ? createElement(components.Footer) : null, + ); + }, + ), +})); + +vi.mock('../../lib/get-short-address', () => ({ + default: (value?: string) => (value ? value.slice(0, 4) : ''), +})); + +vi.mock('../../views/post/post.module.css', () => ({ + default: new Proxy( + {}, + { + get: (_target, property) => String(property), + }, + ), +})); + +vi.mock('../../lib/utils/media-utils', () => ({ + getDisplayMediaInfoType: (type?: string) => type ?? 'unknown', + getHasThumbnail: () => true, + getMediaDimensions: () => '100x100', +})); + +vi.mock('../../lib/utils/post-utils', () => ({ + getTextColorForBackground: () => '#fff', + hashStringToColor: () => '#000', +})); + +vi.mock('../../lib/utils/time-utils', () => ({ + getFormattedDate: () => '2026-03-13', + getFormattedTimeAgo: () => 'moments ago', +})); + +vi.mock('../../lib/utils/pending-approval-moderation', () => ({ + approvePendingCommentModeration: {}, + isPendingApprovalRejected: () => false, + rejectPendingCommentModeration: {}, +})); + +vi.mock('../../lib/utils/url-utils', () => ({ + isValidURL: () => true, +})); + +vi.mock('../../lib/utils/view-utils', () => ({ + isAllView: (pathname: string) => pathname === '/all', + isModQueueView: () => false, + isModView: () => false, + isPendingPostView: () => false, + isPostPageView: (pathname: string) => pathname.includes('/thread/'), + isSubscriptionsView: () => false, +})); + +vi.mock('../../stores/use-mod-queue-store', () => ({ + default: (selector?: (state: { getAlertThresholdSeconds: () => number }) => unknown) => { + const state = { + getAlertThresholdSeconds: () => 0, + }; + + return selector ? selector(state) : state; + }, +})); + +vi.mock('../../hooks/use-directories', () => ({ + findDirectoryByAddress: (_directories: unknown[], address?: string) => (address ? { address, features: {} } : undefined), + useDirectories: () => [{ address: 'music-posting.eth', title: '/mu/ - Music' }], +})); + +vi.mock('../../lib/utils/route-utils', () => ({ + getBoardPath: (address?: string) => (address ? 'mu' : undefined), +})); + +vi.mock('../../hooks/use-author-address-click', () => ({ + default: () => vi.fn(), +})); + +vi.mock('../../hooks/use-comment-media-info', () => ({ + useCommentMediaInfo: (link?: string) => (link ? { type: 'image', url: link } : undefined), +})); + +vi.mock('../../hooks/use-count-links-in-replies', () => ({ + default: () => 0, +})); + +vi.mock('../../hooks/use-fetch-gif-first-frame', () => ({ + default: () => ({ + status: 'idle', + }), +})); + +vi.mock('../../hooks/use-hide', () => ({ + default: () => ({ + hidden: false, + hide: vi.fn(), + unhide: vi.fn(), + }), +})); + +vi.mock('../../hooks/use-state-string', () => ({ + default: () => undefined, +})); + +vi.mock('../../hooks/use-scroll-to-reply', () => ({ + default: () => undefined, +})); + +vi.mock('../../hooks/use-current-time', () => ({ + useCurrentTime: () => 1_710_000_000, +})); + +vi.mock('../../hooks/use-board-pseudonymity-mode', () => ({ + useBoardPseudonymityMode: () => 'none', +})); + +vi.mock('../comment-content', () => ({ + default: ({ comment }: { comment?: TestComment }) => createElement('div', { 'data-testid': 'comment-content' }, comment?.cid ?? 'missing'), +})); + +vi.mock('../comment-media', () => ({ + default: () => createElement('div', { 'data-testid': 'comment-media' }, 'media'), +})); + +vi.mock('../edit-menu/edit-menu', () => ({ + default: () => createElement('div', { 'data-testid': 'edit-menu' }, 'edit'), +})); + +vi.mock('../failed-publish-notice', () => ({ + default: () => createElement('div', { 'data-testid': 'failed-publish-notice' }, 'failed-publish-notice'), +})); + +vi.mock('../embed', () => ({ + canEmbed: () => false, +})); + +vi.mock('../loading-ellipsis', () => ({ + default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), +})); + +vi.mock('../post-desktop/post-menu-desktop', () => ({ + default: ({ postMenu }: { postMenu: { communityAddress?: string } }) => + createElement('div', { 'data-testid': 'post-menu-desktop' }, postMenu.communityAddress ?? 'missing'), +})); + +vi.mock('../reply-quote-preview', () => ({ + default: ({ backlinkReply }: { backlinkReply?: TestComment }) => createElement('div', { 'data-testid': 'reply-quote-preview' }, backlinkReply?.cid ?? 'missing'), +})); + +vi.mock('../tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), +})); + +vi.mock('../../lib/snow', () => ({ + shouldShowSnow: () => false, +})); + +vi.mock('../../stores/use-reply-modal-store', () => ({ + default: () => ({ + openReplyModal: testState.openReplyModalMock, + }), +})); + +vi.mock('../../stores/use-challenges-store', () => ({ + default: { + getState: () => ({ + addChallenge: testState.addChallengeMock, + }), + }, +})); + +vi.mock('../../stores/use-feed-reset-store', () => ({ + default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => + selector({ + setResetFunction: testState.setResetFunctionMock, + }), +})); + +vi.mock('../../hooks/use-register-fresh-replies', () => ({ + default: () => undefined, +})); + +vi.mock('../../lib/utils/challenge-utils', () => ({ + alertChallengeVerificationFailed: vi.fn(), +})); + +vi.mock('../../hooks/use-quoted-by-map', () => ({ + default: () => new Map(), +})); + +vi.mock('../../hooks/use-progressive-render', () => ({ + default: (replies: TestComment[]) => replies, +})); + +vi.mock('../../hooks/use-fresh-replies', () => ({ + default: (replies: TestComment[]) => replies, +})); + +vi.mock('../../lib/constants', () => ({ + BOARD_REPLIES_PREVIEW_FETCH_SIZE: 5, + BOARD_REPLIES_PREVIEW_VISIBLE_COUNT: 3, + REPLIES_PER_PAGE: 20, +})); + +vi.mock('../../lib/utils/replies-preview-utils', () => ({ + computeOmittedCount: () => 0, + filterRepliesForDisplay: (replies: TestComment[]) => replies, + getPreviewDisplayReplies: (replies: TestComment[]) => replies, + getTotalReplyCount: ({ replyCount }: { replyCount?: number }) => replyCount ?? 0, +})); + +vi.mock('../../lib/utils/thread-scroll-utils', () => ({ + getThreadTopNavigationState: () => undefined, + scrollThreadContainerToTop: () => true, +})); + +vi.mock('../../hooks/use-delete-failed-post', () => ({ + default: () => ({ + canDeleteFailedPost: false, + isDeletingFailedPost: false, + onDeleteFailedPost: vi.fn(), + }), +})); + +vi.mock('../post-mobile/post-menu-mobile', () => ({ + default: ({ postMenu }: { postMenu: { communityAddress?: string } }) => + createElement('div', { 'data-testid': 'post-menu-mobile' }, postMenu.communityAddress ?? 'missing'), +})); + +vi.mock('../../lib/utils/reply-backlink-utils', () => ({ + getRenderableMobileBacklinks: () => ({ + directReplyBacklinks: [], + opBacklinks: [], + quotedReplyBacklinks: [], + }), +})); + +let container: HTMLDivElement; +let root: Root; + +const flushEffects = async (count = 3) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const renderWithRoute = async (element: React.ReactNode, initialEntry = '/all') => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element)); + }); + await flushEffects(); +}; + +const makeLegacyThread = (): TestComment => ({ + author: { address: '0xauthor', shortAddress: 'anon' }, + cid: 'post-1', + content: 'Original post', + link: 'https://example.com/file.png', + linkHeight: 100, + linkWidth: 100, + number: 1, + postCid: 'post-1', + replyCount: 1, + replies: { + pages: { + new: { + comments: [ + { + author: { address: '0xreply', shortAddress: 'reply' }, + cid: 'reply-1', + content: 'Reply', + number: 2, + parentCid: 'post-1', + postCid: 'post-1', + subplebbitAddress: 'music-posting.eth', + }, + ], + }, + }, + }, + subplebbitAddress: 'music-posting.eth', + timestamp: 1_710_000_000, +}); + +describe('post community address compatibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.replyComments = []; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders desktop multiboard posts with only subplebbitAddress and still fetches replies', async () => { + await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() })); + + const primaryRepliesComment = testState.replyComments.find((comment) => comment?.cid === 'post-1'); + expect(primaryRepliesComment?.communityAddress).toBe('music-posting.eth'); + expect(primaryRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth'); + expect(container.querySelector('[data-testid="post-menu-desktop"]')?.textContent).toBe('music-posting.eth'); + expect(document.body.querySelector('a[href="/mu"]')?.textContent).toContain('mu'); + expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy(); + expect(container.textContent).toContain('reply-1'); + }); + + it('renders mobile multiboard posts with only subplebbitAddress and still fetches replies', async () => { + await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() })); + + const primaryRepliesComment = testState.replyComments.find((comment) => comment?.cid === 'post-1'); + expect(primaryRepliesComment?.communityAddress).toBe('music-posting.eth'); + expect(primaryRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth'); + expect(container.querySelector('[data-testid="post-menu-mobile"]')?.textContent).toBe('music-posting.eth'); + expect(document.body.querySelector('a[href="/mu"]')?.textContent).toContain('Board: mu'); + expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy(); + expect(container.textContent).toContain('reply-1'); + }); +}); diff --git a/src/components/blotter-message/__tests__/blotter-message.test.tsx b/src/components/blotter-message/__tests__/blotter-message.test.tsx new file mode 100644 index 00000000..6a0d41a3 --- /dev/null +++ b/src/components/blotter-message/__tests__/blotter-message.test.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BlotterMessage from '../blotter-message'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +vi.mock('../blotter-message.module.css', () => ({ + default: { + versionLink: 'versionLink', + }, +})); + +let container: HTMLDivElement; +let root: Root; + +describe('BlotterMessage', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('normalizes legacy subplebbit wording without rewriting plain community text', async () => { + await act(async () => { + root.render( + createElement(BlotterMessage, { + entry: { + id: 'manual-1', + kind: 'manual', + message: 'Moved a subplebbit into a community spotlight', + timestamp: 1_710_000_000, + }, + }), + ); + }); + + expect(container.textContent).toContain('Moved a board into a community spotlight'); + expect(container.textContent).not.toContain('subplebbit'); + expect(container.textContent).not.toContain('board spotlight'); + }); + + it('normalizes release one-liners after the version prefix', async () => { + await act(async () => { + root.render( + createElement(BlotterMessage, { + entry: { + id: 'release-1', + kind: 'release', + message: 'v0.7.0: Fix subplebbit loading in plebchan', + timestamp: 1_710_000_000, + version: '0.7.0', + }, + }), + ); + }); + + expect(container.querySelector('a')?.getAttribute('href')).toBe('https://github.com/bitsocialnet/5chan/releases/tag/v0.7.0'); + expect(container.textContent).toContain('Fix board loading in 5chan'); + }); +}); diff --git a/src/components/board-buttons/__tests__/board-buttons.test.tsx b/src/components/board-buttons/__tests__/board-buttons.test.tsx index 4c977206..e7e7b2e7 100644 --- a/src/components/board-buttons/__tests__/board-buttons.test.tsx +++ b/src/components/board-buttons/__tests__/board-buttons.test.tsx @@ -15,7 +15,7 @@ type DirectoryEntry = { }; const testState = vi.hoisted(() => ({ - accountComment: undefined as { subplebbitAddress?: string } | undefined, + accountComment: undefined as { communityAddress?: string } | undefined, alertThresholdUnit: 'minutes' as 'hours' | 'minutes', alertThresholdValue: 5, commentsByCid: {} as Record, @@ -32,7 +32,7 @@ const testState = vi.hoisted(() => ({ navigateMock: vi.fn(), pageNumber: 7 as number | null, resetMock: vi.fn(), - resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + resolvedCommunityAddress: 'music-posting.eth' as string | undefined, searchText: '', setAlertThresholdMock: vi.fn(), setFilterMock: vi.fn(), @@ -89,8 +89,8 @@ vi.mock('../../../hooks/use-directories', () => ({ useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address), })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../stores/use-catalog-filters-store', () => ({ @@ -230,7 +230,7 @@ describe('BoardButtons', () => { testState.isMobile = true; testState.linkCount = 3; testState.pageNumber = 7; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.resolvedCommunityAddress = 'music-posting.eth'; testState.searchText = ''; testState.showOPComment = false; testState.sortType = 'active'; diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 1cb104ea..96b85ae1 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -5,7 +5,7 @@ import { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, import { usePostPageNumber } from '../../hooks/use-post-page-number'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useFeedResetStore from '../../stores/use-feed-reset-store'; @@ -73,7 +73,7 @@ export const ArchiveButton = ({ address, isInAllView, isInSubscriptionsView, isI const SubscribeButton = ({ address }: BoardButtonsProps) => { const { t } = useTranslation(); - const { subscribed, subscribe, unsubscribe } = useSubscribe({ subplebbitAddress: address }); + const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address }); return ( @@ -119,8 +119,8 @@ const VoteButton = () => { const params = useParams(); const directories = useDirectories(); - // Get the boardIdentifier from params (try boardIdentifier first, then subplebbitAddress for backward compatibility) - const boardIdentifier = params.boardIdentifier || params.subplebbitAddress; + // Get the boardIdentifier from params (try boardIdentifier first, then communityAddress for backward compatibility) + const boardIdentifier = params.boardIdentifier || params.communityAddress; // Only render the vote button if we're on a directory board route if (!boardIdentifier || !isDirectoryBoard(boardIdentifier, directories)) { @@ -386,8 +386,8 @@ export const MobileBoardButtons = () => { const isInModQueueView = isModQueueView(location.pathname); const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); - const resolvedAddress = useResolvedSubplebbitAddress(); - const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; + const resolvedAddress = useResolvedCommunityAddress(); + const communityAddress = resolvedAddress || accountComment?.communityAddress; const { filteredCount, searchText } = useCatalogFiltersStore(); const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); @@ -397,15 +397,15 @@ export const MobileBoardButtons = () => { // Check if we should show the vote button (only for directory boards) const directories = useDirectories(); - const boardIdentifier = params.boardIdentifier || params.subplebbitAddress; + const boardIdentifier = params.boardIdentifier || params.communityAddress; const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories); return ( {isInPostView || isInPendingPostPage ? ( <> - - + + {showBottomButton && } @@ -415,7 +415,7 @@ export const MobileBoardButtons = () => { ) : isInModQueueView ? ( <> { > ) : isInCatalogView ? ( <> - - + + {showBottomButton && } {searchText ? ( @@ -469,11 +469,11 @@ export const MobileBoardButtons = () => { ) : ( <> {showBottomButton && } - + {showVoteButton && } - {!(isInAllView || isInSubscriptionsView || isInModView) && } + {!(isInAllView || isInSubscriptionsView || isInModView) && } {!(isInAllView || isInSubscriptionsView) && } > @@ -487,9 +487,9 @@ export const PostPageStats = () => { const params = useParams(); const location = useLocation(); const commentCid = params?.commentCid as string | undefined; - const resolvedAddress = useResolvedSubplebbitAddress(); + const resolvedAddress = useResolvedCommunityAddress(); const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); - const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; + const communityAddress = resolvedAddress || accountComment?.communityAddress; const comment = useComment({ commentCid }); const postCid = comment?.postCid ?? commentCid; @@ -497,12 +497,12 @@ export const PostPageStats = () => { const { closed, pinned, replyCount } = post || {}; const linkCount = useCountLinksInReplies(post); - const directoryEntry = useDirectoryByAddress(subplebbitAddress); + const directoryEntry = useDirectoryByAddress(communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; const isThreadView = isPostPageView(location.pathname, params); const pageNumber = usePostPageNumber({ - subplebbitAddress, + subplebbitAddress: communityAddress, postCid, enabled: isThreadView, }); @@ -531,8 +531,8 @@ export const DesktopBoardButtons = () => { const params = useParams(); const location = useLocation(); const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); - const resolvedAddress = useResolvedSubplebbitAddress(); - const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; + const resolvedAddress = useResolvedCommunityAddress(); + const communityAddress = resolvedAddress || accountComment?.communityAddress; const isInCatalogView = isCatalogView(location.pathname, params); const isInAllView = isAllView(location.pathname); const isInPendingPostPage = isPendingPostView(location.pathname, params); @@ -549,7 +549,7 @@ export const DesktopBoardButtons = () => { // Check if we should show the vote button (only for directory boards) const directories = useDirectories(); - const boardIdentifier = params.boardIdentifier || params.subplebbitAddress; + const boardIdentifier = params.boardIdentifier || params.communityAddress; const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories); return ( @@ -558,8 +558,8 @@ export const DesktopBoardButtons = () => { {isInPostView || isInPendingPostPage ? ( <> - [] [ - ] + [] [ + ] {showBottomButton && ( <> {' '} @@ -575,7 +575,7 @@ export const DesktopBoardButtons = () => { <> [ { <> {isInCatalogView ? ( <> - [] [ - ]{' '} + [] [ + ]{' '} > ) : ( <> - [] [ - ]{' '} + [] [ + ]{' '} > )} {showBottomButton && ( @@ -645,7 +645,7 @@ export const DesktopBoardButtons = () => { {showVoteButton && !(isInAllView || isInSubscriptionsView || isInModView) && ' '} {!(isInAllView || isInSubscriptionsView || isInModView) && ( <> - [] + [] > )}{' '} {isInCatalogView && ( @@ -670,8 +670,8 @@ const SearchOPsBar = () => { const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const isInModView = isModView(location.pathname); const directories = useDirectories(); - const resolvedAddress = useResolvedSubplebbitAddress(); - const boardPath = resolvedAddress ? getBoardPath(resolvedAddress, directories) : params?.boardIdentifier || params?.subplebbitAddress; + const resolvedAddress = useResolvedCommunityAddress(); + const boardPath = resolvedAddress ? getBoardPath(resolvedAddress, directories) : params?.boardIdentifier || params?.communityAddress; const handleSearch = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx index f4d0e359..09b8cc54 100644 --- a/src/components/board-header/__tests__/board-header.test.tsx +++ b/src/components/board-header/__tests__/board-header.test.tsx @@ -9,7 +9,7 @@ import BoardHeader from '../board-header'; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ - accountComment: undefined as { subplebbitAddress?: string } | undefined, + accountComment: undefined as { communityAddress?: string } | undefined, directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, directoriesMetadata: { title: '/all/ - Directories' } as { title?: string } | undefined, isMobile: false, @@ -18,16 +18,16 @@ const testState = vi.hoisted(() => ({ offlineTitle: 'Board offline', resolvedAddress: 'music-posting.eth' as string | undefined, shouldShowSnow: false, - stableSubplebbit: { + stableCommunity: { address: 'music-posting.eth', shortAddress: 'music-posting.eth', title: '/mu/ - Music', } as { address?: string; shortAddress?: string; title?: string } | undefined, subscriptionsCount: 2, - subplebbits: { + communities: { 'music-posting.eth': { address: 'music-posting.eth' }, } as Record, - useIsSubplebbitOfflineValue: { + useIsCommunityOfflineValue: { isOffline: false, isOnlineStatusLoading: false, offlineIconClass: 'offline', @@ -65,15 +65,15 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts', () => ({ }), })); -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, }), })); -vi.mock('../../../hooks/use-stable-subplebbit', () => ({ - useStableSubplebbit: () => testState.stableSubplebbit, +vi.mock('../../../hooks/use-stable-community', () => ({ + useStableCommunity: () => testState.stableCommunity, })); vi.mock('../../../hooks/use-directories', () => ({ @@ -81,16 +81,16 @@ vi.mock('../../../hooks/use-directories', () => ({ useDirectoriesMetadata: () => testState.directoriesMetadata, })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedAddress, })); vi.mock('../../../hooks/use-is-mobile', () => ({ default: () => testState.isMobile, })); -vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({ - default: () => testState.useIsSubplebbitOfflineValue, +vi.mock('../../../hooks/use-is-community-offline', () => ({ + default: () => testState.useIsCommunityOfflineValue, })); vi.mock('../../../lib/snow', () => ({ @@ -129,16 +129,16 @@ describe('BoardHeader', () => { testState.offlineTitle = 'Board offline'; testState.resolvedAddress = 'music-posting.eth'; testState.shouldShowSnow = false; - testState.stableSubplebbit = { + testState.stableCommunity = { address: 'music-posting.eth', shortAddress: 'music-posting.eth', title: '/mu/ - Music', }; testState.subscriptionsCount = 2; - testState.subplebbits = { + testState.communities = { 'music-posting.eth': { address: 'music-posting.eth' }, }; - testState.useIsSubplebbitOfflineValue = { + testState.useIsCommunityOfflineValue = { isOffline: false, isOnlineStatusLoading: false, offlineIconClass: 'offline', @@ -179,7 +179,7 @@ describe('BoardHeader', () => { }); it('renders the board title, address subtitle, and offline indicator for board routes', async () => { - testState.useIsSubplebbitOfflineValue = { + testState.useIsCommunityOfflineValue = { isOffline: true, isOnlineStatusLoading: false, offlineIconClass: 'offline', diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index 8f9b6cd6..8606a7e0 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -3,15 +3,15 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useParams, useNavigate } from 'react-router-dom'; import { useAccountComment } from '@bitsocialnet/bitsocial-react-hooks'; import useAccountsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts'; -import useSubplebbitsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits'; +import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities'; import getShortAddress from '../../lib/get-short-address'; -import { useStableSubplebbit } from '../../hooks/use-stable-subplebbit'; +import { useStableCommunity } from '../../hooks/use-stable-community'; import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils'; import styles from './board-header.module.css'; import { useDirectoriesMetadata, useDirectories } from '../../hooks/use-directories'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import useIsMobile from '../../hooks/use-is-mobile'; -import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; +import useIsCommunityOffline from '../../hooks/use-is-community-offline'; import { shouldShowSnow } from '../../lib/snow'; import Tooltip from '../tooltip'; import startCase from 'lodash/startCase'; @@ -25,10 +25,10 @@ const ImageBanner = () => { // Separate component for offline indicator to isolate rerenders from updatingState // Only this component will rerender when updatingState changes, not the whole BoardHeader -const OfflineIndicator = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => { - // Subscribe to full subplebbit including transient state for offline detection - const subplebbit = useSubplebbitsStore((state) => (subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined)); - const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsSubplebbitOffline(subplebbit); +const OfflineIndicator = ({ communityAddress }: { communityAddress: string | undefined }) => { + // Subscribe to full community including transient state for offline detection + const community = useCommunitiesStore((state) => (communityAddress ? state.communities[communityAddress] : undefined)); + const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsCommunityOffline(community); if (!isOffline && !isOnlineStatusLoading) { return null; @@ -52,18 +52,18 @@ const BoardHeader = () => { const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const isInModView = isModView(location.pathname); const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); - const resolvedAddress = useResolvedSubplebbitAddress(); - const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; + const resolvedAddress = useResolvedCommunityAddress(); + const communityAddress = resolvedAddress || accountComment?.communityAddress; - // Use stable subplebbit for display fields to avoid rerenders from updatingState - const stableSubplebbit = useStableSubplebbit(subplebbitAddress); - const { address, shortAddress } = stableSubplebbit || {}; + // Use stable community for display fields to avoid rerenders from updatingState + const stableCommunity = useStableCommunity(communityAddress); + const { address, shortAddress } = stableCommunity || {}; const directoriesMetadata = useDirectoriesMetadata(); const directories = useDirectories(); - // Find matching subplebbit from default list to get its title - const defaultSubplebbit = subplebbitAddress ? directories.find((s) => s.address === subplebbitAddress) : null; + // Find matching community from default list to get its title + const defaultCommunity = communityAddress ? directories.find((s) => s.address === communityAddress) : null; // Use accounts store with selector to only subscribe to subscriptions count const subscriptionsCount = useAccountsStore((state) => { @@ -79,14 +79,14 @@ const BoardHeader = () => { ? '/subs/ - Subscriptions' : isInModView ? startCase(t('boards_you_moderate')) - : defaultSubplebbit?.title || stableSubplebbit?.title; - const subtitle = isInAllView ? '' : isInSubscriptionsView ? subscriptionsSubtitle : isInModView ? '/mod/' : `${address || subplebbitAddress || ''}`; + : defaultCommunity?.title || stableCommunity?.title; + const subtitle = isInAllView ? '' : isInSubscriptionsView ? subscriptionsSubtitle : isInModView ? '/mod/' : `${address || communityAddress || ''}`; return ( {!useIsMobile() && ( - + )} @@ -95,8 +95,8 @@ const BoardHeader = () => { ? shortAddress.endsWith('.eth') || shortAddress.endsWith('.sol') ? shortAddress.slice(0, -4) : shortAddress - : subplebbitAddress && getShortAddress(subplebbitAddress))} - {!isInAllView && !isInSubscriptionsView && !isInModView && } + : communityAddress && getShortAddress(communityAddress))} + {!isInAllView && !isInSubscriptionsView && !isInModView && } {isInSubscriptionsView ? ( diff --git a/src/components/board-offline-alert/board-offline-alert.tsx b/src/components/board-offline-alert/board-offline-alert.tsx index 9730a57a..d8e489ad 100644 --- a/src/components/board-offline-alert/board-offline-alert.tsx +++ b/src/components/board-offline-alert/board-offline-alert.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react'; -import useSubplebbitsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits'; +import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities'; import { normalizeBoardAddress, useDirectoryByAddress } from '../../hooks/use-directories'; -import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import useIsCommunityOffline from '../../hooks/use-is-community-offline'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; const BOARD_ALIAS_SUFFIXES = ['.bso', '.eth'] as const; @@ -33,29 +33,29 @@ const getBoardAddressCandidates = (addresses: Array) => { interface BoardOfflineAlertProps { className: string; hidden?: boolean; - subplebbitAddress?: string; + communityAddress?: string; } -const BoardOfflineAlert = ({ className, hidden = false, subplebbitAddress }: BoardOfflineAlertProps) => { - const resolvedSubplebbitAddress = useResolvedSubplebbitAddress(); - const directoryEntry = useDirectoryByAddress(resolvedSubplebbitAddress || subplebbitAddress); +const BoardOfflineAlert = ({ className, hidden = false, communityAddress }: BoardOfflineAlertProps) => { + const resolvedCommunityAddress = useResolvedCommunityAddress(); + const directoryEntry = useDirectoryByAddress(resolvedCommunityAddress || communityAddress); const addressCandidates = useMemo( - () => getBoardAddressCandidates([resolvedSubplebbitAddress, directoryEntry?.address, subplebbitAddress]), - [directoryEntry?.address, resolvedSubplebbitAddress, subplebbitAddress], + () => getBoardAddressCandidates([resolvedCommunityAddress, directoryEntry?.address, communityAddress]), + [directoryEntry?.address, resolvedCommunityAddress, communityAddress], ); // Probe common aliases first so loading/offline state stays consistent across route and post payload address formats. - const subplebbit = useSubplebbitsStore((state) => { + const community = useCommunitiesStore((state) => { for (const candidate of addressCandidates) { - const matchedSubplebbit = state.subplebbits[candidate]; - if (matchedSubplebbit) { - return matchedSubplebbit; + const matchedCommunity = state.communities[candidate]; + if (matchedCommunity) { + return matchedCommunity; } } return undefined; }); - const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit); + const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsCommunityOffline(community); if (hidden || (!isOffline && !isOnlineStatusLoading)) { return null; diff --git a/src/components/boards-bar/__tests__/boards-bar.test.tsx b/src/components/boards-bar/__tests__/boards-bar.test.tsx index b732b3a7..b286353c 100644 --- a/src/components/boards-bar/__tests__/boards-bar.test.tsx +++ b/src/components/boards-bar/__tests__/boards-bar.test.tsx @@ -9,8 +9,8 @@ import BoardsBar from '../boards-bar'; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ - accountComment: undefined as { subplebbitAddress?: string } | undefined, - accountSubplebbitAddresses: ['music-posting.eth'] as string[], + accountComment: undefined as { communityAddress?: string; subplebbitAddress?: string } | undefined, + accountCommunityAddresses: ['music-posting.eth'] as string[], directories: [ { address: 'music-posting.eth', title: '/mu/ - Music' }, { address: 'tech-posting.eth', title: '/g/ - Technology' }, @@ -21,7 +21,7 @@ const testState = vi.hoisted(() => ({ openBoardsBarEditModalMock: vi.fn(), openCreateBoardModalMock: vi.fn(), openDirectoryModalMock: vi.fn(), - resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + resolvedCommunityAddress: 'music-posting.eth' as string | undefined, showSubscriptionsInBoardsBar: true, subscriptions: ['custom.eth'] as string[], visibleDirectories: new Set(['mu']), @@ -68,8 +68,8 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts', () => ({ }), })); -vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({ - useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses, +vi.mock('../../../hooks/use-account-community-addresses', () => ({ + useAccountCommunityAddresses: () => testState.accountCommunityAddresses, })); vi.mock('../../../hooks/use-directories', async () => { @@ -81,13 +81,13 @@ vi.mock('../../../hooks/use-directories', async () => { }; }); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useBoardPath: (subplebbitAddress: string | undefined) => { - if (subplebbitAddress === 'music-posting.eth') return 'mu'; - if (subplebbitAddress === 'tech-posting.eth') return 'g'; - return subplebbitAddress; +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useBoardPath: (communityAddress: string | undefined) => { + if (communityAddress === 'music-posting.eth') return 'mu'; + if (communityAddress === 'tech-posting.eth') return 'g'; + return communityAddress; }, - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../stores/use-create-board-modal-store', () => ({ @@ -150,7 +150,7 @@ describe('BoardsBar', () => { beforeEach(() => { vi.clearAllMocks(); testState.accountComment = undefined; - testState.accountSubplebbitAddresses = ['music-posting.eth']; + testState.accountCommunityAddresses = ['music-posting.eth']; testState.directories = [ { address: 'music-posting.eth', title: '/mu/ - Music' }, { address: 'tech-posting.eth', title: '/g/ - Technology' }, @@ -161,7 +161,7 @@ describe('BoardsBar', () => { testState.openCreateBoardModalMock.mockReset(); testState.openDirectoryModalMock.mockReset(); testState.initializeVisibilityMock.mockReset(); - testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.resolvedCommunityAddress = 'music-posting.eth'; testState.showSubscriptionsInBoardsBar = true; testState.subscriptions = ['custom.eth']; testState.visibleDirectories = new Set(['mu']); @@ -258,4 +258,15 @@ describe('BoardsBar', () => { expect(mobileNav?.style.transform).toBe('translateY(-23px)'); }); + + it('keeps the mobile board context for legacy account comments', async () => { + testState.resolvedCommunityAddress = undefined; + testState.accountComment = { subplebbitAddress: 'music-posting.eth' }; + + await renderBoardsBar('/pending/7'); + + const select = container.querySelector('select'); + expect(select).toBeTruthy(); + expect(Array.from(select?.querySelectorAll('option') ?? []).some((option) => option.value === 'mu')).toBe(true); + }); }); diff --git a/src/components/boards-bar/boards-bar.tsx b/src/components/boards-bar/boards-bar.tsx index 920755c1..ff124da0 100644 --- a/src/components/boards-bar/boards-bar.tsx +++ b/src/components/boards-bar/boards-bar.tsx @@ -5,10 +5,11 @@ import getShortAddress from '../../lib/get-short-address'; import { useAccountComment } from '@bitsocialnet/bitsocial-react-hooks'; import useAccountsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts'; import { isAllView, isCatalogView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useAccountSubplebbitAddresses } from '../../hooks/use-account-subplebbit-addresses'; +import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useDirectories, useDirectoriesMetadata, DirectoryCommunity } from '../../hooks/use-directories'; -import { useBoardPath, useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useBoardPath, useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { getBoardPath, extractDirectoryFromTitle } from '../../lib/utils/route-utils'; +import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import useCreateBoardModalStore from '../../stores/use-create-board-modal-store'; import useBoardsBarEditModalStore from '../../stores/use-boards-bar-edit-modal-store'; import useBoardsBarVisibilityStore from '../../stores/use-boards-bar-visibility-store'; @@ -81,9 +82,9 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) => // Helper function to find board address by directory code const findBoardAddressByCode = (code: string, directories: DirectoryCommunity[]): string | null => { - const entry = directories.find((subplebbit) => { - if (!subplebbit.title) return false; - const directory = extractDirectoryFromTitle(subplebbit.title); + const entry = directories.find((community) => { + if (!community.title) return false; + const directory = extractDirectoryFromTitle(community.title); return directory === code; }); return entry?.address || null; @@ -117,7 +118,7 @@ const BoardsBarDesktop = () => { }, ); - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); // Show all subscriptions when enabled; no separate per-address tracking (avoids drift when subscribing from board-buttons) const visibleSubscriptionAddresses = showSubscriptionsInBoardsBar ? subscriptions : []; @@ -205,7 +206,7 @@ const BoardsBarDesktop = () => { [all / subs - {accountSubplebbitAddresses.length > 0 && ( + {accountCommunityAddresses.length > 0 && ( <> {' '} / mod @@ -300,12 +301,12 @@ const BoardsBarDesktop = () => { ); }; -const BoardsBarMobile = ({ subplebbitAddress }: { subplebbitAddress?: string }) => { +const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) => { const { t } = useTranslation(); const navigate = useNavigate(); const directories = useDirectories(); const directoriesMetadata = useDirectoriesMetadata(); - const displaySubplebbitAddress = subplebbitAddress && subplebbitAddress.length > 30 ? subplebbitAddress.slice(0, 30).concat('...') : subplebbitAddress; + const displayCommunityAddress = communityAddress && communityAddress.length > 30 ? communityAddress.slice(0, 30).concat('...') : communityAddress; const [showSearchBar, setShowSearchBar] = useState(false); // Filter to only show directory boards (those with titles) @@ -317,13 +318,13 @@ const BoardsBarMobile = ({ subplebbitAddress }: { subplebbitAddress?: string }) const isInCatalogView = isCatalogView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInModView = isModView(location.pathname); - const boardPath = useBoardPath(subplebbitAddress); - const selectValue = isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : boardPath || subplebbitAddress; + const boardPath = useBoardPath(communityAddress); + const selectValue = isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : boardPath || communityAddress; - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); - // Check if current subplebbit is a directory board - const currentIsDirectoryBoard = directoryBoards.some((board) => board.address === subplebbitAddress); + // Check if current community is a directory board + const currentIsDirectoryBoard = directoryBoards.some((board) => board.address === communityAddress); // Build multiboards with full titles, then combine with directory boards and sort alphabetically const sortedBoardOptions = useMemo(() => { @@ -334,7 +335,7 @@ const BoardsBarMobile = ({ subplebbitAddress }: { subplebbitAddress?: string }) const multiboards: Array<{ value: string; label: string }> = [ { value: 'all', label: allTitle }, { value: 'subs', label: subsTitle }, - ...(accountSubplebbitAddresses.length > 0 ? [{ value: 'mod', label: modTitle }] : []), + ...(accountCommunityAddresses.length > 0 ? [{ value: 'mod', label: modTitle }] : []), ]; const directoryOptions = directoryBoards.map((board) => { @@ -343,7 +344,7 @@ const BoardsBarMobile = ({ subplebbitAddress }: { subplebbitAddress?: string }) }); return [...multiboards, ...directoryOptions].sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); - }, [directoriesMetadata?.title, t, accountSubplebbitAddresses.length, directoryBoards]); + }, [directoriesMetadata?.title, t, accountCommunityAddresses.length, directoryBoards]); const boardSelect = ( - {!currentIsDirectoryBoard && subplebbitAddress && {displaySubplebbitAddress}} + {!currentIsDirectoryBoard && communityAddress && {displayCommunityAddress}} {sortedBoardOptions.map((opt) => ( {opt.label} @@ -415,13 +416,13 @@ const BoardsBar = () => { const params = useParams(); const commentIndex = params?.accountCommentIndex ? parseInt(params.accountCommentIndex) : undefined; const accountComment = useAccountComment({ commentIndex }); - const resolvedSubplebbitAddress = useResolvedSubplebbitAddress(); - const subplebbitAddress = resolvedSubplebbitAddress || accountComment?.subplebbitAddress; + const resolvedCommunityAddress = useResolvedCommunityAddress(); + const communityAddress = resolvedCommunityAddress || getCommentCommunityAddress(accountComment); return ( <> - + > ); }; diff --git a/src/components/catalog-filters/__tests__/catalog-filters.test.tsx b/src/components/catalog-filters/__tests__/catalog-filters.test.tsx index 59688007..2abec93c 100644 --- a/src/components/catalog-filters/__tests__/catalog-filters.test.tsx +++ b/src/components/catalog-filters/__tests__/catalog-filters.test.tsx @@ -13,13 +13,16 @@ type FilterItem = { enabled: boolean; filteredCids: Set; hide: boolean; - subplebbitCounts: Map; - subplebbitFilteredCids: Map>; + communityCounts: Map; + communityFilteredCids: Map>; + subplebbitCounts?: Map; + subplebbitFilteredCids?: Map>; text: string; top: boolean; }; const testState = vi.hoisted(() => ({ + currentCommunityAddress: 'music-posting.eth' as string | null, currentSubplebbitAddress: 'music-posting.eth' as string | null, filterItems: [] as FilterItem[], resetCountsMock: vi.fn(), @@ -33,8 +36,10 @@ const createFilterItem = (overrides: Partial = {}): FilterItem => ({ enabled: true, filteredCids: new Set(), hide: true, - subplebbitCounts: new Map(), - subplebbitFilteredCids: new Map>(), + communityCounts: new Map(), + communityFilteredCids: new Map>(), + subplebbitCounts: undefined, + subplebbitFilteredCids: undefined, text: '', top: false, ...overrides, @@ -42,6 +47,7 @@ const createFilterItem = (overrides: Partial = {}): FilterItem => ({ function getCatalogFiltersState() { return { + currentCommunityAddress: testState.currentCommunityAddress, currentSubplebbitAddress: testState.currentSubplebbitAddress, filterItems: testState.filterItems, saveAndApplyFilters: testState.saveAndApplyFiltersMock, @@ -54,7 +60,7 @@ function useCatalogFiltersStoreMock(selector?: (state: ReturnType ({ - resetCountsForCurrentSubplebbit: testState.resetCountsMock, + resetCountsForCurrentCommunity: testState.resetCountsMock, }); vi.mock('react-i18next', () => ({ @@ -134,19 +140,20 @@ describe('CatalogFilters', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); + testState.currentCommunityAddress = 'music-posting.eth'; testState.currentSubplebbitAddress = 'music-posting.eth'; testState.filterItems = [ createFilterItem({ count: 2, - subplebbitCounts: new Map([['music-posting.eth', 2]]), - subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['alpha-cid'])]]), + communityCounts: new Map([['music-posting.eth', 2]]), + communityFilteredCids: new Map([['music-posting.eth', new Set(['alpha-cid'])]]), text: 'alpha', }), createFilterItem({ count: 4, hide: false, - subplebbitCounts: new Map([['music-posting.eth', 4]]), - subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['beta-cid'])]]), + communityCounts: new Map([['music-posting.eth', 4]]), + communityFilteredCids: new Map([['music-posting.eth', new Set(['beta-cid'])]]), text: 'beta', top: true, }), @@ -271,4 +278,40 @@ describe('CatalogFilters', () => { expect(testState.resetFeedMock).toHaveBeenCalledTimes(1); expect(container.querySelector('[title="close"]')).toBeNull(); }); + + it('shows filter hit counts when only the legacy currentSubplebbitAddress is populated', async () => { + testState.currentCommunityAddress = null; + + renderCatalogFilters(); + await openModal(); + + expect(container.textContent).toContain('x2'); + expect(container.textContent).toContain('x4'); + }); + + it('shows filter hit counts when only the legacy subplebbit count payload is populated', async () => { + testState.currentCommunityAddress = null; + testState.filterItems = [ + createFilterItem({ + communityCounts: new Map(), + communityFilteredCids: new Map>(), + subplebbitCounts: new Map([['music-posting.eth', 2]]), + subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['alpha-cid'])]]), + text: 'alpha', + }), + createFilterItem({ + communityCounts: new Map(), + communityFilteredCids: new Map>(), + subplebbitCounts: new Map([['music-posting.eth', 4]]), + subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['beta-cid'])]]), + text: 'beta', + }), + ]; + + renderCatalogFilters(); + await openModal(); + + expect(container.textContent).toContain('x2'); + expect(container.textContent).toContain('x4'); + }); }); diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx index 65c9a18d..0c54fbf8 100644 --- a/src/components/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -6,18 +6,76 @@ import FiltersProtip from './filters-protip'; import HighlightColorPicker from './highlight-color-picker'; import styles from './catalog-filters.module.css'; +type CatalogFilterItemInput = { + text: string; + enabled: boolean; + count: number; + filteredCids: Set; + subplebbitCounts?: Map; + subplebbitFilteredCids?: Map>; + communityCounts?: Map; + communityFilteredCids?: Map>; + hide?: boolean; + top?: boolean; + color?: string; + id?: string; +}; + +type CatalogFilterItemStore = { + text: string; + enabled: boolean; + count: number; + filteredCids: Set; + communityCounts: Map; + communityFilteredCids: Map>; + subplebbitCounts: Map; + subplebbitFilteredCids: Map>; + hide: boolean; + top: boolean; + color: string; + id?: string; +}; + +const selectFilterMap = (preferred?: Map, legacy?: Map) => { + if (preferred && preferred.size > 0) return preferred; + if (legacy && legacy.size > 0) return legacy; + return preferred || legacy || new Map(); +}; + +const toCatalogFilterItem = (item: CatalogFilterItemInput): CatalogFilterItemStore => { + const counts = selectFilterMap(item.communityCounts, item.subplebbitCounts); + const filteredByCommunity = selectFilterMap(item.communityFilteredCids, item.subplebbitFilteredCids); + + return { + ...item, + count: item.count || 0, + filteredCids: item.filteredCids || new Set(), + communityCounts: counts, + communityFilteredCids: filteredByCommunity, + subplebbitCounts: counts, + subplebbitFilteredCids: filteredByCommunity, + hide: item.hide ?? true, + top: item.top ?? false, + color: item.color || '', + }; +}; + const FiltersTable = ({ onSave }: { onSave: () => void }) => { const { t } = useTranslation(); - const { filterItems, saveAndApplyFilters, currentSubplebbitAddress } = useCatalogFiltersStore(); + const { currentSubplebbitAddress, currentCommunityAddress, filterItems, saveAndApplyFilters } = useCatalogFiltersStore((state) => ({ + currentSubplebbitAddress: state.currentSubplebbitAddress, + // legacy fallback kept for compatibility while worker B/store migration is in progress + currentCommunityAddress: (state as { currentCommunityAddress?: string | null }).currentCommunityAddress ?? null, + filterItems: state.filterItems as CatalogFilterItemInput[], + saveAndApplyFilters: state.saveAndApplyFilters, + })); + const currentCommunityAddressResolved = currentCommunityAddress ?? currentSubplebbitAddress; const resetFeed = useFeedResetStore((state) => state.reset); const [localFilterItems, setLocalFilterItems] = useState(() => filterItems.map((item, i) => ({ - ...item, + ...toCatalogFilterItem(item), id: `filter-${i}-${Date.now()}`, - hide: item.hide ?? true, - top: item.top ?? false, - color: item.color ?? '', })), ); @@ -37,6 +95,8 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { enabled: true, count: 0, filteredCids: new Set(), + communityCounts: new Map(), + communityFilteredCids: new Map>(), subplebbitCounts: new Map(), subplebbitFilteredCids: new Map>(), hide: true, @@ -52,7 +112,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { saveAndApplyFilters(nonEmptyFilters); - useCatalogFiltersStore.getState().resetCountsForCurrentSubplebbit(); + const filtersState = useCatalogFiltersStore.getState() as { + resetCountsForCurrentCommunity?: () => void; + resetCountsForCurrentSubplebbit?: () => void; + }; + filtersState.resetCountsForCurrentCommunity?.(); + filtersState.resetCountsForCurrentSubplebbit?.(); if (resetFeed) { resetFeed(); @@ -169,7 +234,9 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { - {currentSubplebbitAddress && item.subplebbitFilteredCids?.has(currentSubplebbitAddress) && `x${item.subplebbitCounts?.get(currentSubplebbitAddress) ?? 0}`} + {currentCommunityAddressResolved && + item.communityFilteredCids?.has(currentCommunityAddressResolved) && + `x${item.communityCounts?.get(currentCommunityAddressResolved) ?? 0}`} ))} @@ -193,7 +260,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { const FiltersModal = ({ closeModal }: { closeModal: () => void }) => { const { t } = useTranslation(); const [showHelp, setShowHelp] = useState(false); - const currentSubplebbitAddress = useCatalogFiltersStore((state) => state.currentSubplebbitAddress); + const { currentSubplebbitAddress, currentCommunityAddress } = useCatalogFiltersStore((state) => ({ + currentSubplebbitAddress: state.currentSubplebbitAddress, + // legacy fallback kept for compatibility while worker B/store migration is in progress + currentCommunityAddress: (state as { currentCommunityAddress?: string | null }).currentCommunityAddress ?? null, + })); + const currentCommunityAddressResolved = currentCommunityAddress ?? currentSubplebbitAddress; const openHelp = () => setShowHelp(true); const closeHelp = () => setShowHelp(false); @@ -257,7 +329,7 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => { onClick={closeModal} /> - {showHelp ? : } + {showHelp ? : } > ); diff --git a/src/components/catalog-row/__tests__/catalog-row.test.tsx b/src/components/catalog-row/__tests__/catalog-row.test.tsx index f37ae6bd..33de9f00 100644 --- a/src/components/catalog-row/__tests__/catalog-row.test.tsx +++ b/src/components/catalog-row/__tests__/catalog-row.test.tsx @@ -24,7 +24,16 @@ type TestComment = { postCid?: string; removed?: boolean; replyCount?: number; + replies?: { + pages?: Record< + string, + { + comments?: TestComment[]; + } + >; + }; spoiler?: boolean; + communityAddress?: string; subplebbitAddress?: string; thumbnailUrl?: string; timestamp?: number; @@ -44,6 +53,7 @@ const testState = vi.hoisted(() => ({ linkCount: 0, matchedFilters: new Map(), mediaInfoByLink: {} as Record, + lastRepliesComment: undefined as TestComment | undefined, replies: [] as TestComment[], roleByAddress: {} as Record, showOPComment: true, @@ -63,9 +73,28 @@ vi.mock('react-i18next', () => ({ })); vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ - useReplies: ({ comment }: { comment?: TestComment }) => ({ - replies: comment ? testState.replies : [], - }), + useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => { + if (comment) { + testState.lastRepliesComment = comment; + } + + const preloadedReplies = + comment?.replies?.pages?.[sortType || 'best']?.comments ?? Object.values(comment?.replies?.pages ?? {}).find((page) => page?.comments?.length)?.comments; + + const compatiblePreloadedReplies: TestComment[] = []; + if (preloadedReplies?.length && comment?.communityAddress) { + for (const reply of preloadedReplies) { + if (!reply?.communityAddress || reply.communityAddress !== comment.communityAddress) { + break; + } + compatiblePreloadedReplies.push(reply); + } + } + + return { + replies: comment ? (compatiblePreloadedReplies.length ? compatiblePreloadedReplies : testState.replies) : [], + }; + }, })); vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({ @@ -202,6 +231,7 @@ describe('CatalogRow', () => { testState.linkCount = 0; testState.matchedFilters = new Map(); testState.mediaInfoByLink = {}; + testState.lastRepliesComment = undefined; testState.replies = []; testState.roleByAddress = {}; testState.showOPComment = true; @@ -296,7 +326,7 @@ describe('CatalogRow', () => { locked: true, pinned: true, replyCount: 5, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', timestamp: 100, title: 'Thread title', }; @@ -335,7 +365,7 @@ describe('CatalogRow', () => { content: 'Alias test', link: 'https://example.com/media.png', replyCount: 4, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', title: 'Alias title', }; @@ -348,6 +378,52 @@ describe('CatalogRow', () => { expect(container.querySelector('[title=\"(R)eplies / (I)mage Replies\"]')).toBeTruthy(); }); + it('normalizes legacy board addresses before fetching hover preview replies', async () => { + testState.directories = [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }]; + testState.mediaInfoByLink['https://example.com/legacy.png'] = { type: 'image', url: 'https://example.com/legacy.png' }; + testState.replies = []; + + const post: TestComment = { + author: { address: 'author-1', displayName: 'Alice' }, + cid: 'post-legacy', + content: 'Legacy address thread', + link: 'https://example.com/legacy.png', + replyCount: 1, + replies: { + pages: { + new: { + comments: [ + { + author: { address: 'author-2', displayName: 'Bob' }, + cid: 'reply-legacy', + subplebbitAddress: 'music-posting.eth', + timestamp: 200, + }, + ], + }, + }, + }, + subplebbitAddress: 'music-posting.eth', + timestamp: 100, + title: 'Legacy title', + }; + + await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog'); + vi.useFakeTimers(); + + const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-legacy"] > div'); + await act(async () => { + previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + vi.advanceTimersByTime(260); + await Promise.resolve(); + }); + + expect(testState.lastRepliesComment?.communityAddress).toBe('music-posting.eth'); + expect(testState.lastRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth'); + expect(document.body.textContent).toContain('Legacy title by Alice'); + expect(document.body.textContent).toContain('last_reply_by Bob'); + }); + it('renders hidden and text-only threads with canonical board thread links', async () => { testState.hiddenCids = new Set(['hidden-1']); testState.showOPComment = false; @@ -358,14 +434,14 @@ describe('CatalogRow', () => { cid: 'hidden-1', content: 'hidden text', link: 'https://example.com/hidden.png', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, { author: { address: 'text-author', displayName: 'Anon' }, cid: 'text-1', content: 'Plain thread body', replyCount: 1, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', title: 'Text title', }, ]; diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index 2225ec1b..c19bd77f 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -23,6 +23,7 @@ import PostMenuDesktop from '../post-desktop/post-menu-desktop'; import styles from './catalog-row.module.css'; import capitalize from 'lodash/capitalize'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; +import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; interface CatalogPostMediaProps { cid: string; @@ -117,8 +118,10 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight const CatalogPost = memo( ({ post }: { post: Comment }) => { const { t } = useTranslation(); - const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, subplebbitAddress, timestamp, title, thumbnailUrl } = post || {}; - const linkCount = useCountLinksInReplies(post); + const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]); + const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } = + resolvedPost || {}; + const linkCount = useCountLinksInReplies(resolvedPost); const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); @@ -130,10 +133,10 @@ const CatalogPost = memo( const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const directories = useDirectories(); - const directoryEntry = findDirectoryByAddress(directories, subplebbitAddress); + const directoryEntry = findDirectoryByAddress(directories, communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : ''; - const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]); + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : ''; + const postMenuProps = useMemo(() => selectPostMenuProps(resolvedPost), [resolvedPost]); const postLink = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; @@ -185,16 +188,16 @@ const CatalogPost = memo( if (showPortal) update(); }, [showPortal, update]); - const { replies } = useReplies({ comment: showPortal ? post : undefined, flat: true }); + const { replies } = useReplies({ comment: showPortal ? resolvedPost : undefined, flat: true }); const lastReply = replies?.length > 0 ? replies[replies.length - 1] : null; const { isCommentAuthorMod: isCatalogPostAuthorMod, commentAuthorRole: catalogPostAuthorRole } = useEditCommentPrivileges({ commentAuthorAddress: author?.address, - subplebbitAddress, + communityAddress: communityAddress ?? '', }); const { isCommentAuthorMod: isLastReplyAuthorMod, commentAuthorRole: lastReplyAuthorRole } = useEditCommentPrivileges({ commentAuthorAddress: lastReply?.author?.address, - subplebbitAddress, + communityAddress: communityAddress ?? '', }); const postContent = ( @@ -292,7 +295,7 @@ const CatalogPost = memo( {author?.displayName || capitalize(t('anonymous'))} {isCatalogPostAuthorMod && {` ## Board ${catalogPostAuthorRole}`}} - {(isInAllView || isInSubscriptionsView) && subplebbitAddress && ` to p/${getShortAddress(subplebbitAddress)}`} + {(isInAllView || isInSubscriptionsView) && communityAddress && ` to p/${getShortAddress(communityAddress)}`} {getFormattedTimeAgo(timestamp)} {replyCount > 0 && ( @@ -313,6 +316,8 @@ const CatalogPost = memo( (prevProps, nextProps) => { const prev = prevProps.post; const next = nextProps.post; + const prevCommunityAddress = prev?.communityAddress ?? prev?.subplebbitAddress; + const nextCommunityAddress = next?.communityAddress ?? next?.subplebbitAddress; // Compare all fields that affect rendering to avoid stale displays return ( prev?.cid === next?.cid && @@ -329,7 +334,7 @@ const CatalogPost = memo( prev?.thumbnailUrl === next?.thumbnailUrl && prev?.linkWidth === next?.linkWidth && prev?.linkHeight === next?.linkHeight && - prev?.subplebbitAddress === next?.subplebbitAddress + prevCommunityAddress === nextCommunityAddress ); }, ); diff --git a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx index 93ef7bfb..60592804 100644 --- a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx +++ b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx @@ -94,8 +94,8 @@ const createPublication = () => ({ link: 'https://example.com/link', parentCid: 'parent-1', publishChallengeAnswers: vi.fn(), - shortSubplebbitAddress: 'mu', - subplebbitAddress: 'music-posting.eth', + shortCommunityAddress: 'mu', + communityAddress: 'music-posting.eth', title: 'Subject', }); diff --git a/src/components/challenge-modal/challenge-modal.tsx b/src/components/challenge-modal/challenge-modal.tsx index 1bb5b994..f5178e66 100644 --- a/src/components/challenge-modal/challenge-modal.tsx +++ b/src/components/challenge-modal/challenge-modal.tsx @@ -27,15 +27,15 @@ const ImageChallenge = ({ challenge }: { challenge: string }) => void; onDone: () => void; publicationDetails: React.ReactNode; } -const IframeChallenge = ({ challenge, shortSubplebbitAddress, subplebbitAddress, readableUrl, onCancel, onDone, publicationDetails }: IframeChallengeProps) => { +const IframeChallenge = ({ challenge, shortCommunityAddress, communityAddress, readableUrl, onCancel, onDone, publicationDetails }: IframeChallengeProps) => { const account = useAccount(); const [theme] = useTheme(); const [showIframeConfirmation, setShowIframeConfirmation] = useState(true); @@ -101,7 +101,7 @@ const IframeChallenge = ({ challenge, shortSubplebbitAddress, subplebbitAddress, {publicationDetails} - {shortSubplebbitAddress || subplebbitAddress || 'unknown board'} wants to open {readableUrl || 'an external site'} + {shortCommunityAddress || communityAddress || 'unknown board'} wants to open {readableUrl || 'an external site'} @@ -146,10 +146,10 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => { const publicationContent = publicationType === 'vote' ? getPublicationPreview(publicationTarget) : getPublicationPreview(publication); const votePreview = getVotePreview(publication); - const { author, content, link, title, parentCid, shortSubplebbitAddress, subplebbitAddress } = publication || {}; + const { author, content, link, title, parentCid, shortCommunityAddress, communityAddress } = publication || {}; const { displayName } = author || {}; const parentAddress = useParentAddress(parentCid); - const subplebbit = shortSubplebbitAddress || subplebbitAddress; + const community = shortCommunityAddress || communityAddress; const [currentChallengeIndex, setCurrentChallengeIndex] = useState(0); const [answers, setAnswers] = useState([]); @@ -268,7 +268,7 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => { } const extraTitleParts: string[] = []; - if (subplebbit) extraTitleParts.push(`p/${subplebbit}`); + if (community) extraTitleParts.push(`p/${community}`); if (publicationType === 'vote' && votePreview) extraTitleParts.push(votePreview.trim()); if (publication?.parentCid) extraTitleParts.push(parentAddress ? `reply ${parentAddress}` : 'reply'); if (publicationContent && publicationType !== 'vote') extraTitleParts.push(publicationContent); @@ -318,8 +318,8 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => { void | Promise) => void | Promise type TestComment = { author?: { + community?: { + banExpiresAt?: number; + }; subplebbit?: { banExpiresAt?: number; }; @@ -33,7 +36,7 @@ type TestComment = { reason?: string; removed?: boolean; state?: string; - subplebbitAddress?: string; + communityAddress?: string; }; const testState = vi.hoisted(() => ({ @@ -87,7 +90,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined), })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages', () => ({ default: (selector: (state: { comments: Record }) => unknown) => selector({ comments: testState.commentsByCid, @@ -273,6 +276,23 @@ describe('CommentContent', () => { expect(queryMarkdownText()[0]).toHaveLength(1105); }); + it('keeps the ban indicator for legacy author subplebbit data', async () => { + await renderContent({ + author: { + subplebbit: { + banExpiresAt: 1700000000, + }, + }, + cid: 'post-1', + communityAddress: 'music-posting.eth', + content: 'body', + postCid: 'post-1', + }); + + expect(container.textContent).toContain('(user_banned)'); + expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('title')).toContain('ban:short:music-posting.eth:2024-01-01 12:00:00'); + }); + it('shows and hides the original content for edited comments', async () => { await renderContent({ cid: 'post-1', @@ -343,7 +363,7 @@ describe('CommentContent', () => { it('renders pending approval, ban details, and loading or failed states', async () => { await renderContent({ author: { - subplebbit: { + community: { banExpiresAt: 1_704_067_200, }, }, @@ -352,7 +372,7 @@ describe('CommentContent', () => { pendingApproval: true, postCid: 'post-1', reason: 'rules violation', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }); expect(container.textContent).toContain('pending_mod_approval'); diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 85757a95..d1622a3d 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -2,7 +2,7 @@ import { Fragment, type ReactNode, useMemo, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { Comment, useComment } from '@bitsocialnet/bitsocial-react-hooks'; -import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages'; +import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages'; import usePostNumberStore from '../../stores/use-post-number-store'; import getShortAddress from '../../lib/get-short-address'; import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils'; @@ -16,10 +16,11 @@ import Markdown from '../../components/markdown'; import Tooltip from '../../components/tooltip'; import styles from '../../views/post/post.module.css'; import capitalize from 'lodash/capitalize'; +import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => { const quotedNumber = usePostNumberStore((state) => state.cidToNumber[cid]); - const commentFromStore = useSubplebbitsPagesStore((state) => state.comments[cid]); + const commentFromStore = useCommunitiesPagesStore((state) => state.comments[cid]); const commentFromHook = useComment({ commentCid: cid, onlyIfCached: true }); // Prefer hook version to ensure 'number' property is populated for deeper nested replies in Virtuoso const quotedComment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore; @@ -69,10 +70,14 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p const isInPostView = isPostPageView(location.pathname, params); const [showOriginal, setShowOriginal] = useState(false); const isMobile = useIsMobile(); + const resolvedPost = withResolvedCommentCommunityAddress(post); - const { cid, content, deleted, edit, original, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state, subplebbitAddress } = post || {}; - const purged = post?.commentModeration?.purged; - const banned = !!post?.author?.subplebbit?.banExpiresAt; + const { cid, content, deleted, edit, original, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {}; + const communityAddress = getCommentCommunityAddress(resolvedPost); + const purged = resolvedPost?.commentModeration?.purged; + const banExpiresAt = + resolvedPost?.author?.community?.banExpiresAt ?? (resolvedPost?.author as { subplebbit?: { banExpiresAt?: number } } | undefined)?.subplebbit?.banExpiresAt; + const banned = !!banExpiresAt; const [showFullComment, setShowFullComment] = useState(false); const displayContent = @@ -83,7 +88,7 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p ? content.slice(0, 2000) : content); - const quotelinkReplyFromStore = useSubplebbitsPagesStore((state) => state.comments[parentCid]); + const quotelinkReplyFromStore = useCommunitiesPagesStore((state) => state.comments[parentCid]); const quotelinkReplyFromHook = useComment({ commentCid: parentCid, onlyIfCached: true }); // Prefer hook version to ensure 'number' property is populated for deeper nested replies in Virtuoso const quotelinkReply = quotelinkReplyFromHook?.number !== undefined ? quotelinkReplyFromHook : quotelinkReplyFromStore; @@ -117,7 +122,7 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p const parentNumber = parentCid ? cidToNumber[parentCid] : undefined; const shouldShowReplyingToReply = isReplyingToReply && parentNumber !== undefined && !contentNumbers.has(parentNumber); - const stateString = useStateString(post); + const stateString = useStateString(resolvedPost); const hasFailedState = state === 'failed'; const loadingString = ( @@ -162,9 +167,10 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p ) ) : ( <> - {!showOriginal && } + {!showOriginal && } {pendingApproval && ( <> + ({t('pending_mod_approval')}) > @@ -197,7 +203,7 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p )} {edit && original?.content !== content && ( - {showOriginal && } + {showOriginal && } diff --git a/src/components/comment-media/comment-media.tsx b/src/components/comment-media/comment-media.tsx index 4353acd9..e669c543 100644 --- a/src/components/comment-media/comment-media.tsx +++ b/src/components/comment-media/comment-media.tsx @@ -181,7 +181,7 @@ const Thumbnail = ({ onClick={() => setShowThumbnail(false)} /> ) : isOutOfFeed ? ( - {thumbnailComponent} + {thumbnailComponent} ) : isMobile || isReply ? ( {thumbnailComponent} @@ -311,7 +311,7 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display const spoilerDimensions = { '--width': '150px', '--height': '150px' } as React.CSSProperties; return ( {hasError ? ( @@ -374,7 +374,7 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display ) : ( {hasError ? ( diff --git a/src/components/edit-menu/__tests__/edit-menu.test.tsx b/src/components/edit-menu/__tests__/edit-menu.test.tsx index 85a8e69d..afdd9009 100644 --- a/src/components/edit-menu/__tests__/edit-menu.test.tsx +++ b/src/components/edit-menu/__tests__/edit-menu.test.tsx @@ -20,6 +20,7 @@ const testState = vi.hoisted(() => ({ } as Record, addChallengeMock: vi.fn(), authorOptions: undefined as Record | undefined, + authorPrivilegesOptions: undefined as Record | undefined, isMobile: false, modOptions: undefined as Record | undefined, privileges: { @@ -84,7 +85,10 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ })); vi.mock('../../../hooks/use-author-privileges', () => ({ - default: () => testState.privileges, + default: (options: Record) => { + testState.authorPrivilegesOptions = options; + return testState.privileges; + }, })); vi.mock('../../../hooks/use-is-mobile', () => ({ @@ -122,7 +126,7 @@ const basePost = { reason: '', removed: false, spoiler: false, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', } as Record; const renderMenu = async (post = basePost) => { @@ -177,6 +181,7 @@ describe('EditMenu', () => { }, }; testState.authorOptions = undefined; + testState.authorPrivilegesOptions = undefined; testState.isMobile = false; testState.modOptions = undefined; testState.privileges = { @@ -248,8 +253,14 @@ describe('EditMenu', () => { deleted: true, reason: 'cleanup', spoiler: false, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }); + expect(testState.authorPrivilegesOptions).toMatchObject({ + commentAuthorAddress: '0xauthor', + communityAddress: 'music-posting.eth', + postCid: 'post-1', + }); + expect(testState.authorPrivilegesOptions).not.toHaveProperty('subplebbitAddress'); }); it('lets moderators change moderation flags, ban duration, and save them', async () => { @@ -287,7 +298,7 @@ describe('EditMenu', () => { shortAddress: '0xmod', }, commentCid: 'comment-1', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }); expect(testState.modOptions?.commentModeration).toMatchObject({ reason: 'rule violation', diff --git a/src/components/edit-menu/edit-menu.tsx b/src/components/edit-menu/edit-menu.tsx index 6182d834..f8513ffb 100644 --- a/src/components/edit-menu/edit-menu.tsx +++ b/src/components/edit-menu/edit-menu.tsx @@ -15,6 +15,7 @@ import useChallengesStore from '../../stores/use-challenges-store'; import capitalize from 'lodash/capitalize'; import useIsMobile from '../../hooks/use-is-mobile'; import useAuthorPrivileges from '../../hooks/use-author-privileges'; +import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; const { addChallenge } = useChallengesStore.getState(); @@ -32,30 +33,32 @@ const timestampToDays = (timestamp: number) => { const EditMenu = ({ post }: { post: Comment }) => { const { t } = useTranslation(); const isMobile = useIsMobile(); - const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {}; - const authorDisplayName = post?.author?.displayName; - const modBanExpiresAt = post?.commentModeration?.author?.banExpiresAt; - const purged = post?.commentModeration?.purged ?? false; + const resolvedPost = withResolvedCommentCommunityAddress(post); + const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {}; + const communityAddress = getCommentCommunityAddress(resolvedPost); + const authorDisplayName = resolvedPost?.author?.displayName; + const modBanExpiresAt = resolvedPost?.commentModeration?.author?.banExpiresAt; + const purged = resolvedPost?.commentModeration?.purged ?? false; const [isEditMenuOpen, setIsEditMenuOpen] = useState(false); const [isContentEditorOpen, setIsContentEditorOpen] = useState(false); const account = useAccount(); const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({ commentAuthorAddress: author?.address, - subplebbitAddress, + communityAddress: communityAddress || '', postCid, }); const signer = isAccountCommentAuthor ? account?.signer : null; - const latestPostRef = useRef(post); + const latestPostRef = useRef(resolvedPost); useEffect(() => { - latestPostRef.current = post; - }, [post]); + latestPostRef.current = resolvedPost; + }, [resolvedPost]); const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []); const defaultPublishEditOptions = useMemo(() => { return { commentCid: cid, - subplebbitAddress, + communityAddress, // Author edit properties content: isAccountCommentAuthor ? content : undefined, deleted: isAccountCommentAuthor ? (deleted ?? false) : undefined, @@ -79,14 +82,14 @@ const EditMenu = ({ post }: { post: Comment }) => { alert('Comment edit failed. ' + error.message); }, }; - }, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, modBanExpiresAt, onChallenge]); + }, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, communityAddress, modBanExpiresAt, onChallenge]); const [publishCommentEditOptions, setPublishCommentEditOptions] = useState(defaultPublishEditOptions); const authorEditOptions = useMemo( () => ({ commentCid: cid, - subplebbitAddress, + communityAddress, signer, author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author, content: publishCommentEditOptions.content, @@ -100,13 +103,13 @@ const EditMenu = ({ post }: { post: Comment }) => { alert('Comment edit failed. ' + error.message); }, }), - [publishCommentEditOptions, cid, subplebbitAddress, signer, account?.author, author?.address, authorDisplayName, onChallenge], + [publishCommentEditOptions, cid, communityAddress, signer, account?.author, author?.address, authorDisplayName, onChallenge], ); const modEditOptions = useMemo( () => ({ commentCid: cid, - subplebbitAddress, + communityAddress, commentModeration: { locked: parentCid === undefined ? publishCommentEditOptions.commentModeration?.locked : undefined, pinned: publishCommentEditOptions.commentModeration?.pinned, @@ -124,7 +127,7 @@ const EditMenu = ({ post }: { post: Comment }) => { alert('Comment moderation failed. ' + error.message); }, }), - [publishCommentEditOptions, cid, subplebbitAddress, account?.author, parentCid, onChallenge], + [publishCommentEditOptions, cid, communityAddress, account?.author, parentCid, onChallenge], ); const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions); diff --git a/src/components/footer/__tests__/footer.test.tsx b/src/components/footer/__tests__/footer.test.tsx index a50bacbc..61acbd96 100644 --- a/src/components/footer/__tests__/footer.test.tsx +++ b/src/components/footer/__tests__/footer.test.tsx @@ -143,7 +143,7 @@ describe('footer', () => { createElement(StyleOnlyFooterFirstRow), createElement(CatalogFooterFirstRow, { isInAllView: true, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }), createElement(ThreadFooterStyleRow), createElement(PageFooterMobile, { @@ -165,7 +165,7 @@ describe('footer', () => { await renderWithRouter( createElement(ThreadFooterFirstRow, { postCid: 'post-cid', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', threadNumber: 42, }), '/all/thread/post-cid', @@ -184,7 +184,7 @@ describe('footer', () => { createElement(ThreadFooterFirstRow, { isThreadClosed: true, postCid: 'post-cid', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', threadNumber: 42, }), '/all/thread/post-cid', @@ -204,7 +204,7 @@ describe('footer', () => { await renderWithRouter( createElement(ThreadFooterMobile, { postCid: 'post-cid', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', threadNumber: 55, }), ); @@ -229,7 +229,7 @@ describe('footer', () => { createElement(ThreadFooterMobile, { isThreadClosed: true, postCid: 'post-cid', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', threadNumber: 55, }), ); diff --git a/src/components/footer/footer.tsx b/src/components/footer/footer.tsx index 6c41f38e..bbb99569 100644 --- a/src/components/footer/footer.tsx +++ b/src/components/footer/footer.tsx @@ -62,22 +62,22 @@ export const StyleOnlyFooterFirstRow = () => { * -------------------------------------------------------------------------- */ interface CatalogFooterFirstRowProps { - subplebbitAddress?: string; + communityAddress?: string; isInAllView?: boolean; isInSubscriptionsView?: boolean; isInModView?: boolean; } -export const CatalogFooterFirstRow = ({ subplebbitAddress, isInAllView = false, isInSubscriptionsView = false, isInModView = false }: CatalogFooterFirstRowProps) => { +export const CatalogFooterFirstRow = ({ communityAddress, isInAllView = false, isInSubscriptionsView = false, isInModView = false }: CatalogFooterFirstRowProps) => { const { t } = useTranslation(); return ( - [] + [] - [] + [] [] @@ -117,12 +117,12 @@ export const ThreadFooterStyleRow = () => { interface ThreadFooterFirstRowProps { postCid: string; threadNumber: number | undefined; - subplebbitAddress: string; + communityAddress: string; /** Thread closed - disable Post a Reply */ isThreadClosed?: boolean; } -export const ThreadFooterFirstRow = ({ postCid, threadNumber, subplebbitAddress, isThreadClosed = false }: ThreadFooterFirstRowProps) => { +export const ThreadFooterFirstRow = ({ postCid, threadNumber, communityAddress, isThreadClosed = false }: ThreadFooterFirstRowProps) => { const { t } = useTranslation(); const location = useLocation(); const params = useParams(); @@ -134,17 +134,17 @@ export const ThreadFooterFirstRow = ({ postCid, threadNumber, subplebbitAddress, const handlePostReplyClick = () => { if (isThreadClosed) return; - openReplyModalEmpty(postCid, threadNumber, subplebbitAddress); + openReplyModalEmpty(postCid, threadNumber, communityAddress); }; return ( - [] + [] - [] + [] [] @@ -196,11 +196,11 @@ export const PageFooterMobile = ({ children }: { children: React.ReactNode }) => interface ThreadFooterMobileProps { postCid: string; threadNumber: number | undefined; - subplebbitAddress: string; + communityAddress: string; isThreadClosed?: boolean; } -export const ThreadFooterMobile = ({ postCid, threadNumber, subplebbitAddress, isThreadClosed = false }: ThreadFooterMobileProps) => { +export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, isThreadClosed = false }: ThreadFooterMobileProps) => { const { t } = useTranslation(); const location = useLocation(); const params = useParams(); @@ -213,13 +213,13 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, subplebbitAddress, i const post = useComment({ commentCid: postCid }); const { replyCount } = post || {}; const linkCount = useCountLinksInReplies(post); - const directoryEntry = useDirectoryByAddress(subplebbitAddress); + const directoryEntry = useDirectoryByAddress(communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; - const pageNumber = usePostPageNumber({ subplebbitAddress, postCid, enabled: true }); + const pageNumber = usePostPageNumber({ subplebbitAddress: communityAddress, postCid, enabled: true }); const handlePostReplyClick = () => { if (isThreadClosed) return; - openReplyModalEmpty(postCid, threadNumber, subplebbitAddress); + openReplyModalEmpty(postCid, threadNumber, communityAddress); }; return ( @@ -231,8 +231,8 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, subplebbitAddress, i - - + + diff --git a/src/components/markdown/__tests__/external-number-quote-link.test.tsx b/src/components/markdown/__tests__/external-number-quote-link.test.tsx index 286a9ef3..c1dcdd40 100644 --- a/src/components/markdown/__tests__/external-number-quote-link.test.tsx +++ b/src/components/markdown/__tests__/external-number-quote-link.test.tsx @@ -118,7 +118,7 @@ describe('ExternalNumberQuoteLink', () => { comment: { cid: 'cid-77' }, isUnavailable: false, route: '/fit/thread/cid-77', - subplebbitAddress: 'fit', + communityAddress: 'fit', }); await act(async () => { diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx index 851d53f2..1923bf8d 100644 --- a/src/components/markdown/__tests__/markdown.test.tsx +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -57,7 +57,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.comments[commentCid] : undefined), })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages', () => ({ default: (selector: (state: { comments: typeof testState.comments }) => unknown) => selector({ comments: testState.comments, @@ -135,7 +135,7 @@ vi.mock('../external-number-quote-link', () => ({ let container: HTMLDivElement; let root: Root; -const renderMarkdown = async (props: { content: string; postCid?: string; subplebbitAddress?: string; title?: string }, initialEntry = '/mu/thread/post-1') => { +const renderMarkdown = async (props: { content: string; postCid?: string; communityAddress?: string; title?: string }, initialEntry = '/mu/thread/post-1') => { await act(async () => { root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(Markdown, props))); }); @@ -198,7 +198,7 @@ describe('Markdown', () => { await renderMarkdown({ content: '>>42', postCid: 'comment-42', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }); const quotePreview = container.querySelector('[data-testid="reply-quote-preview"]'); @@ -211,7 +211,7 @@ describe('Markdown', () => { it('renders lazy same-board and cross-board number quotes when the cid is not cached', async () => { await renderMarkdown({ content: '>>42 >>>/fit/77', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }); const lazyLinks = Array.from(container.querySelectorAll('[data-testid="external-number-quote-link"]')); diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index c5da8448..f2cf869e 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -13,7 +13,7 @@ import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } f import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils'; import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils'; import usePostNumberStore from '../../stores/use-post-number-store'; -import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages'; +import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages'; import { useComment } from '@bitsocialnet/bitsocial-react-hooks'; import ReplyQuotePreview from '../reply-quote-preview'; import ExternalNumberQuoteLink from './external-number-quote-link'; @@ -240,11 +240,11 @@ function tokenize(text: string): Token[] { interface RenderContext { isInCatalogView: boolean; postCid?: string; - subplebbitAddress?: string; + communityAddress?: string; } function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] { - const { isInCatalogView, postCid, subplebbitAddress } = context; + const { isInCatalogView, postCid, communityAddress } = context; return tokens.map((token, i) => { switch (token.type) { @@ -261,12 +261,12 @@ function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[ ); } - return {renderAnchorLink(href, href, postCid, subplebbitAddress)}; + return {renderAnchorLink(href, href, postCid, communityAddress)}; } case 'quoteLink': return ( - + ); case 'crossBoardNumberQuoteLink': @@ -295,12 +295,12 @@ interface MarkdownProps { content: string; title?: string; postCid?: string; - subplebbitAddress?: string; + communityAddress?: string; } -const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: number; threadPostCid?: string; subplebbitAddress?: string }) => { - const cid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress]?.[number] : undefined)); - const commentFromStore = useSubplebbitsPagesStore((state) => (cid ? state.comments[cid] : undefined)); +const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number: number; threadPostCid?: string; communityAddress?: string }) => { + const cid = usePostNumberStore((state) => (communityAddress ? state.numberToCid[communityAddress]?.[number] : undefined)); + const commentFromStore = useCommunitiesPagesStore((state) => (cid ? state.comments[cid] : undefined)); const commentFromHook = useComment({ commentCid: cid, onlyIfCached: true }); const comment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore; const isOP = Boolean(threadPostCid && cid === threadPostCid); @@ -311,14 +311,14 @@ const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: ); } - if (!cid && subplebbitAddress) { + if (!cid && communityAddress) { return ( >${number}`, - subplebbitAddress, + subplebbitAddress: communityAddress, }} /> ); @@ -327,7 +327,7 @@ const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: return ; }; -const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, subplebbitAddress?: string) => { +const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, communityAddress?: string) => { if (!href) { return {children}; } @@ -380,7 +380,7 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid ); }; -const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) => { +const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => { const location = useLocation(); const params = useParams(); const isInCatalogView = isCatalogView(location.pathname, params); @@ -400,7 +400,7 @@ const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) const isGreentext = /^>[^>]/.test(line) || line === '>'; const tokens = tokenize(line); - const lineElements = renderTokens(tokens, { isInCatalogView, postCid, subplebbitAddress }); + const lineElements = renderTokens(tokens, { isInCatalogView, postCid, communityAddress }); if (isGreentext) { elements.push( @@ -414,7 +414,7 @@ const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) }); return elements; - }, [content, isInCatalogView, postCid, subplebbitAddress]); + }, [content, isInCatalogView, postCid, communityAddress]); return ( diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 5215743c..97a5aa62 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -52,6 +52,7 @@ import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../../lib/utils/replies-preview-utils'; import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; import useDeleteFailedPost from '../../hooks/use-delete-failed-post'; +import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; const { addChallenge } = useChallengesStore.getState(); @@ -97,7 +98,7 @@ const PostInfo = ({ directRepliesByParentCid, }: PostProps & { directRepliesByParentCid?: Map }) => { const { t } = useTranslation(); - const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {}; + const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, communityAddress, timestamp } = post || {}; const purged = post?.commentModeration?.purged; const title = post?.title?.trim(); const { address, shortAddress } = author || {}; @@ -107,7 +108,7 @@ const PostInfo = ({ const isReply = parentCid; const { showOmittedReplies } = useShowOmittedReplies(); const directories = useDirectories(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const postMenuProps = selectPostMenuProps(post); const params = useParams(); @@ -125,7 +126,7 @@ const PostInfo = ({ // Check if post is pending approval and user is mod (for post page view) const pendingApproval = post?.pendingApproval; - const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && subplebbitAddress; + const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress; // Moderation actions for pending approval posts const { @@ -134,7 +135,7 @@ const PostInfo = ({ error: approvePendingError, } = usePublishCommentModeration({ commentCid: cid, - subplebbitAddress: shouldShowPendingApprovalButtons ? subplebbitAddress : undefined, + communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined, commentModeration: approvePendingCommentModeration, onChallenge: async (...args: any) => { addChallenge([...args, post]); @@ -153,7 +154,7 @@ const PostInfo = ({ error: rejectPendingError, } = usePublishCommentModeration({ commentCid: cid, - subplebbitAddress: shouldShowPendingApprovalButtons ? subplebbitAddress : undefined, + communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined, commentModeration: rejectPendingCommentModeration, onChallenge: async (...args: any) => { addChallenge([...args, post]); @@ -220,7 +221,7 @@ const PostInfo = ({ const userIDBackgroundColor = hashStringToColor(userID); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); - const pseudonymityMode = useBoardPseudonymityMode(subplebbitAddress); + const pseudonymityMode = useBoardPseudonymityMode(communityAddress); const showUserID = pseudonymityMode === 'per-post'; const handleUserAddressClick = useAuthorAddressClick(); @@ -246,7 +247,7 @@ const PostInfo = ({ ? isReply ? alert(t('this_reply_was_removed')) : alert(t('this_thread_was_removed')) - : openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, subplebbitAddress); + : openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress); }; const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined; @@ -552,7 +553,7 @@ interface PostMediaProps { linkHeight: number; linkWidth: number; parentCid: string; - subplebbitAddress: string; + communityAddress?: string; isInAllView: boolean; isInSubscriptionsView: boolean; isInModView: boolean; @@ -568,7 +569,7 @@ const PostMedia = ({ linkHeight, linkWidth, parentCid, - subplebbitAddress, + communityAddress, isInAllView, isInSubscriptionsView, isInModView, @@ -589,20 +590,22 @@ const PostMedia = ({ const [showThumbnail, setShowThumbnail] = useState(true); const mediaDimensions = getMediaDimensions(commentMediaInfo); - const directoryEntry = findDirectoryByAddress(directories, subplebbitAddress); + const directoryEntry = findDirectoryByAddress(directories, communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; - const boardPath = getBoardPath(subplebbitAddress, directories); + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const displayBoardPath = - boardPath !== subplebbitAddress + boardPath && communityAddress && boardPath !== communityAddress ? boardPath - : subplebbitAddress.endsWith('.eth') || subplebbitAddress.endsWith('.sol') - ? subplebbitAddress - : getShortAddress(subplebbitAddress); + : communityAddress && (communityAddress.endsWith('.eth') || communityAddress.endsWith('.sol')) + ? communityAddress + : communityAddress + ? getShortAddress(communityAddress) + : undefined; return ( - {subplebbitAddress && (isInAllView || isInSubscriptionsView || isInModView) && boardPath && !parentCid && ( + {communityAddress && (isInAllView || isInSubscriptionsView || isInModView) && boardPath && !parentCid && ( <> {t('board')}: {displayBoardPath}{' '} > @@ -701,11 +704,12 @@ const Reply = ({ if (editedComment) { post = editedComment; } + post = withResolvedCommentCommunityAddress(post); - const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; + const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, communityAddress, thumbnailUrl, parentCid } = post || {}; const purged = post?.commentModeration?.purged; const directories = useDirectories(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const location = useLocation(); const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; @@ -746,7 +750,7 @@ const Reply = ({ linkHeight={linkHeight} linkWidth={linkWidth} parentCid={parentCid} - subplebbitAddress={subplebbitAddress} + communityAddress={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} @@ -774,8 +778,10 @@ const PostDesktop = ({ onReject, }: PostProps) => { const { t } = useTranslation(); - const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; - const purged = post?.commentModeration?.purged; + const resolvedPost = withResolvedCommentCommunityAddress(post); + const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, communityAddress, thumbnailUrl, parentCid } = + resolvedPost || {}; + const purged = resolvedPost?.commentModeration?.purged; const params = useParams(); const location = useLocation(); const navigationType = useNavigationType(); @@ -786,14 +792,14 @@ const PostDesktop = ({ const isInModView = isModView(location.pathname); const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView; const directories = useDirectories(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const displayBoardPath = - boardPath && subplebbitAddress - ? boardPath !== subplebbitAddress + boardPath && communityAddress + ? boardPath !== communityAddress ? boardPath - : subplebbitAddress.endsWith('.eth') || subplebbitAddress.endsWith('.sol') - ? subplebbitAddress - : getShortAddress(subplebbitAddress) + : communityAddress.endsWith('.eth') || communityAddress.endsWith('.sol') + ? communityAddress + : getShortAddress(communityAddress) : undefined; const { hidden, unhide, hide } = useHide({ cid }); @@ -805,14 +811,14 @@ const PostDesktop = ({ const shouldFetchFull = showReplies && !isModQueue && (showAllReplies || showOmittedReplies[cid]); const previewRepliesResult = useReplies({ - comment: shouldFetchPreview ? post : undefined, + comment: shouldFetchPreview ? resolvedPost : undefined, sortType: 'new', flat: true, repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE, accountComments: { newerThan: Infinity, append: true }, }); const fullRepliesResult = useReplies({ - comment: shouldFetchFull ? post : undefined, + comment: shouldFetchFull ? resolvedPost : undefined, sortType: 'old', flat: true, repliesPerPage: REPLIES_PER_PAGE, @@ -839,7 +845,7 @@ const PostDesktop = ({ : previewReplies : getPreviewDisplayReplies(previewReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); const freshRepliesForRender = useFreshReplies(repliesForRender); - useRegisterFreshReplies(post, freshRepliesForRender); + useRegisterFreshReplies(resolvedPost, freshRepliesForRender); const setResetFunction = useFeedResetStore((s) => s.setResetFunction); useEffect(() => { if ((isInPostPageView || isInPendingPostView) && reset) { @@ -848,12 +854,12 @@ const PostDesktop = ({ }); } }, [isInPostPageView, isInPendingPostView, reset, setResetFunction]); - const visiblelinksCount = useCountLinksInReplies(post, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); - const totalLinksCount = useCountLinksInReplies(post); + const visiblelinksCount = useCountLinksInReplies(resolvedPost, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); + const totalLinksCount = useCountLinksInReplies(resolvedPost); const replyCount = freshRepliesForRender.length; const totalReplyCount = getTotalReplyCount({ - replyCount: post?.replyCount, + replyCount: resolvedPost?.replyCount, fullLoadedCount: fullReplies.length, previewLoadedCount: previewReplies.length, }); @@ -863,9 +869,9 @@ const PostDesktop = ({ }); const linksCount = totalLinksCount - visiblelinksCount; - const stateString = useStateString(post) || t('downloading_board'); + const stateString = useStateString(resolvedPost) || t('downloading_board'); const hasFailedState = state === 'failed'; - const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(post); + const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(resolvedPost); const failedPublishNotice = canDeleteFailedPost ? : undefined; const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); @@ -890,7 +896,7 @@ const PostDesktop = ({ return map; })(); - const quotedByMap = useQuotedByMap(filteredReplies, subplebbitAddress); + const quotedByMap = useQuotedByMap(filteredReplies, communityAddress); const visibleReplies = useProgressiveRender(filteredReplies, { batchSize: 50, @@ -966,7 +972,7 @@ const PostDesktop = ({ className={`${styles.opContainer} ${shouldShowSnow() && hasThumbnail ? styles.xmasHatWrapper : ''}`} > {shouldShowSnow() && hasThumbnail && } - {!link && !parentCid && subplebbitAddress && isMultiboardView && boardPath && ( + {!link && !parentCid && communityAddress && isMultiboardView && boardPath && ( {t('board')}: {displayBoardPath} @@ -984,7 +990,7 @@ const PostDesktop = ({ linkHeight={linkHeight} linkWidth={linkWidth} parentCid={parentCid} - subplebbitAddress={subplebbitAddress} + communityAddress={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} @@ -992,10 +998,10 @@ const PostDesktop = ({ )} {!isHidden && !content && !(deleted || removed || purged) && } - {!isHidden && } + {!isHidden && } {!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && ( @@ -1042,7 +1048,7 @@ const PostDesktop = ({ )} {/* Virtuoso infinite scroll for post page view when there's more content to paginate */} - {!isHidden && showAllReplies && !isInPendingPostView && showReplies && hasMore && !!post?.replyCount && ( + {!isHidden && showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && ( @@ -1079,7 +1085,7 @@ const PostDesktop = ({ reply={reply} roles={roles} postReplyCount={replyCount} - threadNumber={post?.number} + threadNumber={resolvedPost?.number} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} /> @@ -1097,7 +1103,7 @@ const PostDesktop = ({ reply={reply} roles={roles} postReplyCount={replyCount} - threadNumber={post?.number} + threadNumber={resolvedPost?.number} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} /> @@ -1113,7 +1119,7 @@ const PostDesktop = ({ stateString && !hasFailedState && state !== 'succeeded' && - !(post?.timestamp && !post?.updatedAt) && + !(resolvedPost?.timestamp && !resolvedPost?.updatedAt) && isInPostPageView && !(!showReplies && !showAllReplies) ? ( diff --git a/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx b/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx index 49b7b0f0..0a2284e5 100644 --- a/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx +++ b/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx @@ -106,7 +106,7 @@ const basePostMenu = { linkHeight: 0, linkWidth: 0, postCid: 'cid-1', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', thumbnailUrl: undefined as string | undefined, }; diff --git a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx index 48dda7c5..252735dd 100644 --- a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx +++ b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx @@ -39,13 +39,13 @@ const safeCopyToClipboard = async (text: string, label: string): Promise void } - | { subplebbitAddress: string; linkType: Exclude; onClose: () => void; cid?: undefined }; + | { cid: string; communityAddress: string; linkType: 'thread'; onClose: () => void } + | { communityAddress: string; linkType: Exclude; onClose: () => void; cid?: undefined }; -const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkButtonProps) => { +const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkButtonProps) => { const { t } = useTranslation(); const directories = useDirectories(); - const boardIdentifier = getBoardPath(subplebbitAddress, directories); + const boardIdentifier = getBoardPath(communityAddress, directories); const handleClick = async () => { await safeCopyShareLink(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined); onClose(); @@ -163,9 +163,13 @@ type PostMenuDesktopProps = { postMenu: PostMenuProps; }; +type PostMenuLegacyAddress = Pick & { communityAddress?: string }; + const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => { const { t } = useTranslation(); - const { authorAddress, cid, link, thumbnailUrl, linkWidth, linkHeight, postCid, subplebbitAddress } = postMenu || {}; + const { authorAddress, cid, link, thumbnailUrl, linkWidth, linkHeight, postCid } = postMenu || {}; + const postMenuLegacyAddress = (postMenu as PostMenuLegacyAddress) || {}; + const resolvedCommunityAddress = postMenuLegacyAddress.communityAddress || postMenuLegacyAddress.subplebbitAddress; const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth ?? 0, linkHeight ?? 0); const { thumbnail, type, url } = commentMediaInfo || {}; const [menuBtnRotated, setMenuBtnRotated] = useState(false); @@ -225,7 +229,7 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => { createPortal( - {cid && subplebbitAddress && } + {cid && resolvedCommunityAddress && } {cid && } {authorAddress && } {!(isInPostPageView && postCid === cid) && ( diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index b4bb344f..e893c602 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -13,8 +13,8 @@ const testState = vi.hoisted(() => ({ author: { displayName: 'Alice' }, subscriptions: ['music-posting.eth'], }, - accountComment: undefined as { subplebbitAddress?: string } | undefined, - accountSubplebbitAddresses: ['mod.eth'] as string[], + accountComment: undefined as { communityAddress?: string } | undefined, + accountCommunityAddresses: ['mod.eth'] as string[], comments: {} as Record, directories: [ { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, @@ -36,12 +36,12 @@ const testState = vi.hoisted(() => ({ replyIndex: undefined as number | undefined, resetPublishPostOptionsMock: vi.fn(), resetPublishReplyOptionsMock: vi.fn(), - resolvedSubplebbitAddress: undefined as string | undefined, + resolvedCommunityAddress: undefined as string | undefined, setAccountMock: vi.fn(), setPublishPostOptionsMock: vi.fn(), setPublishReplyOptionsMock: vi.fn(), showUploadControls: true, - subplebbits: { + communities: { 'music-posting.eth': { address: 'music-posting.eth' }, } as Record, uploadComplete: undefined as ((uploadedUrl: string) => void) | undefined, @@ -70,16 +70,16 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useEditedComment: () => ({ editedComment: testState.editedComment }), })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits', () => ({ - default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => unknown) => selector({ subplebbits: testState.subplebbits }), +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities', () => ({ + default: (selector: (state: { communities: typeof testState.communities }) => unknown) => selector({ communities: testState.communities }), })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages', () => ({ default: (selector: (state: { comments: typeof testState.comments }) => unknown) => selector({ comments: testState.comments }), })); -vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({ - useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses, +vi.mock('../../../hooks/use-account-community-addresses', () => ({ + useAccountCommunityAddresses: () => testState.accountCommunityAddresses, })); vi.mock('../../../hooks/use-directories', () => ({ @@ -88,8 +88,8 @@ vi.mock('../../../hooks/use-directories', () => ({ normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ @@ -98,7 +98,7 @@ vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ }), })); -vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({ +vi.mock('../../../hooks/use-is-community-offline', () => ({ default: () => ({ isOffline: testState.isOffline, isOnlineStatusLoading: testState.isOnlineStatusLoading, @@ -114,8 +114,8 @@ vi.mock('../../../hooks/use-publish-post', async () => { const React = await vi.importActual('react'); return { - default: ({ subplebbitAddress }: { subplebbitAddress?: string }) => { - const [publishPostOptions, setPublishPostOptionsState] = React.useState>(subplebbitAddress ? { subplebbitAddress } : {}); + default: ({ communityAddress }: { communityAddress?: string }) => { + const [publishPostOptions, setPublishPostOptionsState] = React.useState>(communityAddress ? { communityAddress } : {}); return { postIndex: testState.postIndex, @@ -135,11 +135,11 @@ vi.mock('../../../hooks/use-publish-reply', async () => { const React = await vi.importActual('react'); return { - default: ({ cid, postCid, subplebbitAddress }: { cid: string; postCid?: string; subplebbitAddress: string }) => { + default: ({ cid, postCid, communityAddress }: { cid: string; postCid?: string; communityAddress: string }) => { const [publishReplyOptions, setPublishReplyOptionsState] = React.useState>({ parentCid: cid, postCid: postCid ?? cid, - subplebbitAddress, + communityAddress, }); return { @@ -269,7 +269,7 @@ describe('PostForm', () => { subscriptions: ['music-posting.eth'], }; testState.accountComment = undefined; - testState.accountSubplebbitAddresses = ['mod.eth']; + testState.accountCommunityAddresses = ['mod.eth']; testState.comments = {}; testState.directories = [ { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, @@ -285,12 +285,12 @@ describe('PostForm', () => { testState.publishReplyError = null; testState.publishReplyStateMessage = null; testState.replyIndex = undefined; - testState.resolvedSubplebbitAddress = undefined; + testState.resolvedCommunityAddress = undefined; testState.showUploadControls = true; testState.uploadComplete = undefined; testState.uploadMode = 'always'; testState.uploadedFileName = 'picked.png'; - testState.subplebbits = { + testState.communities = { 'music-posting.eth': { address: 'music-posting.eth' }, }; testState.handleUploadMock.mockReset(); @@ -382,12 +382,12 @@ describe('PostForm', () => { await clickByText(table as HTMLTableElement, 'post'); expect(testState.publishPostMock).toHaveBeenCalledTimes(1); - expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ subplebbitAddress: 'music-posting.eth' }); + expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' }); }); it('redirects to the pending route when a post publish index is already available on mount', async () => { testState.postIndex = 7; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.resolvedCommunityAddress = 'music-posting.eth'; await renderPostForm('/mu'); await clickByText(container, 'start_new_thread'); @@ -404,7 +404,7 @@ describe('PostForm', () => { }, }; testState.replyIndex = 4; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.resolvedCommunityAddress = 'music-posting.eth'; await renderPostForm('/mu/thread/thread-cid'); await clickByText(container, 'post_a_reply'); @@ -421,7 +421,7 @@ describe('PostForm', () => { }, }; testState.isOffline = true; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.resolvedCommunityAddress = 'music-posting.eth'; await renderPostForm('/mu/thread/thread-cid'); await clickByText(container, 'post_a_reply'); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index cb96fb2e..f910c5b0 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -3,14 +3,14 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialnet/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; -import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages'; +import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages'; import { getLinkMediaInfo } from '../../lib/utils/media-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useAccountSubplebbitAddresses } from '../../hooks/use-account-subplebbit-addresses'; +import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import useIsMobile from '../../hooks/use-is-mobile'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import usePublishPost from '../../hooks/use-publish-post'; import usePublishReply from '../../hooks/use-publish-reply'; @@ -101,9 +101,9 @@ interface PostFormFieldsProps { isInSubscriptionsView: boolean; isInModView: boolean; directories: ReturnType; - accountSubplebbitAddresses: string[]; + accountCommunityAddresses: string[]; subscriptions: string[]; - subplebbitAddress: string | undefined; + communityAddress: string | undefined; requirePostLinkIsMedia: boolean; onPublishReply: () => void; onPublishPost: () => void; @@ -134,9 +134,9 @@ const PostFormFields = ({ isInSubscriptionsView, isInModView, directories, - accountSubplebbitAddresses, + accountCommunityAddresses, subscriptions, - subplebbitAddress, + communityAddress, requirePostLinkIsMedia, onPublishReply, onPublishPost, @@ -263,18 +263,18 @@ const PostFormFields = ({ {t('board')} - setPublishPostOptions({ subplebbitAddress: e.target.value })} value={subplebbitAddress}> + setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}> {t('choose_one')} {isInAllView && directories - .filter((subplebbit) => subplebbit.title && subplebbit.address) - .map((subplebbit) => ( - - {subplebbit.title} + .filter((community) => community.title && community.address) + .map((community) => ( + + {community.title} ))} {isInModView && - accountSubplebbitAddresses.map((address: string) => ( + accountCommunityAddresses.map((address: string) => ( {address && getShortAddress(address)} @@ -300,10 +300,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const author = account?.author || {}; const { displayName } = author || {}; const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); - const resolvedAddress = useResolvedSubplebbitAddress(); - const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; - const { setPublishPostOptions, postIndex, publishPost, publishPostOptions, resetPublishPostOptions } = usePublishPost({ subplebbitAddress }); - const effectiveBoardAddress = subplebbitAddress || publishPostOptions.subplebbitAddress; + const resolvedAddress = useResolvedCommunityAddress(); + const communityAddress = resolvedAddress || accountComment?.communityAddress; + const { setPublishPostOptions, postIndex, publishPost, publishPostOptions, resetPublishPostOptions } = usePublishPost({ subplebbitAddress: communityAddress }); + const effectiveBoardAddress = communityAddress || publishPostOptions.communityAddress; const textRef = useRef(null); const urlRef = useRef(null); @@ -321,7 +321,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); const [lengthError, setLengthError] = useState(null); @@ -370,7 +370,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } - if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.subplebbitAddress) { + if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) { alert(t('no_board_selected_warning')); return; } @@ -392,7 +392,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const isInPostView = isPostPageView(location.pathname, params); const cid = params?.commentCid as string; const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = - usePublishReply({ cid, subplebbitAddress }); + usePublishReply({ cid, subplebbitAddress: communityAddress, postCid }); const handleContentChange = (e: React.ChangeEvent) => { const content = e.target.value; @@ -486,9 +486,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} directories={directories} - accountSubplebbitAddresses={accountSubplebbitAddresses} + accountCommunityAddresses={accountCommunityAddresses} subscriptions={subscriptions} - subplebbitAddress={subplebbitAddress} + communityAddress={communityAddress} requirePostLinkIsMedia={requirePostLinkIsMedia} onPublishReply={onPublishReply} onPublishPost={onPublishPost} @@ -516,7 +516,7 @@ const PostForm = () => { const isMobile = useIsMobile(); const commentCid = params?.commentCid; - const post = useSubplebbitsPagesStore((state) => state.comments[commentCid as string]); + const post = useCommunitiesPagesStore((state) => state.comments[commentCid as string]); let comment: Comment = post; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment }); @@ -530,15 +530,15 @@ const PostForm = () => { const [showForm, setShowForm] = useState(false); const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); - const resolvedAddress = useResolvedSubplebbitAddress(); - const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; + const resolvedAddress = useResolvedCommunityAddress(); + const communityAddress = resolvedAddress || accountComment?.communityAddress; const shouldShowOfflineAlert = !(isInAllView || isInSubscriptionsView || isInModView) && showForm; if (isMobile) { return ( - {shouldShowOfflineAlert && } + {shouldShowOfflineAlert && } {isInModQueueView ? ( {t('moderation_queue')} ) : isThreadClosed ? ( @@ -562,7 +562,7 @@ const PostForm = () => { return ( - {shouldShowOfflineAlert && } + {shouldShowOfflineAlert && } {isInModQueueView ? ( {t('moderation_queue')} ) : isThreadClosed ? ( diff --git a/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx b/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx index fa284212..e147ee2d 100644 --- a/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx +++ b/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx @@ -120,7 +120,7 @@ const basePostMenu = { parentCid: undefined as string | undefined, postCid: 'cid-1', removed: false, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', thumbnailUrl: undefined as string | undefined, }; diff --git a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx index 943230f0..32c75b92 100644 --- a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx +++ b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx @@ -52,13 +52,13 @@ type HideButtonProps = { }; type CopyLinkButtonProps = - | { cid: string; subplebbitAddress: string; linkType: 'thread'; onClose: () => void } - | { subplebbitAddress: string; linkType: Exclude; onClose: () => void; cid?: undefined }; + | { cid: string; communityAddress: string; linkType: 'thread'; onClose: () => void } + | { communityAddress: string; linkType: Exclude; onClose: () => void; cid?: undefined }; -const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkButtonProps) => { +const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkButtonProps) => { const { t } = useTranslation(); const directories = useDirectories(); - const boardIdentifier = getBoardPath(subplebbitAddress, directories); + const boardIdentifier = getBoardPath(communityAddress, directories); const handleClick = async () => { await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined); onClose(); @@ -188,11 +188,15 @@ type PostMenuMobileProps = { editMenuPost: Comment; }; +type PostMenuLegacyAddress = Pick & { communityAddress?: string }; + const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => { - const { authorAddress, cid, deleted, link, linkHeight, linkWidth, parentCid, postCid, removed, subplebbitAddress, thumbnailUrl } = postMenu || {}; + const { authorAddress, cid, deleted, link, linkHeight, linkWidth, parentCid, postCid, removed, thumbnailUrl } = postMenu || {}; + const postMenuLegacyAddress = (postMenu as PostMenuLegacyAddress) || {}; + const resolvedCommunityAddress = postMenuLegacyAddress.communityAddress || postMenuLegacyAddress.subplebbitAddress; const { isAccountMod, isAccountCommentAuthor } = useEditCommentPrivileges({ commentAuthorAddress: authorAddress || '', - subplebbitAddress: subplebbitAddress || '', + subplebbitAddress: resolvedCommunityAddress || '', }); const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth || 0, linkHeight || 0); const { thumbnail, type, url } = commentMediaInfo || {}; @@ -246,10 +250,10 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => { createPortal( - {cid && subplebbitAddress && } + {cid && resolvedCommunityAddress && } {cid && } {authorAddress && } - {cid && subplebbitAddress && } + {cid && resolvedCommunityAddress && } {link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && } , diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 0ea09fe9..54640525 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -47,6 +47,7 @@ import { filterRepliesForDisplay, getPreviewDisplayReplies } from '../../lib/uti import { getRenderableMobileBacklinks } from '../../lib/utils/reply-backlink-utils'; import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; import useDeleteFailedPost from '../../hooks/use-delete-failed-post'; +import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; const { addChallenge } = useChallengesStore.getState(); @@ -63,17 +64,18 @@ const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: PostProps) => { const { t } = useTranslation(); const directories = useDirectories(); - const { author, cid, deleted, link, linkHeight, linkWidth, locked, parentCid, pinned, postCid, reason, removed, state, subplebbitAddress, timestamp, thumbnailUrl } = - post || {}; - const purged = post?.commentModeration?.purged; - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; + const resolvedPost = withResolvedCommentCommunityAddress(post); + const { author, cid, deleted, link, linkHeight, linkWidth, locked, parentCid, pinned, postCid, reason, removed, state, communityAddress, timestamp, thumbnailUrl } = + resolvedPost || {}; + const purged = resolvedPost?.commentModeration?.purged; + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const displayBoardPath = - boardPath && subplebbitAddress - ? boardPath !== subplebbitAddress + boardPath && communityAddress + ? boardPath !== communityAddress ? boardPath - : subplebbitAddress.endsWith('.eth') || subplebbitAddress.endsWith('.sol') - ? subplebbitAddress - : getShortAddress(subplebbitAddress) + : communityAddress.endsWith('.eth') || communityAddress.endsWith('.sol') + ? communityAddress + : getShortAddress(communityAddress) : undefined; const isReply = parentCid; const title = post?.title?.trim(); @@ -98,8 +100,8 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const isAccountMod = accountRole === 'admin' || accountRole === 'owner' || accountRole === 'moderator'; // Check if post is pending approval and user is mod (for post page view) - const pendingApproval = post?.pendingApproval; - const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && subplebbitAddress; + const pendingApproval = resolvedPost?.pendingApproval; + const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress; // Moderation actions for pending approval posts const { @@ -108,10 +110,10 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos error: approvePendingError, } = usePublishCommentModeration({ commentCid: cid, - subplebbitAddress: shouldShowPendingApprovalButtons ? subplebbitAddress : undefined, + communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined, commentModeration: approvePendingCommentModeration, onChallenge: async (...args: any) => { - addChallenge([...args, post]); + addChallenge([...args, resolvedPost]); }, onChallengeVerification: async (challengeVerification, comment) => { alertChallengeVerificationFailed(challengeVerification, comment); @@ -127,10 +129,10 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos error: rejectPendingError, } = usePublishCommentModeration({ commentCid: cid, - subplebbitAddress: shouldShowPendingApprovalButtons ? subplebbitAddress : undefined, + communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined, commentModeration: rejectPendingCommentModeration, onChallenge: async (...args: any) => { - addChallenge([...args, post]); + addChallenge([...args, resolvedPost]); }, onChallengeVerification: async (challengeVerification, comment) => { alertChallengeVerificationFailed(challengeVerification, comment); @@ -186,7 +188,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const hasThumbnail = getHasThumbnail(commentMediaInfo, link); // Check if post is awaiting approval and over threshold (for mod queue view) - const approved = post?.approved; + const approved = resolvedPost?.approved; const alreadyApproved = approved === true; const alreadyRejected = isPendingApprovalRejected(post); const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; @@ -195,9 +197,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; const hasFailedState = state === 'failed'; - const postMenuProps = selectPostMenuProps(post); + const postMenuProps = selectPostMenuProps(resolvedPost); - const pseudonymityMode = useBoardPseudonymityMode(subplebbitAddress); + const pseudonymityMode = useBoardPseudonymityMode(communityAddress); const showUserID = pseudonymityMode === 'per-post'; const handleUserAddressClick = useAuthorAddressClick(); @@ -214,7 +216,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const userIDBackgroundColor = hashStringToColor(userID); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); - const { hidden } = useHide(post); + const { hidden } = useHide(resolvedPost); const { openReplyModal } = useReplyModalStore(); @@ -227,7 +229,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos ? isReply ? alert(t('this_reply_was_removed')) : alert(t('this_thread_was_removed')) - : openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, subplebbitAddress); + : openReplyModal && openReplyModal(cid, resolvedPost?.number, postCid, threadNumber, communityAddress); }; const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined; @@ -248,7 +250,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos return ( <> - + @@ -343,7 +345,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos )} - {subplebbitAddress && (isInAllView || isInSubscriptionsView || isInModView) && !isReply && boardPath && displayBoardPath && ( + {communityAddress && (isInAllView || isInSubscriptionsView || isInModView) && !isReply && boardPath && displayBoardPath && ( {' '} Board: {displayBoardPath} @@ -379,7 +381,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos } }} > - {post?.number || '?'} + {resolvedPost?.number || '?'} ) : ( @@ -416,7 +418,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos - {(hasThumbnail || link) && !(deleted || removed || purged) && } + {(hasThumbnail || link) && !(deleted || removed || purged) && } > ); }; @@ -491,10 +493,11 @@ const Reply = ({ if (editedComment) { post = editedComment; } - const { author, cid, deleted, postCid, reason, removed, subplebbitAddress } = post || {}; + post = withResolvedCommentCommunityAddress(post); + const { author, cid, deleted, postCid, reason, removed, communityAddress } = post || {}; const purged = post?.commentModeration?.purged; const directories = useDirectories(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const location = useLocation(); const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; const isRouteLinkToReply = cid ? location.pathname.startsWith(route) : false; @@ -536,27 +539,28 @@ const PostMobile = ({ onReject, }: PostProps) => { const { t } = useTranslation(); - const { author, cid, parentCid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {}; + const resolvedPost = withResolvedCommentCommunityAddress(post); + const { author, cid, parentCid, pinned, postCid, replyCount, state, communityAddress } = resolvedPost || {}; const params = useParams(); const location = useLocation(); const navigationType = useNavigationType(); const isInPendingPostView = isPendingPostView(location.pathname, params); const isInPostView = isPostPageView(location.pathname, params); const directories = useDirectories(); - const directoryEntry = findDirectoryByAddress(directories, subplebbitAddress); + const directoryEntry = findDirectoryByAddress(directories, communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; - const linksCount = useCountLinksInReplies(post); + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; + const linksCount = useCountLinksInReplies(resolvedPost); const shouldFetchReplies = showReplies && !isModQueue; const previewRepliesResult = useReplies({ - comment: shouldFetchReplies && !showAllReplies ? post : undefined, + comment: shouldFetchReplies && !showAllReplies ? resolvedPost : undefined, sortType: 'new', flat: true, repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE, accountComments: { newerThan: Infinity, append: true }, }); const fullRepliesResult = useReplies({ - comment: shouldFetchReplies && showAllReplies ? post : undefined, + comment: shouldFetchReplies && showAllReplies ? resolvedPost : undefined, sortType: 'old', flat: true, repliesPerPage: REPLIES_PER_PAGE, @@ -567,7 +571,7 @@ const PostMobile = ({ const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies; const repliesForRender = updatedReplies?.length ? updatedReplies : replies || []; const freshRepliesForRender = useFreshReplies(repliesForRender); - useRegisterFreshReplies(post, freshRepliesForRender); + useRegisterFreshReplies(resolvedPost, freshRepliesForRender); const reset = (repliesResult as { reset?: () => Promise }).reset; const setResetFunction = useFeedResetStore((s) => s.setResetFunction); useEffect(() => { @@ -581,10 +585,10 @@ const PostMobile = ({ const isInPostPageView = isPostPageView(location.pathname, params); const { hidden, unhide } = useHide({ cid }); - const stateString = useStateString(post) || t('loading_post'); + const stateString = useStateString(resolvedPost) || t('loading_post'); const hasFailedState = state === 'failed'; const isReply = !!parentCid; - const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(post); + const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(resolvedPost); const failedPublishNotice = canDeleteFailedPost ? : undefined; // Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder. @@ -606,7 +610,7 @@ const PostMobile = ({ return map; })(); - const quotedByMap = useQuotedByMap(filteredReplies, subplebbitAddress); + const quotedByMap = useQuotedByMap(filteredReplies, communityAddress); const visibleReplies = useProgressiveRender(filteredReplies, { batchSize: 50, @@ -687,9 +691,9 @@ const PostMobile = ({ data-post-cid={postCid} > {shouldShowSnow() && } - - - + + + {!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && ( @@ -717,8 +721,8 @@ const PostMobile = ({ ) : ( <> - {isReply && boardPath && (post?.threadCid || post?.parentCid) && ( - + {isReply && boardPath && (resolvedPost?.threadCid || resolvedPost?.parentCid) && ( + {t('view_thread')} )} @@ -740,7 +744,7 @@ const PostMobile = ({ )} {/* Virtuoso infinite scroll for post page view when there's more content to paginate */} - {showAllReplies && !isInPendingPostView && showReplies && hasMore && !!post?.replyCount && ( + {showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && ( @@ -776,7 +780,7 @@ const PostMobile = ({ postReplyCount={replyCount} reply={reply} roles={roles} - threadNumber={post?.number} + threadNumber={resolvedPost?.number} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} /> @@ -793,7 +797,7 @@ const PostMobile = ({ postReplyCount={replyCount} reply={reply} roles={roles} - threadNumber={post?.number} + threadNumber={resolvedPost?.number} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} /> @@ -804,7 +808,7 @@ const PostMobile = ({ stateString && !hasFailedState && state !== 'succeeded' && - !(post?.timestamp && !post?.updatedAt) && + !(resolvedPost?.timestamp && !resolvedPost?.updatedAt) && isInPostPageView && !(!showReplies && !showAllReplies) ? ( diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index da60198d..0300a25b 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -34,12 +34,12 @@ const testState = vi.hoisted(() => ({ quoteInsertSelectedText: '', replyIndex: undefined as number | undefined, resetPublishReplyOptionsMock: vi.fn(), - resolvedSubplebbitAddress: undefined as string | undefined, + resolvedCommunityAddress: undefined as string | undefined, selectedText: 'selected text', setAccountMock: vi.fn(), setPublishReplyOptionsMock: vi.fn(), springStartMock: vi.fn(), - subplebbits: { + communities: { 'music-posting.eth': { address: 'music-posting.eth', }, @@ -74,16 +74,16 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits', () => ({ - default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => T) => +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities', () => ({ + default: (selector: (state: { communities: typeof testState.communities }) => T) => selector({ - subplebbits: testState.subplebbits, + communities: testState.communities, }), })); -vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({ - default: (subplebbit?: { address?: string }) => - (subplebbit?.address ? testState.offlineStates[subplebbit.address] : undefined) || { +vi.mock('../../../hooks/use-is-community-offline', () => ({ + default: (community?: { address?: string }) => + (community?.address ? testState.offlineStates[community.address] : undefined) || { isOffline: testState.offlineWarningVisible, isOnlineStatusLoading: testState.offlineStatusLoading, offlineTitle: testState.offlineTitle, @@ -125,8 +125,8 @@ vi.mock('../../../hooks/use-directories', () => ({ normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../hooks/use-publish-reply', () => ({ @@ -199,7 +199,7 @@ const flushEffects = async (count = 4) => { } }; -const renderReplyModal = async (initialEntry = '/mu/thread/post-1', subplebbitAddress = 'music-posting.eth') => { +const renderReplyModal = async (initialEntry = '/mu/thread/post-1', communityAddress = 'music-posting.eth') => { await act(async () => { root.render( createElement( @@ -212,7 +212,7 @@ const renderReplyModal = async (initialEntry = '/mu/thread/post-1', subplebbitAd postCid: 'post-cid', scrollY: 120, showReplyModal: true, - subplebbitAddress, + communityAddress, threadNumber: 42, }), ), @@ -270,12 +270,12 @@ describe('ReplyModal', () => { testState.quoteInsertSelectedText = ''; testState.replyIndex = undefined; testState.resetPublishReplyOptionsMock.mockReset(); - testState.resolvedSubplebbitAddress = undefined; + testState.resolvedCommunityAddress = undefined; testState.selectedText = 'selected text'; testState.setAccountMock.mockReset(); testState.setPublishReplyOptionsMock.mockReset(); testState.springStartMock.mockReset(); - testState.subplebbits = { + testState.communities = { 'music-posting.eth': { address: 'music-posting.eth', }, @@ -318,14 +318,14 @@ describe('ReplyModal', () => { await renderReplyModal('/mu/thread/post-1'); expect(container.querySelector('[class*="offlineBoard"]')).toBeNull(); - expect(container.textContent).not.toContain('subplebbit_offline_info'); + expect(container.textContent).not.toContain('community_offline_info'); }); it('prefers the resolved board entry when the modal prop address uses a different alias', async () => { - testState.offlineTitle = 'subplebbit_offline_info'; + testState.offlineTitle = 'community_offline_info'; testState.offlineWarningVisible = true; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; - testState.subplebbits = { + testState.resolvedCommunityAddress = 'music-posting.eth'; + testState.communities = { 'music-posting.eth': { address: 'music-posting.eth', }, @@ -341,7 +341,7 @@ describe('ReplyModal', () => { await renderReplyModal('/mu/thread/post-1', 'music-posting.bso'); expect(container.querySelector('[class*="offlineBoard"]')).toBeNull(); - expect(container.textContent).not.toContain('subplebbit_offline_info'); + expect(container.textContent).not.toContain('community_offline_info'); }); it('validates empty and invalid replies, then publishes once the payload is valid', async () => { diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 8e7e2197..1f27a578 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -27,24 +27,24 @@ interface ReplyModalProps { threadNumber: number | null; postCid: string; scrollY: number; - subplebbitAddress: string; + communityAddress: string; } -const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => { +const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, communityAddress }: ReplyModalProps) => { const { t } = useTranslation(); const location = useLocation(); const params = useParams(); const isInAllView = isAllView(location.pathname); const isInModView = isModView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); - const directoryEntry = useDirectoryByAddress(subplebbitAddress); + const directoryEntry = useDirectoryByAddress(communityAddress); const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = usePublishReply({ cid: parentCid, - subplebbitAddress, + subplebbitAddress: communityAddress, postCid, }); const account = useAccount(); @@ -375,7 +375,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa publishReplyError && {publishReplyError} )} {publishReplyStateMessage && {publishReplyStateMessage}} - + ); diff --git a/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx b/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx index c233fa62..e2fd5b0d 100644 --- a/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx +++ b/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx @@ -14,7 +14,7 @@ type TestComment = { }; cid?: string; number?: number; - subplebbitAddress?: string; + communityAddress?: string; }; const testState = vi.hoisted(() => ({ @@ -217,7 +217,7 @@ describe('ReplyQuotePreview', () => { quotelinkReply: { cid: 'reply-cid', number: 7, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }); @@ -244,7 +244,7 @@ describe('ReplyQuotePreview', () => { quotelinkReply: { cid: 'thread-cid', number: 1, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }); @@ -273,7 +273,7 @@ describe('ReplyQuotePreview', () => { quotelinkReply: { cid: 'thread-cid', number: 1, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }); @@ -301,7 +301,7 @@ describe('ReplyQuotePreview', () => { quotelinkReply: { cid: 'thread-cid', number: 1, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }); @@ -330,7 +330,7 @@ describe('ReplyQuotePreview', () => { backlinkReply: { cid: 'reply-cid', number: 7, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, isBacklinkReply: true, }); @@ -354,7 +354,7 @@ describe('ReplyQuotePreview', () => { quotelinkReply: { cid: 'reply-cid', number: 9, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }); @@ -403,7 +403,7 @@ describe('ReplyQuotePreview', () => { }, cid: 'reply-cid', number: 10, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, showTrailingBreak: false, }); @@ -420,7 +420,7 @@ describe('ReplyQuotePreview', () => { backlinkReply: { cid: 'reply-cid', number: 5, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, isBacklinkReply: true, }); @@ -448,7 +448,7 @@ describe('ReplyQuotePreview', () => { quotelinkReply: { cid: 'thread-cid', number: 1, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }); @@ -475,7 +475,7 @@ describe('ReplyQuotePreview', () => { backlinkReply: { cid: 'reply-cid', number: 5, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, isBacklinkReply: true, }); diff --git a/src/components/reply-quote-preview/reply-quote-preview.tsx b/src/components/reply-quote-preview/reply-quote-preview.tsx index 1a6f7e2c..cb9d376b 100644 --- a/src/components/reply-quote-preview/reply-quote-preview.tsx +++ b/src/components/reply-quote-preview/reply-quote-preview.tsx @@ -10,6 +10,7 @@ import { findPreferredScrollTarget, getThreadTopNavigationState, scrollThreadCon import useIsMobile from '../../hooks/use-is-mobile'; import styles from '../../views/post/post.module.css'; import { Post } from '../../views/post'; +import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; interface ReplyQuotePreviewProps { isBacklinkReply?: boolean; @@ -133,13 +134,15 @@ const DesktopQuotePreview = ({ const navigate = useNavigate(); const location = useLocation(); + const normalizedBacklinkReply = withResolvedCommentCommunityAddress(backlinkReply); + const normalizedQuotelinkReply = withResolvedCommentCommunityAddress(quotelinkReply); const isOnThreadPage = location.pathname.includes('/thread/'); - const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => { + const handleClick = (e: React.MouseEvent, cid: string | undefined, communityAddress: string | undefined, isOpQuote = false) => { e.preventDefault(); - if (cid && subplebbitAddress) { - const boardPath = getBoardPath(subplebbitAddress, directories); + if (cid && communityAddress) { + const boardPath = getBoardPath(communityAddress, directories); const threadRoute = `/${boardPath}/thread/${cid}`; if (isOpQuote) { if (isOnThreadPage && scrollThreadContainerToTop(cid)) return; @@ -170,8 +173,13 @@ const DesktopQuotePreview = ({ setOutOfViewCid(null); }; - const backlinkBoardPath = backlinkReply?.subplebbitAddress ? getBoardPath(backlinkReply.subplebbitAddress, directories) : undefined; - const backlinkRoute = backlinkReply?.cid ? (backlinkBoardPath ? `/${backlinkBoardPath}/thread/${backlinkReply.cid}` : `/thread/${backlinkReply.cid}`) : '#'; + const backlinkCommunityAddress = getCommentCommunityAddress(normalizedBacklinkReply); + const backlinkBoardPath = backlinkCommunityAddress ? getBoardPath(backlinkCommunityAddress, directories) : undefined; + const backlinkRoute = normalizedBacklinkReply?.cid + ? backlinkBoardPath + ? `/${backlinkBoardPath}/thread/${normalizedBacklinkReply.cid}` + : `/thread/${normalizedBacklinkReply.cid}` + : '#'; const replyBacklink = ( <> @@ -179,18 +187,18 @@ const DesktopQuotePreview = ({ className={styles.backlink} to={backlinkRoute} ref={refs.setReference} - onMouseOver={() => handleMouseOver(backlinkReply?.cid)} - onMouseLeave={() => handleMouseLeave(backlinkReply?.cid)} - onClick={(e) => handleClick(e, backlinkReply?.cid, backlinkReply?.subplebbitAddress)} + onMouseOver={() => handleMouseOver(normalizedBacklinkReply?.cid)} + onMouseLeave={() => handleMouseLeave(normalizedBacklinkReply?.cid)} + onClick={(e) => handleClick(e, normalizedBacklinkReply?.cid, backlinkCommunityAddress)} > {'>>'} - {backlinkReply?.number ?? '?'} + {normalizedBacklinkReply?.number ?? '?'} - {hoveredCid === backlinkReply?.cid && - outOfViewCid === backlinkReply?.cid && + {hoveredCid === normalizedBacklinkReply?.cid && + outOfViewCid === normalizedBacklinkReply?.cid && createPortal( - + , document.body, )} @@ -199,15 +207,19 @@ const DesktopQuotePreview = ({ const account = useAccount(); - const resolvedQuotelinkNumber = quotelinkReply?.number ?? quotelinkNumber; - const resolvedQuotelinkCid = quotelinkReply?.cid; - const resolvedQuotelinkSubplebbitAddress = quotelinkReply?.subplebbitAddress; - const quoteTargetAvailability = getQuoteTargetAvailability(quotelinkReply); + const resolvedQuotelinkNumber = normalizedQuotelinkReply?.number ?? quotelinkNumber; + const resolvedQuotelinkCid = normalizedQuotelinkReply?.cid; + const resolvedQuotelinkCommunityAddress = getCommentCommunityAddress(normalizedQuotelinkReply); + const quoteTargetAvailability = getQuoteTargetAvailability(normalizedQuotelinkReply); const quotelinkUnavailable = Boolean(isQuotelinkUnavailable || quoteTargetAvailability === 'unavailable'); const quotelinkPendingResolution = !quotelinkUnavailable && quoteTargetAvailability === 'unresolved'; const quotelinkClassName = quotelinkUnavailable ? `${styles.quoteLink} ${styles.quoteLinkUnavailable}` : styles.quoteLink; - const quotelinkBoardPath = quotelinkReply?.subplebbitAddress ? getBoardPath(quotelinkReply.subplebbitAddress, directories) : undefined; - const quotelinkRoute = quotelinkReply?.cid ? (quotelinkBoardPath ? `/${quotelinkBoardPath}/thread/${quotelinkReply.cid}` : `/thread/${quotelinkReply.cid}`) : '#'; + const quotelinkBoardPath = resolvedQuotelinkCommunityAddress ? getBoardPath(resolvedQuotelinkCommunityAddress, directories) : undefined; + const quotelinkRoute = normalizedQuotelinkReply?.cid + ? quotelinkBoardPath + ? `/${quotelinkBoardPath}/thread/${normalizedQuotelinkReply.cid}` + : `/thread/${normalizedQuotelinkReply.cid}` + : '#'; const shouldShowQuotelinkPreview = shouldShowFloatingQuotePreview({ hoveredCid, outOfViewCid, @@ -218,7 +230,7 @@ const DesktopQuotePreview = ({ <> {formatQuoteNumber(resolvedQuotelinkNumber)} {isOP && ' (OP)'} - {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} + {normalizedQuotelinkReply?.author?.address === account?.author?.address && ' (You)'} > ); @@ -235,7 +247,7 @@ const DesktopQuotePreview = ({ className={quotelinkClassName} onMouseOver={() => handleMouseOver(resolvedQuotelinkCid)} onMouseLeave={() => handleMouseLeave(resolvedQuotelinkCid)} - onClick={(e) => handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkSubplebbitAddress, !!isOP)} + onClick={(e) => handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkCommunityAddress, !!isOP)} > {quotelinkLabel} @@ -244,7 +256,7 @@ const DesktopQuotePreview = ({ {shouldShowQuotelinkPreview && createPortal( - + , document.body, )} @@ -267,6 +279,8 @@ const MobileQuotePreview = ({ const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const directories = useDirectories(); + const normalizedBacklinkReply = withResolvedCommentCommunityAddress(backlinkReply); + const normalizedQuotelinkReply = withResolvedCommentCommunityAddress(quotelinkReply); const { refs, floatingStyles, update } = useFloating({ placement: 'bottom', @@ -287,10 +301,10 @@ const MobileQuotePreview = ({ const location = useLocation(); const isOnThreadPage = location.pathname.includes('/thread/'); - const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => { + const handleClick = (e: React.MouseEvent, cid: string | undefined, communityAddress: string | undefined, isOpQuote = false) => { e.preventDefault(); - if (cid && subplebbitAddress) { - const boardPath = getBoardPath(subplebbitAddress, directories); + if (cid && communityAddress) { + const boardPath = getBoardPath(communityAddress, directories); const threadRoute = `/${boardPath}/thread/${cid}`; if (isOpQuote) { if (isOnThreadPage && scrollThreadContainerToTop(cid)) return; @@ -325,27 +339,32 @@ const MobileQuotePreview = ({ handleMouseOver(backlinkReply?.cid)} - onMouseLeave={() => handleMouseLeave(backlinkReply?.cid)} + onMouseOver={() => handleMouseOver(normalizedBacklinkReply?.cid)} + onMouseLeave={() => handleMouseLeave(normalizedBacklinkReply?.cid)} > - {`>>${backlinkReply?.number ?? '?'}`} + {`>>${normalizedBacklinkReply?.number ?? '?'}`} - {backlinkReply?.number && + {normalizedBacklinkReply?.number && (() => { - const backlinkBoardPath = backlinkReply?.subplebbitAddress ? getBoardPath(backlinkReply.subplebbitAddress, directories) : undefined; - const backlinkRoute = backlinkReply?.cid ? (backlinkBoardPath ? `/${backlinkBoardPath}/thread/${backlinkReply.cid}` : `/thread/${backlinkReply.cid}`) : '#'; + const backlinkCommunityAddress = getCommentCommunityAddress(normalizedBacklinkReply); + const backlinkBoardPath = backlinkCommunityAddress ? getBoardPath(backlinkCommunityAddress, directories) : undefined; + const backlinkRoute = normalizedBacklinkReply?.cid + ? backlinkBoardPath + ? `/${backlinkBoardPath}/thread/${normalizedBacklinkReply.cid}` + : `/thread/${normalizedBacklinkReply.cid}` + : '#'; return ( - handleClick(e, backlinkReply?.cid, backlinkReply?.subplebbitAddress)}> + handleClick(e, normalizedBacklinkReply?.cid, backlinkCommunityAddress)}> {' '} # ); })()} - {hoveredCid === backlinkReply?.cid && - outOfViewCid === backlinkReply?.cid && + {hoveredCid === normalizedBacklinkReply?.cid && + outOfViewCid === normalizedBacklinkReply?.cid && createPortal( - + , document.body, )} @@ -353,10 +372,10 @@ const MobileQuotePreview = ({ ); const account = useAccount(); - const resolvedQuotelinkNumber = quotelinkReply?.number ?? quotelinkNumber; - const resolvedQuotelinkCid = quotelinkReply?.cid; - const resolvedQuotelinkSubplebbitAddress = quotelinkReply?.subplebbitAddress; - const quoteTargetAvailability = getQuoteTargetAvailability(quotelinkReply); + const resolvedQuotelinkNumber = normalizedQuotelinkReply?.number ?? quotelinkNumber; + const resolvedQuotelinkCid = normalizedQuotelinkReply?.cid; + const resolvedQuotelinkCommunityAddress = getCommentCommunityAddress(normalizedQuotelinkReply); + const quoteTargetAvailability = getQuoteTargetAvailability(normalizedQuotelinkReply); const quotelinkUnavailable = Boolean(isQuotelinkUnavailable || quoteTargetAvailability === 'unavailable'); const quotelinkPendingResolution = !quotelinkUnavailable && quoteTargetAvailability === 'unresolved'; const quotelinkClassName = quotelinkUnavailable ? `${styles.quoteLink} ${styles.quoteLinkUnavailable}` : styles.quoteLink; @@ -377,20 +396,20 @@ const MobileQuotePreview = ({ > {formatQuoteNumber(resolvedQuotelinkNumber)} {isOP && ' (OP)'} - {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} + {normalizedQuotelinkReply?.author?.address === account?.author?.address && ' (You)'} {!quotelinkUnavailable && !quotelinkPendingResolution && resolvedQuotelinkNumber && (() => { - const quotelinkBoardPath = resolvedQuotelinkSubplebbitAddress ? getBoardPath(resolvedQuotelinkSubplebbitAddress, directories) : undefined; + const quotelinkBoardPath = resolvedQuotelinkCommunityAddress ? getBoardPath(resolvedQuotelinkCommunityAddress, directories) : undefined; const quotelinkRoute = resolvedQuotelinkCid ? quotelinkBoardPath ? `/${quotelinkBoardPath}/thread/${resolvedQuotelinkCid}` : `/thread/${resolvedQuotelinkCid}` : '#'; return ( - handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkSubplebbitAddress, !!isOP)}> + handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkCommunityAddress, !!isOP)}> {' '} # @@ -400,7 +419,7 @@ const MobileQuotePreview = ({ {shouldShowQuotelinkPreview && createPortal( - + , document.body, )} diff --git a/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx b/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx index 8d03ff02..0b206aa3 100644 --- a/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx +++ b/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx @@ -289,7 +289,7 @@ describe('AccountSettings', () => { name: 'Imported', author: { address: '0x999' }, subscriptions: ['business.eth'], - subplebbits: { + communities: { 'business.eth': { title: '/biz/' }, 'music-posting.bso': { title: '/mu/' }, }, diff --git a/src/components/settings-modal/account-settings/account-settings.tsx b/src/components/settings-modal/account-settings/account-settings.tsx index a8ec6710..e2c63afc 100644 --- a/src/components/settings-modal/account-settings/account-settings.tsx +++ b/src/components/settings-modal/account-settings/account-settings.tsx @@ -99,20 +99,20 @@ const AccountSettingsEditor = ({ } const accountData = safeParseJSON<{ - account?: { subplebbits?: Record; subscriptions?: string[]; author?: { address?: string }; name?: string }; + account?: { communities?: Record; subscriptions?: string[]; author?: { address?: string }; name?: string }; }>(fileContent); if (!accountData) { alert('Invalid JSON in file.'); return; } - if (accountData.account?.subplebbits) { - const subplebbitAddresses = Object.keys(accountData.account.subplebbits); + if (accountData.account?.communities) { + const communityAddresses = Object.keys(accountData.account.communities); if (!accountData.account.subscriptions) { accountData.account.subscriptions = []; } const uniqueSubscriptions = [...accountData.account.subscriptions]; - for (const address of subplebbitAddresses) { + for (const address of communityAddresses) { if (!uniqueSubscriptions.includes(address)) { uniqueSubscriptions.push(address); } diff --git a/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx b/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx index 5bd50e61..6e2cf8b9 100644 --- a/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx +++ b/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx @@ -27,8 +27,8 @@ vi.mock('react-i18next', () => ({ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useAccount: () => accountState.value, - useSubscribe: ({ subplebbitAddress }: { subplebbitAddress: string }) => - subscriptionMocks.byAddress.get(subplebbitAddress) ?? { + useSubscribe: ({ communityAddress }: { communityAddress: string }) => + subscriptionMocks.byAddress.get(communityAddress) ?? { subscribed: false, subscribe: vi.fn(), unsubscribe: vi.fn(), diff --git a/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx b/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx index 0caa0b28..ce57d88d 100644 --- a/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx +++ b/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx @@ -6,7 +6,7 @@ import { memo, useState } from 'react'; const SubscriptionButton = ({ address }: { address: string }) => { const { t } = useTranslation(); - const { subscribed, subscribe, unsubscribe } = useSubscribe({ subplebbitAddress: address }); + const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address }); const [recentlyUnsubscribed, setRecentlyUnsubscribed] = useState(false); const handleClick = () => { diff --git a/src/hooks/__tests__/selector-hooks.test.tsx b/src/hooks/__tests__/selector-hooks.test.tsx index 6e3a74ad..18bc214c 100644 --- a/src/hooks/__tests__/selector-hooks.test.tsx +++ b/src/hooks/__tests__/selector-hooks.test.tsx @@ -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; diff --git a/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx b/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx index 48474262..f0d713eb 100644 --- a/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx +++ b/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx @@ -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; diff --git a/src/hooks/__tests__/use-popular-posts.test.tsx b/src/hooks/__tests__/use-popular-posts.test.tsx index 329b2ff1..d381533f 100644 --- a/src/hooks/__tests__/use-popular-posts.test.tsx +++ b/src/hooks/__tests__/use-popular-posts.test.tsx @@ -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; diff --git a/src/hooks/__tests__/use-post-page-number.test.tsx b/src/hooks/__tests__/use-post-page-number.test.tsx index fd447c65..ec59aa5a 100644 --- a/src/hooks/__tests__/use-post-page-number.test.tsx +++ b/src/hooks/__tests__/use-post-page-number.test.tsx @@ -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'], }); }); diff --git a/src/hooks/__tests__/use-stable-subplebbit.test.tsx b/src/hooks/__tests__/use-stable-subplebbit.test.tsx index 924e7984..abfd0f52 100644 --- a/src/hooks/__tests__/use-stable-subplebbit.test.tsx +++ b/src/hooks/__tests__/use-stable-subplebbit.test.tsx @@ -8,13 +8,13 @@ import { useStableSubplebbit, useSubplebbitField } from '../use-stable-subplebbi const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ - subplebbits: {} as Record, + communities: {} as Record, })); -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', diff --git a/src/hooks/__tests__/use-state-string.test.tsx b/src/hooks/__tests__/use-state-string.test.tsx index e4d51305..540feb07 100644 --- a/src/hooks/__tests__/use-state-string.test.tsx +++ b/src/hooks/__tests__/use-state-string.test.tsx @@ -9,23 +9,23 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise const testState = vi.hoisted(() => ({ clientsStates: {} as Record, - subplebbit: undefined as + community: undefined as | { publishingState?: string; state?: string; updatingState?: string; } | undefined, - subplebbitsStates: {} as Record, + communitiesStates: {} as Record, })); 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'], }, }; diff --git a/src/hooks/use-account-communities-with-metadata.ts b/src/hooks/use-account-communities-with-metadata.ts new file mode 100644 index 00000000..7660209b --- /dev/null +++ b/src/hooks/use-account-communities-with-metadata.ts @@ -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], + ); +}; diff --git a/src/hooks/use-account-community-addresses.ts b/src/hooks/use-account-community-addresses.ts new file mode 100644 index 00000000..16fc850d --- /dev/null +++ b/src/hooks/use-account-community-addresses.ts @@ -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]); +}; diff --git a/src/hooks/use-account-subplebbit-addresses.ts b/src/hooks/use-account-subplebbit-addresses.ts index deb201ce..b0a01672 100644 --- a/src/hooks/use-account-subplebbit-addresses.ts +++ b/src/hooks/use-account-subplebbit-addresses.ts @@ -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'; diff --git a/src/hooks/use-account-subplebbits-with-metadata.ts b/src/hooks/use-account-subplebbits-with-metadata.ts index f2451fb2..0e781c30 100644 --- a/src/hooks/use-account-subplebbits-with-metadata.ts +++ b/src/hooks/use-account-subplebbits-with-metadata.ts @@ -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'; diff --git a/src/hooks/use-author-privileges.ts b/src/hooks/use-author-privileges.ts index 6e8b2cee..0e3665ea 100644 --- a/src/hooks/use-author-privileges.ts +++ b/src/hooks/use-author-privileges.ts @@ -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'; diff --git a/src/hooks/use-board-pseudonymity-mode.ts b/src/hooks/use-board-pseudonymity-mode.ts index eb77727e..675ad8a3 100644 --- a/src/hooks/use-board-pseudonymity-mode.ts +++ b/src/hooks/use-board-pseudonymity-mode.ts @@ -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; }; diff --git a/src/hooks/use-catalog-feed-rows.ts b/src/hooks/use-catalog-feed-rows.ts index 0b717549..b6da9304 100644 --- a/src/hooks/use-catalog-feed-rows.ts +++ b/src/hooks/use-catalog-feed-rows.ts @@ -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) ); }); diff --git a/src/hooks/use-communities-stats.ts b/src/hooks/use-communities-stats.ts new file mode 100644 index 00000000..20be4a78 --- /dev/null +++ b/src/hooks/use-communities-stats.ts @@ -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((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; diff --git a/src/hooks/use-is-community-offline.ts b/src/hooks/use-is-community-offline.ts new file mode 100644 index 00000000..2a67ff60 --- /dev/null +++ b/src/hooks/use-is-community-offline.ts @@ -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; diff --git a/src/hooks/use-is-subplebbit-offline.ts b/src/hooks/use-is-subplebbit-offline.ts index 1651f5ea..01017bd6 100644 --- a/src/hooks/use-is-subplebbit-offline.ts +++ b/src/hooks/use-is-subplebbit-offline.ts @@ -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; diff --git a/src/hooks/use-popular-posts.ts b/src/hooks/use-popular-posts.ts index d50f5efe..d62b74ef 100644 --- a/src/hooks/use-popular-posts.ts +++ b/src/hooks/use-popular-posts.ts @@ -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, subplebbitAddresses: string[]) => { - const inputKey = [...subplebbitAddresses].sort().join(','); +const usePopularPosts = (communities: Array, communityAddresses: string[]) => { + const inputKey = [...communityAddresses].sort().join(','); const committedRef = useRef({ posts: [], revealed: false, @@ -74,7 +74,7 @@ const usePopularPosts = (subplebbits: Array, 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, 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(() => { if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) { @@ -93,16 +93,16 @@ const usePopularPosts = (subplebbits: Array, subplebbitA try { const selectedLinks = new Set(); 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)) { + for (const post of Object.values(community.posts.pages.hot.comments as Record)) { const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, thumbnailUrl } = post; try { @@ -131,9 +131,9 @@ const usePopularPosts = (subplebbits: Array, 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); diff --git a/src/hooks/use-post-page-number.ts b/src/hooks/use-post-page-number.ts index a8d86214..9b52b4fb 100644 --- a/src/hooks/use-post-page-number.ts +++ b/src/hooks/use-post-page-number.ts @@ -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); diff --git a/src/hooks/use-publish-post.ts b/src/hooks/use-publish-post.ts index e11f16a8..63e9cd92 100644 --- a/src/hooks/use-publish-post.ts +++ b/src/hooks/use-publish-post.ts @@ -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) => { diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts index c075f2c9..a1fef78b 100644 --- a/src/hooks/use-publish-reply.ts +++ b/src/hooks/use-publish-reply.ts @@ -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) => { @@ -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) { diff --git a/src/hooks/use-register-fresh-replies.ts b/src/hooks/use-register-fresh-replies.ts index bb5808d5..d23349eb 100644 --- a/src/hooks/use-register-fresh-replies.ts +++ b/src/hooks/use-register-fresh-replies.ts @@ -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(','); diff --git a/src/hooks/use-resolved-community-address.ts b/src/hooks/use-resolved-community-address.ts new file mode 100644 index 00000000..5bb57413 --- /dev/null +++ b/src/hooks/use-resolved-community-address.ts @@ -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]); +}; diff --git a/src/hooks/use-resolved-subplebbit-address.ts b/src/hooks/use-resolved-subplebbit-address.ts index 6a4d31ea..ff76de13 100644 --- a/src/hooks/use-resolved-subplebbit-address.ts +++ b/src/hooks/use-resolved-subplebbit-address.ts @@ -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'; diff --git a/src/hooks/use-stable-community.ts b/src/hooks/use-stable-community.ts new file mode 100644 index 00000000..8cf6e2cc --- /dev/null +++ b/src/hooks/use-stable-community.ts @@ -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 | Community | undefined; + +const getCommunityByAddress = (communities: Record | 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 | undefined, obj2: Record | 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 = (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; diff --git a/src/hooks/use-stable-subplebbit.ts b/src/hooks/use-stable-subplebbit.ts index af1c0142..469f8386 100644 --- a/src/hooks/use-stable-subplebbit.ts +++ b/src/hooks/use-stable-subplebbit.ts @@ -1,89 +1 @@ -import useSubplebbitsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits'; -import { normalizeBoardAddress } from './use-directories'; - -const getSubplebbitByAddress = (subplebbits: Record | 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 | undefined, obj2: Record | 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 = (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'; diff --git a/src/hooks/use-state-string.ts b/src/hooks/use-state-string.ts index 2c360108..1c8b9e44 100644 --- a/src/hooks/use-state-string.ts +++ b/src/hooks/use-state-string.ts @@ -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 = { '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(); + const pagesStatesCommunityAddresses = new Set(); 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; diff --git a/src/hooks/use-subplebbits-stats.ts b/src/hooks/use-subplebbits-stats.ts index 6920ded1..20a257db 100644 --- a/src/hooks/use-subplebbits-stats.ts +++ b/src/hooks/use-subplebbits-stats.ts @@ -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((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'; diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 1e0724c7..c79cffd9 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -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; diff --git a/src/lib/utils/__tests__/pattern-utils.test.ts b/src/lib/utils/__tests__/pattern-utils.test.ts index c7f504ed..a6b41b3d 100644 --- a/src/lib/utils/__tests__/pattern-utils.test.ts +++ b/src/lib/utils/__tests__/pattern-utils.test.ts @@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const testState = vi.hoisted(() => ({ consoleErrorMock: vi.fn(), - subplebbits: {} as Record }>, + communities: {} as Record }>, })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits', () => ({ +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities', () => ({ default: { getState: () => ({ - subplebbits: testState.subplebbits, + communities: testState.communities, }), }, })); @@ -20,7 +20,7 @@ describe('pattern-utils', () => { beforeEach(() => { vi.clearAllMocks(); - testState.subplebbits = { + testState.communities = { 'music-posting.eth': { roles: { 'author-1': { role: 'moderator' }, diff --git a/src/lib/utils/comment-utils.ts b/src/lib/utils/comment-utils.ts new file mode 100644 index 00000000..760dadbd --- /dev/null +++ b/src/lib/utils/comment-utils.ts @@ -0,0 +1,103 @@ +type CommentWithLegacyCommunityAddress = { + communityAddress?: string; + replies?: { + pages?: Record< + string, + | { + comments?: Array; + } + | undefined + >; + }; + subplebbitAddress?: string; +}; + +export const getCommentCommunityAddress = (comment?: unknown) => { + if (!comment || typeof comment !== 'object') { + return undefined; + } + + const record = comment as { communityAddress?: unknown; subplebbitAddress?: unknown }; + if (typeof record.communityAddress === 'string' && record.communityAddress) { + return record.communityAddress; + } + if (typeof record.subplebbitAddress === 'string' && record.subplebbitAddress) { + return record.subplebbitAddress; + } + + return undefined; +}; + +const withResolvedReplyPages = (replies?: CommentWithLegacyCommunityAddress['replies']) => { + if (!replies?.pages) { + return replies; + } + + let nextPages = replies.pages; + let pagesChanged = false; + + for (const [sortType, page] of Object.entries(replies.pages)) { + if (!page?.comments?.length) { + continue; + } + + let nextComments = page.comments; + let commentsChanged = false; + + page.comments.forEach((reply, index) => { + const normalizedReply = withResolvedCommentCommunityAddress(reply); + if (normalizedReply === reply) { + return; + } + + if (!commentsChanged) { + nextComments = [...(page.comments ?? [])]; + commentsChanged = true; + } + nextComments[index] = normalizedReply; + }); + + if (!commentsChanged) { + continue; + } + + if (!pagesChanged) { + nextPages = { ...replies.pages }; + pagesChanged = true; + } + + nextPages[sortType] = { + ...page, + comments: nextComments, + }; + } + + if (!pagesChanged) { + return replies; + } + + return { + ...replies, + pages: nextPages, + }; +}; + +export const withResolvedCommentCommunityAddress = (comment: T): T => { + if (!comment) { + return comment; + } + + const communityAddress = getCommentCommunityAddress(comment); + const replies = withResolvedReplyPages(comment.replies); + const needsResolvedCommunityAddress = !!communityAddress && comment.communityAddress !== communityAddress; + + if (!needsResolvedCommunityAddress && replies === comment.replies) { + return comment; + } + + return { + ...comment, + ...(needsResolvedCommunityAddress ? { communityAddress } : {}), + ...(replies !== comment.replies ? { replies } : {}), + } as T; +}; diff --git a/src/lib/utils/external-quote-resolver.ts b/src/lib/utils/external-quote-resolver.ts index 44504655..ebdc2a52 100644 --- a/src/lib/utils/external-quote-resolver.ts +++ b/src/lib/utils/external-quote-resolver.ts @@ -1,7 +1,7 @@ import type { Comment } from '@bitsocialnet/bitsocial-react-hooks'; import feedsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/feeds'; import repliesStore, { feedOptionsToFeedName } from '@bitsocialnet/bitsocial-react-hooks/dist/stores/replies'; -import subplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages'; +import communitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages'; import type { DirectoryCommunity } from '../../hooks/use-directories'; import usePostNumberStore from '../../stores/use-post-number-store'; import type { ExternalQuoteReference, ExternalQuoteSearchStatus } from './external-quote-utils'; @@ -26,7 +26,7 @@ type ResolvedExternalQuoteTarget = { comment?: Comment; isUnavailable: boolean; route: string; - subplebbitAddress: string; + communityAddress: string; }; const waitFor = async (callback: () => T | undefined | false, timeoutMs = WAIT_FOR_STORE_TIMEOUT_MS) => { @@ -54,36 +54,39 @@ const isUnavailableComment = ( } | null, ) => Boolean(comment?.deleted || comment?.removed || comment?.commentModeration?.purged); -const getBoardFeedName = (accountId: string, subplebbitAddress: string) => - `external-quote-board-${accountId}-${subplebbitAddress}-${BOARD_FEED_SORT_TYPE}-${BOARD_SEARCH_POSTS_PER_PAGE}`; +const getBoardFeedName = (accountId: string, communityAddress: string) => + `external-quote-board-${accountId}-${communityAddress}-${BOARD_FEED_SORT_TYPE}-${BOARD_SEARCH_POSTS_PER_PAGE}`; -const getCachedComment = (cid?: string) => (cid ? subplebbitsPagesStore.getState().comments[cid] : undefined); +const getCachedComment = (cid?: string) => (cid ? communitiesPagesStore.getState().comments[cid] : undefined); -const findLoadedCommentByNumber = ({ number, subplebbitAddress }: { number: number; subplebbitAddress: string }) => { - const comments = Object.values(subplebbitsPagesStore.getState().comments) as Array; +const findLoadedCommentByNumber = ({ number, communityAddress }: { number: number; communityAddress: string }) => { + const comments = Object.values(communitiesPagesStore.getState().comments) as Array; - return comments.find((comment) => comment?.subplebbitAddress === subplebbitAddress && comment?.number === number && comment?.cid); + return comments.find((comment) => { + const address = (comment as { communityAddress?: string }).communityAddress || comment?.subplebbitAddress; + return address === communityAddress && comment?.number === number && comment?.cid; + }); }; const buildResolvedTarget = ({ cid, comment, directories, - subplebbitAddress, + communityAddress, }: { cid: string; comment?: Comment; directories: DirectoryCommunity[]; - subplebbitAddress: string; + communityAddress: string; }): ResolvedExternalQuoteTarget => { - const boardPath = getBoardPath(subplebbitAddress, directories); + const boardPath = getBoardPath(communityAddress, directories); return { boardPath, cid, comment, isUnavailable: isUnavailableComment(comment), route: `/${boardPath}/thread/${cid}`, - subplebbitAddress, + communityAddress, }; }; @@ -118,7 +121,7 @@ const loadBoardThreads = async ({ number, onStatus, quoteDisplay, - subplebbitAddress, + communityAddress, directories, }: { account: ResolverAccount; @@ -126,7 +129,7 @@ const loadBoardThreads = async ({ number: number; onStatus?: (status: ExternalQuoteSearchStatus) => void; quoteDisplay: string; - subplebbitAddress: string; + communityAddress: string; }) => { const accountId = account.id; if (!accountId) { @@ -138,7 +141,7 @@ const loadBoardThreads = async ({ kind: 'same-board', number, raw: quoteDisplay, - subplebbitAddress, + communityAddress, }, directories, ); @@ -149,10 +152,10 @@ const loadBoardThreads = async ({ quoteDisplay, }); - const feedName = getBoardFeedName(accountId, subplebbitAddress); + const feedName = getBoardFeedName(accountId, communityAddress); const feedState = feedsStore.getState(); if (!feedState.feedsOptions[feedName]) { - await feedState.addFeedToStore(feedName, [subplebbitAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE); + await feedState.addFeedToStore(feedName, [communityAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE); } await waitForBoardFeedPage(feedName, 0, 1); @@ -210,7 +213,7 @@ const searchThreadReplies = async ({ number, onStatus, quoteDisplay, - subplebbitAddress, + communityAddress, threads, }: { account: ResolverAccount; @@ -218,7 +221,7 @@ const searchThreadReplies = async ({ number: number; onStatus?: (status: ExternalQuoteSearchStatus) => void; quoteDisplay: string; - subplebbitAddress: string; + communityAddress: string; threads: Comment[]; }) => { const accountId = account.id; @@ -231,7 +234,7 @@ const searchThreadReplies = async ({ kind: 'same-board', number, raw: quoteDisplay, - subplebbitAddress, + communityAddress, }, directories, ); @@ -302,21 +305,21 @@ export const resolveExternalQuoteTarget = async ({ throw new Error('Missing active account while resolving external quote'); } - const targetSubplebbitAddress = getExternalQuoteBoardAddress(reference, directories); + const targetCommunityAddress = getExternalQuoteBoardAddress(reference, directories); const quoteDisplay = reference.raw; - const cachedCid = usePostNumberStore.getState().numberToCid[targetSubplebbitAddress]?.[reference.number]; + const cachedCid = usePostNumberStore.getState().numberToCid[targetCommunityAddress]?.[reference.number]; if (cachedCid) { return buildResolvedTarget({ cid: cachedCid, comment: getCachedComment(cachedCid), directories, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, }); } const loadedComment = findLoadedCommentByNumber({ number: reference.number, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, }); if (loadedComment?.cid) { registerComments([loadedComment]); @@ -324,7 +327,7 @@ export const resolveExternalQuoteTarget = async ({ cid: loadedComment.cid, comment: loadedComment, directories, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, }); } @@ -334,7 +337,7 @@ export const resolveExternalQuoteTarget = async ({ number: reference.number, onStatus, quoteDisplay, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, }); if (matchingThread?.cid) { @@ -343,7 +346,7 @@ export const resolveExternalQuoteTarget = async ({ cid: matchingThread.cid, comment: matchingThread, directories, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, }); } @@ -353,7 +356,7 @@ export const resolveExternalQuoteTarget = async ({ number: reference.number, onStatus, quoteDisplay, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, threads, }); @@ -366,6 +369,6 @@ export const resolveExternalQuoteTarget = async ({ cid: matchingReply.cid, comment: matchingReply, directories, - subplebbitAddress: targetSubplebbitAddress, + communityAddress: targetCommunityAddress, }); }; diff --git a/src/lib/utils/external-quote-utils.ts b/src/lib/utils/external-quote-utils.ts index bfafec2a..e5f63d92 100644 --- a/src/lib/utils/external-quote-utils.ts +++ b/src/lib/utils/external-quote-utils.ts @@ -1,5 +1,5 @@ import type { DirectoryCommunity } from '../../hooks/use-directories'; -import { getBoardPath, getSubplebbitAddress } from './route-utils'; +import { getBoardPath, getCommunityAddress, getSubplebbitAddress } from './route-utils'; import { QUOTE_NUMBER_REGEX } from './url-utils'; const CROSSBOARD_NUMBER_BOARD_PART = '(?:[a-zA-Z0-9]{1,10}|12D3KooW[a-zA-Z0-9]{44}|[a-zA-Z0-9\\-.]+)'; @@ -11,7 +11,9 @@ export type SameBoardExternalQuoteReference = { kind: 'same-board'; number: number; raw: string; - subplebbitAddress: string; + communityAddress?: string; + // legacy compatibility alias + subplebbitAddress?: string; }; export type CrossBoardExternalQuoteReference = { @@ -23,35 +25,59 @@ export type CrossBoardExternalQuoteReference = { export type ExternalQuoteReference = SameBoardExternalQuoteReference | CrossBoardExternalQuoteReference; +const getAddressForCanonicalReference = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]): string => { + if (reference.kind === 'cross-board') { + return resolveLegacyCommunityAddress(reference.boardIdentifier, directories); + } + + return resolveLegacyCommunityAddress(reference.communityAddress || reference.subplebbitAddress || '', directories); +}; + const getExternalQuoteKey = (reference: ExternalQuoteReference) => reference.kind === 'cross-board' ? `${reference.kind}:${reference.boardIdentifier}:${reference.number}` - : `${reference.kind}:${reference.subplebbitAddress}:${reference.number}`; + : `${reference.kind}:${reference.communityAddress || reference.subplebbitAddress}:${reference.number}`; + +const resolveLegacyCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]) => { + // Canonical resolver in route utils handles directory or address mapping. + const address = getCommunityAddress(boardIdentifier, communities); + if (address) { + return address; + } + + // Backward-compat helper alias if needed by callers with older util behavior. + return getSubplebbitAddress(boardIdentifier, communities); +}; export const getExternalQuoteBoardAddress = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) => - reference.kind === 'cross-board' ? getSubplebbitAddress(reference.boardIdentifier, directories) : reference.subplebbitAddress; + getAddressForCanonicalReference(reference, directories); export const getExternalQuoteBoardLabel = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) => { - const address = getExternalQuoteBoardAddress(reference, directories); + const address = getAddressForCanonicalReference(reference, directories); return getBoardPath(address, directories); }; export const extractUnresolvedExternalQuoteReferences = ({ content, scopedNumberToCid, + communityAddress, subplebbitAddress, }: { content?: string; scopedNumberToCid?: Record; + // canonical input + communityAddress?: string; + // backward-compatible input name subplebbitAddress?: string; }) => { + const effectiveCommunityAddress = communityAddress || subplebbitAddress; if (!content) { return [] as ExternalQuoteReference[]; } const references = new Map(); - if (subplebbitAddress) { + if (effectiveCommunityAddress) { for (const match of content.matchAll(new RegExp(QUOTE_NUMBER_REGEX.source, 'g'))) { const number = Number.parseInt(match[1], 10); if (Number.isNaN(number) || scopedNumberToCid?.[number]) { @@ -62,7 +88,8 @@ export const extractUnresolvedExternalQuoteReferences = ({ kind: 'same-board', number, raw: `>>${number}`, - subplebbitAddress, + communityAddress: effectiveCommunityAddress, + subplebbitAddress: effectiveCommunityAddress, }; references.set(getExternalQuoteKey(reference), reference); } diff --git a/src/lib/utils/pattern-utils.ts b/src/lib/utils/pattern-utils.ts index f17dae64..8e1bdd1a 100644 --- a/src/lib/utils/pattern-utils.ts +++ b/src/lib/utils/pattern-utils.ts @@ -1,5 +1,9 @@ import type { Comment } from '@bitsocialnet/bitsocial-react-hooks'; -import useSubplebbitsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits'; +import communitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities'; + +type CommunityLike = { + roles?: Record; +}; /** * Checks if a text matches a pattern according to various pattern matching rules: @@ -138,12 +142,14 @@ export const displayNameMatchesPattern = (comment: Comment, pattern: string): bo * @returns True if the user has the specified role, false otherwise */ export const userHasRole = (comment: Comment, role: string): boolean => { - if (!role || !comment?.author?.address || !comment?.subplebbitAddress) { + const communityAddress = (comment as { communityAddress?: string }).communityAddress ?? comment?.subplebbitAddress; + + if (!role || !comment?.author?.address || !communityAddress) { return false; } - const subplebbits = useSubplebbitsStore.getState().subplebbits; - const subplebbit = subplebbits[comment.subplebbitAddress]; + const communities = communitiesStore.getState().communities; + const subplebbit = communities[communityAddress] as CommunityLike | undefined; if (!subplebbit?.roles) { return false; diff --git a/src/lib/utils/post-menu-props.ts b/src/lib/utils/post-menu-props.ts index 7daeaa2d..c30c0074 100644 --- a/src/lib/utils/post-menu-props.ts +++ b/src/lib/utils/post-menu-props.ts @@ -1,9 +1,11 @@ import type { Comment } from '@bitsocialnet/bitsocial-react-hooks'; +import { getCommentCommunityAddress } from './comment-utils'; export type PostMenuProps = { cid?: string; postCid?: string; parentCid?: string; + communityAddress?: string; subplebbitAddress?: string; authorAddress?: string; link?: string; @@ -14,16 +16,21 @@ export type PostMenuProps = { removed?: boolean; }; -export const selectPostMenuProps = (post?: Comment): PostMenuProps => ({ - cid: post?.cid, - postCid: post?.postCid, - parentCid: post?.parentCid, - subplebbitAddress: post?.subplebbitAddress, - authorAddress: post?.author?.address, - link: post?.link, - linkWidth: post?.linkWidth, - linkHeight: post?.linkHeight, - thumbnailUrl: post?.thumbnailUrl, - deleted: post?.deleted, - removed: post?.removed, -}); +export const selectPostMenuProps = (post?: Comment): PostMenuProps => { + const communityAddress = getCommentCommunityAddress(post); + + return { + cid: post?.cid, + postCid: post?.postCid, + parentCid: post?.parentCid, + communityAddress, + subplebbitAddress: post?.subplebbitAddress, + authorAddress: post?.author?.address, + link: post?.link, + linkWidth: post?.linkWidth, + linkHeight: post?.linkHeight, + thumbnailUrl: post?.thumbnailUrl, + deleted: post?.deleted, + removed: post?.removed, + }; +}; diff --git a/src/lib/utils/post-page-resolution.ts b/src/lib/utils/post-page-resolution.ts index 3b90be39..2ec75ff9 100644 --- a/src/lib/utils/post-page-resolution.ts +++ b/src/lib/utils/post-page-resolution.ts @@ -10,8 +10,18 @@ export interface CommentWithCid { } /** Minimal FeedOptions shape for board-feed filtering */ +type LegacyFeedOptionsLike = { + subplebbitAddresses?: string[]; + sortType: string; + postsPerPage?: number; + filter?: unknown; + newerThan?: number; + modQueue?: unknown; + accountComments?: unknown; +}; + export interface FeedOptionsLike { - subplebbitAddresses: string[]; + communityAddresses?: string[]; sortType: string; postsPerPage?: number; filter?: unknown; @@ -21,7 +31,7 @@ export interface FeedOptionsLike { } /** FeedsOptions-like map */ -export type FeedsOptionsLike = Record; +export type FeedsOptionsLike = Record; /** Loaded feeds map: feedName -> Comment[] */ export type LoadedFeedsLike = Record; @@ -46,14 +56,29 @@ export function findPostPageInFeed(feed: CommentWithCid[], postCid: string, guiP * Strict board-feed filter criteria. * A feed is a "board feed" iff: * - sortType === 'active' - * - single-sub feed (subplebbitAddresses.length === 1) + * - single-board feed (one community) * - no filter, no newerThan, no modQueue, no accountComments */ -export function isBoardFeedOptions(opts: FeedOptionsLike, subplebbitAddress: string): boolean { +const getCommunityAddresses = (opts: FeedOptionsLike | LegacyFeedOptionsLike): string[] => { + if ('communityAddresses' in opts && Array.isArray(opts.communityAddresses)) { + return opts.communityAddresses; + } + if ('subplebbitAddresses' in opts && Array.isArray(opts.subplebbitAddresses)) { + return opts.subplebbitAddresses; + } + return []; +}; + +/** + * Supports both canonical `communityAddresses` and legacy `subplebbitAddresses`. + */ +export function isBoardFeedOptions(opts: FeedOptionsLike | LegacyFeedOptionsLike, communityAddress: string): boolean { + const communityAddresses = getCommunityAddresses(opts); + return ( opts.sortType === 'active' && - opts.subplebbitAddresses?.length === 1 && - opts.subplebbitAddresses[0] === subplebbitAddress && + communityAddresses.length === 1 && + communityAddresses[0] === communityAddress && !opts.filter && opts.newerThan == null && !opts.modQueue && @@ -67,7 +92,7 @@ export function isBoardFeedOptions(opts: FeedOptionsLike, subplebbitAddress: str * * @param feedsOptions - Feeds store feedsOptions * @param loadedFeeds - Feeds store loadedFeeds - * @param subplebbitAddress - Board subplebbit address + * @param communityAddress - Board community address * @param postCid - CID of the post (OP) to locate * @param guiPostsPerPage - Posts per GUI page * @returns 1-based page number, or undefined if not found in any matching feed @@ -75,15 +100,15 @@ export function isBoardFeedOptions(opts: FeedOptionsLike, subplebbitAddress: str export function findPostPageInLoadedBoardFeeds( feedsOptions: FeedsOptionsLike, loadedFeeds: LoadedFeedsLike, - subplebbitAddress: string, + communityAddress: string, postCid: string, guiPostsPerPage: number, ): number | undefined { - if (!subplebbitAddress || !postCid || guiPostsPerPage <= 0) return undefined; + if (!communityAddress || !postCid || guiPostsPerPage <= 0) return undefined; for (const feedName of Object.keys(feedsOptions)) { const opts = feedsOptions[feedName]; - if (!opts || !isBoardFeedOptions(opts, subplebbitAddress)) continue; + if (!opts || !isBoardFeedOptions(opts as FeedOptionsLike, communityAddress)) continue; const feed = loadedFeeds[feedName]; if (!feed || !Array.isArray(feed)) continue; diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts index 5ae7ca12..6fb56da8 100644 --- a/src/lib/utils/route-utils.ts +++ b/src/lib/utils/route-utils.ts @@ -85,7 +85,7 @@ export const getBoardPath = (communityAddress: string, communities: DirectoryCom /** * Convert URL path (directory code or address) to community address */ -export const getSubplebbitAddress = (boardIdentifier: string, communities: DirectoryCommunity[]): string => { +export const getCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]): string => { const directoryToAddress = getDirectoryToAddressMap(communities); // Check if it's a directory code @@ -98,6 +98,11 @@ export const getSubplebbitAddress = (boardIdentifier: string, communities: Direc return boardIdentifier; }; +/** + * Back-compat alias kept for route params and comments. + */ +export const getSubplebbitAddress = getCommunityAddress; + /** * Compare two addresses; returns true if they refer to the same board (handles .bso/.eth aliases). */ diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index a8c8bed3..450b51f8 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -8,6 +8,9 @@ interface FilterItem { enabled: boolean; count: number; filteredCids: Set; + communityCounts: Map; + communityFilteredCids: Map>; + // legacy read/write compatibility subplebbitCounts: Map; subplebbitFilteredCids: Map>; hide: boolean; @@ -26,20 +29,64 @@ interface CatalogFiltersStore { initializeFilter: () => void; filteredCount: number; filteredCids: Set; - incrementFilterCount: (filterIndex: number, cid: string, subplebbitAddress: string) => void; + incrementFilterCount: (filterIndex: number, cid: string, communityAddress: string) => void; recalcFilteredCount: () => void; - currentSubplebbitAddress: string | null; - setCurrentSubplebbitAddress: (address: string | null) => void; - getFilteredCountForCurrentSubplebbit: () => number; + currentCommunityAddress: string | null; + setCurrentCommunityAddress: (address: string | null) => void; + getFilteredCountForCurrentCommunity: () => number; searchText: string; setSearchFilter: (text: string) => void; clearSearchFilter: () => void; + resetCountsForCurrentCommunity: () => void; + // legacy compatibility aliases + currentSubplebbitAddress: string | null; + setCurrentSubplebbitAddress: (address: string | null) => void; + getFilteredCountForCurrentSubplebbit: () => number; resetCountsForCurrentSubplebbit: () => void; matchedFilters: Map; setMatchedFilter: (cid: string, color: string) => void; clearMatchedFilters: () => void; } +type RawFilterItem = { + text?: string; + enabled?: boolean; + count?: number; + filteredCids?: Set; + communityCounts?: Map; + communityFilteredCids?: Map>; + subplebbitCounts?: Map; + subplebbitFilteredCids?: Map>; + hide?: boolean; + top?: boolean; + color?: string; +}; + +const getCommentCommunityAddress = (comment: Comment): string | undefined => { + return ((comment as { communityAddress?: string }).communityAddress || (comment as { subplebbitAddress?: string }).subplebbitAddress) as string | undefined; +}; + +const toMap = (value: unknown, fallback: Map): Map => (value instanceof Map ? (value as Map) : fallback); + +const normalizeFilterItem = (item: RawFilterItem): FilterItem => { + const communityCounts = toMap(item.communityCounts ?? item.subplebbitCounts, new Map()); + const communityFilteredCids = toMap(item.communityFilteredCids ?? item.subplebbitFilteredCids, new Map>()); + + return { + text: item.text || '', + enabled: item.enabled ?? true, + count: item.count || 0, + filteredCids: item.filteredCids || new Set(), + communityCounts, + communityFilteredCids, + subplebbitCounts: communityCounts, + subplebbitFilteredCids: communityFilteredCids, + hide: item.hide ?? true, + top: item.top ?? false, + color: item.color || '', + }; +}; + const useCatalogFiltersStore = create( persist( (set, get) => ({ @@ -48,6 +95,7 @@ const useCatalogFiltersStore = create( filterItems: [], filteredCount: 0, filteredCids: new Set(), + currentCommunityAddress: null, currentSubplebbitAddress: null, matchedFilters: new Map(), setMatchedFilter: (cid: string, color: string) => { @@ -64,8 +112,8 @@ const useCatalogFiltersStore = create( clearMatchedFilters: () => { set({ matchedFilters: new Map() }); }, - setCurrentSubplebbitAddress: (address: string | null) => { - const prevAddress = get().currentSubplebbitAddress; + setCurrentCommunityAddress: (address: string | null) => { + const prevAddress = get().currentCommunityAddress; if (address !== prevAddress) { set((state) => { @@ -77,44 +125,49 @@ const useCatalogFiltersStore = create( newItem.count = 0; newItem.filteredCids = new Set(); - // Ensure subplebbitCounts is a Map - if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) { - newItem.subplebbitCounts = new Map(); + if (!newItem.communityCounts || !(newItem.communityCounts instanceof Map)) { + newItem.communityCounts = new Map(); + } + if (!newItem.communityFilteredCids || !(newItem.communityFilteredCids instanceof Map)) { + newItem.communityFilteredCids = new Map(); } - // Ensure subplebbitFilteredCids is a Map - if (!newItem.subplebbitFilteredCids || !(newItem.subplebbitFilteredCids instanceof Map)) { - newItem.subplebbitFilteredCids = new Map(); - } + // keep legacy aliases in sync + newItem.subplebbitCounts = newItem.communityCounts; + newItem.subplebbitFilteredCids = newItem.communityFilteredCids; - // Only initialize for the new address if it doesn't already exist - if (!newItem.subplebbitFilteredCids.has(address)) { - newItem.subplebbitFilteredCids.set(address, new Set()); + if (!newItem.communityFilteredCids.has(address)) { + newItem.communityFilteredCids.set(address, new Set()); } - if (!newItem.subplebbitCounts.has(address)) { - newItem.subplebbitCounts.set(address, 0); + if (!newItem.communityCounts.has(address)) { + newItem.communityCounts.set(address, 0); } return newItem; }); return { + currentCommunityAddress: address, currentSubplebbitAddress: address, filterItems: updatedFilterItems, filteredCount: 0, // This will be recalculated below }; } - return { currentSubplebbitAddress: address }; + return { + currentCommunityAddress: address, + currentSubplebbitAddress: address, + }; }); - // Recalculate the filtered count for the current subplebbit + // Recalculate the filtered count for the current community get().recalcFilteredCount(); get().updateFilter(); } else { - set({ currentSubplebbitAddress: address }); + set({ currentCommunityAddress: address, currentSubplebbitAddress: address }); } }, + setCurrentSubplebbitAddress: (address: string | null) => get().setCurrentCommunityAddress(address), searchText: '', setSearchFilter: (text: string) => { set({ searchText: text }); @@ -125,34 +178,12 @@ const useCatalogFiltersStore = create( get().updateFilter(); }, setFilterItems: (items: FilterItem[]) => { - const nonEmptyItems = items - .filter((item) => item.text.trim() !== '') - .map((item) => ({ - ...item, - count: item.count || 0, - filteredCids: item.filteredCids || new Set(), - subplebbitCounts: item.subplebbitCounts || new Map(), - subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(), - hide: item.hide ?? true, - top: item.top ?? false, - color: item.color || '', - })); + const nonEmptyItems = items.filter((item) => item.text.trim() !== '').map((item) => normalizeFilterItem(item)); set({ filterItems: nonEmptyItems }); get().recalcFilteredCount(); }, saveAndApplyFilters: (items: FilterItem[]) => { - const nonEmptyItems = items - .filter((item) => item.text.trim() !== '') - .map((item) => ({ - ...item, - count: item.count || 0, - filteredCids: item.filteredCids || new Set(), - subplebbitCounts: item.subplebbitCounts || new Map(), - subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(), - hide: item.hide ?? true, - top: item.top ?? false, - color: item.color || '', - })); + const nonEmptyItems = items.filter((item) => item.text.trim() !== '').map((item) => normalizeFilterItem(item)); // Compare new filter items with existing ones to detect pattern changes const existingItems = get().filterItems; @@ -166,8 +197,10 @@ const useCatalogFiltersStore = create( ...newItem, count: existingItem.count, filteredCids: existingItem.filteredCids, - subplebbitCounts: existingItem.subplebbitCounts, - subplebbitFilteredCids: existingItem.subplebbitFilteredCids, + communityCounts: existingItem.communityCounts, + communityFilteredCids: existingItem.communityFilteredCids, + subplebbitCounts: existingItem.communityCounts, + subplebbitFilteredCids: existingItem.communityFilteredCids, color: newItem.color || '', }; } @@ -177,6 +210,8 @@ const useCatalogFiltersStore = create( ...newItem, count: 0, filteredCids: new Set(), + communityCounts: new Map(), + communityFilteredCids: new Map>(), subplebbitCounts: new Map(), subplebbitFilteredCids: new Map>(), color: newItem.color || '', @@ -200,7 +235,7 @@ const useCatalogFiltersStore = create( filter: (comment: Comment) => { if (!comment?.cid) return true; - const currentSubplebbit = state.currentSubplebbitAddress; + const currentCommunityAddress = state.currentCommunityAddress; // Apply search filter if (state.searchText.trim() !== '') { @@ -212,18 +247,19 @@ const useCatalogFiltersStore = create( // Apply content filters const { filterItems } = state; let shouldHide = false; + const commentCommunityAddress = getCommentCommunityAddress(comment); for (let i = 0; i < filterItems.length; i++) { const item = filterItems[i]; if (item.enabled && item.text.trim() !== '') { if (commentMatchesPattern(comment, item.text)) { - // If we have a current subplebbit and this is a match, increment the count - if (currentSubplebbit && comment.subplebbitAddress === currentSubplebbit) { + // If we have a current community and this is a match, increment the count + if (currentCommunityAddress && commentCommunityAddress && commentCommunityAddress === currentCommunityAddress) { // We need to use a timeout to avoid modifying state during a state update setTimeout(() => { const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); if (filterIndex !== -1) { - get().incrementFilterCount(filterIndex, comment.cid, comment.subplebbitAddress); + get().incrementFilterCount(filterIndex, comment.cid, commentCommunityAddress); } }, 0); } @@ -242,37 +278,39 @@ const useCatalogFiltersStore = create( initializeFilter: () => { get().updateFilter(); }, - incrementFilterCount: (filterIndex: number, cid: string, subplebbitAddress: string) => { + incrementFilterCount: (filterIndex: number, cid: string, communityAddress: string) => { set((state) => { const newFilterItems = [...state.filterItems]; if (newFilterItems[filterIndex]) { const item = newFilterItems[filterIndex]; - // Ensure subplebbitFilteredCids is a Map - const subplebbitFilteredCids = new Map(item.subplebbitFilteredCids); - if (!subplebbitFilteredCids.has(subplebbitAddress)) { - subplebbitFilteredCids.set(subplebbitAddress, new Set()); + // Ensure communityFilteredCids is a Map + const communityFilteredCids = new Map(item.communityFilteredCids); + if (!communityFilteredCids.has(communityAddress)) { + communityFilteredCids.set(communityAddress, new Set()); } - const cidSet = subplebbitFilteredCids.get(subplebbitAddress)!; + const cidSet = communityFilteredCids.get(communityAddress)!; - // Only increment the count if this CID hasn't been counted for this subplebbit yet + // Only increment the count if this CID hasn't been counted for this community yet if (!cidSet.has(cid)) { const newItemFilteredCids = new Set(item.filteredCids); newItemFilteredCids.add(cid); cidSet.add(cid); - subplebbitFilteredCids.set(subplebbitAddress, cidSet); + communityFilteredCids.set(communityAddress, cidSet); - const subplebbitCounts = new Map(item.subplebbitCounts); - const currentCount = subplebbitCounts.get(subplebbitAddress) || 0; - subplebbitCounts.set(subplebbitAddress, currentCount + 1); + const communityCounts = new Map(item.communityCounts); + const currentCount = communityCounts.get(communityAddress) || 0; + communityCounts.set(communityAddress, currentCount + 1); newFilterItems[filterIndex] = { ...item, count: item.count + 1, filteredCids: newItemFilteredCids, - subplebbitCounts, - subplebbitFilteredCids, + communityCounts, + communityFilteredCids, + subplebbitCounts: communityCounts, + subplebbitFilteredCids: communityFilteredCids, }; return { filterItems: newFilterItems }; @@ -285,13 +323,13 @@ const useCatalogFiltersStore = create( }, recalcFilteredCount: () => { set((state) => { - const currentSubplebbit = state.currentSubplebbitAddress; - if (!currentSubplebbit) return { filteredCount: 0 }; + const currentCommunityAddress = state.currentCommunityAddress; + if (!currentCommunityAddress) return { filteredCount: 0 }; let filteredCount = 0; for (const item of state.filterItems) { if (item.enabled && item.hide) { - const subCount = item.subplebbitCounts?.get(currentSubplebbit) || 0; + const subCount = item.communityCounts?.get(currentCommunityAddress) || 0; filteredCount += subCount; } } @@ -299,40 +337,45 @@ const useCatalogFiltersStore = create( return { filteredCount }; }); }, - getFilteredCountForCurrentSubplebbit: () => { + getFilteredCountForCurrentCommunity: () => { const state = get(); - const currentSubplebbit = state.currentSubplebbitAddress; - if (!currentSubplebbit) return 0; + const currentCommunityAddress = state.currentCommunityAddress; + if (!currentCommunityAddress) return 0; let filteredCount = 0; for (const item of state.filterItems) { if (item.enabled && item.hide) { - const subCount = item.subplebbitCounts?.get(currentSubplebbit) || 0; + const subCount = item.communityCounts?.get(currentCommunityAddress) || 0; filteredCount += subCount; } } return filteredCount; }, - resetCountsForCurrentSubplebbit: () => { - const currentSubplebbit = get().currentSubplebbitAddress; - if (!currentSubplebbit) return; + getFilteredCountForCurrentSubplebbit: () => get().getFilteredCountForCurrentCommunity(), + resetCountsForCurrentCommunity: () => { + const currentCommunityAddress = get().currentCommunityAddress; + if (!currentCommunityAddress) return; set((state) => { const updatedFilterItems = state.filterItems.map((item) => { const newItem = { ...item }; // Ensure maps are properly initialized - if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) { - newItem.subplebbitCounts = new Map(); + if (!newItem.communityCounts || !(newItem.communityCounts instanceof Map)) { + newItem.communityCounts = new Map(); } - if (!newItem.subplebbitFilteredCids || !(newItem.subplebbitFilteredCids instanceof Map)) { - newItem.subplebbitFilteredCids = new Map(); + if (!newItem.communityFilteredCids || !(newItem.communityFilteredCids instanceof Map)) { + newItem.communityFilteredCids = new Map(); } - // Reset counts for current subplebbit - newItem.subplebbitCounts.set(currentSubplebbit, 0); - newItem.subplebbitFilteredCids.set(currentSubplebbit, new Set()); + // keep legacy aliases in sync + newItem.subplebbitCounts = newItem.communityCounts; + newItem.subplebbitFilteredCids = newItem.communityFilteredCids; + + // Reset counts for current community + newItem.communityCounts.set(currentCommunityAddress, 0); + newItem.communityFilteredCids.set(currentCommunityAddress, new Set()); return newItem; }); @@ -346,6 +389,7 @@ const useCatalogFiltersStore = create( // Trigger filter reapplication to start counting again get().updateFilter(); }, + resetCountsForCurrentSubplebbit: () => get().resetCountsForCurrentCommunity(), }), { name: 'catalog-filters-storage', @@ -365,19 +409,15 @@ const useCatalogFiltersStore = create( if (persistedObj && persistedObj.filterItems) { return { ...persistedObj, - filterItems: persistedObj.filterItems.map((item: any) => ({ - text: item.text, - enabled: item.enabled, - hide: item.hide, - top: item.top, - count: 0, - filteredCids: new Set(), - subplebbitCounts: new Map(), - subplebbitFilteredCids: new Map>(), + currentCommunityAddress: persistedObj.currentCommunityAddress || persistedObj.currentSubplebbitAddress || null, + currentSubplebbitAddress: persistedObj.currentCommunityAddress || persistedObj.currentSubplebbitAddress || null, + filterItems: persistedObj.filterItems.map((item: RawFilterItem) => ({ + ...normalizeFilterItem(item), })), filteredCount: 0, }; } + return persistedObj || persisted; }, }, diff --git a/src/stores/use-communities-loading-start-timestamps-store.ts b/src/stores/use-communities-loading-start-timestamps-store.ts new file mode 100644 index 00000000..8564f139 --- /dev/null +++ b/src/stores/use-communities-loading-start-timestamps-store.ts @@ -0,0 +1,46 @@ +import { useEffect, useMemo } from 'react'; +import { create } from 'zustand'; + +interface CommunitiesLoadingStartTimestampsState { + timestamps: Record; + addCommunities: (communityAddresses: string[]) => void; +} + +const useCommunitiesLoadingStartTimestampsStore = create((set, get) => ({ + timestamps: {}, + addCommunities: (communityAddresses) => { + const { timestamps } = get(); + const newTimestamps: Record = {}; + + communityAddresses.forEach((communityAddress) => { + if (!timestamps[communityAddress]) { + newTimestamps[communityAddress] = Math.round(Date.now() / 1000); + } + }); + + if (Object.keys(newTimestamps).length) { + set((state) => ({ timestamps: { ...state.timestamps, ...newTimestamps } })); + } + }, +})); + +const useCommunitiesLoadingStartTimestamps = (communityAddresses?: string[]) => { + const timestampsStore = useCommunitiesLoadingStartTimestampsStore((state) => state.timestamps); + const addCommunities = useCommunitiesLoadingStartTimestampsStore((state) => state.addCommunities); + + useEffect(() => { + if (communityAddresses) { + addCommunities(communityAddresses); + } + }, [communityAddresses, addCommunities]); + + const communitiesLoadingStartTimestamps = useMemo(() => { + return communityAddresses?.map((communityAddress) => timestampsStore[communityAddress]) || []; + }, [timestampsStore, communityAddresses]); + + return communitiesLoadingStartTimestamps; +}; + +export const useSubplebbitsLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps; + +export default useCommunitiesLoadingStartTimestamps; diff --git a/src/stores/use-community-offline-store.ts b/src/stores/use-community-offline-store.ts new file mode 100644 index 00000000..4823a339 --- /dev/null +++ b/src/stores/use-community-offline-store.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; + +interface CommunityOfflineState { + state?: string; + updatedAt?: number; + updatingState?: string; + initialLoad: boolean; +} + +interface CommunityOfflineStore { + communityOfflineState: Record; + setCommunityOfflineState: (address: string, state: Partial) => void; + initializeCommunityOfflineState: (address: string) => void; +} + +const useCommunityOfflineStore = create((set) => ({ + communityOfflineState: {}, + setCommunityOfflineState: (address, newState) => + set((state) => ({ + communityOfflineState: { + ...state.communityOfflineState, + [address]: { + ...state.communityOfflineState[address], + ...newState, + }, + }, + })), + initializeCommunityOfflineState: (address) => { + set((state) => ({ + communityOfflineState: { + ...state.communityOfflineState, + [address]: { + initialLoad: true, + }, + }, + })); + + setTimeout(() => { + set((state) => ({ + communityOfflineState: { + ...state.communityOfflineState, + [address]: { + ...state.communityOfflineState[address], + initialLoad: false, + }, + }, + })); + }, 30_000); + }, +})); + +/** + * Back-compat exports for old naming. + */ +export const useSubplebbitOfflineStore = useCommunityOfflineStore; +export const useCommunityOfflineStoreForLegacy = useCommunityOfflineStore; + +export default useCommunityOfflineStore; diff --git a/src/stores/use-post-number-store.ts b/src/stores/use-post-number-store.ts index 3ed8b99d..e5dc4080 100644 --- a/src/stores/use-post-number-store.ts +++ b/src/stores/use-post-number-store.ts @@ -2,8 +2,7 @@ import { create } from 'zustand'; import type { Comment } from '@bitsocialnet/bitsocial-react-hooks'; interface PostNumberState { - // Post numbers are only unique within a subplebbit, so scope by address - // to avoid collisions in /all/ where multiple boards are shown together. + // Post numbers are only unique within a board, so scope by canonical community address. numberToCid: Record>; cidToNumber: Record; registerComments: (comments: Comment[]) => void; @@ -23,7 +22,7 @@ const usePostNumberStore = create((set) => ({ for (const c of comments) { const num = c?.number; const cid = c?.cid; - const addr = c?.subplebbitAddress; + const addr = c?.communityAddress || c?.subplebbitAddress; if (typeof num !== 'number' || !cid || !addr) continue; const existingCid = nextNumberToCid[addr]?.[num]; diff --git a/src/stores/use-publish-post-store.ts b/src/stores/use-publish-post-store.ts index d84dace7..f226a1b9 100644 --- a/src/stores/use-publish-post-store.ts +++ b/src/stores/use-publish-post-store.ts @@ -5,6 +5,7 @@ import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; type SubmitState = { author?: any | undefined; displayName?: string | undefined; + communityAddress: string | undefined; subplebbitAddress: string | undefined; title: string | undefined; content: string | undefined; @@ -18,6 +19,7 @@ type SubmitState = { const usePublishPostStore = create((set) => ({ author: undefined, displayName: undefined, + communityAddress: undefined, subplebbitAddress: undefined, title: undefined, content: undefined, @@ -27,6 +29,7 @@ const usePublishPostStore = create((set) => ({ setPublishPostStore: (comment: Comment) => set(() => { const { subplebbitAddress, author, content, link, spoiler, title } = comment; + const communityAddress = (comment as { communityAddress?: string }).communityAddress || subplebbitAddress; const displayName = 'displayName' in comment ? comment.displayName || undefined : author?.displayName; @@ -36,7 +39,8 @@ const usePublishPostStore = create((set) => ({ const updatedAuthor = displayName ? { ...baseAuthor, displayName } : baseAuthor; const publishCommentOptions: PublishCommentOptions = { - subplebbitAddress, + communityAddress, + subplebbitAddress: communityAddress, title, content, link, @@ -57,7 +61,8 @@ const usePublishPostStore = create((set) => ({ return { author: updatedAuthor, displayName, - subplebbitAddress, + communityAddress, + subplebbitAddress: communityAddress, title, content, link, @@ -70,6 +75,7 @@ const usePublishPostStore = create((set) => ({ author: undefined, displayName: undefined, subplebbitAddress: undefined, + communityAddress: undefined, title: undefined, content: undefined, link: undefined, diff --git a/src/stores/use-publish-reply-store.ts b/src/stores/use-publish-reply-store.ts index 903d6822..03ae0f54 100644 --- a/src/stores/use-publish-reply-store.ts +++ b/src/stores/use-publish-reply-store.ts @@ -23,7 +23,8 @@ const usePublishReplyStore = create((set) => ({ setPublishReplyStore: (comment: Comment) => set((state) => { - const { subplebbitAddress, parentCid, author, content, link, spoiler } = comment; + const { parentCid, author, content, link, spoiler } = comment; + const communityAddress = (comment as { communityAddress?: string }).communityAddress || (comment as { subplebbitAddress?: string }).subplebbitAddress; const displayName = 'displayName' in comment ? comment.displayName || undefined : author?.displayName; @@ -33,7 +34,8 @@ const usePublishReplyStore = create((set) => ({ const updatedAuthor = displayName ? { ...baseAuthor, displayName } : baseAuthor; const publishCommentOptions: PublishCommentOptions = { - subplebbitAddress, + communityAddress, + subplebbitAddress: communityAddress, parentCid, postCid: comment?.postCid || parentCid, content, diff --git a/src/stores/use-subplebbit-offline-store.ts b/src/stores/use-subplebbit-offline-store.ts index abaa6979..1f61e068 100644 --- a/src/stores/use-subplebbit-offline-store.ts +++ b/src/stores/use-subplebbit-offline-store.ts @@ -1,51 +1,39 @@ -import { create } from 'zustand'; +import useCommunityOfflineStore from './use-community-offline-store'; -interface SubplebbitOfflineState { +type LegacySubplebbitOfflineState = { + initialLoad: boolean; state?: string; updatedAt?: number; updatingState?: string; - initialLoad: boolean; -} +}; -interface SubplebbitOfflineStore { - subplebbitOfflineState: Record; - setSubplebbitOfflineState: (address: string, state: Partial) => void; +type LegacySubplebbitOfflineStore = { + subplebbitOfflineState: Record; + setSubplebbitOfflineState: (address: string, state: Partial) => void; initializesubplebbitOfflineState: (address: string) => void; -} +}; -const useSubplebbitOfflineStore = create((set) => ({ - subplebbitOfflineState: {}, - setSubplebbitOfflineState: (address, newState) => - set((state) => ({ - subplebbitOfflineState: { - ...state.subplebbitOfflineState, - [address]: { - ...state.subplebbitOfflineState[address], - ...newState, - }, - }, - })), - initializesubplebbitOfflineState: (address) => { - set((state) => ({ - subplebbitOfflineState: { - ...state.subplebbitOfflineState, - [address]: { - initialLoad: true, - }, - }, - })); - setTimeout(() => { - set((state) => ({ - subplebbitOfflineState: { - ...state.subplebbitOfflineState, - [address]: { - ...state.subplebbitOfflineState[address], - initialLoad: false, - }, - }, - })); - }, 30000); - }, -})); +type CommunityOfflineStoreState = { + communityOfflineState: Record; + setCommunityOfflineState: (address: string, state: Partial) => void; + initializeCommunityOfflineState: (address: string) => void; +}; -export default useSubplebbitOfflineStore; +const toLegacyState = (state: CommunityOfflineStoreState) => ({ + subplebbitOfflineState: state.communityOfflineState, + setSubplebbitOfflineState: state.setCommunityOfflineState, + initializesubplebbitOfflineState: state.initializeCommunityOfflineState, +}); + +const useSubplebbitOfflineStore = (): LegacySubplebbitOfflineStore => { + const state = useCommunityOfflineStore(); + return toLegacyState(state); +}; + +const useSubplebbitOfflineStoreWithState = useSubplebbitOfflineStore as typeof useSubplebbitOfflineStore & { + getState: () => LegacySubplebbitOfflineStore; +}; +useSubplebbitOfflineStoreWithState.getState = () => toLegacyState(useCommunityOfflineStore.getState()); + +export { useSubplebbitOfflineStore }; +export default useSubplebbitOfflineStoreWithState; diff --git a/src/stores/use-subplebbits-loading-start-timestamps-store.ts b/src/stores/use-subplebbits-loading-start-timestamps-store.ts index 80ecdb5b..52d66c24 100644 --- a/src/stores/use-subplebbits-loading-start-timestamps-store.ts +++ b/src/stores/use-subplebbits-loading-start-timestamps-store.ts @@ -1,42 +1,8 @@ -import { useEffect, useMemo } from 'react'; -import { create } from 'zustand'; +import useSubplebbitStore from './use-communities-loading-start-timestamps-store'; -interface SubplebbitsLoadingStartTimestampsState { - timestamps: Record; - addSubplebbits: (subplebbitAddresses: string[]) => void; -} - -const useSubplebbitsLoadingStartTimestampsStore = create((set, get) => ({ - timestamps: {}, - addSubplebbits: (subplebbitAddresses) => { - const { timestamps } = get(); - const newTimestamps: Record = {}; - subplebbitAddresses.forEach((subplebbitAddress) => { - if (!timestamps[subplebbitAddress]) { - newTimestamps[subplebbitAddress] = Math.round(Date.now() / 1000); - } - }); - if (Object.keys(newTimestamps).length) { - set((state) => ({ timestamps: { ...state.timestamps, ...newTimestamps } })); - } - }, -})); - -const useSubplebbitsLoadingStartTimestamps = (subplebbitAddresses?: string[]) => { - const timestampsStore = useSubplebbitsLoadingStartTimestampsStore((state) => state.timestamps); - const addSubplebbits = useSubplebbitsLoadingStartTimestampsStore((state) => state.addSubplebbits); - - useEffect(() => { - if (subplebbitAddresses) { - addSubplebbits(subplebbitAddresses); - } - }, [subplebbitAddresses, addSubplebbits]); - - const subplebbitsLoadingStartTimestamps = useMemo(() => { - return subplebbitAddresses?.map((subplebbitAddress) => timestampsStore[subplebbitAddress]) || []; - }, [timestampsStore, subplebbitAddresses]); - - return subplebbitsLoadingStartTimestamps; +const useSubplebbitLoadingStartTimestamps = (subplebbitAddresses?: string[]) => { + const addLegacyInput = subplebbitAddresses?.map((address) => address); + return useSubplebbitStore(addLegacyInput); }; -export default useSubplebbitsLoadingStartTimestamps; +export default useSubplebbitLoadingStartTimestamps; diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index 5111f0cf..3bcf9b70 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -11,7 +11,7 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise type TestComment = { cid: string; pinned?: boolean; - subplebbitAddress?: string; + communityAddress?: string; deleted?: boolean; postCid?: string; removed?: boolean; @@ -22,7 +22,7 @@ type TestComment = { const testState = vi.hoisted(() => ({ account: { subscriptions: [] as string[] }, accountComments: [] as TestComment[], - accountSubplebbitAddresses: [] as string[], + accountCommunityAddresses: [] as string[], directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, directoryByAddress: { 'music-posting.eth': { @@ -43,16 +43,16 @@ const testState = vi.hoisted(() => ({ }, resetMock: vi.fn(), registerCommentsMock: vi.fn(), - resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + resolvedCommunityAddress: 'music-posting.eth' as string | undefined, setEnableInfiniteScrollMock: vi.fn(), setResetFunctionMock: vi.fn(), - subplebbit: { + community: { error: undefined as Error | undefined, shortAddress: 'music-posting.eth', state: 'ready', title: '/mu/ - Music', }, - subplebbitSnapshot: { + communitySnapshot: { shortAddress: 'music-posting.eth', title: '/mu/ - Music', } as { shortAddress?: string; title?: string }, @@ -73,11 +73,11 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ loadMore: testState.loadMoreMock, reset: testState.resetMock, }), - useSubplebbit: () => testState.subplebbit, + useCommunity: () => testState.community, })); -vi.mock('../../../hooks/use-stable-subplebbit', () => ({ - useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot), +vi.mock('../../../hooks/use-stable-community', () => ({ + useCommunityField: (_address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => selector(testState.communitySnapshot), })); vi.mock('react-virtuoso', () => ({ @@ -111,8 +111,8 @@ vi.mock('react-virtuoso', () => ({ ), })); -vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({ - useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses, +vi.mock('../../../hooks/use-account-community-addresses', () => ({ + useAccountCommunityAddresses: () => testState.accountCommunityAddresses, })); vi.mock('../../../hooks/use-directories', () => ({ @@ -125,8 +125,8 @@ vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../hooks/use-state-string', () => ({ @@ -236,7 +236,7 @@ describe('Board', () => { latestLocation = ''; testState.account = { subscriptions: [] }; testState.accountComments = []; - testState.accountSubplebbitAddresses = []; + testState.accountCommunityAddresses = []; testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; testState.directoryByAddress = { 'music-posting.eth': { @@ -254,14 +254,14 @@ describe('Board', () => { maxGuiPages: 3, paginationFeedPostsPerPage: 6, }; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; - testState.subplebbit = { + testState.resolvedCommunityAddress = 'music-posting.eth'; + testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'ready', title: '/mu/ - Music', }; - testState.subplebbitSnapshot = { + testState.communitySnapshot = { shortAddress: 'music-posting.eth', title: '/mu/ - Music', }; @@ -290,16 +290,16 @@ describe('Board', () => { it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feed = [ - { cid: 'pinned-post', pinned: true, subplebbitAddress: 'music-posting.eth' }, - { cid: 'older-post', subplebbitAddress: 'music-posting.eth' }, - { cid: 'oldest-post', subplebbitAddress: 'music-posting.eth' }, + { cid: 'pinned-post', pinned: true, communityAddress: 'music-posting.eth' }, + { cid: 'older-post', communityAddress: 'music-posting.eth' }, + { cid: 'oldest-post', communityAddress: 'music-posting.eth' }, ]; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; @@ -332,9 +332,9 @@ describe('Board', () => { it('redirects oversized board pages back to the last available page', async () => { testState.feed = [ - { cid: 'first-post', subplebbitAddress: 'music-posting.eth' }, - { cid: 'second-post', subplebbitAddress: 'music-posting.eth' }, - { cid: 'third-post', subplebbitAddress: 'music-posting.eth' }, + { cid: 'first-post', communityAddress: 'music-posting.eth' }, + { cid: 'second-post', communityAddress: 'music-posting.eth' }, + { cid: 'third-post', communityAddress: 'music-posting.eth' }, ]; await renderBoard({ initialEntry: '/mu/4', routePath: '/:boardIdentifier/*' }); @@ -344,8 +344,8 @@ describe('Board', () => { it('registers visible feed posts with the post-number store', async () => { testState.feed = [ - { cid: 'first-post', subplebbitAddress: 'music-posting.eth' }, - { cid: 'second-post', subplebbitAddress: 'music-posting.eth' }, + { cid: 'first-post', communityAddress: 'music-posting.eth' }, + { cid: 'second-post', communityAddress: 'music-posting.eth' }, ]; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); @@ -368,7 +368,7 @@ describe('Board', () => { }); it('surfaces board load errors when the feed is empty', async () => { - testState.subplebbit = { + testState.community = { error: new Error('board failed'), shortAddress: 'music-posting.eth', state: 'failed', diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 06bb69c2..fc1a3b2e 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -1,16 +1,16 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; -import { Comment, useAccount, useAccountComments, useFeed, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks'; -import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; +import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocialnet/bitsocial-react-hooks'; +import { useCommunityField } from '../../hooks/use-stable-community'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { useTranslation } from 'react-i18next'; import styles from './board.module.css'; import mobileFooterStyles from '../../components/footer/footer.module.css'; import { shouldShowSnow } from '../../lib/snow'; -import { useAccountSubplebbitAddresses } from '../../hooks/use-account-subplebbit-addresses'; +import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useDirectoryAddresses, useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { useFeedStateString } from '../../hooks/use-state-string'; import useFeedResetStore from '../../stores/use-feed-reset-store'; import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'; @@ -31,15 +31,15 @@ const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; const BOARD_SORT_TYPE = 'active' as const; interface BoardFooterProps { - subplebbitAddresses: string[]; + communityAddresses: string[]; hasMore: boolean; combinedFeedLength: number; isInAllView: boolean; isInSubscriptionsView: boolean; isInModView: boolean; - subplebbitState: string | undefined; + communityState: string | undefined; subscriptionsLength: number; - accountSubplebbitAddressesLength: number; + accountCommunityAddressesLength: number; /** Show loading ellipsis. True when infinite scroll, or when pagination + empty feed (initial load). */ showLoadingEllipsis?: boolean; } @@ -48,37 +48,37 @@ interface BoardFooterProps { // The useFeedStateString hook is called here instead of in Board to isolate re-renders // caused by backend IPFS state changes to just this footer component const BoardFooter = ({ - subplebbitAddresses, + communityAddresses, hasMore, combinedFeedLength, isInAllView, isInSubscriptionsView, isInModView, - subplebbitState, + communityState, subscriptionsLength, - accountSubplebbitAddressesLength, + accountCommunityAddressesLength, showLoadingEllipsis = true, }: BoardFooterProps) => { const { t } = useTranslation(); - const loadingStateString = useFeedStateString(subplebbitAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); + const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); let footerContent; if (combinedFeedLength === 0) { footerContent = t('no_threads'); } - if (hasMore || (subplebbitAddresses && subplebbitAddresses.length === 0)) { + if (hasMore || (communityAddresses && communityAddresses.length === 0)) { footerContent = null; } return ( {footerContent} - {subplebbitState === 'failed' ? ( - {subplebbitState} + {communityState === 'failed' ? ( + {communityState} ) : isInSubscriptionsView && subscriptionsLength === 0 ? ( {t('not_subscribed_to_any_board')} - ) : isInModView && accountSubplebbitAddressesLength === 0 ? ( + ) : isInModView && accountCommunityAddressesLength === 0 ? ( {t('not_mod_of_any_board')} ) : ( showLoadingEllipsis && hasMore && @@ -104,8 +104,8 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i const isInModView = viewType ? viewType === 'mod' : false; const directories = useDirectories(); - const resolvedAddressFromUrl = useResolvedSubplebbitAddress(); - const subplebbitAddress = useMemo(() => { + const resolvedAddressFromUrl = useResolvedCommunityAddress(); + const communityAddress = useMemo(() => { if (boardIdentifierProp) { return getSubplebbitAddress(boardIdentifierProp, directories); } @@ -118,9 +118,9 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i const account = useAccount(); const subscriptions = account?.subscriptions; - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); - const subplebbitAddresses = useMemo(() => { + const communityAddresses = useMemo(() => { if (isInAllView) { return filteredDirectoryAddresses; } @@ -128,31 +128,30 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i return subscriptions || []; } if (isInModView) { - return accountSubplebbitAddresses; + return accountCommunityAddresses; } - return [subplebbitAddress]; - }, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directoryAddresses, filteredDirectoryAddresses, subscriptions, accountSubplebbitAddresses]); + return [communityAddress]; + }, [isInAllView, isInSubscriptionsView, isInModView, communityAddress, directoryAddresses, filteredDirectoryAddresses, subscriptions, accountCommunityAddresses]); const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll); const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView; const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll; - const community = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : subplebbitAddress); - const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(community); + const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress); + const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory); const feedOptions = useMemo( () => ({ - subplebbitAddresses, + communityAddresses, sortType: BOARD_SORT_TYPE, postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage, }), - [subplebbitAddresses, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage], + [communityAddresses, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage], ); const { feed, hasMore, loadMore, reset } = useFeed(feedOptions); const { accountComments } = useAccountComments(); - const feedContextKey = `${isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : (subplebbitAddress ?? 'board')}-${BOARD_SORT_TYPE}-${viewType ?? 'board'}-${effectiveInfiniteScroll}`; const pathWithoutSettings = location.pathname.replace(/\/settings$/, ''); const currentPage = getPageFromFeedPath(pathWithoutSettings); const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings); @@ -172,6 +171,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i () => accountComments.filter((comment) => { const { cid, deleted, postCid, removed, state, timestamp } = comment || {}; + const commentCommunityAddress = comment?.communityAddress || comment?.subplebbitAddress; return ( !deleted && !removed && @@ -179,11 +179,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i state === 'succeeded' && cid && cid === postCid && - comment?.subplebbitAddress === subplebbitAddress && + commentCommunityAddress === communityAddress && !feedCids.has(cid) ); }), - [accountComments, subplebbitAddress, feedCids], + [accountComments, communityAddress, feedCids], ); // show newest account comment at the top of the feed but after pinned posts @@ -247,13 +247,13 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i } }, [combinedFeed, registerComments]); - // Use stable subplebbit fields to avoid rerenders from updatingState - const subplebbitTitle = useSubplebbitField(subplebbitAddress, (sub) => sub?.title); - const shortAddress = useSubplebbitField(subplebbitAddress, (sub) => sub?.shortAddress); - // useSubplebbitField only reads from store, doesn't trigger fetching - const subplebbit = useSubplebbit({ subplebbitAddress }); - const { error: subplebbitError, state: subplebbitState } = subplebbit || {}; - const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : subplebbitTitle; + // Use stable community fields to avoid rerenders from updatingState + const communityTitle = useCommunityField(communityAddress, (community) => community?.title); + const shortAddress = useCommunityField(communityAddress, (community) => community?.shortAddress); + // useCommunityField only reads from store, doesn't trigger fetching + const communityData = useCommunity({ communityAddress }); + const { error: communityError, state: communityState } = communityData || {}; + const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle; // Memoize footer component to preserve identity across renders (Virtuoso optimization) // Note: useFeedStateString is called inside BoardFooter to isolate re-renders from backend state changes @@ -262,15 +262,15 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i Footer: () => ( <> - + > )} @@ -330,16 +330,16 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i ), }), [ - subplebbitAddresses, + communityAddresses, hasMore, combinedFeed.length, isInAllView, isInSubscriptionsView, isInModView, - subplebbitState, - subplebbitAddress, + communityState, + communityAddress, subscriptions?.length, - accountSubplebbitAddresses?.length, + accountCommunityAddresses?.length, effectiveInfiniteScroll, isForcedInfiniteScroll, paginationBasePath, @@ -399,12 +399,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i } else if (isDirectory) { boardTitle = `/${boardIdentifier}/`; } else { - boardTitle = title ? title : shortAddress || subplebbitAddress || ''; + boardTitle = title ? title : shortAddress || communityAddress || ''; } document.title = boardTitle + ' - 5chan'; - }, [title, shortAddress, subplebbitAddress, isVisible, params.boardIdentifier, boardIdentifierProp, directories, isInAllView, isInSubscriptionsView, isInModView, t]); + }, [title, shortAddress, communityAddress, isVisible, params.boardIdentifier, boardIdentifierProp, directories, isInAllView, isInSubscriptionsView, isInModView, t]); - const shouldShowErrorToUser = subplebbitError?.message && feed.length === 0; + const shouldShowErrorToUser = communityError?.message && feed.length === 0; const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed; return ( @@ -413,7 +413,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i {shouldShowErrorToUser && ( - + )} {effectiveInfiniteScroll ? ( diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index 9701d9f5..4faa9f18 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -13,7 +13,7 @@ type TestComment = { content?: string; title?: string; pinned?: boolean; - subplebbitAddress?: string; + communityAddress?: string; deleted?: boolean; postCid?: string; removed?: boolean; @@ -54,13 +54,13 @@ const testState = vi.hoisted(() => ({ paginationFeedPostsPerPage: 6, }, resetMock: vi.fn(), - resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + resolvedCommunityAddress: 'music-posting.eth' as string | undefined, searchText: '', - setCurrentSubplebbitAddressMock: vi.fn(), + setCurrentCommunityAddressMock: vi.fn(), setMatchedFilterMock: vi.fn(), setResetFunctionMock: vi.fn(), sortType: 'new' as 'active' | 'new', - subplebbit: { + community: { error: undefined as Error | undefined, shortAddress: 'music-posting.eth', state: 'ready', @@ -74,7 +74,7 @@ function getCatalogFiltersState() { filterItems: testState.filterItems, incrementFilterCount: testState.incrementFilterCountMock, searchText: testState.searchText, - setCurrentSubplebbitAddress: testState.setCurrentSubplebbitAddressMock, + setCurrentSubplebbitAddress: testState.setCurrentCommunityAddressMock, setMatchedFilter: testState.setMatchedFilterMock, }; } @@ -101,7 +101,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ loadMore: testState.loadMoreMock, reset: testState.resetMock, }), - useSubplebbit: () => testState.subplebbit, + useCommunity: () => testState.community, })); vi.mock('react-virtuoso', () => ({ @@ -152,8 +152,8 @@ vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../hooks/use-state-string', () => ({ @@ -192,8 +192,8 @@ vi.mock('../../../components/catalog-row', () => ({ })); vi.mock('../../../components/footer', () => ({ - CatalogFooterFirstRow: ({ subplebbitAddress }: { subplebbitAddress?: string }) => - createElement('div', { 'data-testid': 'catalog-first-row' }, subplebbitAddress || 'multi'), + CatalogFooterFirstRow: ({ communityAddress }: { communityAddress?: string }) => + createElement('div', { 'data-testid': 'catalog-first-row' }, communityAddress || 'multi'), PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow), PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-mobile' }, children), })); @@ -287,10 +287,10 @@ describe('Catalog', () => { maxGuiPages: 3, paginationFeedPostsPerPage: 6, }; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.resolvedCommunityAddress = 'music-posting.eth'; testState.searchText = ''; testState.sortType = 'new'; - testState.subplebbit = { + testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'ready', @@ -300,7 +300,7 @@ describe('Catalog', () => { testState.incrementFilterCountMock.mockReset(); testState.loadMoreMock.mockReset(); testState.resetMock.mockReset(); - testState.setCurrentSubplebbitAddressMock.mockReset(); + testState.setCurrentCommunityAddressMock.mockReset(); testState.setMatchedFilterMock.mockReset(); testState.setResetFunctionMock.mockReset(); document.title = 'before'; @@ -317,9 +317,9 @@ describe('Catalog', () => { it('applies catalog filters, promotes top matches, and clears board filter state on unmount', async () => { testState.feed = [ - { cid: 'boring-post', title: 'plain talk', content: 'nothing special', subplebbitAddress: 'music-posting.eth' }, - { cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', subplebbitAddress: 'music-posting.eth' }, - { cid: 'top-post', title: 'cats forever', content: 'hello world', subplebbitAddress: 'music-posting.eth' }, + { cid: 'boring-post', title: 'plain talk', content: 'nothing special', communityAddress: 'music-posting.eth' }, + { cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', communityAddress: 'music-posting.eth' }, + { cid: 'top-post', title: 'cats forever', content: 'hello world', communityAddress: 'music-posting.eth' }, ]; testState.filterItems = [ { count: 0, enabled: true, filteredCids: new Set(), hide: true, text: 'spoiler', top: false }, @@ -329,7 +329,7 @@ describe('Catalog', () => { await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' }); expect(document.title).toBe('/mu/ - catalog - 5chan'); - expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenCalledWith('music-posting.eth'); + expect(testState.setCurrentCommunityAddressMock).toHaveBeenCalledWith('music-posting.eth'); expect(testState.clearMatchedFiltersMock).toHaveBeenCalled(); expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post', 'row:boring-post']); expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth'); @@ -338,14 +338,14 @@ describe('Catalog', () => { act(() => root.unmount()); - expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenLastCalledWith(null); + expect(testState.setCurrentCommunityAddressMock).toHaveBeenLastCalledWith(null); expect(testState.clearMatchedFiltersMock).toHaveBeenCalledTimes(3); root = createRoot(container); }); it('canonicalizes multiboard catalog paths and keeps load-more wired for infinite scrolling', async () => { - testState.feed = [{ cid: 'all-post', title: 'one', subplebbitAddress: 'music-posting.eth' }]; + testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }]; testState.hasMore = true; await renderCatalog({ diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 653cbf64..47e139ad 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -1,13 +1,13 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Comment, useAccount, useFeed, useSubplebbit, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks'; +import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { useFeedStateString } from '../../hooks/use-state-string'; import useWindowWidth from '../../hooks/use-window-width'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; @@ -28,7 +28,7 @@ import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; interface CatalogFooterProps { - subplebbitAddresses: string[]; + communityAddresses: string[]; hasMore: boolean; combinedFeedLength: number; isInAllView: boolean; @@ -42,7 +42,7 @@ interface CatalogFooterProps { // The useFeedStateString hook is called here instead of in Catalog to isolate re-renders // caused by backend IPFS state changes to just this footer component const CatalogFooter = ({ - subplebbitAddresses, + communityAddresses, hasMore, combinedFeedLength, isInAllView, @@ -52,13 +52,13 @@ const CatalogFooter = ({ }: CatalogFooterProps) => { const { t } = useTranslation(); - const loadingStateString = useFeedStateString(subplebbitAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); + const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); let footerContent; if (combinedFeedLength === 0) { footerContent = t('no_threads'); } - if (hasMore || (subplebbitAddresses && subplebbitAddresses.length === 0)) { + if (hasMore || (communityAddresses && communityAddresses.length === 0)) { footerContent = ( <> {showLoadingEllipsis && ( @@ -75,7 +75,7 @@ const CatalogFooter = ({ // Separate component for the loading state when there's no feed // This also calls useFeedStateString internally to isolate re-renders interface CatalogLoadingProps { - subplebbitAddresses: string[]; + communityAddresses: string[]; hasMore: boolean; combinedFeedLength: number; state: string | undefined; @@ -83,10 +83,10 @@ interface CatalogLoadingProps { error: Error | undefined; } -const CatalogLoading = ({ subplebbitAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error }: CatalogLoadingProps) => { +const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error }: CatalogLoadingProps) => { const { t } = useTranslation(); - const rawFeedStateString = useFeedStateString(subplebbitAddresses); + const rawFeedStateString = useFeedStateString(communityAddresses); const loadingStateString = rawFeedStateString || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); return ( @@ -107,8 +107,8 @@ const CatalogLoading = ({ subplebbitAddresses, hasMore, combinedFeedLength, stat const createContentFilter = ( filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean; color?: string }[], - subplebbitAddress: string, - onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void, + communityAddress: string, + onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void, ) => { // Create a unique key based on the enabled filter items const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== ''); @@ -133,10 +133,10 @@ const createContentFilter = ( const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); if (filterIndex !== -1) { if (onFilterMatch) { - onFilterMatch(filterIndex, comment.cid, subplebbitAddress); + onFilterMatch(filterIndex, comment.cid, communityAddress); } else { // Fallback to the store method if no callback provided - useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, subplebbitAddress); + useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, communityAddress); } // If the filter has a color, track it in the matchedFilters map @@ -164,10 +164,10 @@ const createContentFilter = ( const createCombinedFilter = ( filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean; color?: string }[], searchText: string, - subplebbitAddress: string, - onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void, + communityAddress: string, + onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void, ) => { - const contentFilter = createContentFilter(filterItems, subplebbitAddress, onFilterMatch); + const contentFilter = createContentFilter(filterItems, communityAddress, onFilterMatch); const searchFilter = { filter: (comment: Comment) => { @@ -210,8 +210,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const effectiveInfiniteScroll = isMultiboard; const directories = useDirectories(); - const resolvedAddressFromUrl = useResolvedSubplebbitAddress(); - const subplebbitAddress = useMemo(() => { + const resolvedAddressFromUrl = useResolvedCommunityAddress(); + const communityAddress = useMemo(() => { if (boardIdentifierProp) { return getSubplebbitAddress(boardIdentifierProp, directories); } @@ -224,16 +224,16 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const subscriptions = account?.subscriptions; const filteredDirectoryAddresses = useFilteredDirectoryAddresses(); - const subplebbitAddresses = useMemo(() => { + const communityAddresses = useMemo(() => { if (isInAllView) { return filteredDirectoryAddresses; } if (isInSubscriptionsView) { return (subscriptions || []).filter(Boolean); // Filter out any undefined/null values } - // Only include subplebbitAddress if it's defined - return subplebbitAddress ? [subplebbitAddress] : []; - }, [isInAllView, isInSubscriptionsView, subplebbitAddress, filteredDirectoryAddresses, subscriptions]); + // Only include communityAddress if it's defined + return communityAddress ? [communityAddress] : []; + }, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]); const { imageSize } = useCatalogStyleStore(); const columnWidth = imageSize === 'Large' ? 270 : 180; @@ -241,8 +241,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const columnCount = Math.floor(useWindowWidth() / columnWidth); const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25; - const community = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : subplebbitAddress); - const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(community); + const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress); + const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory); // Canonical redirect for multiboard catalog paths with numeric page segment (e.g. /all/catalog/1w/5 -> /all/catalog/1w) useEffect(() => { @@ -257,26 +257,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const feedSortType = sortType === 'new' ? 'new' : 'active'; // Create a stable callback for filter matching - const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => { - useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid, subplebbitAddress); + const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => { + useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid, communityAddress); }, []); - // Set the current subplebbit address + // Set the current community address useEffect(() => { - useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(subplebbitAddress || null); + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(communityAddress || null); return () => { useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(null); }; - }, [subplebbitAddress]); + }, [communityAddress]); const feedOptions = useMemo(() => { return { - subplebbitAddresses, + communityAddresses, sortType: feedSortType, postsPerPage: isMultiboard ? 10 : paginationFeedPostsPerPage, - filter: createCombinedFilter(filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch), + filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch), }; - }, [subplebbitAddresses, feedSortType, isMultiboard, paginationFeedPostsPerPage, filterItems, searchText, subplebbitAddress, handleFilterMatch]); + }, [communityAddresses, feedSortType, isMultiboard, paginationFeedPostsPerPage, filterItems, searchText, communityAddress, handleFilterMatch]); const { feed, hasMore, loadMore, reset } = useFeed(feedOptions); const { accountComments } = useAccountComments(); @@ -289,6 +289,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, () => accountComments.filter((comment) => { const { cid, deleted, postCid, removed, state, timestamp } = comment || {}; + const commentCommunityAddress = comment?.communityAddress || comment?.subplebbitAddress; // Basic filtering conditions const basicConditions = @@ -298,7 +299,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, state === 'succeeded' && cid && cid === postCid && - comment?.subplebbitAddress === subplebbitAddress && + commentCommunityAddress === communityAddress && !feedCids.has(cid); // If search is active, also check search conditions @@ -312,7 +313,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return basicConditions; }), - [accountComments, subplebbitAddress, feedCids, searchText], + [accountComments, communityAddress, feedCids, searchText], ); // show newest account comment at the top of the feed but after pinned posts @@ -347,8 +348,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, } }, [reset, setResetFunction, isVisible]); - const subplebbit = useSubplebbit({ subplebbitAddress }); - const { error, shortAddress, state, title } = subplebbit || {}; + const community = useCommunity({ communityAddress }); + const { error, shortAddress, state, title } = community || {}; // Memoize footer component to preserve identity across renders (Virtuoso optimization) // Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes @@ -357,7 +358,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, Footer: () => ( <> - - + + @@ -386,7 +387,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, > ), }), - [subplebbitAddresses, hasMore, cappedFeed.length, subplebbitAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll], + [communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll], ); const isFeedLoaded = feed.length > 0 || state === 'failed'; @@ -424,7 +425,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return [...topPosts, ...regularPosts]; }, [sortedFeed, filterItems]); - const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit); + const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, community); const virtuosoRef = useRef(null); const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`; @@ -469,18 +470,18 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, } else if (isDirectory) { documentTitle = `/${boardIdentifier}/`; } else { - documentTitle = title ? title : shortAddress || subplebbitAddress || ''; + documentTitle = title ? title : shortAddress || communityAddress || ''; } document.title = documentTitle + ` - ${t('catalog')} - 5chan`; - }, [title, shortAddress, subplebbitAddress, isInAllView, isInSubscriptionsView, t, isVisible, params.boardIdentifier, boardIdentifierProp, directories]); + }, [title, shortAddress, communityAddress, isInAllView, isInSubscriptionsView, t, isVisible, params.boardIdentifier, boardIdentifierProp, directories]); - // Clear matched filters when component mounts or when subplebbit changes + // Clear matched filters when component mounts or when community changes useEffect(() => { clearMatchedFilters(); return () => { clearMatchedFilters(); }; - }, [clearMatchedFilters, subplebbitAddress]); + }, [clearMatchedFilters, communityAddress]); // Memoize filter color application to avoid redundant iterations useMemo(() => { @@ -529,7 +530,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, <> - - + + diff --git a/src/views/home/__tests__/home.test.tsx b/src/views/home/__tests__/home.test.tsx index 01a464b0..24e30af5 100644 --- a/src/views/home/__tests__/home.test.tsx +++ b/src/views/home/__tests__/home.test.tsx @@ -13,8 +13,8 @@ const testState = vi.hoisted(() => ({ directories: [] as Array<{ address: string; title?: string }>, directoryAddresses: [] as string[], navigateMock: vi.fn(), - subplebbits: {} as Record, - subplebbitsStats: {} as Record, + communities: {} as Record, + communityStats: {} as Record, })); vi.mock('react-i18next', () => ({ @@ -33,7 +33,7 @@ vi.mock('react-router-dom', async () => { }); vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ - useSubplebbits: () => ({ subplebbits: testState.subplebbits }), + useCommunities: () => ({ communities: testState.communities }), })); vi.mock('../../../hooks/use-directories', () => ({ @@ -41,11 +41,10 @@ vi.mock('../../../hooks/use-directories', () => ({ useDirectoryAddresses: () => testState.directoryAddresses, })); -vi.mock('../../../hooks/use-subplebbits-stats', () => ({ - SubplebbitStatsCollector: ({ subplebbitAddress }: { subplebbitAddress: string }) => - createElement('div', { 'data-testid': 'stats-collector', 'data-address': subplebbitAddress }), - useSubplebbitsStatsStore: (selector: (state: { subplebbitsStats: typeof testState.subplebbitsStats }) => unknown) => - selector({ subplebbitsStats: testState.subplebbitsStats }), +vi.mock('../../../hooks/use-communities-stats', () => ({ + CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) => + createElement('div', { 'data-testid': 'stats-collector', 'data-address': communityAddress }), + useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }), })); vi.mock('../../../stores/use-directory-modal-store', () => ({ @@ -59,8 +58,8 @@ vi.mock('../boards-list', () => ({ })); vi.mock('../popular-threads-box', () => ({ - default: ({ directories, subplebbits }: { directories: unknown[]; subplebbits: Record }) => - createElement('div', { 'data-testid': 'popular-threads-box' }, `popular:${directories.length}:${Object.keys(subplebbits).length}`), + default: ({ directories, communities }: { directories: unknown[]; communities: Record }) => + createElement('div', { 'data-testid': 'popular-threads-box' }, `popular:${directories.length}:${Object.keys(communities).length}`), })); vi.mock('../../../components/site-legal-meta', () => ({ @@ -95,11 +94,11 @@ describe('Home', () => { { address: 'tech-posting.eth', title: '/g/ - Technology' }, ]; testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth']; - testState.subplebbits = { + testState.communities = { 'music-posting.eth': { address: 'music-posting.eth' }, 'tech-posting.eth': { address: 'tech-posting.eth' }, }; - testState.subplebbitsStats = { + testState.communityStats = { 'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 }, 'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 }, }; diff --git a/src/views/home/boards-list/boards-list.tsx b/src/views/home/boards-list/boards-list.tsx index b54d60af..7520aec4 100644 --- a/src/views/home/boards-list/boards-list.tsx +++ b/src/views/home/boards-list/boards-list.tsx @@ -1,6 +1,6 @@ import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useAccountSubplebbitAddresses } from '../../../hooks/use-account-subplebbit-addresses'; +import { useAccountCommunityAddresses } from '../../../hooks/use-account-community-addresses'; import { useDirectoriesState, useDirectories, DirectoryCommunity } from '../../../hooks/use-directories'; import { getBoardPath } from '../../../lib/utils/route-utils'; import useDisclaimerModalStore from '../../../stores/use-disclaimer-modal-store'; @@ -64,7 +64,7 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => { const { useCatalogLinks, boardFilter } = useBoardsFilterStore(); const directories = useDirectories(); - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); const handleLinkClick = (e: React.MouseEvent, address: string) => { e.preventDefault(); @@ -598,7 +598,7 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => { Subscriptions - {accountSubplebbitAddresses.length > 0 && ( + {accountCommunityAddresses.length > 0 && ( {t('boards_you_moderate_nav')} diff --git a/src/views/home/home.tsx b/src/views/home/home.tsx index dedca4d5..c6523cfa 100644 --- a/src/views/home/home.tsx +++ b/src/views/home/home.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useRef, FormEvent } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { useSubplebbits } from '@bitsocialnet/bitsocial-react-hooks'; +import { useCommunities } from '@bitsocialnet/bitsocial-react-hooks'; import styles from './home.module.css'; import { useDirectories, useDirectoryAddresses } from '../../hooks/use-directories'; -import { SubplebbitStatsCollector, useSubplebbitsStatsStore } from '../../hooks/use-subplebbits-stats'; +import { CommunityStatsCollector, useCommunitiesStatsStore } from '../../hooks/use-communities-stats'; import PopularThreadsBox from './popular-threads-box'; import BoardsList from './boards-list'; import SiteLegalMeta from '../../components/site-legal-meta'; @@ -81,7 +81,7 @@ const InfoBox = () => { const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => { const { t } = useTranslation(); - const subplebbitsStats = useSubplebbitsStatsStore((state) => state.subplebbitsStats); + const communitiesStats = useCommunitiesStatsStore((state) => state.communityStats); const { totalPosts, currentUsers, boardsTracked } = useMemo(() => { let totalPosts = 0; @@ -89,7 +89,7 @@ const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => { let boardsTracked = 0; directoryAddresses.forEach((address) => { - const stat = subplebbitsStats[address]; + const stat = communitiesStats[address]; if (stat) { totalPosts += stat.allPostCount || 0; currentUsers += stat.weekActiveUserCount || 0; @@ -98,13 +98,13 @@ const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => { }); return { totalPosts, currentUsers, boardsTracked }; - }, [subplebbitsStats, directoryAddresses]); + }, [communitiesStats, directoryAddresses]); return ( <> - {/* Render collectors to fetch stats for each subplebbit */} + {/* Render collectors to fetch stats for each community */} {directoryAddresses.map((address) => ( - + ))} @@ -183,7 +183,7 @@ export const HomeLogo = () => { const Home = () => { const directories = useDirectories(); const directoryAddresses = useDirectoryAddresses(); - const { subplebbits } = useSubplebbits({ subplebbitAddresses: directoryAddresses }); + const { communities } = useCommunities({ communityAddresses: directoryAddresses }); const { closeDirectoryModal } = useDirectoryModalStore(); useEffect(() => { @@ -206,7 +206,7 @@ const Home = () => { - + diff --git a/src/views/home/popular-threads-box/popular-threads-box.tsx b/src/views/home/popular-threads-box/popular-threads-box.tsx index 125e3365..df5207e3 100644 --- a/src/views/home/popular-threads-box/popular-threads-box.tsx +++ b/src/views/home/popular-threads-box/popular-threads-box.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Comment, Subplebbit } from '@bitsocialnet/bitsocial-react-hooks'; +import { Comment, Community } from '@bitsocialnet/bitsocial-react-hooks'; import styles from '../home.module.css'; import usePopularPosts from '../../../hooks/use-popular-posts'; import { useFeedStateString } from '../../../hooks/use-state-string'; @@ -58,16 +58,17 @@ const PopularThreadCard = memo( const PopularThreadsBox = ({ directories, directoryAddresses, - subplebbits, + communities, }: { directories: DirectoryCommunity[]; directoryAddresses: string[]; - subplebbits: Array; + communities: Array; }) => { const { t } = useTranslation(); const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore(); + const getCommentCommunityAddress = (post: Comment) => post.communityAddress || post.subplebbitAddress; - const { filteredBoardAddresses, filteredSubplebbits } = useMemo(() => { + const { filteredBoardAddresses, filteredCommunities } = useMemo(() => { const filteredEntries = directoryAddresses.flatMap((address, index) => { const directoryEntry = findDirectoryByAddress(directories, address); if (showWorksafeContentOnly && directoryEntry?.nsfw) { @@ -77,16 +78,16 @@ const PopularThreadsBox = ({ return []; } - return [{ address, subplebbit: subplebbits[index] }]; + return [{ address, community: communities[index] }]; }); return { filteredBoardAddresses: filteredEntries.map((entry) => entry.address), - filteredSubplebbits: filteredEntries.map((entry) => entry.subplebbit), + filteredCommunities: filteredEntries.map((entry) => entry.community), }; - }, [directories, directoryAddresses, showNsfwContentOnly, showWorksafeContentOnly, subplebbits]); + }, [directories, directoryAddresses, showNsfwContentOnly, showWorksafeContentOnly, communities]); - const { popularPosts, isLoading } = usePopularPosts(filteredSubplebbits, filteredBoardAddresses); + const { popularPosts, isLoading } = usePopularPosts(filteredCommunities, filteredBoardAddresses); const loadingStateString = useFeedStateString(filteredBoardAddresses) || t('loading'); return ( @@ -100,9 +101,10 @@ const PopularThreadsBox = ({ ) : ( popularPosts.map((post: Comment) => { - const directoryEntry = findDirectoryByAddress(directories, post.subplebbitAddress); + const communityAddress = getCommentCommunityAddress(post); + const directoryEntry = findDirectoryByAddress(directories, communityAddress); const boardTitle = directoryEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || ''; - const boardPath = post.subplebbitAddress ? getBoardPath(post.subplebbitAddress, directories) : ''; + const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : ''; return ; }) )} diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 609ab239..66f0673d 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, Link } from 'react-router-dom'; -import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks'; +import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; import { Virtuoso } from 'react-virtuoso'; import styles from './mod-queue.module.css'; import useModQueueStore from '../../stores/use-mod-queue-store'; @@ -24,7 +24,7 @@ import useFeedResetStore from '../../stores/use-feed-reset-store'; import useChallengesStore from '../../stores/use-challenges-store'; import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils'; import Tooltip from '../../components/tooltip'; -import { useAccountSubplebbitAddresses } from '../../hooks/use-account-subplebbit-addresses'; +import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import useIsMobile from '../../hooks/use-is-mobile'; import { useCurrentTime } from '../../hooks/use-current-time'; import { Post } from '../post/post'; @@ -42,21 +42,23 @@ const getBoardDisplayPath = (address: string, path: string): string => { return getShortAddress(address) || address; }; +const getCommentCommunityAddress = (comment: Comment) => comment?.communityAddress || comment?.subplebbitAddress; + interface ModQueueViewProps { boardIdentifier?: string; // If provided, shows queue for single board } interface ModQueueFooterProps { hasMore: boolean; - subplebbitAddresses: string[]; + communityAddresses: string[]; } // 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 // caused by backend IPFS state changes to just this footer component -const ModQueueFooter = ({ hasMore, subplebbitAddresses }: ModQueueFooterProps) => { +const ModQueueFooter = ({ hasMore, communityAddresses }: ModQueueFooterProps) => { const { t } = useTranslation(); - const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading'); + const loadingStateString = useFeedStateString(communityAddresses) || t('loading'); return hasMore ? ( @@ -154,7 +156,8 @@ const ModQueueActions = ({ status, errorMessage, isPublishing, handleApprove, ha const useModQueueActions = (comment: Comment): ModQueueActionState => { const { t } = useTranslation(); - const { cid, subplebbitAddress, approved, removed, pendingApproval } = comment || {}; + const { cid, approved, removed, pendingApproval } = comment || {}; + const communityAddress = comment?.communityAddress || comment?.subplebbitAddress; const [initiatedAction, setInitiatedAction] = useState(null); const alreadyApproved = approved === true; @@ -166,7 +169,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => { error: approveError, } = usePublishCommentModeration({ commentCid: cid, - subplebbitAddress, + communityAddress, commentModeration: approvePendingCommentModeration, onChallenge: async (...args: any) => { addChallenge([...args, comment]); @@ -185,7 +188,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => { error: rejectError, } = usePublishCommentModeration({ commentCid: cid, - subplebbitAddress, + communityAddress, commentModeration: rejectPendingCommentModeration, onChallenge: async (...args: any) => { addChallenge([...args, comment]); @@ -251,23 +254,8 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; - const { - content, - title, - timestamp, - subplebbitAddress, - cid, - threadCid, - link, - thumbnailUrl, - linkWidth, - linkHeight, - removed, - approved, - pendingApproval, - number, - parentCid, - } = displayComment; + const { content, title, timestamp, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, pendingApproval, number, parentCid } = displayComment; + const commentCommunityAddress = getCommentCommunityAddress(displayComment); // Check if already moderated (from previous session or API update) // Note: `approved` and `removed` are direct fields on the comment from CommentUpdate, @@ -378,23 +366,8 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; - const { - content, - title, - timestamp, - subplebbitAddress, - cid, - threadCid, - link, - thumbnailUrl, - linkWidth, - linkHeight, - removed, - approved, - pendingApproval, - number, - parentCid, - } = displayComment; + const { content, title, timestamp, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, pendingApproval, number, parentCid } = displayComment; + const commentCommunityAddress = getCommentCommunityAddress(displayComment); const alreadyApproved = approved === true; const alreadyRejected = isPendingApprovalRejected({ approved, removed, pendingApproval }); @@ -487,7 +460,7 @@ const ModQueueFeedPost = ({ comment }: { comment: Comment }) => { interface ModQueueBoardSummaryProps { feed: Comment[]; directories: DirectoryCommunity[]; - accountSubplebbitAddresses: string[]; + accountCommunityAddresses: string[]; } const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): string | null => { @@ -499,20 +472,20 @@ const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): strin return entry?.address || null; }; -const ModQueueBoardSummary = ({ feed, directories, accountSubplebbitAddresses }: ModQueueBoardSummaryProps) => { +const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => { const { t } = useTranslation(); const { selectedBoardFilter, setSelectedBoardFilter, getAlertThresholdSeconds } = useModQueueStore(); const currentTime = useCurrentTime(); const alertThresholdSeconds = getAlertThresholdSeconds(); - const modAddressSet = useMemo(() => new Set(accountSubplebbitAddresses), [accountSubplebbitAddresses]); + const modAddressSet = useMemo(() => new Set(accountCommunityAddresses), [accountCommunityAddresses]); const boardCounts = useMemo(() => { const counts = new Map(); - for (const address of accountSubplebbitAddresses) { + for (const address of accountCommunityAddresses) { counts.set(address, { normal: 0, urgent: 0 }); } for (const item of feed) { - const addr = item.subplebbitAddress; + const addr = getCommentCommunityAddress(item); if (!addr) continue; const entry = counts.get(addr); if (!entry) continue; @@ -524,7 +497,7 @@ const ModQueueBoardSummary = ({ feed, directories, accountSubplebbitAddresses }: else entry.normal++; } return counts; - }, [feed, accountSubplebbitAddresses, currentTime, alertThresholdSeconds]); + }, [feed, accountCommunityAddresses, currentTime, alertThresholdSeconds]); const { totalNormal, totalUrgent } = useMemo(() => { let normal = 0; @@ -551,7 +524,7 @@ const ModQueueBoardSummary = ({ feed, directories, accountSubplebbitAddresses }: } } // Directory boards not in BOARD_CODE_GROUPS (custom dirs) - for (const addr of accountSubplebbitAddresses) { + for (const addr of accountCommunityAddresses) { const path = getBoardPath(addr, directories); if (path !== addr && !seen.has(addr)) { ordered.push(addr); @@ -559,18 +532,18 @@ const ModQueueBoardSummary = ({ feed, directories, accountSubplebbitAddresses }: } } // Non-directory boards (own category, like subscriptions in boardsbar) - for (const addr of accountSubplebbitAddresses) { + for (const addr of accountCommunityAddresses) { if (!seen.has(addr)) { ordered.push(addr); } } return ordered; - }, [accountSubplebbitAddresses, directories, modAddressSet]); + }, [accountCommunityAddresses, directories, modAddressSet]); const handleSelectAll = useCallback(() => setSelectedBoardFilter(null), [setSelectedBoardFilter]); const handleSelectBoard = useCallback((address: string) => setSelectedBoardFilter(address), [setSelectedBoardFilter]); - if (accountSubplebbitAddresses.length === 0) { + if (accountCommunityAddresses.length === 0) { return null; } @@ -742,7 +715,7 @@ const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, i export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => { const { getAlertThresholdSeconds } = useModQueueStore(); - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); const directories = useDirectories(); @@ -753,23 +726,23 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp return undefined; }, [boardIdentifier, directories]); - const subplebbitAddresses = useMemo(() => { + const communityAddresses = useMemo(() => { if (resolvedAddress) { return [resolvedAddress]; } - return accountSubplebbitAddresses; - }, [resolvedAddress, accountSubplebbitAddresses]); + return accountCommunityAddresses; + }, [resolvedAddress, accountCommunityAddresses]); // If specific board, check if user is mod using resolved address - const isModOfBoard = resolvedAddress ? accountSubplebbitAddresses.includes(resolvedAddress) : true; + const isModOfBoard = resolvedAddress ? accountCommunityAddresses.includes(resolvedAddress) : true; // Only fetch if we have addresses to check and permissions - const shouldFetch = subplebbitAddresses.length > 0 && isModOfBoard; + const shouldFetch = communityAddresses.length > 0 && isModOfBoard; - const feedAddresses = shouldFetch ? subplebbitAddresses : []; + const feedAddresses = shouldFetch ? communityAddresses : []; const feedOptions = useMemo( () => ({ - subplebbitAddresses: feedAddresses, + communityAddresses: feedAddresses, modQueue: ['pendingApproval'], sortType: 'new' as const, postsPerPage: 200, @@ -778,13 +751,13 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp ); const { feed } = useFeed(feedOptions); - if (!shouldFetch || subplebbitAddresses.length === 0) { + if (!shouldFetch || communityAddresses.length === 0) { return null; } const alertThresholdSeconds = getAlertThresholdSeconds(); // Use key to reset statusMap state when switching boards (prevents stale counts from previous board) - const contentKey = subplebbitAddresses.join(','); + const contentKey = communityAddresses.join(','); return ; }; @@ -794,7 +767,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp const { selectedBoardFilter, viewMode } = useModQueueStore(); const isMobile = useIsMobile(); - const accountSubplebbitAddresses = useAccountSubplebbitAddresses(); + const accountCommunityAddresses = useAccountCommunityAddresses(); const directories = useDirectories(); @@ -807,42 +780,43 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp return undefined; }, [boardIdentifier, directories]); - const subplebbitAddresses = useMemo(() => { + const communityAddresses = useMemo(() => { if (resolvedAddress) return [resolvedAddress]; - return accountSubplebbitAddresses; - }, [resolvedAddress, accountSubplebbitAddresses]); + return accountCommunityAddresses; + }, [resolvedAddress, accountCommunityAddresses]); - const subplebbitAddress = subplebbitAddresses[0]; - const subplebbit = useSubplebbit({ subplebbitAddress }); - const { error: subplebbitError } = subplebbit || {}; + const communityAddress = communityAddresses[0]; + const community = useCommunity({ communityAddress }); + const { error: communityError } = community || {}; const feedOptions = useMemo( () => ({ - subplebbitAddresses, + communityAddresses, modQueue: ['pendingApproval'], postsPerPage: 50, }), - [subplebbitAddresses], + [communityAddresses], ); const { feed, hasMore, loadMore, reset } = useFeed(feedOptions); const filteredFeed = useMemo(() => { if (!selectedBoardFilter) return feed; - return feed.filter((item) => item.subplebbitAddress === selectedBoardFilter); + return feed.filter((item) => getCommentCommunityAddress(item) === selectedBoardFilter); }, [feed, selectedBoardFilter]); const addressToPathMap = useMemo(() => { const map = new Map(); - for (const addr of subplebbitAddresses) { + for (const addr of communityAddresses) { map.set(addr, getBoardPath(addr, directories)); } return map; - }, [subplebbitAddresses, directories]); + }, [communityAddresses, directories]); const showBoardColumn = !resolvedAddress; const compactRowItemContent = useCallback( (index: number, comment: Comment) => { - const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories); + const commentCommunityAddress = getCommentCommunityAddress(comment); + const path = addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); return ( ); }, @@ -858,14 +832,15 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp ); const compactCardItemContent = useCallback( (_index: number, comment: Comment) => { - const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories); + const commentCommunityAddress = getCommentCommunityAddress(comment); + const path = addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); return ( ); }, @@ -883,16 +858,16 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp () => ({ Footer: () => ( <> - {subplebbitError?.message && feed.length === 0 && ( + {communityError?.message && feed.length === 0 && ( - + )} - + > ), }), - [hasMore, subplebbitAddresses, subplebbitError, feed.length], + [hasMore, communityAddresses, communityError, feed.length], ); const pageFooter = ( @@ -919,7 +894,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp {!resolvedAddress && ( - + )} @@ -953,7 +928,9 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp ) : ( <> {filteredFeed.map((comment, index) => { - const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories); + const commentCommunityAddress = getCommentCommunityAddress(comment); + const path = + addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); return ( ); })} - {subplebbitError?.message && feed.length === 0 && ( + {communityError?.message && feed.length === 0 && ( - + )} - + > )} > @@ -991,23 +968,25 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp ) : ( <> {filteredFeed.map((comment) => { - const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories); + const commentCommunityAddress = getCommentCommunityAddress(comment); + const path = + addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); return ( ); })} - {subplebbitError?.message && feed.length === 0 && ( + {communityError?.message && feed.length === 0 && ( - + )} - + > )} > @@ -1030,12 +1009,12 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp {filteredFeed.map((comment) => ( ))} - {subplebbitError?.message && feed.length === 0 && ( + {communityError?.message && feed.length === 0 && ( - + )} - + > )} > diff --git a/src/views/not-found/__tests__/not-found.test.tsx b/src/views/not-found/__tests__/not-found.test.tsx index d7be08dc..d2aa5738 100644 --- a/src/views/not-found/__tests__/not-found.test.tsx +++ b/src/views/not-found/__tests__/not-found.test.tsx @@ -14,7 +14,7 @@ const testState = vi.hoisted(() => ({ }, resolvedAddress: 'music-posting.eth', shortAddress: 'mu', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', })); vi.mock('react-router-dom', async () => { @@ -26,8 +26,8 @@ vi.mock('react-router-dom', async () => { }; }); -vi.mock('../../../hooks/use-stable-subplebbit', () => ({ - useSubplebbitField: (_address: string, selector: (subplebbit: { address?: string; shortAddress?: string }) => string | undefined) => +vi.mock('../../../hooks/use-stable-community', () => ({ + useCommunityField: (_address: string, selector: (community: { address?: string; shortAddress?: string }) => string | undefined) => selector({ address: testState.resolvedAddress, shortAddress: testState.shortAddress, @@ -39,7 +39,7 @@ vi.mock('../../../hooks/use-directories', () => ({ })); vi.mock('../../../lib/utils/route-utils', () => ({ - getSubplebbitAddress: () => testState.subplebbitAddress, + getSubplebbitAddress: () => testState.communityAddress, })); vi.mock('../../home', () => ({ @@ -68,7 +68,7 @@ describe('NotFound', () => { }; testState.resolvedAddress = 'music-posting.eth'; testState.shortAddress = 'mu'; - testState.subplebbitAddress = 'music-posting.eth'; + testState.communityAddress = 'music-posting.eth'; container = document.createElement('div'); document.body.appendChild(container); @@ -96,7 +96,7 @@ describe('NotFound', () => { }; testState.resolvedAddress = ''; testState.shortAddress = ''; - testState.subplebbitAddress = ''; + testState.communityAddress = ''; await renderNotFound(); diff --git a/src/views/not-found/not-found.tsx b/src/views/not-found/not-found.tsx index f5a50173..50a9ae4a 100644 --- a/src/views/not-found/not-found.tsx +++ b/src/views/not-found/not-found.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom'; -import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; +import { useCommunityField } from '../../hooks/use-stable-community'; import { useDirectories } from '../../hooks/use-directories'; import { getSubplebbitAddress } from '../../lib/utils/route-utils'; import { HomeLogo } from '../home'; @@ -12,10 +12,10 @@ const NotFound = () => { const pathParts = location.pathname.split('/').filter(Boolean); const boardIdentifier = pathParts[0] && pathParts[0] !== 'not-found' && pathParts[0] !== 'faq' ? pathParts[0] : ''; const directories = useDirectories(); - const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : ''; + const communityAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : ''; // Only subscribe to address and shortAddress to avoid rerenders from updatingState changes - const address = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.address); - const shortAddress = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.shortAddress); + const address = useCommunityField(communityAddress, (community) => community?.address); + const shortAddress = useCommunityField(communityAddress, (community) => community?.shortAddress); return ( @@ -32,7 +32,7 @@ const NotFound = () => { <> - [Back to p/{shortAddress || subplebbitAddress}] + [Back to p/{shortAddress || communityAddress}] > )} diff --git a/src/views/pending-post/__tests__/pending-post.test.tsx b/src/views/pending-post/__tests__/pending-post.test.tsx index fdda392f..7d451797 100644 --- a/src/views/pending-post/__tests__/pending-post.test.tsx +++ b/src/views/pending-post/__tests__/pending-post.test.tsx @@ -9,7 +9,7 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise type TestComment = { cid?: string; - subplebbitAddress?: string; + communityAddress?: string; }; const testState = vi.hoisted(() => ({ @@ -98,7 +98,7 @@ describe('PendingPost', () => { testState.accountCommentIndex = '0'; testState.accountComments = [{}]; testState.post = { - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }; await renderPendingPost(); @@ -124,7 +124,7 @@ describe('PendingPost', () => { testState.getBoardPathMock.mockReturnValue('mu'); testState.post = { cid: 'post-cid', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }; await renderPendingPost(); diff --git a/src/views/pending-post/pending-post.tsx b/src/views/pending-post/pending-post.tsx index 5443376b..f04d34ec 100644 --- a/src/views/pending-post/pending-post.tsx +++ b/src/views/pending-post/pending-post.tsx @@ -29,8 +29,9 @@ const PendingPost = () => { }, [isValidAccountCommentIndex, navigate]); useEffect(() => { - if (post?.cid && post?.subplebbitAddress) { - const boardPath = getBoardPath(post.subplebbitAddress, directories); + const postCommunityAddress = post?.communityAddress || post?.subplebbitAddress; + if (post?.cid && postCommunityAddress) { + const boardPath = getBoardPath(postCommunityAddress, directories); navigate(`/${boardPath}/thread/${post.cid}`, { replace: true }); } }, [post, navigate, directories]); diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index 8345dc1e..9ef9d8b4 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -20,6 +20,7 @@ type TestComment = { replyCount?: number; replies?: unknown[]; state?: string; + communityAddress?: string; subplebbitAddress?: string; timestamp?: number; title?: string; @@ -27,18 +28,19 @@ type TestComment = { const testState = vi.hoisted(() => ({ cachedComments: {} as Record, + communityFieldAddress: undefined as string | undefined, commentsByCid: {} as Record, directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, editedCommentsByCid: {} as Record, isMobile: false, navigateMock: vi.fn(), - resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, - subplebbit: { + resolvedCommunityAddress: 'music-posting.eth' as string | undefined, + community: { error: undefined as Error | undefined, shortAddress: 'music-posting.eth', title: '/mu/ - Music', }, - subplebbitSnapshot: { + communitySnapshot: { roles: { '0xmod': { role: 'admin' }, }, @@ -64,22 +66,25 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useEditedComment: ({ comment }: { comment?: TestComment }) => ({ editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined, }), - useSubplebbit: () => testState.subplebbit, + useCommunity: () => testState.community, })); -vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ +vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages', () => ({ default: (selector: (state: { comments: typeof testState.cachedComments }) => unknown) => selector({ comments: testState.cachedComments, }), })); -vi.mock('../../../hooks/use-stable-subplebbit', () => ({ - useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot), +vi.mock('../../../hooks/use-stable-community', () => ({ + useCommunityField: (address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => { + testState.communityFieldAddress = address; + return selector(testState.communitySnapshot); + }, })); -vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ - useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +vi.mock('../../../hooks/use-resolved-community-address', () => ({ + useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../hooks/use-directories', async () => { @@ -104,25 +109,25 @@ vi.mock('../../../components/footer', () => ({ ThreadFooterFirstRow: ({ isThreadClosed, postCid, - subplebbitAddress, + communityAddress, threadNumber, }: { isThreadClosed: boolean; postCid: string; - subplebbitAddress: string; + communityAddress: string; threadNumber?: number; - }) => createElement('div', { 'data-testid': 'thread-footer-first-row' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`), + }) => createElement('div', { 'data-testid': 'thread-footer-first-row' }, `${postCid}:${threadNumber}:${communityAddress}:${String(isThreadClosed)}`), ThreadFooterMobile: ({ isThreadClosed, postCid, - subplebbitAddress, + communityAddress, threadNumber, }: { isThreadClosed: boolean; postCid: string; - subplebbitAddress: string; + communityAddress: string; threadNumber?: number; - }) => createElement('div', { 'data-testid': 'thread-footer-mobile' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`), + }) => createElement('div', { 'data-testid': 'thread-footer-mobile' }, `${postCid}:${threadNumber}:${communityAddress}:${String(isThreadClosed)}`), ThreadFooterStyleRow: () => createElement('div', { 'data-testid': 'thread-footer-style-row' }, 'thread-footer-style-row'), })); @@ -182,17 +187,18 @@ describe('Post', () => { beforeEach(() => { vi.clearAllMocks(); testState.cachedComments = {}; + testState.communityFieldAddress = undefined; testState.commentsByCid = {}; testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; testState.editedCommentsByCid = {}; testState.isMobile = false; - testState.resolvedSubplebbitAddress = 'music-posting.eth'; - testState.subplebbit = { + testState.resolvedCommunityAddress = 'music-posting.eth'; + testState.community = { error: undefined, shortAddress: 'music-posting.eth', title: '/mu/ - Music', }; - testState.subplebbitSnapshot = { + testState.communitySnapshot = { roles: { '0xmod': { role: 'admin' }, }, @@ -251,17 +257,17 @@ describe('Post', () => { it('renders edited posts through the desktop and mobile presenters with stable role data', async () => { testState.editedCommentsByCid = { - 'post-1': { cid: 'edited-post', subplebbitAddress: 'music-posting.eth' }, + 'post-1': { cid: 'edited-post', communityAddress: 'music-posting.eth' }, }; await act(async () => { - root.render(createElement(Post, { post: { cid: 'post-1', subplebbitAddress: 'music-posting.eth' } })); + root.render(createElement(Post, { post: { cid: 'post-1', communityAddress: 'music-posting.eth' } })); }); expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('edited-post:none:1'); testState.isMobile = true; await act(async () => { - root.render(createElement(Post, { post: { cid: 'post-2', subplebbitAddress: 'music-posting.eth' } })); + root.render(createElement(Post, { post: { cid: 'post-2', communityAddress: 'music-posting.eth' } })); }); expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1'); }); @@ -271,7 +277,7 @@ describe('Post', () => { 'cached-cid': { cid: 'cached-cid', state: 'updating', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, }; testState.cachedComments = { @@ -280,7 +286,7 @@ describe('Post', () => { content: 'cached body', number: 42, replyCount: 0, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', title: 'Cached thread', }, }; @@ -290,6 +296,7 @@ describe('Post', () => { expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('cached-cid:none:1'); expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false'); expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false'); + expect(testState.communityFieldAddress).toBe('music-posting.eth'); expect(document.title).toBe('/mu/ - Cached thread... - 5chan'); expect(window.scrollTo).toHaveBeenCalledWith(0, 0); expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); @@ -301,7 +308,7 @@ describe('Post', () => { cid: 'thread-cid', number: 8, replyCount: 0, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', title: 'Thread title', }, }; @@ -326,7 +333,7 @@ describe('Post', () => { 'comment-1': { cid: 'comment-1', postCid: 'comment-1', - subplebbitAddress: 'other.eth', + communityAddress: 'other.eth', title: 'Other board thread', }, }; @@ -336,13 +343,42 @@ describe('Post', () => { expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true }); }); + it('hydrates multiboard thread pages from a legacy-only comment address', async () => { + testState.resolvedCommunityAddress = undefined; + testState.commentsByCid = { + 'legacy-cid': { + cid: 'legacy-cid', + state: 'updating', + subplebbitAddress: 'music-posting.eth', + }, + }; + testState.cachedComments = { + 'legacy-cid': { + cid: 'legacy-cid', + content: 'cached body', + number: 7, + replyCount: 0, + subplebbitAddress: 'music-posting.eth', + title: 'Legacy thread', + }, + }; + + await renderPostPage('/all/thread/legacy-cid'); + + expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('legacy-cid:none:1'); + expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('legacy-cid:7:music-posting.eth:false'); + expect(testState.communityFieldAddress).toBe('music-posting.eth'); + expect(document.title).toBe('all - Legacy thread... - 5chan'); + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + it('renders reply pages using the root post, highlights the reply target, and shows thread errors', async () => { testState.commentsByCid = { 'reply-cid': { cid: 'reply-cid', parentCid: 'root-cid', postCid: 'root-cid', - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', }, 'root-cid': { cid: 'root-cid', @@ -351,7 +387,7 @@ describe('Post', () => { number: 99, replies: [], replyCount: 4, - subplebbitAddress: 'music-posting.eth', + communityAddress: 'music-posting.eth', title: 'Root thread', }, }; @@ -369,7 +405,7 @@ describe('Post', () => { error: new Error('missing comment'), }, }; - testState.subplebbit = { + testState.community = { error: new Error('board failed'), shortAddress: 'music-posting.eth', title: '/mu/ - Music', diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index b9364003..a2c7e519 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -1,13 +1,14 @@ import { memo, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks'; -import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages'; -import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; +import { Comment, Role, useComment, useEditedComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; +import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages'; +import { useCommunityField } from '../../hooks/use-stable-community'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { isAllView } from '../../lib/utils/view-utils'; -import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; +import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { useDirectories } from '../../hooks/use-directories'; import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils'; +import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import ErrorDisplay from '../../components/error-display/error-display'; import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer'; @@ -17,11 +18,11 @@ import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/ import styles from './post.module.css'; // useComment may not return cached feed data immediately due to its updatedAt comparison logic. -// This hook falls back to the subplebbit pages store (populated by useFeed) so content +// This hook falls back to the communities pages store (populated by useFeed) so content // from the catalog appears instantly instead of going through a loading phase. const useCommentWithFeedCache = (options: { commentCid: string | undefined }) => { const comment = useComment(options); - const cachedComment = useSubplebbitsPagesStore((state) => state.comments[options?.commentCid || '']); + const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']); return useMemo(() => { if (!cachedComment || comment?.timestamp) return comment; @@ -53,7 +54,8 @@ export interface PostProps { export const Post = memo( ({ post, showAllReplies = false, showReplies = true, targetReplyCid, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject }: PostProps) => { // Only subscribe to roles field to avoid rerenders from updatingState changes - const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles); + const communityAddress = post?.communityAddress || post?.subplebbitAddress; + const roles = useCommunityField(communityAddress, (community) => community?.roles); const isMobile = useIsMobile(); let comment = post; @@ -130,21 +132,23 @@ const PostPage = () => { const params = useParams(); const location = useLocation(); const { commentCid } = params; - const subplebbitAddress = useResolvedSubplebbitAddress(); + const resolvedCommunityAddress = useResolvedCommunityAddress(); const isInAllView = isAllView(location.pathname); const comment = useCommentWithFeedCache({ commentCid }); + const commentCommunityAddress = getCommentCommunityAddress(comment); + const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress; const consumedThreadTopScrollRef = useRef(null); const navigate = useNavigate(); useEffect(() => { - if (comment?.subplebbitAddress && subplebbitAddress && !areSameBoardAddress(comment.subplebbitAddress, subplebbitAddress)) { + if (commentCommunityAddress && resolvedCommunityAddress && !areSameBoardAddress(commentCommunityAddress, resolvedCommunityAddress)) { navigate('/not-found', { replace: true }); } - }, [comment?.subplebbitAddress, subplebbitAddress, navigate]); + }, [commentCommunityAddress, resolvedCommunityAddress, navigate]); - const subplebbit = useSubplebbit({ subplebbitAddress }); - const { error: subplebbitError, shortAddress, title } = subplebbit || {}; + const community = useCommunity({ communityAddress }); + const { error: communityError, shortAddress, title } = community || {}; const directories = useDirectories(); // if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread @@ -192,17 +196,17 @@ const PostPage = () => { } else if (isDirectory) { boardTitle = `/${boardIdentifier}/`; } else { - boardTitle = title ? title : shortAddress || subplebbitAddress || ''; + boardTitle = title ? title : shortAddress || communityAddress || ''; } const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30); const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : ''; document.title = `${boardTitle}${postTitlePart} - 5chan`; - }, [title, shortAddress, subplebbitAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]); + }, [title, shortAddress, communityAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]); const shouldShowCommentError = comment?.error?.message && !comment?.cid; const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0; - const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid; + const shouldShowCommunityError = communityError?.message && !post?.cid; const targetReplyCid = comment?.parentCid ? comment?.cid : undefined; @@ -214,9 +218,9 @@ const PostPage = () => { )} - {shouldShowSubplebbitError && ( + {shouldShowCommunityError && ( - + )} {shouldShowCommentError && ( @@ -224,13 +228,13 @@ const PostPage = () => { )} - {post?.cid && subplebbitAddress ? ( + {post?.cid && communityAddress ? ( <> } + firstRow={} styleRow={} /> - + > ) : null} diff --git a/src/views/rules/rules.tsx b/src/views/rules/rules.tsx index 86fc9075..e12cc4eb 100644 --- a/src/views/rules/rules.tsx +++ b/src/views/rules/rules.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, FormEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks'; +import { useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; import { Footer, HomeLogo } from '../home'; import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import { getSubplebbitAddress, getBoardPath } from '../../lib/utils/route-utils'; @@ -21,12 +21,12 @@ const getBoardName = (title?: string): string => { return match ? match[1] : title; }; -const BoardRulesDisplay = ({ subplebbitAddress, directories }: { subplebbitAddress: string; directories: DirectoryCommunity[] }) => { - const subplebbit = useSubplebbit({ subplebbitAddress }); - const { rules, state, title, shortAddress } = subplebbit || {}; +const BoardRulesDisplay = ({ communityAddress, directories }: { communityAddress: string; directories: DirectoryCommunity[] }) => { + const community = useCommunity({ communityAddress }); + const { rules, state, title, shortAddress } = community || {}; let loadingText: string | null = null; - if (!subplebbit) { + if (!community) { loadingText = 'connecting...'; } else { switch (state) { @@ -47,7 +47,7 @@ const BoardRulesDisplay = ({ subplebbitAddress, directories }: { subplebbitAddre const isLoaded = state === 'succeeded'; - const defaultSub = directories.find((sub) => sub.address === subplebbitAddress); + const defaultSub = directories.find((sub) => sub.address === communityAddress); let displayTitle: string; if (defaultSub?.title) { const shortCode = getBoardShortCode(defaultSub.title); @@ -59,10 +59,10 @@ const BoardRulesDisplay = ({ subplebbitAddress, directories }: { subplebbitAddre if (shortCode && boardName && boardName !== title) { displayTitle = `Rules for: /${shortCode}/ - ${boardName}`; } else { - displayTitle = `Rules for: ${shortAddress || subplebbitAddress}`; + displayTitle = `Rules for: ${shortAddress || communityAddress}`; } } else { - displayTitle = `Rules for: ${shortAddress || subplebbitAddress}`; + displayTitle = `Rules for: ${shortAddress || communityAddress}`; } return ( @@ -197,7 +197,7 @@ const Rules = () => { - {selectedAddress && } + {selectedAddress && } diff --git a/yarn.lock b/yarn.lock index 18e69214..804213f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1046,9 +1046,9 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@bitsocialnet/bitsocial-react-hooks@https://github.com/bitsocialnet/bitsocial-react-hooks.git#0f8c9061cd08a0725a7b9fbd85b922d65e44d677": +"@bitsocialnet/bitsocial-react-hooks@https://github.com/bitsocialnet/bitsocial-react-hooks.git#0e1d9ccd9c158cd0f161a62471cbf91d4928d317": version "0.1.0" - resolved "https://github.com/bitsocialnet/bitsocial-react-hooks.git#0f8c9061cd08a0725a7b9fbd85b922d65e44d677" + resolved "https://github.com/bitsocialnet/bitsocial-react-hooks.git#0e1d9ccd9c158cd0f161a62471cbf91d4928d317" dependencies: "@noble/ed25519" "1.7.5" "@plebbit/plebbit-js" "https://github.com/plebbit/plebbit-js.git#10ed04e3866dd986a7f802bc9efd027f0e7d465b"