From 8cf03a7bb4951d115de101ba847d7b9de9253a3a Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 18 Jun 2026 14:18:14 +0700 Subject: [PATCH] fix(pubsub): repair browser pure p2p publishing --- ...rotocol-pkc-js-npm-0.0.47-e2131d72c8.patch | 4 +- src/__tests__/app.test.tsx | 7 + src/app.tsx | 3 + src/globals.d.ts | 1 + ...-browser-pure-p2p-account-upgrade.test.tsx | 164 ++++++++++++++++++ .../use-browser-pure-p2p-account-upgrade.ts | 58 +++++++ src/lib/__tests__/p2p-runtime.test.ts | 16 ++ src/lib/p2p-browser-config.ts | 9 +- src/lib/p2p-runtime.ts | 11 ++ yarn.lock | 2 +- 10 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx create mode 100644 src/hooks/use-browser-pure-p2p-account-upgrade.ts 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 index 83bbf8b5..ec0b1f1d 100644 --- a/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch +++ b/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch @@ -1,5 +1,5 @@ diff --git a/dist/browser/helia/helia-for-pkc.js b/dist/browser/helia/helia-for-pkc.js -index e6821667b0601ac56a850e989bfedf76c14796a2..1a2ae7ab9b0afc605c9b4dcddccf25ac02aa1337 100644 +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) { @@ -45,7 +45,7 @@ index e6821667b0601ac56a850e989bfedf76c14796a2..1a2ae7ab9b0afc605c9b4dcddccf25ac unsubscribe: async (topic, handler, options) => { throwIfHeliaIsStoppingOrStopped(); diff --git a/dist/browser/publications/publication.js b/dist/browser/publications/publication.js -index 5e17c02a39c2715c7ac932695fe90d5078bce405..2d43d996e2b3cdab759e89c296eb9ed8aae857db 100644 +index 5e17c02a39c2715c7ac932695fe90d5078bce405..8c61955ee6ef6dd353e57584b4aaf745e143f6d4 100644 --- a/dist/browser/publications/publication.js +++ b/dist/browser/publications/publication.js @@ -842,8 +842,14 @@ class Publication extends TypedEmitter { diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index add84446..c7fa4d18 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -30,7 +30,9 @@ const testState = vi.hoisted(() => ({ isMobile: false, isSpecialEnabled: false, shouldShowSnow: true, + createAccountMock: vi.fn().mockResolvedValue(undefined), removeSnowMock: vi.fn(), + setAccountMock: vi.fn().mockResolvedValue(undefined), replyModalState: { activeCid: null, closeModal: vi.fn(), @@ -49,7 +51,10 @@ const testState = vi.hoisted(() => ({ })); vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + createAccount: () => testState.createAccountMock(), + setAccount: (account: unknown) => testState.setAccountMock(account), useAccount: () => testState.account, + useAccounts: () => ({ accounts: testState.account ? [testState.account] : [] }), useAccountComment: ({ commentIndex }: { commentIndex?: number }) => (typeof commentIndex === 'number' ? testState.accountComments[commentIndex] : undefined), useCommunity: (options?: { communityAddress?: string; community?: { name?: string; publicKey?: string } }) => { const communityAddress = options?.communityAddress ?? options?.community?.name ?? options?.community?.publicKey; @@ -365,6 +370,8 @@ describe('App', () => { testState.isDirectoryCandidate = false; testState.communities = {}; testState.useThemeMock.mockReset(); + testState.createAccountMock.mockReset().mockResolvedValue(undefined); + testState.setAccountMock.mockReset().mockResolvedValue(undefined); testState.closeCreateBoardModalMock.mockReset(); testState.initSnowMock.mockReset(); testState.removeSnowMock.mockReset(); diff --git a/src/app.tsx b/src/app.tsx index 2774d70f..387dc2cb 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -13,6 +13,7 @@ import useIsMobile from './hooks/use-is-mobile'; import { useAccountCommunityAddresses } from './hooks/use-account-community-addresses'; import useTheme from './hooks/use-theme'; import { useDirectories } from './hooks/use-directories'; +import { useBrowserPureP2PAccountUpgrade } from './hooks/use-browser-pure-p2p-account-upgrade'; import { useCommunityIdentifier } from './hooks/use-community-identifiers'; import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address'; import useSafeAccountComment from './hooks/use-safe-account-comment'; @@ -281,6 +282,8 @@ const ModQueueRoute = () => { }; const App = () => { + useBrowserPureP2PAccountUpgrade(); + // Feed routes are always rendered by FeedCacheContainer (Virtuoso used for all modes) const boardFeedElement = null; const catalogFeedElement = null; diff --git a/src/globals.d.ts b/src/globals.d.ts index 671bd4c7..621851ed 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,5 +1,6 @@ declare global { interface Window { + BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING?: boolean; isElectron: boolean; defaultPkcOptions?: Record; } diff --git a/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx b/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx new file mode 100644 index 00000000..23391f98 --- /dev/null +++ b/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx @@ -0,0 +1,164 @@ +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'; + +(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; + +const testState = vi.hoisted(() => ({ + account: undefined as Record | undefined, + accounts: [] as Record[] | undefined, + createAccountMock: vi.fn().mockResolvedValue(undefined), + setAccountMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + createAccount: () => testState.createAccountMock(), + setAccount: (account: unknown) => testState.setAccountMock(account), + useAccount: () => testState.account, + useAccounts: () => ({ accounts: testState.accounts }), +})); + +let container: HTMLDivElement; +const originalLocation = window.location; +let reloadMock: ReturnType; +let root: Root; + +const loadHook = async () => (await import('../use-browser-pure-p2p-account-upgrade')).useBrowserPureP2PAccountUpgrade; + +const TestComponent = ({ useUpgrade }: { useUpgrade: () => void }) => { + useUpgrade(); + return null; +}; + +const renderHook = async () => { + const useUpgrade = await loadHook(); + await act(async () => { + root.render(createElement(TestComponent, { useUpgrade })); + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +describe('useBrowserPureP2PAccountUpgrade', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + testState.account = undefined; + testState.accounts = []; + testState.createAccountMock.mockReset().mockResolvedValue(undefined); + testState.setAccountMock.mockReset().mockResolvedValue(undefined); + reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + hostname: '5chan.app', + reload: reloadMock, + }, + }); + window.electronApi = undefined; + window.isElectron = false; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + vi.useRealTimers(); + if (root) { + act(() => root.unmount()); + } + container?.remove(); + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = undefined; + window.electronApi = undefined; + window.isElectron = false; + }); + + it('upgrades stale gateway browser accounts and reloads after saving', async () => { + testState.account = { + id: 'account-1', + name: 'Account 1', + pkcOptions: { + httpRoutersOptions: ['https://router.old.example'], + ipfsGatewayUrls: ['https://gateway.old.example'], + pubsubKuboRpcClientsOptions: ['https://pubsub.old.example/api/v0'], + }, + }; + testState.accounts = [testState.account]; + + await renderHook(); + + expect(testState.setAccountMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'account-1', + pkcOptions: expect.objectContaining({ + ipfsGatewayUrls: undefined, + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + pkcRpcClientsOptions: undefined, + pubsubKuboRpcClientsOptions: undefined, + }), + }), + ); + expect(reloadMock).toHaveBeenCalledOnce(); + }); + + it('does not upgrade browser full-node accounts', async () => { + testState.account = { + id: 'account-1', + name: 'Account 1', + pkcOptions: { + pkcRpcClientsOptions: ['ws://node.example/key'], + }, + }; + testState.accounts = [testState.account]; + + await renderHook(); + + expect(testState.setAccountMock).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('recovers a missing browser account after the hooks store finishes initializing', async () => { + vi.useFakeTimers(); + window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = true; + + await renderHook(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(testState.createAccountMock).not.toHaveBeenCalled(); + + window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false; + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + + expect(testState.createAccountMock).toHaveBeenCalledOnce(); + expect(reloadMock).toHaveBeenCalledOnce(); + }); + + it('treats temporarily missing accounts as empty while recovering', async () => { + vi.useFakeTimers(); + testState.accounts = undefined; + window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false; + + await renderHook(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + + expect(testState.createAccountMock).toHaveBeenCalledOnce(); + expect(reloadMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/use-browser-pure-p2p-account-upgrade.ts b/src/hooks/use-browser-pure-p2p-account-upgrade.ts new file mode 100644 index 00000000..61253849 --- /dev/null +++ b/src/hooks/use-browser-pure-p2p-account-upgrade.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; +import { createAccount, setAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks'; +import { getBrowserPureP2PAccountOptions, shouldUpgradeBrowserPureP2PAccount } from '../lib/p2p-runtime'; + +type AccountShape = Record & { + id?: string; +}; + +const ACCOUNT_RECOVERY_CHECK_MS = 1000; + +export const useBrowserPureP2PAccountUpgrade = () => { + const account = useAccount() as AccountShape | undefined; + const { accounts = [] } = useAccounts(); + const recoveryStartedRef = useRef(false); + const upgradeAccountIdRef = useRef(undefined); + + useEffect(() => { + if (account?.id || accounts.length > 0 || recoveryStartedRef.current) return; + + const intervalId = window.setInterval(() => { + if (window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING) return; + + recoveryStartedRef.current = true; + window.clearInterval(intervalId); + void createAccount() + .then(() => { + window.location.reload(); + }) + .catch((error) => { + recoveryStartedRef.current = false; + console.error('Failed to recover missing browser account', error); + }); + }, ACCOUNT_RECOVERY_CHECK_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [account?.id, accounts.length]); + + useEffect(() => { + if (!account?.id || !shouldUpgradeBrowserPureP2PAccount(account)) return; + if (upgradeAccountIdRef.current === account.id) return; + + upgradeAccountIdRef.current = account.id; + + void setAccount({ + ...account, + pkcOptions: getBrowserPureP2PAccountOptions(account), + }) + .then(() => { + window.location.reload(); + }) + .catch((error) => { + upgradeAccountIdRef.current = undefined; + console.error('Failed to upgrade browser account to pure P2P options', error); + }); + }, [account]); +}; diff --git a/src/lib/__tests__/p2p-runtime.test.ts b/src/lib/__tests__/p2p-runtime.test.ts index a4882003..c6cede5b 100644 --- a/src/lib/__tests__/p2p-runtime.test.ts +++ b/src/lib/__tests__/p2p-runtime.test.ts @@ -5,6 +5,7 @@ import { getP2PRuntimeMode, isBrowserPureP2PEnabled, shouldShowP2PSettingsSection, + shouldUpgradeBrowserPureP2PAccount, } from '../p2p-runtime'; const browserWindow = { @@ -91,6 +92,20 @@ describe('p2p-runtime', () => { expect(shouldShowP2PSettingsSection(account, browserWindowWithDisabledPureP2P)).toBe(true); }); + it('upgrades only stale gateway browser accounts when pure p2p is enabled', () => { + const gatewayAccount = { pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }; + const browserAccount = { pkcOptions: { libp2pJsClientsOptions: [{ key: 'libp2pjs' }] } }; + const mixedBrowserAccount = { pkcOptions: { libp2pJsClientsOptions: [{ key: 'libp2pjs' }], pubsubKuboRpcClientsOptions: ['https://pubsub.example/api/v0'] } }; + const fullNodeAccount = { pkcOptions: { pkcRpcClientsOptions: ['ws://node.example'] } }; + + expect(shouldUpgradeBrowserPureP2PAccount(gatewayAccount, browserWindow)).toBe(true); + expect(shouldUpgradeBrowserPureP2PAccount(browserAccount, browserWindow)).toBe(false); + expect(shouldUpgradeBrowserPureP2PAccount(mixedBrowserAccount, browserWindow)).toBe(true); + expect(shouldUpgradeBrowserPureP2PAccount(fullNodeAccount, browserWindow)).toBe(false); + expect(shouldUpgradeBrowserPureP2PAccount(gatewayAccount, browserWindowWithDisabledPureP2P)).toBe(false); + expect(shouldUpgradeBrowserPureP2PAccount(gatewayAccount, electronWindow)).toBe(false); + }); + it('builds browser p2p and gateway account options without a direct pkc-js import', () => { const account = { pkcOptions: { @@ -104,6 +119,7 @@ describe('p2p-runtime', () => { libp2pJsClientsOptions: [{ key: 'libp2pjs' }], ipfsGatewayUrls: undefined, pkcRpcClientsOptions: undefined, + pubsubKuboRpcClientsOptions: undefined, }); expect(getBrowserGatewayAccountOptions(account)).toMatchObject({ httpRoutersOptions: ['https://custom-router.example'], diff --git a/src/lib/p2p-browser-config.ts b/src/lib/p2p-browser-config.ts index 7447cd7d..6322816b 100644 --- a/src/lib/p2p-browser-config.ts +++ b/src/lib/p2p-browser-config.ts @@ -1,12 +1,14 @@ export const PURE_P2P_BROWSER_SETTING_KEY = '5chan:pure-p2p-browser-enabled'; export const BROWSER_PURE_P2P_DEFAULT_ENABLED = true; +const BROWSER_PUBSUB_KUBO_RPC_CLIENTS_OPTIONS = ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0']; + export const P2P_BROWSER_PKC_OPTIONS = { libp2pJsClientsOptions: [{ key: 'libp2pjs' }], ipfsGatewayUrls: undefined, kuboRpcClientsOptions: undefined, pubsubHttpClientsOptions: undefined, - pubsubKuboRpcClientsOptions: undefined, + pubsubKuboRpcClientsOptions: undefined as string[] | undefined, httpRoutersOptions: ['https://peers.plebpubsub.xyz', 'https://routing.lol', 'https://peers.pleb.bot'], }; @@ -15,7 +17,7 @@ const GATEWAY_BROWSER_PKC_OPTIONS = { kuboRpcClientsOptions: undefined, libp2pJsClientsOptions: undefined, pubsubHttpClientsOptions: undefined, - pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'], + pubsubKuboRpcClientsOptions: BROWSER_PUBSUB_KUBO_RPC_CLIENTS_OPTIONS, httpRoutersOptions: ['https://routing.lol', 'https://peers.pleb.bot', 'https://peers.plebpubsub.xyz', 'https://peers.forumindex.com'], }; @@ -27,9 +29,12 @@ type P2PBrowserConfigWindow = { localStorage?: Pick; }; +const cloneArray = (value: T[] | undefined) => (value ? [...value] : undefined); + export const getBrowserPureP2PPkcOptions = () => ({ ...P2P_BROWSER_PKC_OPTIONS, libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })), + pubsubKuboRpcClientsOptions: cloneArray(P2P_BROWSER_PKC_OPTIONS.pubsubKuboRpcClientsOptions), httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions], }); diff --git a/src/lib/p2p-runtime.ts b/src/lib/p2p-runtime.ts index fab7e0cc..140d8308 100644 --- a/src/lib/p2p-runtime.ts +++ b/src/lib/p2p-runtime.ts @@ -30,6 +30,10 @@ const hasArrayItems = (value: unknown) => Array.isArray(value) && value.length > const hasObjectItems = (value: unknown) => !!value && typeof value === 'object' && Object.keys(value).length > 0; +const hasMixedBrowserPureP2POptions = (protocolOptions: AccountProtocolOptions | undefined) => + hasArrayItems(protocolOptions?.libp2pJsClientsOptions) && + (hasArrayItems(protocolOptions?.kuboRpcClientsOptions) || hasArrayItems(protocolOptions?.pubsubKuboRpcClientsOptions)); + export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = window): P2PRuntimeMode | null => { const accountShape = toAccountShape(account); const protocolOptions = accountShape?.pkcOptions; @@ -66,6 +70,13 @@ export const getBrowserPureP2PAccountOptions = (account?: unknown) => ({ pkcRpcClientsOptions: undefined, }); +export const shouldUpgradeBrowserPureP2PAccount = (account?: unknown, targetWindow: Window = window) => { + if (!account || typeof account !== 'object' || !canConfigureBrowserPureP2P(targetWindow) || !isBrowserPureP2PEnabled(account, targetWindow)) return false; + + const protocolOptions = toAccountShape(account)?.pkcOptions; + return getP2PRuntimeMode(account, targetWindow) === null || hasMixedBrowserPureP2POptions(protocolOptions); +}; + export const getBrowserGatewayAccountOptions = (account?: unknown) => { const protocolOptions = toAccountShape(account)?.pkcOptions; const gatewayOptions = getBrowserGatewayPkcOptions(); diff --git a/yarn.lock b/yarn.lock index 106b04d8..7db16ba7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6272,7 +6272,7 @@ __metadata: "@pkcprotocol/pkc-js@patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch": version: 0.0.47 - resolution: "@pkcprotocol/pkc-js@patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch::version=0.0.47&hash=df4e0a" + resolution: "@pkcprotocol/pkc-js@patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch::version=0.0.47&hash=bbfad6" dependencies: "@enhances/with-resolvers": "npm:0.0.5" "@helia/block-brokers": "npm:5.2.4"