diff --git a/apps/blog/app/[param]/(user-profile)/layout.tsx b/apps/blog/app/[param]/(user-profile)/layout.tsx index 581bcf3a9a67688e374af1d72c40c85a0011eb0d..2c09e096366683aafe57e46b5d31b9354659bffa 100644 --- a/apps/blog/app/[param]/(user-profile)/layout.tsx +++ b/apps/blog/app/[param]/(user-profile)/layout.tsx @@ -3,7 +3,8 @@ import { ReactNode } from 'react'; import { Metadata } from 'next'; import { dehydrate, Hydrate } from '@tanstack/react-query'; import { getQueryClient } from '@/blog/lib/react-query'; -import { getAccountFull, getAccountReputations, getDynamicGlobalProperties } from '@transaction/lib/hive-api'; +import { getAccountFullCached } from '@/blog/lib/cached-api'; +import { getAccountReputations, getDynamicGlobalProperties } from '@transaction/lib/hive-api'; import { getTwitterInfo } from '@transaction/lib/custom-api'; import { isUsernameValid } from '@/blog/utils/validate-links'; import { notFound } from 'next/navigation'; @@ -21,12 +22,9 @@ export async function generateMetadata({ params }: { params: { param: string } } }; } const username = raw.startsWith('%40') ? raw.replace('%40', '') : raw.replace('@', ''); - const queryClient = getQueryClient(); try { - const account = await queryClient.fetchQuery({ - queryKey: ['profileData', username], - queryFn: () => getAccountFull(username) - }); + // Use cached version - deduplicated with Layout's prefetch within the same request + const account = await getAccountFullCached(username); const image = account?.profile?.profile_image || 'https://hive.blog/images/hive-blog-share.png'; const about = account?.profile?.about || `Profile of @${username} on Hive.`; const title = `Blog ${username}`; @@ -78,9 +76,10 @@ const Layout = async ({ children, params }: { children: ReactNode; params: { par } try { + // Use cached version - deduplicated with generateMetadata within the same request await queryClient.prefetchQuery({ queryKey: ['profileData', username], - queryFn: () => getAccountFull(username) + queryFn: () => getAccountFullCached(username) }); await queryClient.prefetchQuery({ queryKey: ['accountReputationData', username], diff --git a/apps/blog/app/[param]/[p2]/[permlink]/layout.tsx b/apps/blog/app/[param]/[p2]/[permlink]/layout.tsx index 68101be81084d4bf7e07dfecd1c7cacbb4c22c3c..d86d2a6e40934bbf454ba4d0abd957ec69818440 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/layout.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/layout.tsx @@ -1,7 +1,6 @@ import { Metadata } from 'next'; import React, { PropsWithChildren } from 'react'; -import { getPost } from '@transaction/lib/bridge-api'; -import { getQueryClient } from '@/blog/lib/react-query'; +import { getPostCached } from '@/blog/lib/cached-api'; import { getObserverFromCookies } from '@/blog/lib/auth-utils'; import { getLogger } from '@ui/lib/logging'; @@ -23,12 +22,9 @@ export async function generateMetadata({ const permlink = params?.permlink; const observer = getObserverFromCookies(); - const queryClient = getQueryClient(); try { - const post = await queryClient.fetchQuery({ - queryKey: ['postData', author, permlink], - queryFn: () => getPost(author, permlink, observer) - }); + // Use cached version - deduplicated with page's prefetch within the same request + const post = await getPostCached(author, permlink, observer); const title = post?.title ? `${post.title} ` : 'Hive Blog'; const description = diff --git a/apps/blog/app/[param]/[p2]/[permlink]/page.tsx b/apps/blog/app/[param]/[p2]/[permlink]/page.tsx index a7d16afeb89ab4421c1d11852fd87764f907f9c3..efc9354f7c70cd9419a4aa19b88f71f4972eb4b5 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/page.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/page.tsx @@ -2,7 +2,8 @@ import { Suspense } from 'react'; import { getQueryClient } from '@/blog/lib/react-query'; import { dehydrate, Hydrate } from '@tanstack/react-query'; import PostContent from './content'; -import { getCommunity, getListCommunityRoles, getPost } from '@transaction/lib/bridge-api'; +import { getPostCached } from '@/blog/lib/cached-api'; +import { getCommunity, getListCommunityRoles } from '@transaction/lib/bridge-api'; import { getDiscussion } from '@transaction/lib/bridge-api'; import { getActiveVotes } from '@transaction/lib/hive-api'; import { getObserverFromCookies } from '@/blog/lib/auth-utils'; @@ -33,9 +34,10 @@ const PostPage = async ({ if (!isPermlinkValid(permlink)) notFound(); try { const observer = getObserverFromCookies(); + // Use cached version - deduplicated with layout's generateMetadata within the same request await queryClient.prefetchQuery({ queryKey: ['postData', username, permlink], - queryFn: () => getPost(username, permlink, observer) + queryFn: () => getPostCached(username, permlink, observer) }); await queryClient.prefetchQuery({ diff --git a/apps/blog/features/community-profile/sort-page.tsx b/apps/blog/features/community-profile/sort-page.tsx index f0aa08d7b1b645195135db3d7266a3cd330f5779..5b7c00dc1d05fec39f5d99ba60223619044db690 100644 --- a/apps/blog/features/community-profile/sort-page.tsx +++ b/apps/blog/features/community-profile/sort-page.tsx @@ -2,16 +2,10 @@ import { getObserverFromCookies } from '@/blog/lib/auth-utils'; import { getQueryClient } from '@/blog/lib/react-query'; import { SortTypes } from '@/blog/lib/utils'; import { dehydrate, Hydrate } from '@tanstack/react-query'; -import { - getAccountNotifications, - getCommunity, - getPostsRanked, - getSubscribers -} from '@transaction/lib/bridge-api'; +import { getPostsRanked } from '@transaction/lib/bridge-api'; import { Entry } from '@transaction/lib/extended-hive.chain'; import { ReactNode } from 'react'; import { getLogger } from '@ui/lib/logging'; -import { isCommunity } from '@ui/lib/utils'; const logger = getLogger('app'); @@ -27,20 +21,7 @@ const SortPage = async ({ const queryClient = getQueryClient(); try { const observer = getObserverFromCookies(); - if (isCommunity(tag)) { - await queryClient.prefetchQuery({ - queryKey: ['community', tag], - queryFn: async () => await getCommunity(tag, observer) - }); - await queryClient.prefetchQuery({ - queryKey: ['subscribers', tag], - queryFn: async () => await getSubscribers(tag) - }); - await queryClient.prefetchQuery({ - queryKey: ['AccountNotification', tag], - queryFn: async () => await getAccountNotifications(tag) - }); - } + // Community data (getCommunity) is already prefetched in the layout's PrefetchComponent await queryClient.prefetchInfiniteQuery({ queryKey: ['entriesInfinite', sort, tag], queryFn: async ({ pageParam }) => { diff --git a/apps/blog/features/layouts/community/prefetch-component.tsx b/apps/blog/features/layouts/community/prefetch-component.tsx index 67f8aa4fcaa47bb31423c20565b646eff4f2ca6a..bde69dcdd50a58e0807d933a1c1730ef086cc527 100644 --- a/apps/blog/features/layouts/community/prefetch-component.tsx +++ b/apps/blog/features/layouts/community/prefetch-component.tsx @@ -1,12 +1,7 @@ import { ReactNode } from 'react'; import { getQueryClient } from '@/blog/lib/react-query'; import { dehydrate, Hydrate } from '@tanstack/react-query'; -import { - getAccountNotifications, - getCommunities, - getCommunity, - getSubscribers -} from '@transaction/lib/bridge-api'; +import { getCommunities, getCommunity } from '@transaction/lib/bridge-api'; import CommunityLayout from './community-layout'; import { getObserverFromCookies } from '@/blog/lib/auth-utils'; import { getLogger } from '@ui/lib/logging'; @@ -26,17 +21,11 @@ const PrefetchComponent = async ({ children, community }: { children: ReactNode; queryFn: () => getCommunities(sort, query, observer) }); if (isCommunity(community)) { + // Only prefetch what's needed for page metadata and initial render + // Subscribers and notifications are not critical for SSR - let client fetch await queryClient.prefetchQuery({ queryKey: ['community', community], - queryFn: async () => await getCommunity(community, observer) - }); - await queryClient.prefetchQuery({ - queryKey: ['subscribers', community], - queryFn: async () => await getSubscribers(community) - }); - await queryClient.prefetchQuery({ - queryKey: ['AccountNotification', community], - queryFn: async () => await getAccountNotifications(community) + queryFn: () => getCommunity(community, observer) }); } } catch (error) { diff --git a/apps/blog/features/layouts/sorts/server-side-layout.tsx b/apps/blog/features/layouts/sorts/server-side-layout.tsx index 4b5e2b8ae117c9641afeca6a73fdb165656b744c..bec6c07efedf0c82325b373286d3905fa2ffff4d 100644 --- a/apps/blog/features/layouts/sorts/server-side-layout.tsx +++ b/apps/blog/features/layouts/sorts/server-side-layout.tsx @@ -1,7 +1,8 @@ import { getObserverFromCookies } from '@/blog/lib/auth-utils'; import { getQueryClient } from '@/blog/lib/react-query'; +import { DEFAULT_OBSERVER } from '@/blog/lib/utils'; import { dehydrate, Hydrate } from '@tanstack/react-query'; -import { getCommunities } from '@transaction/lib/bridge-api'; +import { getCommunities, getSubscriptions } from '@transaction/lib/bridge-api'; import { ReactNode } from 'react'; import { getLogger } from '@ui/lib/logging'; @@ -18,6 +19,14 @@ const ServerSideLayout = async ({ children }: { children: ReactNode }) => { queryKey: ['communitiesList', sort], queryFn: () => getCommunities(sort, query, observer) }); + // Only prefetch subscriptions if logged in (observer is actual username, not default) + // For non-logged-in users, client-side query is disabled anyway + if (observer !== DEFAULT_OBSERVER) { + await queryClient.prefetchQuery({ + queryKey: ['subscriptions', observer], + queryFn: () => getSubscriptions(observer) + }); + } } catch (error) { logger.error(error, 'Error in ServerSideLayout:'); } diff --git a/apps/blog/features/search/ai-result.tsx b/apps/blog/features/search/ai-result.tsx index cdd07f8879d0ba7506e6c29fb597a0da5d6e7daf..73b63d6125fded151dc70c0e96f9eb7c1f74f7f9 100644 --- a/apps/blog/features/search/ai-result.tsx +++ b/apps/blog/features/search/ai-result.tsx @@ -21,8 +21,8 @@ const AIResult = ({ query, nsfwPreferences }: { query: string; nsfwPreferences: const { t } = useTranslation('common_blog'); const observer = user.isLoggedIn ? user.username : DEFAULT_OBSERVER; - const [currentPage, setCurrentPage] = useState(0); - const [displayedPosts, setDisplayedPosts] = useState([]); + const [loadedStubPosts, setLoadedStubPosts] = useState([]); + const [currentPage, setCurrentPage] = useState(1); const [isLoadingMore, setIsLoadingMore] = useState(false); // Fetch all results in a single call @@ -48,7 +48,7 @@ const AIResult = ({ query, nsfwPreferences }: { query: string; nsfwPreferences: staleTime: 5 * 60 * 1000 // Cache for 5 minutes }); - // Separate full posts and stubs + // Separate full posts and stubs from search results const { fullPosts, stubPosts } = useMemo(() => { if (!searchResults) return { fullPosts: [], stubPosts: [] }; @@ -72,13 +72,11 @@ const AIResult = ({ query, nsfwPreferences }: { query: string; nsfwPreferences: return { fullPosts: full, stubPosts: stubs }; }, [searchResults]); - // Initialize displayed posts with full posts from initial response - useEffect(() => { - if (fullPosts.length > 0 && displayedPosts.length === 0) { - setDisplayedPosts(fullPosts); - setCurrentPage(1); - } - }, [fullPosts]); + // Combine initial full posts with additionally loaded stub posts + // This ensures data persists when navigating back (fullPosts comes from React Query cache) + const displayedPosts = useMemo(() => { + return [...fullPosts, ...loadedStubPosts]; + }, [fullPosts, loadedStubPosts]); // Calculate if there are more posts to load const hasNextPage = useMemo(() => { @@ -110,10 +108,10 @@ const AIResult = ({ query, nsfwPreferences }: { query: string; nsfwPreferences: }); if (fullPostData) { - // Filter out null or invalid posts before adding to displayed posts + // Filter out null or invalid posts before adding to loaded posts const validPosts = fullPostData.filter((post) => post && post.post_id); if (validPosts.length > 0) { - setDisplayedPosts((prev) => [...prev, ...validPosts]); + setLoadedStubPosts((prev) => [...prev, ...validPosts]); } setCurrentPage((prev) => prev + 1); } @@ -131,10 +129,10 @@ const AIResult = ({ query, nsfwPreferences }: { query: string; nsfwPreferences: } }, [inView, hasNextPage, isLoadingMore]); - // Reset on query change + // Reset loaded stub posts on query change useEffect(() => { - setCurrentPage(0); - setDisplayedPosts([]); + setCurrentPage(1); + setLoadedStubPosts([]); }, [query]); if (!query) return null; diff --git a/apps/blog/lib/cached-api.ts b/apps/blog/lib/cached-api.ts new file mode 100644 index 0000000000000000000000000000000000000000..aad9535cdf8128ed78b317f58e8d78748f15e81e --- /dev/null +++ b/apps/blog/lib/cached-api.ts @@ -0,0 +1,15 @@ +import { cache } from 'react'; +import { getAccountFull } from '@transaction/lib/hive-api'; +import { getPost } from '@transaction/lib/bridge-api'; + +/** + * Request-level cached version of getAccountFull. + * Deduplicates calls within the same request (e.g., generateMetadata + layout). + */ +export const getAccountFullCached = cache(getAccountFull); + +/** + * Request-level cached version of getPost. + * Deduplicates calls within the same request (e.g., generateMetadata + page). + */ +export const getPostCached = cache(getPost); diff --git a/apps/blog/playwright/tests/e2e/mainTimeline.spec.ts b/apps/blog/playwright/tests/e2e/mainTimeline.spec.ts index c64cfa9b84f5d88f38cf1c2705cb0c6bce4643f1..434e5cef017534569b31a25f4538a81f11a38362 100644 --- a/apps/blog/playwright/tests/e2e/mainTimeline.spec.ts +++ b/apps/blog/playwright/tests/e2e/mainTimeline.spec.ts @@ -756,8 +756,8 @@ test.describe('Home page tests', () => { await homePage.getFirstPostUpvoteButton.hover(); // Wait for tooltip to be visible instead of fixed timeout await expect(homePage.getFirstPostUpvoteButtonTooltip).toBeVisible({ timeout: 15000 }); - // Validate the tooltip message - expect(await homePage.getFirstPostUpvoteButtonTooltip.textContent()).toBe('UpvoteUpvote'); + // Validate the tooltip message (may include payout warning for old posts) + expect(await homePage.getFirstPostUpvoteButtonTooltip.textContent()).toContain('Upvote'); // Upvote icon color expect( await homePage.getElementCssPropertyValue( @@ -803,8 +803,8 @@ test.describe('Home page tests', () => { await homePage.getFirstPostUpvoteButton.hover(); // Wait for tooltip to be visible instead of fixed timeout await expect(homePage.getFirstPostUpvoteButtonTooltip).toBeVisible({ timeout: 15000 }); - // Validate the tooltip message - expect(await homePage.getFirstPostUpvoteButtonTooltip.textContent()).toBe('UpvoteUpvote'); + // Validate the tooltip message (may include payout warning for old posts) + expect(await homePage.getFirstPostUpvoteButtonTooltip.textContent()).toContain('Upvote'); // Upvote icon color expect( await homePage.getElementCssPropertyValue( @@ -857,8 +857,8 @@ test.describe('Home page tests', () => { await homePage.getFirstPostDownvoteButton.hover(); // Wait for tooltip to be visible instead of fixed timeout await expect(homePage.getFirstPostDownvoteButtonTooltip).toBeVisible({ timeout: 15000 }); - // Validate the tooltip message - expect(await homePage.getFirstPostDownvoteButtonTooltip.textContent()).toBe('DownvoteDownvote'); + // Validate the tooltip message (may include payout warning for old posts) + expect(await homePage.getFirstPostDownvoteButtonTooltip.textContent()).toContain('Downvote'); // Downvote icon color expect( await homePage.getElementCssPropertyValue( @@ -904,8 +904,8 @@ test.describe('Home page tests', () => { await homePage.getFirstPostDownvoteButton.hover(); // Wait for tooltip to be visible instead of fixed timeout await expect(homePage.getFirstPostDownvoteButtonTooltip).toBeVisible({ timeout: 15000 }); - // Validate the tooltip message - expect(await homePage.getFirstPostDownvoteButtonTooltip.textContent()).toBe('DownvoteDownvote'); + // Validate the tooltip message (may include payout warning for old posts) + expect(await homePage.getFirstPostDownvoteButtonTooltip.textContent()).toContain('Downvote'); // Downvote icon color expect( await homePage.getElementCssPropertyValue( diff --git a/apps/blog/playwright/tests/e2e/profileRepliesPage.spec.ts b/apps/blog/playwright/tests/e2e/profileRepliesPage.spec.ts index 5c14daa2793aaf33327c197a346b0573173f8459..72be98af803f4aa13b526de5535d64bee9873a6a 100644 --- a/apps/blog/playwright/tests/e2e/profileRepliesPage.spec.ts +++ b/apps/blog/playwright/tests/e2e/profileRepliesPage.spec.ts @@ -238,8 +238,8 @@ test.describe('Replies Tab in Profile page of @gtg', () => { // Hover upvote button await profilePage.postUpvoteButton.first().hover(); await profilePage.page.waitForTimeout(1000); - // Validate the tooltip message - expect(await profilePage.postUpvoteTooltip.textContent()).toBe('UpvoteUpvote'); + // Validate the tooltip message (may include payout warning for old posts) + expect(await profilePage.postUpvoteTooltip.textContent()).toContain('Upvote'); // Upvote icon color expect( await profilePage.getElementCssPropertyValue( @@ -270,8 +270,8 @@ test.describe('Replies Tab in Profile page of @gtg', () => { // Hover Downvote button await profilePage.postDownvoteButton.first().hover(); await profilePage.page.waitForTimeout(1000); - // Validate the tooltip message - expect(await profilePage.postDownvoteTooltip.textContent()).toBe('DownvoteDownvote'); + // Validate the tooltip message (may include payout warning for old posts) + expect(await profilePage.postDownvoteTooltip.textContent()).toContain('Downvote'); // Upvote icon color expect( await profilePage.getElementCssPropertyValue( diff --git a/packages/transaction/lib/bridge.ts b/packages/transaction/lib/bridge.ts deleted file mode 100644 index 1c0f38af158db0da7af4ffb73e25e5fac84abcb5..0000000000000000000000000000000000000000 --- a/packages/transaction/lib/bridge.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { hiveChainService } from './hive-chain-service'; -import { getLogger } from '@ui/lib/logging'; -import { IGetPostHeader, IFollowList, IAccountRelationship, Entry, IUnreadNotifications, Community, IAccountNotification, FollowListType } from './extended-hive.chain'; -import {commonVariables} from'@ui/lib/common-variables'; - -const logger = getLogger('app'); - -const chain = await hiveChainService.getHiveChain(); - -export type Subscription = Array; - -export const DATA_LIMIT = 20; - -const resolvePost = (post: Entry, observer: string): Promise => { - const { json_metadata: json } = post; - - if (json.original_author && json.original_permlink && json.tags && json.tags[0] === 'cross-post') { - return getPost(json.original_author, json.original_permlink, observer) - .then((resp) => { - if (resp) { - return { - ...post, - original_entry: resp - }; - } - - return post; - }) - .catch(() => { - return post; - }); - } - - return new Promise((resolve) => { - resolve(post); - }); -}; - -const resolvePosts = (posts: Entry[], observer: string): Promise => { - const promises = posts.map((p) => resolvePost(p, observer)); - - return Promise.all(promises); -}; - -export const getPostsRanked = async ( - sort: string, - tag: string = '', - start_author: string = '', - start_permlink: string = '', - observer: string, - limit: number = DATA_LIMIT -): Promise => { - return chain - .api.bridge.get_ranked_posts({ - sort, - start_author, - start_permlink, - limit, - tag, - observer - }) - .then((resp) => { - // logger.info('getPostsRanked result: %o', resp); - if (resp) { - return resolvePosts(resp, observer); - } - - return resp; - }); -}; - -export const getAccountPosts = async ( - sort: string, - account: string, - observer: string, - start_author: string = '', - start_permlink: string = '', - limit: number = DATA_LIMIT -): Promise => { - return chain - .api.bridge.get_account_posts({ - sort, - account, - start_author, - start_permlink, - limit, - observer - }) - .then((resp) => { - if (resp) { - return resolvePosts(resp, observer); - } - - return resp; - }); -}; - -export const getPost = async ( - author: string = '', - permlink: string = '', - observer: string = '' -): Promise => { - return chain - .api.bridge.get_post({ - author, - permlink, - observer - }) - .then((resp) => { - if (resp) { - return resolvePost(resp, observer); - } - - return resp; - }); -}; - -// I have problem with this func, I pass good account name but RPC call it with empty string -export const getAccountNotifications = async ( - account: string, - lastId: number | null = null, - limit = 50 -): Promise => { - const params: { account: string; last_id?: number; limit: number } = { - account, - limit - }; - - if (lastId) { - params.last_id = lastId; - } - return chain.api.bridge.account_notifications(params); -}; - -export const getDiscussion = async ( - author: string, - permlink: string, - observer?: string -): Promise | null> => { - return chain.api.bridge.get_discussion({ - author, - permlink, - observer - }); -}; - -export const getCommunity = async ( - name: string, - observer: string | undefined = '' -): Promise => { - return chain.api.bridge.get_community({ name, observer }); -}; - -export const getListCommunityRoles = async (community: string): Promise => { - return chain.api.bridge.list_community_roles({ community }); -}; - -export const getCommunities = async ( - sort: string, - query?: string | null, - // last: string = '', - // limit: number = 100, - observer: string = 'hive.blog' -): Promise => { - return chain.api.bridge.list_communities({ - // limit, - query, - sort, - observer: observer !== '' ? observer : commonVariables.defaultObserver, - }); -}; - -export const normalizePost = async (post: Entry): Promise => { - return chain.api.bridge.normalize_post({ - post - }); -}; - -export const getRelationshipBetweenAccounts = async ( - follower: string, - following: string -): Promise => { - return chain.api.bridge.get_relationship_between_accounts([follower, following]); -}; - -export type TwitterInfo = { - twitter_username: string; - twitter_profile: string; -}; - -export const getTwitterInfo = async (username: string) => { - const response = await fetch(`https://hiveposh.com/api/v0/twitter/${username}`); - if (!response.ok) { - throw new Error(`Posh API Error: ${response.status}`); - } - - const data = await response.json(); - const { error } = data; - if (error) { - throw new Error(`Posh API Error: ${error}`); - } - - return data; -}; diff --git a/packages/ui/hooks/use-search.ts b/packages/ui/hooks/use-search.ts index f4ddbc0cd49c999869199f1324e657c5e0860d04..7853104edc95fd776d36ac5db2a9bf386dc24e37 100644 --- a/packages/ui/hooks/use-search.ts +++ b/packages/ui/hooks/use-search.ts @@ -29,6 +29,18 @@ export function useSearch() { const [inputValue, setInputValue] = useState(query ?? aiQuery ?? topicQuery ?? ''); const [mode, setMode] = useState(currentMode ?? 'ai'); const [secondInputValue, setSecondInputValue] = useState(userTopicQuery ?? ''); + + // Sync state with URL params (e.g., when navigating back) + useEffect(() => { + const newMode = getMode(query, aiQuery, userTopicQuery); + if (newMode) { + setMode(newMode); + } + // Always sync with URL - use empty string as fallback for reset + setInputValue(aiQuery ?? query ?? topicQuery ?? ''); + setSecondInputValue(userTopicQuery ?? ''); + }, [query, aiQuery, userTopicQuery, topicQuery]); + useEffect(() => { if (inputValue.startsWith('/')) { setMode('userTopic');