From eb47214f08b09cef3559d8f00bd03d2138905b50 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 18 Jun 2026 18:54:48 +0700 Subject: [PATCH] Fix empty board loading state and browser P2P patch (#1175) * fix(board): show loaded empty boards * fix(p2p): patch browser hooks runtime * fix(board): stop flash loading for explicit empty boards * fix(board): wait for feed on preloaded empty pages --- ...rotocol-pkc-js-npm-0.0.47-e2131d72c8.patch | 89 --- ...rotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch | 167 +++++ .yarnrc.yml | 2 +- package.json | 6 +- .../__tests__/raw-board-thread-state.test.ts | 40 ++ src/lib/utils/raw-board-thread-state.ts | 8 +- src/views/board/__tests__/board.test.tsx | 103 +++ src/views/board/board.tsx | 31 +- yarn.lock | 615 ++++++++++++++---- 9 files changed, 821 insertions(+), 240 deletions(-) delete mode 100644 .yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch create mode 100644 .yarn/patches/@pkcprotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch diff --git a/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch b/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch deleted file mode 100644 index ec0b1f1d..00000000 --- a/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch +++ /dev/null @@ -1,89 +0,0 @@ -diff --git a/dist/browser/helia/helia-for-pkc.js b/dist/browser/helia/helia-for-pkc.js -index e6821667b0601ac56a850e989bfedf76c14796a2..87d84a502bdbf72b8cf22b7a8ac6bd68e542c611 100644 ---- a/dist/browser/helia/helia-for-pkc.js -+++ b/dist/browser/helia/helia-for-pkc.js -@@ -145,6 +145,11 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) { - warmupPromisesByTopic.set(topic, p); - return p; - }; -+ const ignoreBestEffortPubsubWarmupError = (operation, topic, err, options) => { -+ if (options?.signal?.aborted) -+ throw err; -+ log.error(`Best-effort pubsub peer warmup failed before ${operation} on topic`, topic, err); -+ }; - const throwIfHeliaIsStoppingOrStopped = () => { - if (helia.libp2p.status === "stopped" || helia.libp2p.status === "stopping") - throw new PKCError("ERR_HELIAS_STOPPING_OR_STOPPED", { -@@ -290,8 +295,13 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) { - if (!wasAlreadySubscribed) - helia.libp2p.services.pubsub.subscribe(topic); - try { -- await warmupForTopic(topic, options); -- const res = await helia.libp2p.services.pubsub.publish(topic, data); -+ try { -+ await warmupForTopic(topic, options); -+ } -+ catch (err) { -+ ignoreBestEffortPubsubWarmupError("publish", topic, err, options); -+ } -+ const res = await helia.libp2p.services.pubsub.publish(topic, data, { allowPublishToZeroTopicPeers: true }); - log("Published new data to pubsub topic (string, e.g. community address)", topic, "Direct gossipsub recipients (libp2p peer IDs, NOT signer/community addresses):", res.recipients.map((p) => p.toString())); - } - finally { -@@ -314,7 +324,12 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) { - // locally subscribed to). - const warmupPromise = warmupForTopic(topic, options); - helia.libp2p.services.pubsub.subscribe(topic); -- await warmupPromise; -+ try { -+ await warmupPromise; -+ } -+ catch (err) { -+ ignoreBestEffortPubsubWarmupError("subscribe", topic, err, options); -+ } - }, - unsubscribe: async (topic, handler, options) => { - throwIfHeliaIsStoppingOrStopped(); -diff --git a/dist/browser/publications/publication.js b/dist/browser/publications/publication.js -index 5e17c02a39c2715c7ac932695fe90d5078bce405..8c61955ee6ef6dd353e57584b4aaf745e143f6d4 100644 ---- a/dist/browser/publications/publication.js -+++ b/dist/browser/publications/publication.js -@@ -842,8 +842,14 @@ class Publication extends TypedEmitter { - await new Promise((resolve) => setTimeout(resolve, this._setProviderFailureThresholdSeconds * 1000)); - if (this._isAllAttemptsExhausted(providers.length)) { - await this._postSucessOrFailurePublishing(); -- const allAttemptsFailedError = new PKCError("ERR_ALL_PUBSUB_PROVIDERS_THROW_ERRORS", { -- challengeExchanges: this._challengeExchangesFormattedForErrors(), -+ const challengeExchanges = this._challengeExchangesFormattedForErrors(); -+ const didEveryAttemptThrow = challengeExchanges.length > 0 && -+ challengeExchanges.every((exchange) => exchange.challengeRequestPublishError); -+ const allAttemptsFailedError = new PKCError(didEveryAttemptThrow -+ ? "ERR_ALL_PUBSUB_PROVIDERS_THROW_ERRORS" -+ : "ERR_PUBSUB_DID_NOT_RECEIVE_RESPONSE_AFTER_PUBLISHING_CHALLENGE_REQUEST", { -+ challengeExchanges, -+ publishToDifferentProviderThresholdSeconds: this._publishToDifferentProviderThresholdSeconds, - pubsubTopic: this._communityPubsubTopicWithFallback(), - providerHeliaContexts: this._libp2pJsClientHeliaContexts() - }); -diff --git a/dist/browser/runtime/browser/libp2p-extra-transports.js b/dist/browser/runtime/browser/libp2p-extra-transports.js -index 1b7028978d1da8c3f78e9b4d18e790a3ad025e25..2b8eff3ce509cff57ac5be3d31b26b38a5301a52 100644 ---- a/dist/browser/runtime/browser/libp2p-extra-transports.js -+++ b/dist/browser/runtime/browser/libp2p-extra-transports.js -@@ -1,3 +1,4 @@ --const extraLibp2pTransports = []; -+import { webTransport } from "@libp2p/webtransport"; -+const extraLibp2pTransports = typeof globalThis.WebTransport === "function" ? [webTransport()] : []; - export default extraLibp2pTransports; - //# sourceMappingURL=libp2p-extra-transports.js.map -diff --git a/package.json b/package.json -index 2a44f27220191ee556a317bf25005b7fcbde6aaa..335ee82cc7e9850eb1bc036b2a481ae5a575eabd 100644 ---- a/package.json -+++ b/package.json -@@ -78,6 +78,7 @@ - "@libp2p/identify": "4.1.3", - "@libp2p/interface": "3.2.2", - "@libp2p/peer-id": "6.0.8", -+ "@libp2p/webtransport": "6.0.0", - "@multiformats/multiaddr": "13.0.1", - "@noble/curves": "2.2.0", - "@pkcprotocol/pkc-logger": "0.1.0", diff --git a/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch b/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch new file mode 100644 index 00000000..91d6c8f2 --- /dev/null +++ b/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch @@ -0,0 +1,167 @@ +diff --git a/dist/browser/community/community-client-manager.js b/dist/browser/community/community-client-manager.js +index 8b45a48e22aff3ff5c0f9f0a30408fd63dfe40a4..9356e89aca5a84eae0c13a790b2fdbfff88604ca 100644 +--- a/dist/browser/community/community-client-manager.js ++++ b/dist/browser/community/community-client-manager.js +@@ -16,6 +16,7 @@ import { CID } from "kubo-rpc-client"; + import { getAuthorNameFromRuntime } from "../publications/publication-author.js"; + import { selectWinningGatewayCommunity } from "./community-gateway-selection.js"; + export const MAX_FILE_SIZE_BYTES_FOR_COMMUNITY_IPFS = 1024 * 1024; // 1mb ++const BROWSER_P2P_GATEWAY_FALLBACK_IPNS_TIMEOUT_MS = 15000; + export class CommunityClientsManager extends PKCClientsManager { + constructor(community) { + super(community._pkc); +@@ -378,22 +379,37 @@ export class CommunityClientsManager extends PKCClientsManager { + let subRes; + const areWeConnectedToKuboOrHelia = Object.keys(this._pkc.clients.kuboRpcClients).length > 0 || Object.keys(this._pkc.clients.libp2pJsClients).length > 0; + if (areWeConnectedToKuboOrHelia) { ++ const log = Logger("pkc-js:remote-community:update"); + const kuboRpcOrHelia = this.getDefaultKuboRpcClientOrHelia(); ++ const canFallbackToGateways = Object.keys(this._pkc.clients.ipfsGateways).length > 0; ++ const p2pIpnsTimeoutMs = canFallbackToGateways && "_helia" in kuboRpcOrHelia ++ ? Math.min(this._pkc._timeouts["community-ipns"], BROWSER_P2P_GATEWAY_FALLBACK_IPNS_TIMEOUT_MS) ++ : this._pkc._timeouts["community-ipns"]; + // we're connected to kubo or helia + try { +- subRes = await this._fetchCommunityIpnsP2PAndVerify(ipnsName); ++ subRes = await this._fetchCommunityIpnsP2PAndVerify(ipnsName, p2pIpnsTimeoutMs); + } + catch (e) { +- //@ts-expect-error +- e.details = { ++ if (canFallbackToGateways && !this._community._getStopAbortSignal()?.aborted) { ++ log.error("Falling back to gateways after browser P2P community IPNS fetch failed", { ++ communityAddress, ++ ipnsName, ++ error: e ++ }); ++ subRes = await this._fetchCommunityFromGateways(ipnsName); ++ } ++ else { + //@ts-expect-error +- ...e.details, +- ipnsName, +- communityAddress, +- ipnsPubsubTopic: this._community.ipnsPubsubTopic, +- ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid +- }; +- throw e; ++ e.details = { ++ //@ts-expect-error ++ ...e.details, ++ ipnsName, ++ communityAddress, ++ ipnsPubsubTopic: this._community.ipnsPubsubTopic, ++ ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid ++ }; ++ throw e; ++ } + } + finally { + if ("_helia" in kuboRpcOrHelia) +@@ -425,7 +441,7 @@ export class CommunityClientsManager extends PKCClientsManager { + return subRes; + }); + } +- async _fetchCommunityIpnsP2PAndVerify(ipnsName) { ++ async _fetchCommunityIpnsP2PAndVerify(ipnsName, timeoutMs = this._pkc._timeouts["community-ipns"]) { + const log = Logger("pkc-js:clients-manager:_fetchCommunityIpnsP2PAndVerify"); + const kuboRpcOrHelia = this.getDefaultKuboRpcClientOrHelia(); + if ("_helia" in kuboRpcOrHelia) { +@@ -434,7 +450,7 @@ export class CommunityClientsManager extends PKCClientsManager { + else + this.updateKuboRpcState("fetching-ipns", kuboRpcOrHelia.url); + const { cid: latestCommunityCid, ipnsHops } = await this.resolveIpnsToCidP2P(ipnsName, { +- timeoutMs: this._pkc._timeouts["community-ipns"], ++ timeoutMs, + abortSignal: this._community._getStopAbortSignal() + }); + // ipnsHops[0] is the anchor (== ipnsName), ipnsHops.at(-1) is the terminal name whose +diff --git a/dist/browser/helia/helia-for-pkc.js b/dist/browser/helia/helia-for-pkc.js +index 3eca64dd532a19393c55ddf953dfd4ef207c65f8..683fed56722effc54b1b08e5b050e311ed174424 100644 +--- a/dist/browser/helia/helia-for-pkc.js ++++ b/dist/browser/helia/helia-for-pkc.js +@@ -161,6 +161,11 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) { + warmupPromisesByTopic.set(topic, p); + return p; + }; ++ const ignoreBestEffortPubsubWarmupError = (operation, topic, err, options) => { ++ if (options?.signal?.aborted) ++ throw err; ++ log.error(`Best-effort pubsub peer warmup failed before ${operation} on topic`, topic, err); ++ }; + const throwIfHeliaIsStoppingOrStopped = () => { + if (helia.libp2p.status === "stopped" || helia.libp2p.status === "stopping") + throw new PKCError("ERR_HELIAS_STOPPING_OR_STOPPED", { +@@ -299,8 +304,13 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) { + if (!wasAlreadySubscribed) + helia.libp2p.services.pubsub.subscribe(topic); + try { +- await warmupForTopic(topic, options); +- const res = await helia.libp2p.services.pubsub.publish(topic, data); ++ try { ++ await warmupForTopic(topic, options); ++ } ++ catch (err) { ++ ignoreBestEffortPubsubWarmupError("publish", topic, err, options); ++ } ++ const res = await helia.libp2p.services.pubsub.publish(topic, data, { allowPublishToZeroTopicPeers: true }); + log("Published new data to pubsub topic (string, e.g. community address)", topic, "Direct gossipsub recipients (libp2p peer IDs, NOT signer/community addresses):", res.recipients.map((p) => p.toString())); + } + finally { +@@ -323,7 +333,12 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) { + // locally subscribed to). + const warmupPromise = warmupForTopic(topic, options); + helia.libp2p.services.pubsub.subscribe(topic); +- await warmupPromise; ++ try { ++ await warmupPromise; ++ } ++ catch (err) { ++ ignoreBestEffortPubsubWarmupError("subscribe", topic, err, options); ++ } + }, + unsubscribe: async (topic, handler, options) => { + throwIfHeliaIsStoppingOrStopped(); +diff --git a/dist/browser/publications/publication.js b/dist/browser/publications/publication.js +index 5e17c02a39c2715c7ac932695fe90d5078bce405..2d43d996e2b3cdab759e89c296eb9ed8aae857db 100644 +--- a/dist/browser/publications/publication.js ++++ b/dist/browser/publications/publication.js +@@ -842,8 +842,14 @@ class Publication extends TypedEmitter { + await new Promise((resolve) => setTimeout(resolve, this._setProviderFailureThresholdSeconds * 1000)); + if (this._isAllAttemptsExhausted(providers.length)) { + await this._postSucessOrFailurePublishing(); +- const allAttemptsFailedError = new PKCError("ERR_ALL_PUBSUB_PROVIDERS_THROW_ERRORS", { +- challengeExchanges: this._challengeExchangesFormattedForErrors(), ++ const challengeExchanges = this._challengeExchangesFormattedForErrors(); ++ const didEveryAttemptThrow = challengeExchanges.length > 0 && ++ challengeExchanges.every((exchange) => exchange.challengeRequestPublishError); ++ const allAttemptsFailedError = new PKCError(didEveryAttemptThrow ++ ? "ERR_ALL_PUBSUB_PROVIDERS_THROW_ERRORS" ++ : "ERR_PUBSUB_DID_NOT_RECEIVE_RESPONSE_AFTER_PUBLISHING_CHALLENGE_REQUEST", { ++ challengeExchanges, ++ publishToDifferentProviderThresholdSeconds: this._publishToDifferentProviderThresholdSeconds, + pubsubTopic: this._communityPubsubTopicWithFallback(), + providerHeliaContexts: this._libp2pJsClientHeliaContexts() + }); +diff --git a/dist/browser/runtime/browser/libp2p-extra-transports.js b/dist/browser/runtime/browser/libp2p-extra-transports.js +index 1b7028978d1da8c3f78e9b4d18e790a3ad025e25..2b8eff3ce509cff57ac5be3d31b26b38a5301a52 100644 +--- a/dist/browser/runtime/browser/libp2p-extra-transports.js ++++ b/dist/browser/runtime/browser/libp2p-extra-transports.js +@@ -1,3 +1,4 @@ +-const extraLibp2pTransports = []; ++import { webTransport } from "@libp2p/webtransport"; ++const extraLibp2pTransports = typeof globalThis.WebTransport === "function" ? [webTransport()] : []; + export default extraLibp2pTransports; + //# sourceMappingURL=libp2p-extra-transports.js.map +diff --git a/package.json b/package.json +index 7667db9846b0e18a55ce3942d243f2148b2ab5a0..f064d57f444083a6cf2c7a9ecb1b8ab1049e7a46 100644 +--- a/package.json ++++ b/package.json +@@ -78,6 +78,7 @@ + "@libp2p/identify": "4.1.7", + "@libp2p/interface": "3.2.3", + "@libp2p/peer-id": "6.0.10", ++ "@libp2p/webtransport": "6.0.0", + "@multiformats/multiaddr": "13.0.3", + "@noble/curves": "2.2.0", + "@pkcprotocol/pkc-logger": "0.1.0", diff --git a/.yarnrc.yml b/.yarnrc.yml index 5a4394d2..b71fe20a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,6 +5,6 @@ packageExtensions: peerDependencies: "@types/react": ">=18.0.0" react: ">=17.0.1" - "@pkcprotocol/pkc-js@0.0.47": + "@pkcprotocol/pkc-js@0.0.48": dependencies: "@libp2p/webtransport": "6.0.0" diff --git a/package.json b/package.json index 43fed29f..8c41bd22 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "private": true, "dependencies": { "@bbob/parser": "4.3.1", - "@bitsocial/bitsocial-react-hooks": "0.1.17", + "@bitsocial/bitsocial-react-hooks": "0.1.19", "@bitsocial/bso-resolver": "0.0.8", "@capacitor/app": "7.0.1", "@capacitor/browser": "7.0.5", @@ -17,7 +17,7 @@ "@capawesome/capacitor-android-edge-to-edge-support": "7.2.2", "@chenglou/pretext": "0.0.5", "@floating-ui/react": "0.26.1", - "@pkcprotocol/pkc-js": "patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch", + "@pkcprotocol/pkc-js": "patch:@pkcprotocol/pkc-js@npm%3A0.0.48#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch", "@react-spring/web": "10.0.3", "@ruffle-rs/ruffle": "0.2.0", "@types/node": "20.19.37", @@ -244,7 +244,7 @@ "yaml@npm:^2.8.2": "2.8.3", "use-sync-external-store": "1.6.0", "@electron/notarize@npm:^2.1.0": "patch:@electron/notarize@npm%3A2.5.0#~/.yarn/patches/@electron-notarize-npm-2.5.0-b15dc30c99.patch", - "@pkcprotocol/pkc-js@npm:0.0.47": "patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch" + "@pkcprotocol/pkc-js@npm:0.0.48": "patch:@pkcprotocol/pkc-js@npm%3A0.0.48#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.48-7f3bbd0d02.patch" }, "main": "electron/main.js", "lint-staged": { diff --git a/src/lib/utils/__tests__/raw-board-thread-state.test.ts b/src/lib/utils/__tests__/raw-board-thread-state.test.ts index da6c2dbf..387f7444 100644 --- a/src/lib/utils/__tests__/raw-board-thread-state.test.ts +++ b/src/lib/utils/__tests__/raw-board-thread-state.test.ts @@ -61,6 +61,46 @@ describe('getRawBoardThreadState', () => { ).toBe(true); }); + it('treats explicit empty page CIDs as a fully loaded empty board', () => { + const community = { + posts: { + pageCids: {}, + pages: {}, + }, + updatedAt: 1781773422, + } as Community; + + expect( + getRawBoardThreadState({ + accountId: undefined, + communitiesPages: {} as CommunitiesPages, + community, + sortType: 'active', + }), + ).toMatchObject({ + isFullyLoaded: true, + rootThreadCids: new Set(), + }); + }); + + it('does not treat placeholder empty page CIDs as fully loaded', () => { + const community = { + posts: { + pageCids: {}, + pages: {}, + }, + } as Community; + + expect( + getRawBoardThreadState({ + accountId: undefined, + communitiesPages: {} as CommunitiesPages, + community, + sortType: 'active', + }).isFullyLoaded, + ).toBe(false); + }); + it('walks stored board pages without importing side-effectful stores', () => { const community = { posts: { diff --git a/src/lib/utils/raw-board-thread-state.ts b/src/lib/utils/raw-board-thread-state.ts index a96e3f7c..af0a15c1 100644 --- a/src/lib/utils/raw-board-thread-state.ts +++ b/src/lib/utils/raw-board-thread-state.ts @@ -1,11 +1,13 @@ import type { Comment, CommunitiesPages, Community, CommunityPage } from '@bitsocial/bitsocial-react-hooks'; export type RawBoardThreadState = { + hasExplicitEmptyPageCids: boolean; isFullyLoaded: boolean; rootThreadCids: Set; }; const EMPTY_RAW_BOARD_THREAD_STATE: RawBoardThreadState = { + hasExplicitEmptyPageCids: false, isFullyLoaded: false, rootThreadCids: new Set(), }; @@ -80,6 +82,7 @@ export const getRawBoardThreadState = ({ if (pages.length > 0) { return { + hasExplicitEmptyPageCids: false, isFullyLoaded: !pages[pages.length - 1]?.nextCid, rootThreadCids, }; @@ -88,6 +91,8 @@ export const getRawBoardThreadState = ({ const hasPageCid = Boolean(community.posts?.pageCids?.[sortType]); const preloadedPages = (preloadedSortPage ? [preloadedSortPage] : []) as Array<{ comments?: Comment[]; nextCid?: string }>; const hasCompletePreloadedPage = !hasPageCid && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid); + const hasFetchedCommunityUpdate = typeof community.updatedAt === 'number' || typeof community.updateCid === 'string'; + const hasExplicitEmptyPageCids = hasFetchedCommunityUpdate && Boolean(community.posts?.pageCids && !hasPageCid); if (hasCompletePreloadedPage) { for (const page of preloadedPages) { @@ -96,7 +101,8 @@ export const getRawBoardThreadState = ({ } return { - isFullyLoaded: hasCompletePreloadedPage, + hasExplicitEmptyPageCids, + isFullyLoaded: hasCompletePreloadedPage || hasExplicitEmptyPageCids, rootThreadCids, }; }; diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index 9283a276..1382d44d 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -36,6 +36,7 @@ type TestComment = { type TestCommunity = { error?: Error; nameResolved?: boolean; + updatedAt?: number; posts?: { pageCids?: Record; pages?: Record; @@ -687,6 +688,43 @@ describe('Board', () => { expect(container.querySelectorAll('[data-testid="loading-ellipsis"]').length).toBe(1); }); + it('renders an empty flash table when a loaded board reports explicit empty page cids', async () => { + testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }]; + testState.directoryByAddress = { + 'flash-posting.bso': { + address: 'flash-posting.bso', + directoryCode: 'f', + features: { postsPerPage: 50 }, + title: '/f/ - Flash', + }, + }; + testState.resolvedCommunityAddress = 'flash-posting.bso'; + testState.feedState = 'fetching-ipns'; + testState.hasMore = true; + testState.community = { + error: undefined, + posts: { + pageCids: {}, + pages: {}, + }, + shortAddress: 'flash-posting.bso', + state: 'succeeded', + title: '/f/ - Flash', + updatedAt: 1781773422, + }; + testState.communitySnapshot = { + shortAddress: 'flash-posting.bso', + title: '/f/ - Flash', + }; + + await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); + + const table = container.querySelector('#flash-list'); + expect(table).toBeTruthy(); + expect(table?.textContent).toContain('no posts'); + expect(table?.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + }); + it('inserts a nonoko pending account comment after pinned posts on the redirected board index', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feed = [ @@ -1043,6 +1081,71 @@ describe('Board', () => { expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); + it('keeps loading when an empty preloaded board page finishes before the feed', async () => { + testState.feedStateString = undefined; + testState.feedState = 'fetching-ipns'; + testState.hasMore = true; + testState.community = { + error: undefined, + shortAddress: 'music-posting.eth', + state: 'succeeded', + title: '/mu/ - Music', + }; + markRawBoardThreadsFullyLoaded(); + + await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); + + expect(container.textContent).not.toContain('no_threads'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); + expect(container.textContent).toContain('load_more'); + }); + + it('shows no threads when a loaded board reports explicit empty page cids', async () => { + testState.feedStateString = 'Downloading board from peers'; + testState.feedState = 'fetching-ipns'; + testState.hasMore = true; + testState.community = { + error: undefined, + posts: { + pageCids: {}, + pages: {}, + }, + shortAddress: 'blog.bitsocial.bso', + state: 'succeeded', + title: 'Bitsocial Updates', + updatedAt: 1781773422, + }; + testState.communitySnapshot = { + shortAddress: 'blog.bitsocial.bso', + title: 'Bitsocial Updates', + }; + + await renderBoard({ initialEntry: '/blog.bitsocial.bso', routePath: '/:boardIdentifier/*' }); + + expect(container.textContent).toContain('no_threads'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + expect(container.textContent).not.toContain('load_more'); + }); + + it('keeps loading when raw board pages contain threads but the feed has not caught up', async () => { + testState.feedStateString = undefined; + testState.feedState = 'fetching-ipns'; + testState.hasMore = true; + testState.community = { + error: undefined, + shortAddress: 'music-posting.eth', + state: 'succeeded', + title: '/mu/ - Music', + }; + markRawBoardThreadsFullyLoaded([{ cid: 'post-1' }]); + + await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); + + expect(container.textContent).not.toContain('no_threads'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); + expect(container.textContent).toContain('load_more'); + }); + it('does not show no threads after board metadata loads but raw thread pages are still missing', async () => { testState.feedStateString = undefined; testState.feedState = 'succeeded'; diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 2c403372..e2e30064 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -60,6 +60,7 @@ interface BoardFooterProps { combinedFeedLength: number; isSingleCommunityBoard: boolean; isRawBoardThreadStateFullyLoaded: boolean; + isKnownEmptySingleCommunityBoard: boolean; isInSubscriptionsView: boolean; isInModView: boolean; currentTimeFilterName: string; @@ -84,6 +85,7 @@ const BoardFooter = ({ combinedFeedLength, isSingleCommunityBoard, isRawBoardThreadStateFullyLoaded, + isKnownEmptySingleCommunityBoard, isInSubscriptionsView, isInModView, currentTimeFilterName, @@ -102,7 +104,9 @@ const BoardFooter = ({ const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready'; const isFeedSucceeded = feedState === 'succeeded'; const isFeedFailed = feedState === 'failed'; - const canShowNoThreads = isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore; + const canShowNoThreads = + isKnownEmptySingleCommunityBoard || + (isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore); const isEmptyFeedLoading = combinedFeedLength === 0 && !canShowNoThreads && (isSingleCommunityBoard ? communityState !== 'failed' : !isFeedFailed); const showFooterLoading = showLoadingEllipsis && (hasMore || isEmptyFeedLoading); @@ -472,6 +476,14 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t [account?.id, communitiesPages, communityData, isMultiboardView], ); const isRawBoardThreadStateFullyLoaded = rawBoardThreadState?.isFullyLoaded ?? false; + const hasExplicitEmptyPageCids = rawBoardThreadState?.hasExplicitEmptyPageCids ?? false; + const isRawBoardThreadStateEmpty = isRawBoardThreadStateFullyLoaded && (rawBoardThreadState?.rootThreadCids.size ?? 0) === 0; + const isSingleCommunityBoard = !isInAllView && !isInSubscriptionsView && !isInModView; + const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready'; + const isFeedSucceeded = feedState === 'succeeded'; + const isKnownEmptySingleCommunityBoard = + isSingleCommunityBoard && combinedFeed.length === 0 && isLoadedCommunityState && isRawBoardThreadStateEmpty && (hasExplicitEmptyPageCids || isFeedSucceeded); + const effectiveHasMore = isKnownEmptySingleCommunityBoard ? false : hasMore; const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle; // Memoize footer component to preserve identity across renders (Virtuoso optimization) @@ -483,11 +495,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t {shouldUseFlashTable ? null : ( )} - {hasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && ( + {effectiveHasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (