diff --git a/packages/shared/src/components/cards/SimilarPosts/PostEngagementCounts.tsx b/packages/shared/src/components/cards/SimilarPosts/PostEngagementCounts.tsx index 98ab6ed50ee..be4082f648b 100644 --- a/packages/shared/src/components/cards/SimilarPosts/PostEngagementCounts.tsx +++ b/packages/shared/src/components/cards/SimilarPosts/PostEngagementCounts.tsx @@ -7,12 +7,14 @@ import { largeNumberFormat } from '../../../lib'; interface PostEngagementCountsProps { upvotes: number; comments: number; + bookmarks?: number; className?: string; } export function PostEngagementCounts({ upvotes, comments, + bookmarks, className, }: PostEngagementCountsProps): ReactElement { return ( @@ -23,6 +25,12 @@ export function PostEngagementCounts({ {upvotes ? `${largeNumberFormat(upvotes)} Upvotes` : ''} {upvotes && comments ? <> {separatorCharacter} : ''} {comments ? `${largeNumberFormat(comments)} Comments` : ''} + {bookmarks ? ( + <> + {upvotes || comments ? <> {separatorCharacter} : null} + {largeNumberFormat(bookmarks)} Bookmarks + + ) : null}

); } diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 309f04ef1c9..fb025fdc362 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -10,7 +10,8 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { useFeedPreviewMode } from '../../../hooks'; +import { useFeedPreviewMode, useConditionalFeature } from '../../../hooks'; +import { featureShowBookmarkCount } from '../../../lib/featureManagement'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; import { IconSize } from '../../Icon'; @@ -75,6 +76,11 @@ const ActionButtons = ({ }: ActionButtonsProps): ReactElement => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); + const { value: showBookmarkCount } = useConditionalFeature({ + feature: featureShowBookmarkCount, + shouldEvaluate: true, + }); + const bookmarkCount = post?.analytics?.bookmarks ?? 0; const { isUpvoteActive, @@ -224,13 +230,26 @@ const ActionButtons = ({ id: `post-${post.id}-bookmark-btn`, onClick: onToggleBookmark, size: config.buttonSize, + className: classNames( + 'btn-tertiary-bun', + variant === 'list' && 'pointer-events-auto', + ), ...(variant === 'list' && { variant: ButtonVariant.Tertiary, - className: 'pointer-events-auto', }), }} iconSize={config.iconSize} - /> + > + {showBookmarkCount && bookmarkCount > 0 && ( + + )} + unknown; + showBookmarkCount?: boolean; }; const imageClassName = 'w-7 h-7 rounded-full mt-1'; const textContainerClassName = 'flex flex-col ml-3 mr-2 flex-1'; -const DefaultListItem = ({ post, onLinkClick }: PostProps): ReactElement => ( +const DefaultListItem = ({ + post, + onLinkClick, + showBookmarkCount, +}: PostProps): ReactElement => (
( )} @@ -112,6 +120,10 @@ export default function SimilarPosts({ }: SimilarPostsProps): ReactElement { const { logEvent } = useLogContext(); const { logOpts } = useContext(ActiveFeedContext); + const { value: showBookmarkCount } = useConditionalFeature({ + feature: featureShowBookmarkCount, + shouldEvaluate: true, + }); const moreButtonHref = moreButtonProps?.href || process.env.NEXT_PUBLIC_WEBAPP_URL; const moreButtonText = moreButtonProps?.text || 'View all'; @@ -143,6 +155,7 @@ export default function SimilarPosts({ key={post.id} post={post} onLinkClick={() => onLinkClick(post)} + showBookmarkCount={showBookmarkCount} /> ))} diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index d4b9285b48a..d9320c7d0ce 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -228,6 +228,9 @@ export const FEED_POST_INFO_FRAGMENT = gql` numUpvotes numComments numAwards + analytics { + bookmarks + } summary yggdrasilId creatorTwitter @@ -328,6 +331,7 @@ export const SHARED_POST_INFO_FRAGMENT = gql` bookmarked analytics { impressions + bookmarks } numUpvotes numComments diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 058b51d3c03..8532885a58f 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -217,7 +217,7 @@ export interface Post { pollOptions?: PollOption[]; numPollVotes?: number; endsAt?: string; - analytics?: Partial>; + analytics?: Partial>; } export type RelatedPost = Pick< diff --git a/packages/shared/src/hooks/useBookmarkPost.ts b/packages/shared/src/hooks/useBookmarkPost.ts index e91b4136c15..89ded0e0f73 100644 --- a/packages/shared/src/hooks/useBookmarkPost.ts +++ b/packages/shared/src/hooks/useBookmarkPost.ts @@ -6,7 +6,7 @@ import type { QueryKey, } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { Ad, Post, ReadHistoryPost } from '../graphql/posts'; +import type { Ad, Post, PostData, ReadHistoryPost } from '../graphql/posts'; import { ADD_BOOKMARKS_MUTATION, REMOVE_BOOKMARK_MUTATION, @@ -17,6 +17,7 @@ import { useRequestProtocol } from './useRequestProtocol'; import AuthContext from '../contexts/AuthContext'; import { updatePostCache, + getPostByIdKey, RequestKey, updateAdPostInCache, createAdPostRollbackHandler, @@ -32,7 +33,6 @@ import { useActions } from './useActions'; import { bookmarkMutationKey } from './bookmark/types'; import { useLazyModal } from './useLazyModal'; import { LazyModal } from '../components/modals/common/types'; -import type { Bookmark } from '../graphql/bookmarks'; import { useActiveFeedContext } from '../contexts'; export type ToggleBookmarkProps = { @@ -73,6 +73,14 @@ const prepareBookmarkPostLogOptions = ({ }; }; +const getOptimisticBookmarkCount = ( + currentBookmarks: number | null | undefined, + isCurrentlyBookmarked: boolean | null | undefined, +): number => { + const delta = isCurrentlyBookmarked ? -1 : 1; + return Math.max(0, (currentBookmarks ?? 0) + delta); +}; + export type UseBookmarkPost = { toggleBookmark: (props: ToggleBookmarkProps) => Promise; }; @@ -91,11 +99,40 @@ const useBookmarkPost = ({ const postLogEvent = usePostLogEvent(); const { logOpts } = useActiveFeedContext(); - const defaultOnMutate = ({ id }) => { - updatePostCache(client, id, (post) => ({ bookmarked: !post.bookmarked })); + const defaultOnMutate: NonNullable = ({ + id, + }) => { + if (!id) { + return undefined; + } + + const previousPost = client.getQueryData( + getPostByIdKey(id), + )?.post; + + updatePostCache(client, id, (post) => ({ + bookmarked: !post.bookmarked, + analytics: post.analytics + ? { + ...post.analytics, + bookmarks: getOptimisticBookmarkCount( + post.analytics.bookmarks, + post.bookmarked, + ), + } + : post.analytics, + })); return () => { - updatePostCache(client, id, (post) => ({ bookmarked: !post.bookmarked })); + if (!previousPost) { + return; + } + + updatePostCache(client, id, () => ({ + bookmarked: previousPost.bookmarked, + bookmark: previousPost.bookmark, + analytics: previousPost.analytics, + })); }; }; @@ -255,22 +292,31 @@ export const mutateBookmarkFeedPost = ({ const mutationHandler = (post: Post) => { const isBookmarked = !post?.bookmarked; + const nextBookmarks = getOptimisticBookmarkCount( + post?.analytics?.bookmarks, + post?.bookmarked, + ); return { bookmarked: isBookmarked, bookmark: !isBookmarked ? undefined : post?.bookmark, + analytics: post?.analytics + ? { + ...post.analytics, + bookmarks: nextBookmarks, + } + : post?.analytics, }; }; - let previousBookmark: Bookmark; - let previousState: boolean | undefined; const rollbackFunctions: (() => void)[] = []; // Handle regular post update if (postIndexToUpdate !== -1) { const postItem = (items[postIndexToUpdate] as PostItem)?.post; - previousBookmark = postItem?.bookmark; - previousState = postItem?.bookmarked; + const previousBookmark = postItem?.bookmark; + const previousState = postItem?.bookmarked; + const previousAnalytics = postItem?.analytics; optimisticPostUpdateInFeed( items, @@ -290,6 +336,7 @@ export const mutateBookmarkFeedPost = ({ const rollbackMutationHandler = () => ({ bookmarked: previousState, bookmark: previousBookmark, + analytics: previousAnalytics, }); optimisticPostUpdateInFeed( @@ -306,8 +353,9 @@ export const mutateBookmarkFeedPost = ({ const adPost = adItem.ad.data?.post; if (adPost) { - previousBookmark = adPost.bookmark; - previousState = adPost.bookmarked; + const previousBookmark = adPost.bookmark; + const previousState = adPost.bookmarked; + const previousAnalytics = adPost.analytics; // Update the ad's post in the ads cache const adsQueryKey = [RequestKey.Ads, ...feedQueryKey]; @@ -333,6 +381,7 @@ export const mutateBookmarkFeedPost = ({ createAdPostRollbackHandler(id, { bookmarked: previousState, bookmark: previousBookmark, + analytics: previousAnalytics, }), ); }); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 42822f20de5..6b1d9d5d193 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -123,6 +123,11 @@ export const boostSettingsFeature = new Feature('boost_settings', { export const adImprovementsV3Feature = new Feature('ad_improvements_v3', false); +export const featureShowBookmarkCount = new Feature( + 'show_bookmark_count', + false, +); + export const featureYearInReview = new Feature('year_in_review_2025', false); export const featureProfileCompletionIndicator = new Feature(