diff --git a/package.json b/package.json index 6d335539..7aeec77a 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.19", + "@bitsocial/bitsocial-react-hooks": "0.1.21", "@bitsocial/bso-resolver": "0.0.8", "@capacitor/app": "7.0.1", "@capacitor/browser": "7.0.5", diff --git a/src/app.tsx b/src/app.tsx index 387dc2cb..d1a6b19b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense, useEffect } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom'; -import { useAccount, useCommunity } from '@bitsocial/bitsocial-react-hooks'; +import { useAccount, useAccountComment, useCommunity } from '@bitsocial/bitsocial-react-hooks'; import { initSnow, removeSnow, shouldShowSnow } from './lib/snow'; import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils'; import { preloadReplyModal, preloadThemeAssets } from './lib/utils/preload-utils'; @@ -16,8 +16,8 @@ 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'; import useSuspendOffscreenMediaPlayback from './hooks/use-suspend-offscreen-media-playback'; +import { normalizeAccountCommentIndex } from './lib/utils/account-comment-index-utils'; import { getCommentCommunityAddress } from './lib/utils/comment-utils'; import { getBoardPath, @@ -82,7 +82,7 @@ const BoardLayout = () => { const directories = useDirectories(); const communityAddress = useResolvedCommunityAddress(boardIdentifier); const { boardPath: resolvedDirectoryBoardPath, isDirectoryCandidate } = useResolvedDirectoryBoardPath(boardIdentifier); - const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex }); + const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(accountCommentIndex) }); const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost); const { closeCreateBoardModal } = useCreateBoardModalStore(); const isOnPostRoute = isPostRoute(pathname); diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index fbddb2e1..aee93421 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; -import { useAccount, useComment, useSubscribe } from '@bitsocial/bitsocial-react-hooks'; +import { useAccount, useAccountComment, useComment, useSubscribe } from '@bitsocial/bitsocial-react-hooks'; import { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { usePostPageNumber } from '../../hooks/use-post-page-number'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; @@ -9,7 +9,7 @@ import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-director import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; +import { normalizeAccountCommentIndex } from '../../lib/utils/account-comment-index-utils'; import useHiddenCatalogThreads from '../../hooks/use-hidden-catalog-threads'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; @@ -532,7 +532,7 @@ export const MobileBoardButtons = () => { const isInModView = isModView(location.pathname); const isInModQueueView = isModQueueView(location.pathname); - const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const resolvedAddress = useResolvedCommunityAddress(); const communityAddress = resolvedAddress || accountComment?.communityAddress; @@ -646,7 +646,7 @@ export const PostPageStats = () => { const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled); const commentCid = params?.commentCid as string | undefined; const resolvedAddress = useResolvedCommunityAddress(); - const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const communityAddress = resolvedAddress || accountComment?.communityAddress; const communityIdentifier = useCommunityIdentifier(communityAddress); @@ -716,7 +716,7 @@ export const CatalogSearchResultsLabel = () => { export const DesktopBoardButtons = () => { const params = useParams(); const location = useLocation(); - const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const resolvedAddress = useResolvedCommunityAddress(); const communityAddress = resolvedAddress || accountComment?.communityAddress; const isInCatalogView = isCatalogView(location.pathname, params); diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index 523df42f..48afd7d4 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams, useNavigate } from 'react-router-dom'; -import { useCommunity } from '@bitsocial/bitsocial-react-hooks'; +import { useAccountComment, useCommunity } from '@bitsocial/bitsocial-react-hooks'; import { accountsStore as useAccountsStore } from '../../lib/bitsocial-internals/stores'; import getShortAddress from '../../lib/get-short-address'; import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; @@ -11,7 +11,7 @@ import { isArchiveRoute, isDirectoryListRoute } from '../../lib/utils/route-util import styles from './board-header.module.css'; import { useDirectories } from '../../hooks/use-directories'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; +import { normalizeAccountCommentIndex } from '../../lib/utils/account-comment-index-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import useIsCommunityOffline from '../../hooks/use-is-community-offline'; import { shouldShowSnow } from '../../lib/snow'; @@ -54,7 +54,7 @@ const BoardHeader = () => { const isInModView = isModView(location.pathname); const isInArchiveView = isArchiveRoute(location.pathname); const isInDirectoryListView = isDirectoryListRoute(location.pathname); - const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const resolvedAddress = useResolvedCommunityAddress(); const communityAddress = resolvedAddress || accountComment?.communityAddress; diff --git a/src/components/boards-bar/boards-bar.tsx b/src/components/boards-bar/boards-bar.tsx index 442e8d8f..75da5edf 100644 --- a/src/components/boards-bar/boards-bar.tsx +++ b/src/components/boards-bar/boards-bar.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useAccountComment } from '@bitsocial/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; import { accountsStore as useAccountsStore } from '../../lib/bitsocial-internals/stores'; import { isAllView, isCatalogView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import { useBoardPath, useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; +import { normalizeAccountCommentIndex } from '../../lib/utils/account-comment-index-utils'; import { getBoardPath, extractDirectoryFromTitle } from '../../lib/utils/route-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import useCreateBoardModalStore from '../../stores/use-create-board-modal-store'; @@ -418,8 +419,7 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) => const BoardsBar = () => { const params = useParams(); - const commentIndex = params?.accountCommentIndex ? parseInt(params.accountCommentIndex, 10) : undefined; - const accountComment = useSafeAccountComment({ commentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const resolvedCommunityAddress = useResolvedCommunityAddress(); const communityAddress = resolvedCommunityAddress || getCommentCommunityAddress(accountComment); diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 913dceaa..26e4a798 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; -import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { Comment, useAccount, useAccountComment, useEditedComment, useReplies } from '@bitsocial/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; import styles from '../../views/post/post.module.css'; import { CommentMediaInfo, getHasThumbnail, getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink } from '../../lib/utils/media-utils'; @@ -22,7 +22,6 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; import useScrollToReply from '../../hooks/use-scroll-to-reply'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import { useCurrentTime } from '../../hooks/use-current-time'; import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode'; import CommentContent from '../comment-content/comment-content'; @@ -689,7 +688,7 @@ const Reply = ({ postsByAuthorInThread, disableDeferredLayout, }: PostProps & { directRepliesByParentCid?: Map; postsByAuthorInThread?: Map; disableDeferredLayout?: boolean }) => { - const accountReply = useSafeAccountComment({ + const accountReply = useAccountComment({ commentCid: reply?.cid, commentIndex: typeof reply?.index === 'number' ? reply.index : undefined, }); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 073823e9..78236fc9 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; -import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks'; +import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocial/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores'; import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils'; @@ -41,7 +41,7 @@ import { useDirectoryEntry } from '../../hooks/use-directory-entry'; import { useCommunityField } from '../../hooks/use-stable-community'; import useIsMobile from '../../hooks/use-is-mobile'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; +import { normalizeAccountCommentIndex } from '../../lib/utils/account-comment-index-utils'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion'; import usePublishSubmissionGuard from '../../hooks/use-publish-submission-guard'; @@ -544,7 +544,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const [url, setUrl] = useState(''); const author = account?.author || {}; const { displayName } = author || {}; - const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const resolvedAddress = useResolvedCommunityAddress(); const communityAddress = resolvedAddress || accountComment?.communityAddress; const { setPublishPostOptions, postIndex, publishPost, publishPostError, publishPostOptions, resetPublishPostOptions } = usePublishPost({ @@ -1028,7 +1028,7 @@ const PostForm = () => { const [showForm, setShowForm] = useState(false); - const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex }); + const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const resolvedAddress = useResolvedCommunityAddress(); const communityAddress = resolvedAddress || accountComment?.communityAddress; diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index e358d7ec..f401dde1 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; -import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { Comment, useAccount, useAccountComment, useEditedComment, useReplies } from '@bitsocial/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; import styles from '../../views/post/post.module.css'; import { shouldShowSnow } from '../../lib/snow'; @@ -21,7 +21,6 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; import useScrollToReply from '../../hooks/use-scroll-to-reply'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import { useCurrentTime } from '../../hooks/use-current-time'; import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode'; import CommentContent from '../comment-content/comment-content'; @@ -464,7 +463,7 @@ const Reply = ({ postsByAuthorInThread, disableDeferredLayout, }: PostProps & { directRepliesByParentCid?: Map; postsByAuthorInThread?: Map; disableDeferredLayout?: boolean }) => { - const accountReply = useSafeAccountComment({ + const accountReply = useAccountComment({ commentCid: reply?.cid, commentIndex: typeof reply?.index === 'number' ? reply.index : undefined, }); diff --git a/src/components/reply-quote-preview/reply-quote-preview.tsx b/src/components/reply-quote-preview/reply-quote-preview.tsx index 5938eb47..8806ca4c 100644 --- a/src/components/reply-quote-preview/reply-quote-preview.tsx +++ b/src/components/reply-quote-preview/reply-quote-preview.tsx @@ -1,10 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { Comment, useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { Comment, useAccount, useAccountComment } from '@bitsocial/bitsocial-react-hooks'; import { useFloating, offset, shift, size, autoUpdate, Placement } from '@floating-ui/react'; import { useDirectories } from '../../hooks/use-directories'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import { getBoardPath } from '../../lib/utils/route-utils'; import { formatQuoteNumber, getQuoteTargetAvailability, shouldShowFloatingQuotePreview } from '../../lib/utils/quote-link-utils'; import { findPreferredScrollTarget, getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; @@ -90,7 +89,7 @@ const scrollToReplyOnPage = (cid: string) => { const useIsOwnQuotelink = (quotelinkReply?: Comment) => { const account = useAccount(); - const ownQuotelink = useSafeAccountComment({ commentCid: quotelinkReply?.cid }); + const ownQuotelink = useAccountComment({ commentCid: quotelinkReply?.cid }); const quotedAuthorAddress = quotelinkReply?.author?.address; const accountAuthorAddress = account?.author?.address; diff --git a/src/hooks/__tests__/use-safe-account-comment.test.tsx b/src/hooks/__tests__/use-safe-account-comment.test.tsx deleted file mode 100644 index aa6a87f6..00000000 --- a/src/hooks/__tests__/use-safe-account-comment.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -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 useSafeAccountComment from '../use-safe-account-comment'; - -(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 { id?: string; name?: string } | undefined, - accountCommentResult: { cid: 'account-comment' } as { cid?: string }, - calls: [] as Array<{ accountName?: string; commentCid?: string; commentIndex?: number }>, - options: undefined as { accountName?: string; commentCid?: string; commentIndex?: number | string } | undefined, -})); - -vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ - useAccount: (options?: { accountName?: string }) => { - if (!options?.accountName) { - return testState.account; - } - - return testState.account?.name === options.accountName ? testState.account : undefined; - }, - useAccountComment: (options?: { accountName?: string; commentCid?: string; commentIndex?: number }) => { - testState.calls.push(options || {}); - return testState.accountCommentResult; - }, -})); - -let container: HTMLDivElement; -let latestValue: ReturnType; -let root: Root; - -const HookHarness = () => { - latestValue = useSafeAccountComment(testState.options); - return null; -}; - -const renderHook = () => { - act(() => { - root.render(createElement(HookHarness)); - }); -}; - -describe('useSafeAccountComment', () => { - beforeEach(() => { - testState.account = undefined; - testState.accountCommentResult = { cid: 'account-comment' }; - testState.calls = []; - testState.options = undefined; - - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - it('uses a sentinel lookup when there is no active account and no usable lookup input', () => { - renderHook(); - - expect(testState.calls).toEqual([{ commentIndex: -1 }]); - expect(latestValue?.cid).toBe('account-comment'); - }); - - it('normalizes numeric comment indices before delegating to useAccountComment', () => { - testState.options = { commentIndex: '7' }; - - renderHook(); - - expect(testState.calls).toEqual([{ commentIndex: 7 }]); - }); - - it('falls back to the sentinel lookup for malformed string indices', () => { - testState.options = { commentIndex: '7abc' }; - - renderHook(); - - expect(testState.calls).toEqual([{ commentIndex: -1 }]); - }); - - it('falls back to the sentinel lookup when cid lookup is requested before an account exists', () => { - testState.options = { commentCid: 'reply-cid' }; - - renderHook(); - - expect(testState.calls).toEqual([{ commentIndex: -1 }]); - }); - - it('passes comment cid lookups through once the active account exists', () => { - testState.account = { id: 'account-1', name: 'Account 1' }; - testState.options = { commentCid: 'reply-cid' }; - - renderHook(); - - expect(testState.calls).toEqual([{ commentCid: 'reply-cid' }]); - }); -}); diff --git a/src/hooks/use-safe-account-comment.ts b/src/hooks/use-safe-account-comment.ts deleted file mode 100644 index 930db729..00000000 --- a/src/hooks/use-safe-account-comment.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useMemo } from 'react'; -import { useAccount, useAccountComment } from '@bitsocial/bitsocial-react-hooks'; - -type SafeAccountCommentOptions = { - accountName?: string; - commentCid?: string; - commentIndex?: number | string; -}; - -const EMPTY_ACCOUNT_COMMENT_LOOKUP = Object.freeze({ commentIndex: -1 as const }); - -const normalizeCommentIndex = (commentIndex: SafeAccountCommentOptions['commentIndex']) => { - if (commentIndex === undefined || commentIndex === null || commentIndex === '') { - return undefined; - } - - const normalizedCommentIndex = Number(commentIndex); - - return Number.isInteger(normalizedCommentIndex) && normalizedCommentIndex >= 0 ? normalizedCommentIndex : undefined; -}; - -const useSafeAccountComment = (options?: SafeAccountCommentOptions) => { - const account = useAccount(options?.accountName ? { accountName: options.accountName } : undefined); - const normalizedCommentIndex = normalizeCommentIndex(options?.commentIndex); - - const safeOptions = useMemo(() => { - if (typeof normalizedCommentIndex === 'number') { - return { - ...(options?.accountName ? { accountName: options.accountName } : {}), - commentIndex: normalizedCommentIndex, - }; - } - - if (options?.commentCid && account?.id) { - return { - ...(options?.accountName ? { accountName: options.accountName } : {}), - commentCid: options.commentCid, - }; - } - - return EMPTY_ACCOUNT_COMMENT_LOOKUP; - }, [account?.id, normalizedCommentIndex, options?.accountName, options?.commentCid]); - - return useAccountComment(safeOptions); -}; - -export default useSafeAccountComment; diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index d958c8cc..4ea63cbc 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { useAccountComment } from '@bitsocial/bitsocial-react-hooks'; import { isAllView, isModView, isSubscriptionsView } from '../lib/utils/view-utils'; import useThemeStore from '../stores/use-theme-store'; import { useDirectories } from './use-directories'; @@ -7,8 +8,8 @@ import { useResolvedCommunityAddress } from './use-resolved-community-address'; import useSpecialThemeStore from '../stores/use-special-theme-store'; import { getActiveSpecialTheme, getSpecialThemeClass } from '../lib/utils/time-utils'; import { isSfwBoard, updateFavicon } from '../lib/update-favicon'; -import useSafeAccountComment from './use-safe-account-comment'; import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; +import { normalizeAccountCommentIndex } from '../lib/utils/account-comment-index-utils'; const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky']; @@ -23,8 +24,7 @@ const useTheme = (): [string, (theme: string) => void] => { const location = useLocation(); const params = useParams<{ boardIdentifier?: string }>(); const pendingPostParams = useParams<{ accountCommentIndex?: string }>(); - const pendingPostCommentIndex = pendingPostParams?.accountCommentIndex ? parseInt(pendingPostParams.accountCommentIndex, 10) : undefined; - const pendingPost = useSafeAccountComment({ commentIndex: pendingPostCommentIndex }); + const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(pendingPostParams?.accountCommentIndex) }); const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost); const { isEnabled, setIsEnabled } = useSpecialThemeStore(); diff --git a/src/lib/utils/__tests__/account-comment-index-utils.test.ts b/src/lib/utils/__tests__/account-comment-index-utils.test.ts new file mode 100644 index 00000000..309712e3 --- /dev/null +++ b/src/lib/utils/__tests__/account-comment-index-utils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeAccountCommentIndex } from '../account-comment-index-utils'; + +describe('normalizeAccountCommentIndex', () => { + it('accepts non-negative integer numbers and strings', () => { + expect(normalizeAccountCommentIndex(0)).toBe(0); + expect(normalizeAccountCommentIndex(7)).toBe(7); + expect(normalizeAccountCommentIndex('0')).toBe(0); + expect(normalizeAccountCommentIndex('7')).toBe(7); + }); + + it('rejects missing, negative, fractional, and malformed values', () => { + expect(normalizeAccountCommentIndex(undefined)).toBeUndefined(); + expect(normalizeAccountCommentIndex(null)).toBeUndefined(); + expect(normalizeAccountCommentIndex('')).toBeUndefined(); + expect(normalizeAccountCommentIndex(-1)).toBeUndefined(); + expect(normalizeAccountCommentIndex('1.5')).toBeUndefined(); + expect(normalizeAccountCommentIndex('1abc')).toBeUndefined(); + }); +}); diff --git a/src/lib/utils/account-comment-author-utils.ts b/src/lib/utils/account-comment-author-utils.ts deleted file mode 100644 index dbe7f114..00000000 --- a/src/lib/utils/account-comment-author-utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Account } from '@bitsocial/bitsocial-react-hooks'; - -type CommentAuthor = { - address?: string; - avatar?: unknown; - displayName?: string; - flair?: unknown; - shortAddress?: string; - [key: string]: unknown; -}; - -type AccountCommentWithAuthor = { - accountId?: string; - author?: CommentAuthor; -}; - -export const mergeDefinedFields = (base: T | undefined, override: T | undefined): T | undefined => { - if (!override) return base; - - const merged = { ...base } as Record; - for (const [key, value] of Object.entries(override)) { - if (value !== undefined) { - merged[key] = value; - } - } - - return merged as T; -}; - -export function restoreActiveAccountAuthor(accountComment: T, account: Account | undefined): T; -export function restoreActiveAccountAuthor(accountComment: T | undefined, account: Account | undefined): T | undefined; -export function restoreActiveAccountAuthor(accountComment: T | undefined, account: Account | undefined): T | undefined { - const comment = accountComment as AccountCommentWithAuthor | undefined; - - if (!comment || comment.author?.address || !account?.id || comment.accountId !== account.id || !account.author?.address) { - return accountComment; - } - - const accountAuthor = { - address: account.author.address, - shortAddress: account.author.shortAddress, - displayName: account.author.displayName, - avatar: account.author.avatar, - flair: account.author.flair, - }; - - return { - ...accountComment, - author: mergeDefinedFields(comment.author, accountAuthor), - } as T; -} diff --git a/src/lib/utils/account-comment-index-utils.ts b/src/lib/utils/account-comment-index-utils.ts new file mode 100644 index 00000000..80e22b6d --- /dev/null +++ b/src/lib/utils/account-comment-index-utils.ts @@ -0,0 +1,8 @@ +export const normalizeAccountCommentIndex = (commentIndex: number | string | null | undefined): number | undefined => { + if (commentIndex === undefined || commentIndex === null || commentIndex === '') { + return undefined; + } + + const normalizedCommentIndex = Number(commentIndex); + return Number.isInteger(normalizedCommentIndex) && normalizedCommentIndex >= 0 ? normalizedCommentIndex : undefined; +}; diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index fd0cdcef..e9462d50 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -142,6 +142,24 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi return scopedComments; }; +const enrichAccountCommentAuthor = (comment: TestComment): TestComment => { + if (comment.author?.address || !comment.accountId || comment.accountId !== testState.account.id || !testState.account.author?.address) { + return comment; + } + + const accountAuthor = testState.account.author; + + return { + ...comment, + author: { + ...accountAuthor, + ...comment.author, + address: accountAuthor.address, + ...(accountAuthor.shortAddress ? { shortAddress: accountAuthor.shortAddress } : {}), + }, + }; +}; + const getScopedFeed = (options?: { filter?: { filter: (comment: TestComment) => boolean }; newerThan?: number; postsPerPage?: number }) => { let scopedFeed = [...testState.feed]; @@ -165,7 +183,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { testState.accountCommentsCalls.push(options); - return { accountComments: getScopedAccountComments(options) }; + return { accountComments: getScopedAccountComments(options).map(enrichAccountCommentAuthor) }; }, useFeed: (options?: { communities?: unknown[]; diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index b83139db..d6c50f77 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -27,7 +27,6 @@ import { getPageSlice } from '../../lib/utils/board-feed-pagination'; import { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; -import { restoreActiveAccountAuthor } from '../../lib/utils/account-comment-author-utils'; import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils'; import { getRawBoardThreadState } from '../../lib/utils/raw-board-thread-state'; import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils'; @@ -482,15 +481,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t [recentAccountComments, communityAddress, feedCids, nowSeconds], ); const localAccountComments = useMemo(() => { - const comments = (() => { - if (!nonokoPendingAccountComment) return filteredComments; - if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments]; + if (!nonokoPendingAccountComment) return filteredComments; + if (!nonokoPendingAccountComment.cid) return [nonokoPendingAccountComment, ...filteredComments]; - return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)]; - })(); - - return comments.map((comment) => restoreActiveAccountAuthor(comment, account)); - }, [nonokoPendingAccountComment, filteredComments, account]); + return [nonokoPendingAccountComment, ...filteredComments.filter((comment) => comment.cid !== nonokoPendingAccountComment.cid)]; + }, [nonokoPendingAccountComment, filteredComments]); const sortedFeed = useMemo(() => sortBoardActiveFeed(feed), [feed]); const canShowRecentLocalAccountComments = !isSingleCommunityBoard || sortedFeed.length > 0 || isRawBoardThreadStateFullyLoaded; diff --git a/src/views/pending-post/__tests__/pending-post.test.tsx b/src/views/pending-post/__tests__/pending-post.test.tsx index 4c4a3872..f56070f6 100644 --- a/src/views/pending-post/__tests__/pending-post.test.tsx +++ b/src/views/pending-post/__tests__/pending-post.test.tsx @@ -15,6 +15,7 @@ type TestComment = { const testState = vi.hoisted(() => ({ accountCommentIndex: undefined as string | undefined, + accountCommentLookupOptions: undefined as { commentIndex?: number } | undefined, accountComments: [] as TestComment[], challengeCount: 0, directories: [] as Array<{ address: string; title?: string }>, @@ -41,7 +42,10 @@ vi.mock('react-router-dom', async () => { vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useAccount: () => undefined, - useAccountComment: () => testState.post, + useAccountComment: (options?: { commentIndex?: number }) => { + testState.accountCommentLookupOptions = options; + return testState.post; + }, useAccountComments: () => ({ accountComments: testState.accountComments, }), @@ -60,10 +64,11 @@ vi.mock('../../../stores/use-challenges-store', () => ({ })); vi.mock('../../../stores/use-failed-post-retry-store', () => ({ - default: (selector: (state: { retryingAccountCommentIndex: number | null }) => unknown) => selector({ retryingAccountCommentIndex: testState.retryingAccountCommentIndex }), + default: (selector: (state: { retryingAccountCommentIndex: number | null }) => unknown) => + selector({ retryingAccountCommentIndex: testState.retryingAccountCommentIndex }), })); -vi.mock('../../post', () => ({ +vi.mock('../../post/post', () => ({ Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post-view' }, post?.cid ?? 'no-post'), })); @@ -91,6 +96,7 @@ describe('PendingPost', () => { beforeEach(() => { vi.clearAllMocks(); testState.accountCommentIndex = undefined; + testState.accountCommentLookupOptions = undefined; testState.accountComments = []; testState.challengeCount = 0; testState.directories = []; @@ -127,6 +133,16 @@ describe('PendingPost', () => { expect(testState.navigateMock).not.toHaveBeenCalledWith('/not-found', { replace: true }); }); + it('passes normalized numeric-string pending indices to the account comment lookup', async () => { + testState.accountCommentIndex = '01'; + testState.accountComments = [{}, {}]; + + await renderPendingPost(); + + expect(testState.accountCommentLookupOptions).toEqual({ commentIndex: 1 }); + expect(testState.navigateMock).not.toHaveBeenCalledWith('/not-found', { replace: true }); + }); + it('redirects invalid pending indices to not found', async () => { testState.accountCommentIndex = '-1'; testState.accountComments = [{}, {}]; diff --git a/src/views/pending-post/pending-post.tsx b/src/views/pending-post/pending-post.tsx index 8ddcd6a4..28b856af 100644 --- a/src/views/pending-post/pending-post.tsx +++ b/src/views/pending-post/pending-post.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { useAccountComments } from '@bitsocial/bitsocial-react-hooks'; +import { useAccountComment, useAccountComments } from '@bitsocial/bitsocial-react-hooks'; import { useDirectories } from '../../hooks/use-directories'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; +import { normalizeAccountCommentIndex } from '../../lib/utils/account-comment-index-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getBoardPath } from '../../lib/utils/route-utils'; import useChallengesStore from '../../stores/use-challenges-store'; import useFailedPostRetryStore from '../../stores/use-failed-post-retry-store'; -import { Post } from '../post'; +import { Post } from '../post/post'; type PendingAccountComment = { index?: number; @@ -46,9 +46,9 @@ const PendingPost = () => { const { accountComments } = useAccountComments(); const { accountCommentIndex } = useParams<{ accountCommentIndex?: string }>(); const location = useLocation(); - const normalizedAccountCommentIndex = accountCommentIndex === undefined ? undefined : Number(accountCommentIndex); - const hasNormalizedAccountCommentIndex = normalizedAccountCommentIndex !== undefined && !Number.isNaN(normalizedAccountCommentIndex); - const post = useSafeAccountComment({ commentIndex: accountCommentIndex }); + const normalizedAccountCommentIndex = normalizeAccountCommentIndex(accountCommentIndex); + const hasNormalizedAccountCommentIndex = normalizedAccountCommentIndex !== undefined; + const post = useAccountComment({ commentIndex: normalizedAccountCommentIndex }); const postCommunityAddress = getCommentCommunityAddress(post); const hasAddressablePost = Boolean(post?.cid || postCommunityAddress); const navigate = useNavigate(); @@ -70,11 +70,7 @@ const PendingPost = () => { }, [normalizedAccountCommentIndex, pendingBoardPath]); const isValidAccountCommentIndex = - !accountCommentIndex || - (hasNormalizedAccountCommentIndex && - normalizedAccountCommentIndex >= 0 && - Number.isInteger(normalizedAccountCommentIndex) && - hasPendingAccountCommentIndex(accountComments, normalizedAccountCommentIndex)); + !accountCommentIndex || (hasNormalizedAccountCommentIndex && hasPendingAccountCommentIndex(accountComments, normalizedAccountCommentIndex)); useEffect(() => { // A retry deletes this pending row before republishing, briefly invalidating the index. Stay put; diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index dd088ec7..e9fad736 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -69,6 +69,26 @@ const testState = vi.hoisted(() => ({ evictThreadRefreshCachesMock: vi.fn(), })); +const activeAccount = { + author: { address: 'account-author' }, + id: 'active-account', +}; + +const enrichAccountCommentAuthor = (comment: TestComment | undefined): TestComment | undefined => { + if (!comment || comment.author?.address || comment.accountId !== activeAccount.id) { + return comment; + } + + return { + ...comment, + author: { + ...activeAccount.author, + ...comment.author, + address: activeAccount.author.address, + }, + }; +}; + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -84,8 +104,8 @@ vi.mock('react-router-dom', async () => { }); vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ - useAccount: () => ({ id: 'active-account', author: { address: 'account-author' } }), - useAccountComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.accountCommentsByCid[commentCid] : undefined), + useAccount: () => activeAccount, + useAccountComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? enrichAccountCommentAuthor(testState.accountCommentsByCid[commentCid]) : undefined), useComment: ({ commentCid, autoUpdate, community }: { commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }) => { testState.useCommentCalls.push({ commentCid, autoUpdate, community }); return commentCid ? testState.commentsByCid[commentCid] : undefined; diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 2c7fb420..a198d9cf 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -1,6 +1,15 @@ import { memo, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { type Comment, type CommunityIdentifier, type Role, useAccount, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocial/bitsocial-react-hooks'; +import { + type Comment, + type CommunityIdentifier, + type Role, + useAccountComment, + useComment, + useEditedComment, + useCommunity, + useReplies, +} from '@bitsocial/bitsocial-react-hooks'; import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores'; import { useCommunityField } from '../../hooks/use-stable-community'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; @@ -11,7 +20,6 @@ import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; -import { mergeDefinedFields, restoreActiveAccountAuthor } from '../../lib/utils/account-comment-author-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import ErrorDisplay from '../../components/error-display/error-display'; import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer/footer'; @@ -21,7 +29,6 @@ import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/ import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils'; import { REPLIES_PER_PAGE } from '../../lib/constants'; import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store'; -import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils'; import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates'; import styles from './post.module.css'; @@ -102,6 +109,19 @@ const mergeCommentFallback = (comment: CommentWithRefresh | undefined, fallback: }; }; +const mergeDefinedFields = (base: T | undefined, override: T | undefined): T | undefined => { + if (!override) return base; + + const merged = { ...base } as Record; + for (const [key, value] of Object.entries(override)) { + if (value !== undefined) { + merged[key] = value; + } + } + + return merged as T; +}; + const mergeLocalCommentAuthor = (comment: CommentWithRefresh | undefined, localComment: CommentWithRefresh | undefined): CommentWithRefresh | undefined => { if (!localComment?.author) return comment; if (!comment) return localComment; @@ -130,9 +150,7 @@ const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accou const useCommentWithFeedCache = (options: { commentCid: string | undefined; autoUpdate?: boolean; community?: CommunityIdentifier }): CommentWithRefresh | undefined => { const comment = useComment(options); const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']); - const account = useAccount(); - const accountComment = useSafeAccountComment({ commentCid: options.commentCid }) as CommentWithRefresh | undefined; - const accountCommentWithAuthor = useMemo(() => restoreActiveAccountAuthor(accountComment, account), [accountComment, account]); + const accountComment = useAccountComment({ commentCid: options.commentCid }) as CommentWithRefresh | undefined; const commentWithFeedCache = useMemo(() => { if (!cachedComment || comment?.timestamp) return comment; @@ -145,7 +163,7 @@ const useCommentWithFeedCache = (options: { commentCid: string | undefined; auto } as CommentWithRefresh; }, [comment, cachedComment]); - return useMemo(() => mergeLocalAccountComment(commentWithFeedCache, accountCommentWithAuthor), [commentWithFeedCache, accountCommentWithAuthor]); + return useMemo(() => mergeLocalAccountComment(commentWithFeedCache, accountComment), [commentWithFeedCache, accountComment]); }; const mergeRepliesWithQueuedReply = (replies: Comment[], queuedReply: CommentWithRefresh | undefined): Comment[] => { diff --git a/yarn.lock b/yarn.lock index ba675fbe..d0a76fe4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,7 +10,7 @@ __metadata: resolution: "5chan@workspace:." dependencies: "@bbob/parser": "npm:4.3.1" - "@bitsocial/bitsocial-react-hooks": "npm:0.1.19" + "@bitsocial/bitsocial-react-hooks": "npm:0.1.21" "@bitsocial/bso-resolver": "npm:0.0.8" "@capacitor/android": "npm:7.4.5" "@capacitor/app": "npm:7.0.1" @@ -1582,9 +1582,9 @@ __metadata: languageName: node linkType: hard -"@bitsocial/bitsocial-react-hooks@npm:0.1.19": - version: 0.1.19 - resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.19" +"@bitsocial/bitsocial-react-hooks@npm:0.1.21": + version: 0.1.21 + resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.21" dependencies: "@bitsocial/bso-resolver": "npm:0.0.8" "@pkcprotocol/pkc-js": "npm:0.0.48" @@ -1603,7 +1603,7 @@ __metadata: zustand: "npm:4.0.0" peerDependencies: react: ">=16.8" - checksum: 10c0/5645249211c4b0d91b0e9f6c91b75703da15ff5dbf37bc2eab2353959818bb0d2803ee649fae30530a42298ec55c34e2f3900c71025c89af35a0314d622f42a3 + checksum: 10c0/02f9faf7069b6752dc814c6484964bf7cfeb3739690a1ca268d0d3f641d7d79d95015d27cbdf6b2f5656f41ff993a149aafcec2f0f367dea105c088c76b737b7 languageName: node linkType: hard