diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 37f1ba29..970378e7 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -48,7 +48,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), + useCommunity: (options?: { communityAddress?: string; community?: { name?: string; publicKey?: string } }) => { + const communityAddress = options?.communityAddress ?? options?.community?.name ?? options?.community?.publicKey; + return communityAddress ? testState.subplebbits[communityAddress] : undefined; + }, useAccountCommunities: () => ({ accountCommunities: Object.fromEntries(testState.accountSubplebbitAddresses.map((address) => [address, { address }])), }), diff --git a/src/components/settings-modal/crypto-address-setting/__tests__/crypto-address-setting.test.tsx b/src/components/settings-modal/crypto-address-setting/__tests__/crypto-address-setting.test.tsx new file mode 100644 index 00000000..4ceeb3af --- /dev/null +++ b/src/components/settings-modal/crypto-address-setting/__tests__/crypto-address-setting.test.tsx @@ -0,0 +1,213 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CryptoAddressSetting from '../crypto-address-setting'; + +(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 SIGNER_ADDRESS = '12D3KooWSignerPublicKey'; + +const hookMocks = vi.hoisted(() => ({ + setAccount: vi.fn(), + useAccount: vi.fn(), + useResolvedAuthorAddress: vi.fn(), +})); + +const TestedCryptoAddressSetting = ((CryptoAddressSetting as unknown as { type?: React.ComponentType }).type ?? + (CryptoAddressSetting as unknown as React.ComponentType)) as React.ComponentType; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ( + ({ + check: 'check', + crypto_address_not_resolved: 'Crypto address is not resolved yet.', + crypto_address_not_yours: 'Crypto address is not yours.', + crypto_address_verification: 'if the crypto address is resolved p2p', + crypto_address_yours: 'Crypto address belongs to this account.', + enter_crypto_address: 'Enter crypto address.', + loading: 'loading', + save: 'save', + saved: 'saved', + }) as Record + )[key] ?? key, + }), +})); + +vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ + setAccount: hookMocks.setAccount, + useAccount: hookMocks.useAccount, + useResolvedAuthorAddress: hookMocks.useResolvedAuthorAddress, +})); + +let root: Root; +let container: HTMLDivElement; +let alertSpy: ReturnType; +let lastResolveOptions: unknown; +let resolvedAuthorState: { + chainProvider?: { urls?: string[] }; + error?: unknown; + resolvedAddress?: string | null; + state?: string; +}; + +const render = async () => { + await act(async () => { + root.render(createElement(TestedCryptoAddressSetting)); + await Promise.resolve(); + }); +}; + +const rerender = render; + +const getInput = () => { + const input = container.querySelector('input[placeholder="myaddress.bso"]') as HTMLInputElement | null; + if (!input) { + throw new Error('crypto address input not found'); + } + return input; +}; + +const getButtonByText = (text: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').trim() === text); + if (!button) { + throw new Error(`button "${text}" not found`); + } + return button as HTMLButtonElement; +}; + +const setInputValue = async (value: string) => { + await act(async () => { + const input = getInput(); + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (!valueSetter) { + throw new Error('input value setter not found'); + } + valueSetter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); +}; + +describe('CryptoAddressSetting', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + resolvedAuthorState = { + chainProvider: undefined, + error: undefined, + resolvedAddress: undefined, + state: 'initializing', + }; + lastResolveOptions = undefined; + + hookMocks.useAccount.mockReturnValue({ + author: { + address: 'legacy-name.eth', + shortAddress: 'legacy-name.eth', + }, + signer: { + address: SIGNER_ADDRESS, + }, + }); + hookMocks.setAccount.mockResolvedValue({}); + hookMocks.useResolvedAuthorAddress.mockImplementation((options: unknown) => { + lastResolveOptions = options; + return resolvedAuthorState; + }); + + alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + vi.runOnlyPendingTimers(); + root.unmount(); + }); + container.remove(); + alertSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('uses the full author address as the initial field value', async () => { + hookMocks.useAccount.mockReturnValue({ + author: { + address: 'resolved-alias.eth', + shortAddress: 'different-short-address', + }, + signer: { + address: SIGNER_ADDRESS, + }, + }); + + await render(); + + expect(getInput().value).toBe('resolved-alias.eth'); + }); + + it('updates the displayed status after async resolution completes', async () => { + await render(); + + await setInputValue('music-posting.bso'); + + expect(container.textContent).toContain('if the crypto address is resolved p2p'); + + await act(async () => { + getButtonByText('check').click(); + }); + + expect(container.textContent).toContain('loading'); + expect((lastResolveOptions as { author?: { address?: string } } | undefined)?.author?.address).toBe('music-posting.bso'); + + resolvedAuthorState = { + ...resolvedAuthorState, + resolvedAddress: null, + state: 'succeeded', + }; + + await rerender(); + + expect(container.textContent).toContain('Crypto address is not resolved yet.'); + }); + + it('saves a verified alias when it resolves to the signer address', async () => { + await render(); + + await setInputValue('resolved-name.bso'); + + await act(async () => { + getButtonByText('check').click(); + }); + + resolvedAuthorState = { + ...resolvedAuthorState, + resolvedAddress: SIGNER_ADDRESS, + state: 'succeeded', + }; + + await rerender(); + + await act(async () => { + getButtonByText('save').click(); + await Promise.resolve(); + }); + + expect(hookMocks.setAccount).toHaveBeenCalledWith( + expect.objectContaining({ + author: expect.objectContaining({ + address: 'resolved-name.bso', + }), + }), + ); + expect(container.textContent).toContain('saved'); + expect(getInput().value).toBe('resolved-name.bso'); + expect(alertSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx b/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx index 894eb65d..35cfde38 100644 --- a/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx +++ b/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx @@ -12,114 +12,153 @@ const withErrorHandling = async (fn: () => Promise, onError: (e: unknown) } }; -const CryptoAddressSetting = () => { - const { t } = useTranslation(); - const account = useAccount(); +const getInitialCryptoAddress = (address?: string) => (address?.includes('.') ? address : ''); - const [cryptoState, setCryptoState] = useState({ - cryptoAddress: account?.author?.shortAddress.includes('.') ? account.author.shortAddress : '', - checkingCryptoAddress: false, - showResolvingMessage: true, - resolveString: t('crypto_address_verification'), - resolveClass: '', +const getDefaultResolutionStatus = (t: (key: string) => string) => ({ + resolveClass: '', + resolveString: t('crypto_address_verification'), +}); + +const getResolutionStatus = ({ + checkedAddress, + chainProviderUrls, + error, + resolvedAddress, + signerAddress, + state, + t, +}: { + chainProviderUrls?: string[]; + checkedAddress?: string; + error?: unknown; + resolvedAddress?: string | null; + signerAddress?: string; + state?: string; + t: (key: string) => string; +}) => { + if (!checkedAddress) { + return getDefaultResolutionStatus(t); + } + + if (state === 'failed') { + return { + resolveClass: styles.red, + resolveString: error instanceof Error ? `failed to resolve crypto address, error: ${error.message}` : 'cannot resolve crypto address, unknown error', + }; + } + + if (state === 'resolving' || state === 'ready' || state === 'initializing') { + return { + resolveClass: styles.yellow, + resolveString: chainProviderUrls ? `resolving from ${chainProviderUrls.join(', ')}` : t('loading'), + }; + } + + if (resolvedAddress && resolvedAddress === signerAddress) { + return { + resolveClass: styles.green, + resolveString: t('crypto_address_yours'), + }; + } + + if (resolvedAddress && resolvedAddress !== signerAddress) { + return { + resolveClass: styles.red, + resolveString: t('crypto_address_not_yours'), + }; + } + + if (resolvedAddress === null || state === 'succeeded') { + return { + resolveClass: styles.red, + resolveString: t('crypto_address_not_resolved'), + }; + } + + return getDefaultResolutionStatus(t); +}; + +const showSavedIndicator = (setSavedCryptoAddress: (value: boolean) => void) => { + setSavedCryptoAddress(true); + setTimeout(() => { + setSavedCryptoAddress(false); + }, 2000); +}; + +const CryptoAddressSettingContent = ({ account }: { account: ReturnType }) => { + const { t } = useTranslation(); + const [cryptoAddress, setCryptoAddress] = useState(() => getInitialCryptoAddress(account?.author?.address)); + const [checkedAddress, setCheckedAddress] = useState(); + const [savedCryptoAddress, setSavedCryptoAddress] = useState(false); + const [showCryptoAddressInfo, setShowCryptoAddressInfo] = useState(false); + + const signerAddress = account?.signer?.address; + const authorToResolve = checkedAddress ? { ...account?.author, address: checkedAddress } : undefined; + const { resolvedAddress, state, error, chainProvider } = useResolvedAuthorAddress({ author: authorToResolve, cache: false }); + const resolutionStatus = getResolutionStatus({ + chainProviderUrls: chainProvider?.urls, + checkedAddress, + error, + resolvedAddress, + signerAddress, + state, + t, }); - const [savedCryptoAddress, setSavedCryptoAddress] = useState(false); - const [shouldResolve, setShouldResolve] = useState(false); - - const authorToResolve = shouldResolve ? { ...account?.author, address: cryptoState.cryptoAddress } : undefined; - const { resolvedAddress, state, error, chainProvider } = useResolvedAuthorAddress({ author: authorToResolve, cache: false }); - - const [inputValue, setInputValue] = useState(account?.author?.shortAddress.includes('.') ? account.author.shortAddress : ''); - const checkCryptoAddress = () => { - setShouldResolve(true); - const addressToCheck = inputValue || cryptoState.cryptoAddress; + const addressToCheck = cryptoAddress.trim(); if (!addressToCheck || !addressToCheck.includes('.')) { alert(t('enter_crypto_address')); return; } - let resolveString = ''; - let resolveClass = ''; - - if (state === 'failed') { - resolveString = error instanceof Error ? `failed to resolve crypto address, error: ${error.message}` : 'cannot resolve crypto address, unknown error'; - resolveClass = styles.red; - } else if (state === 'resolving') { - resolveString = `resolving from ${chainProvider?.urls}`; - resolveClass = styles.yellow; - } else if (resolvedAddress && resolvedAddress === account?.signer?.address) { - resolveString = t('crypto_address_yours'); - resolveClass = styles.green; - } else if (resolvedAddress && resolvedAddress !== account?.signer?.address) { - resolveString = t('crypto_address_not_yours'); - resolveClass = styles.red; - } else { - resolveString = t('crypto_address_verification'); - resolveClass = ''; - } - - setCryptoState((prevState) => ({ - ...prevState, - cryptoAddress: addressToCheck, - showResolvingMessage: true, - resolveString, - resolveClass, - })); + setCryptoAddress(addressToCheck); + setCheckedAddress(addressToCheck); }; const saveCryptoAddress = async () => { - if (!cryptoState.cryptoAddress || !cryptoState.cryptoAddress.includes('.')) { + const addressToSave = cryptoAddress.trim(); + + if (!addressToSave || !addressToSave.includes('.')) { alert(t('enter_crypto_address')); return; - } else if (cryptoState.cryptoAddress === account?.author?.address) { - setSavedCryptoAddress(true); - setTimeout(() => { - setSavedCryptoAddress(false); - }, 2000); + } + + if (addressToSave === account?.author?.address) { + showSavedIndicator(setSavedCryptoAddress); return; - } else if (resolvedAddress && resolvedAddress !== account?.signer?.address) { - alert(t('crypto_address_not_yours')); - return; - } else if (cryptoState.cryptoAddress && !resolvedAddress) { + } + + if (checkedAddress !== addressToSave || !resolvedAddress) { alert(t('crypto_address_not_resolved')); return; - } else if (resolvedAddress && resolvedAddress === account?.signer?.address) { - const result = await withErrorHandling( - () => setAccount({ ...account, author: { ...account?.author, address: cryptoState.cryptoAddress } }), - (error) => { - if (error instanceof Error) { - alert(error.message); - console.log(error); - } else { - console.error('An unknown error occurred:', error); - } - }, - ); - if (result !== undefined) { - setShouldResolve(false); - setSavedCryptoAddress(true); - setTimeout(() => setSavedCryptoAddress(false), 2000); - setCryptoState((prevState) => ({ - ...prevState, - savedCryptoAddress: true, - cryptoAddress: '', - checkingCryptoAddress: false, - })); - } - setSavedCryptoAddress(true); - setCryptoState((prevState) => ({ - ...prevState, - checkingCryptoAddress: false, - showResolvingMessage: false, - resolveString: t('crypto_address_verification'), - resolveClass: '', - })); } - }; - const [showCryptoAddressInfo, setShowCryptoAddressInfo] = useState(false); + if (resolvedAddress !== signerAddress) { + alert(t('crypto_address_not_yours')); + return; + } + + const result = await withErrorHandling( + () => setAccount({ ...account, author: { ...account?.author, address: addressToSave } }), + (publishError) => { + if (publishError instanceof Error) { + alert(publishError.message); + console.log(publishError); + } else { + console.error('An unknown error occurred:', publishError); + } + }, + ); + + if (result === undefined) { + return; + } + + setCheckedAddress(undefined); + setCryptoAddress(addressToSave); + showSavedIndicator(setSavedCryptoAddress); + }; return (
@@ -127,10 +166,10 @@ const CryptoAddressSetting = () => { { - setInputValue(e.target.value); - setCryptoState((prevState) => ({ ...prevState, cryptoAddress: e.target.value })); + setCheckedAddress(undefined); + setCryptoAddress(e.target.value); }} /> {' '} - {cryptoState.resolveString} + {resolutionStatus.resolveString}
); }; +const CryptoAddressSetting = () => { + const account = useAccount(); + const accountResetKey = account?.id ?? account?.name ?? account?.signer?.address ?? account?.author?.address ?? 'default-account'; + + return ; +}; + export default memo(CryptoAddressSetting); diff --git a/src/hooks/__tests__/use-post-page-number.test.tsx b/src/hooks/__tests__/use-post-page-number.test.tsx index ec59aa5a..cff239f6 100644 --- a/src/hooks/__tests__/use-post-page-number.test.tsx +++ b/src/hooks/__tests__/use-post-page-number.test.tsx @@ -35,7 +35,9 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/feeds', () => ({ })); vi.mock('../use-directories', () => ({ + useDirectories: () => [], useDirectoryByAddress: () => testState.community, + findDirectoryByAddress: () => undefined, })); vi.mock('../use-board-feed-page-size', () => ({ @@ -95,9 +97,9 @@ describe('usePostPageNumber', () => { expect(renderHook({ postCid: 'post-3', subplebbitAddress: 'music.eth' })).toBe(2); expect(testState.preloadOptions).toEqual({ + communities: [{ name: 'music.eth' }], postsPerPage: 20, sortType: 'active', - communityAddresses: ['music.eth'], }); }); @@ -106,9 +108,9 @@ describe('usePostPageNumber', () => { expect(renderHook({ postCid: 'post-4', subplebbitAddress: 'music.eth' })).toBe(2); expect(testState.preloadOptions).toEqual({ + communities: [{ name: 'music.eth' }], postsPerPage: 20, sortType: 'active', - communityAddresses: ['music.eth'], }); }); diff --git a/src/views/archive/__tests__/archive.test.tsx b/src/views/archive/__tests__/archive.test.tsx index 4d7927de..a2c21edf 100644 --- a/src/views/archive/__tests__/archive.test.tsx +++ b/src/views/archive/__tests__/archive.test.tsx @@ -58,6 +58,8 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ vi.mock('../../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, + findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) => + directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address), })); vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index c438b2c2..cdcbb009 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -159,6 +159,8 @@ vi.mock('../../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, useDirectoryAddresses: () => testState.directories.map((entry) => entry.address), useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined), + findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) => + directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address), })); vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index 61594153..29473b90 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -173,6 +173,8 @@ vi.mock('react-virtuoso', () => ({ vi.mock('../../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined), + findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) => + directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address), })); vi.mock('../../../hooks/use-board-feed-page-size', () => ({ diff --git a/src/views/home/__tests__/home.test.tsx b/src/views/home/__tests__/home.test.tsx index 24e30af5..3dc2f3e4 100644 --- a/src/views/home/__tests__/home.test.tsx +++ b/src/views/home/__tests__/home.test.tsx @@ -39,6 +39,8 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ vi.mock('../../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, useDirectoryAddresses: () => testState.directoryAddresses, + findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) => + directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address), })); vi.mock('../../../hooks/use-communities-stats', () => ({ diff --git a/src/views/rules/__tests__/rules.test.tsx b/src/views/rules/__tests__/rules.test.tsx index 1a685bca..922894ca 100644 --- a/src/views/rules/__tests__/rules.test.tsx +++ b/src/views/rules/__tests__/rules.test.tsx @@ -35,7 +35,10 @@ vi.mock('react-router-dom', async () => { }); vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ - useCommunity: ({ communityAddress }: { communityAddress?: string }) => (communityAddress ? testState.communities[communityAddress] : undefined), + useCommunity: (options?: { communityAddress?: string; community?: { name?: string; publicKey?: string } }) => { + const communityAddress = options?.communityAddress ?? options?.community?.name ?? options?.community?.publicKey; + return communityAddress ? testState.communities[communityAddress] : undefined; + }, })); vi.mock('../../../hooks/use-directories', async () => {