diff --git a/packages/extension/__tests__/setup.ts b/packages/extension/__tests__/setup.ts index 12cf7c8fdf..25dbd9656b 100644 --- a/packages/extension/__tests__/setup.ts +++ b/packages/extension/__tests__/setup.ts @@ -71,4 +71,13 @@ Object.defineProperty(global, 'open', { value: jest.fn(), }); +Object.defineProperty(global, 'TransformStream', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + backpressure: jest.fn(), + readable: jest.fn(), + writable: jest.fn(), + })), +}); + structuredCloneJsonPolyfill(); diff --git a/packages/extension/package.json b/packages/extension/package.json index 69612c4e2e..b16386a069 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -24,6 +24,7 @@ "date-fns": "^2.25.0", "date-fns-tz": "1.2.2", "dompurify": "^2.5.4", + "fetch-event-stream": "^0.1.5", "focus-visible": "^5.2.1", "graphql": "^16.9.0", "graphql-request": "^3.6.1", diff --git a/packages/extension/src/companion/Companion.tsx b/packages/extension/src/companion/Companion.tsx index 86d164bc3f..88558953ef 100644 --- a/packages/extension/src/companion/Companion.tsx +++ b/packages/extension/src/companion/Companion.tsx @@ -17,7 +17,7 @@ import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion'; import { getPostByIdKey, updatePostCache, -} from '@dailydotdev/shared/src/hooks/usePostById'; +} from '@dailydotdev/shared/src/lib/query'; import { getCompanionWrapper } from '@dailydotdev/shared/src/lib/extension'; import CompanionMenu from './CompanionMenu'; import CompanionContent from './CompanionContent'; diff --git a/packages/shared/__tests__/setup.ts b/packages/shared/__tests__/setup.ts index b79ec45a7a..40089ae541 100644 --- a/packages/shared/__tests__/setup.ts +++ b/packages/shared/__tests__/setup.ts @@ -53,6 +53,15 @@ Object.defineProperty(global, 'open', { value: jest.fn(), }); +Object.defineProperty(global, 'TransformStream', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + backpressure: jest.fn(), + readable: jest.fn(), + writable: jest.fn(), + })), +}); + jest.mock('next/router', () => ({ useRouter: jest.fn().mockImplementation( () => diff --git a/packages/shared/package.json b/packages/shared/package.json index 5bc72dceb6..e1205f98c8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -106,6 +106,7 @@ "@paddle/paddle-js": "^1.3.2", "@tippyjs/react": "^4.2.6", "check-password-strength": "^2.0.10", + "fetch-event-stream": "^0.1.5", "graphql-ws": "^5.5.5", "node-fetch": "^2.6.6", "react-markdown": "^8.0.7", diff --git a/packages/shared/src/components/PostOptionsMenu.tsx b/packages/shared/src/components/PostOptionsMenu.tsx index bedcc8f0ad..f3dc82a2fd 100644 --- a/packages/shared/src/components/PostOptionsMenu.tsx +++ b/packages/shared/src/components/PostOptionsMenu.tsx @@ -46,14 +46,11 @@ import { useToastNotification, } from '../hooks'; import type { AllFeedPages } from '../lib/query'; -import { generateQueryKey, RequestKey } from '../lib/query'; +import { generateQueryKey, getPostByIdKey, RequestKey } from '../lib/query'; import AuthContext from '../contexts/AuthContext'; import { LogEvent, Origin } from '../lib/log'; import { usePostMenuActions } from '../hooks/usePostMenuActions'; -import usePostById, { - getPostByIdKey, - invalidatePostCacheById, -} from '../hooks/usePostById'; +import usePostById, { invalidatePostCacheById } from '../hooks/usePostById'; import { useLazyModal } from '../hooks/useLazyModal'; import { LazyModal } from './modals/common/types'; import { labels } from '../lib'; diff --git a/packages/shared/src/graphql/feed.ts b/packages/shared/src/graphql/feed.ts index 9227ec808a..f48078bf7d 100644 --- a/packages/shared/src/graphql/feed.ts +++ b/packages/shared/src/graphql/feed.ts @@ -79,6 +79,9 @@ export const FEED_POST_FRAGMENT = gql` } slug clickbaitTitleDetected + translation { + title + } } trending feedMeta diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 5f66d4319b..c1e19c8b96 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -219,6 +219,9 @@ export const FEED_POST_INFO_FRAGMENT = gql` } slug clickbaitTitleDetected + translation { + title + } } `; @@ -269,6 +272,9 @@ export const SHARED_POST_INFO_FRAGMENT = gql` slug domain clickbaitTitleDetected + translation { + title + } } ${PRIVILEGED_MEMBERS_FRAGMENT} ${SOURCE_BASE_FRAGMENT} diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index fe63312c02..e147604983 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -129,6 +129,7 @@ export interface Post { bookmarkList?: BookmarkFolder; domain?: string; clickbaitTitleDetected?: boolean; + translation?: { title?: boolean }; } export type RelatedPost = Pick< diff --git a/packages/shared/src/hooks/bookmark/useFeedBookmarkPost.ts b/packages/shared/src/hooks/bookmark/useFeedBookmarkPost.ts index 56e3be75a6..f3beac8cd1 100644 --- a/packages/shared/src/hooks/bookmark/useFeedBookmarkPost.ts +++ b/packages/shared/src/hooks/bookmark/useFeedBookmarkPost.ts @@ -9,7 +9,7 @@ import type { UseBookmarkPost } from '../useBookmarkPost'; import { mutateBookmarkFeedPost, useBookmarkPost } from '../useBookmarkPost'; import type { UseBookmarkMutationProps } from './types'; import { bookmarkMutationMatcher } from './types'; -import { updatePostCache } from '../usePostById'; +import { updatePostCache } from '../../lib/query'; export type UseFeedBookmarkPost = { feedName: string; diff --git a/packages/shared/src/hooks/bookmark/useMoveBookmarkToFolder.ts b/packages/shared/src/hooks/bookmark/useMoveBookmarkToFolder.ts index 8f59f043d8..56c489cada 100644 --- a/packages/shared/src/hooks/bookmark/useMoveBookmarkToFolder.ts +++ b/packages/shared/src/hooks/bookmark/useMoveBookmarkToFolder.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { MoveBookmarkToFolderProps } from '../../graphql/bookmarks'; import { moveBookmarkToFolder } from '../../graphql/bookmarks'; import type { EmptyResponse } from '../../graphql/emptyResponse'; -import { getPostByIdKey } from '../usePostById'; +import { getPostByIdKey } from '../../lib/query'; import type { PostData } from '../../graphql/posts'; import { useToastNotification } from '../useToastNotification'; diff --git a/packages/shared/src/hooks/notifications/useBookmarkReminder.ts b/packages/shared/src/hooks/notifications/useBookmarkReminder.ts index fe1f69c722..8033a47ffe 100644 --- a/packages/shared/src/hooks/notifications/useBookmarkReminder.ts +++ b/packages/shared/src/hooks/notifications/useBookmarkReminder.ts @@ -7,9 +7,8 @@ import type { } from '../../graphql/bookmarks'; import { setBookmarkReminder } from '../../graphql/bookmarks'; import { useToastNotification } from '../useToastNotification'; -import { updatePostCache } from '../usePostById'; import { useActiveFeedContext } from '../../contexts'; -import { updateCachedPagePost } from '../../lib/query'; +import { updateCachedPagePost, updatePostCache } from '../../lib/query'; import { optimisticPostUpdateInFeed, postLogEvent } from '../../lib/feed'; import type { EmptyResponse } from '../../graphql/emptyResponse'; import { useLogContext } from '../../contexts/LogContext'; diff --git a/packages/shared/src/hooks/post/useBlockPostPanel.ts b/packages/shared/src/hooks/post/useBlockPostPanel.ts index 20ef50f285..a46099c62e 100644 --- a/packages/shared/src/hooks/post/useBlockPostPanel.ts +++ b/packages/shared/src/hooks/post/useBlockPostPanel.ts @@ -79,7 +79,7 @@ export const useBlockPostPanel = ( const client = useQueryClient(); const { user } = useAuthContext(); const { checkHasCompleted, completeAction } = useActions(); - const key = generateQueryKey(RequestKey.PostKey, user, `block:${post?.id}`); + const key = generateQueryKey(RequestKey.Post, user, `block:${post?.id}`); const { data } = useQuery({ queryKey: key, queryFn: () => client.getQueryData(key), diff --git a/packages/shared/src/hooks/post/useMutateComment.ts b/packages/shared/src/hooks/post/useMutateComment.ts index 957bf8a2ca..4260154c59 100644 --- a/packages/shared/src/hooks/post/useMutateComment.ts +++ b/packages/shared/src/hooks/post/useMutateComment.ts @@ -12,9 +12,9 @@ import { generateQueryKey, getAllCommentsQuery, RequestKey, + updatePostCache, } from '../../lib/query'; import { useBackgroundRequest } from '../companion'; -import { updatePostCache } from '../usePostById'; import type { Edge } from '../../graphql/common'; import { useLogContext } from '../../contexts/LogContext'; import { useRequestProtocol } from '../useRequestProtocol'; diff --git a/packages/shared/src/hooks/post/useRelatedPosts.ts b/packages/shared/src/hooks/post/useRelatedPosts.ts index 030de6097c..91ce400491 100644 --- a/packages/shared/src/hooks/post/useRelatedPosts.ts +++ b/packages/shared/src/hooks/post/useRelatedPosts.ts @@ -14,10 +14,10 @@ import { import { generateQueryKey, getNextPageParam, + getPostByIdKey, RequestKey, StaleTime, } from '../../lib/query'; -import { getPostByIdKey } from '../usePostById'; import { useRequestProtocol } from '../useRequestProtocol'; export type UseRelatedPostsProps = { diff --git a/packages/shared/src/hooks/post/useSmartTitle.ts b/packages/shared/src/hooks/post/useSmartTitle.ts index ac576cf1d5..1b8ddc7adc 100644 --- a/packages/shared/src/hooks/post/useSmartTitle.ts +++ b/packages/shared/src/hooks/post/useSmartTitle.ts @@ -3,10 +3,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gqlClient } from '../../graphql/common'; import type { Post } from '../../graphql/posts'; import { POST_FETCH_SMART_TITLE_QUERY } from '../../graphql/posts'; -import { getPostByIdKey } from '../usePostById'; import { usePlusSubscription } from '../usePlusSubscription'; import { useAuthContext } from '../../contexts/AuthContext'; -import { generateQueryKey, RequestKey } from '../../lib/query'; +import { generateQueryKey, getPostByIdKey, RequestKey } from '../../lib/query'; import { disabledRefetch } from '../../lib/func'; import { useActions } from '../useActions'; import { ActionType } from '../../graphql/actions'; @@ -115,7 +114,7 @@ export const useSmartTitle = (post: Post): UseSmartTitle => { return fetchedSmartTitle ? smartTitle : post?.title || post?.sharedPost?.title; - }, [fetchedSmartTitle, smartTitle, post]); + }, [fetchedSmartTitle, smartTitle, post?.title, post?.sharedPost?.title]); const shieldActive = useMemo(() => { return ( diff --git a/packages/shared/src/hooks/translation/useTranslation.ts b/packages/shared/src/hooks/translation/useTranslation.ts new file mode 100644 index 0000000000..c5ada6eea3 --- /dev/null +++ b/packages/shared/src/hooks/translation/useTranslation.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { InfiniteData, QueryKey } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { events } from 'fetch-event-stream'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { apiUrl } from '../../lib/config'; +import type { FeedData, Post } from '../../graphql/posts'; +import { + updateCachedPagePost, + findIndexOfPostInData, + updatePostCache, +} from '../../lib/query'; +import { useSettingsContext } from '../../contexts/SettingsContext'; + +export enum ServerEvents { + Connect = 'connect', + Message = 'message', + Disconnect = 'disconnect', + Error = 'error', +} + +type UseTranslation = (props: { + queryKey: QueryKey; + queryType: 'post' | 'feed'; +}) => { + fetchTranslations: (id: Post[]) => void; +}; + +type TranslateEvent = { + id: string; + title: string; +}; + +const updateTranslation = (post: Post, translation: TranslateEvent): Post => { + const updatedPost = post; + if (post.title) { + updatedPost.title = translation.title; + updatedPost.translation = { title: !!translation.title }; + } else { + updatedPost.sharedPost.title = translation.title; + updatedPost.sharedPost.translation = { title: !!translation.title }; + } + + return updatedPost; +}; + +export const useTranslation: UseTranslation = ({ queryKey, queryType }) => { + const abort = useRef(); + const { user, accessToken, isLoggedIn } = useAuthContext(); + const { flags } = useSettingsContext(); + const queryClient = useQueryClient(); + + const { language } = user || {}; + const isStreamActive = isLoggedIn && !!language; + + const updateFeed = useCallback( + (translatedPost: TranslateEvent) => { + const updatePost = updateCachedPagePost(queryKey, queryClient); + const feedData = + queryClient.getQueryData>(queryKey); + const { pageIndex, index } = findIndexOfPostInData( + feedData, + translatedPost.id, + true, + ); + if (index > -1) { + updatePost( + pageIndex, + index, + updateTranslation( + feedData.pages[pageIndex].page.edges[index].node, + translatedPost, + ), + ); + } + }, + [queryKey, queryClient], + ); + + const updatePost = useCallback( + (translatedPost: TranslateEvent) => { + updatePostCache(queryClient, translatedPost.id, (post) => + updateTranslation(post, translatedPost), + ); + }, + [queryClient], + ); + + const fetchTranslations = useCallback( + async (posts: Post[]) => { + if (!isStreamActive) { + return; + } + if (posts.length === 0) { + return; + } + + const postIds = posts + .filter((node) => + node?.title + ? !node?.translation?.title + : !node?.sharedPost?.translation?.title, + ) + .filter((node) => + flags?.clickbaitShieldEnabled && node?.title + ? !node.clickbaitTitleDetected + : !node.sharedPost?.clickbaitTitleDetected, + ) + .filter(Boolean) + .map((node) => (node?.title ? node.id : node?.sharedPost.id)); + + if (postIds.length === 0) { + return; + } + + const params = new URLSearchParams(); + postIds.forEach((id) => { + params.append('id', id); + }); + + const response = await fetch(`${apiUrl}/translate/post/title?${params}`, { + signal: abort.current?.signal, + headers: { + Accept: 'text/event-stream', + Authorization: `Bearer ${accessToken?.token}`, + 'Content-Language': language as string, + }, + }); + + if (!response.ok) { + return; + } + + // eslint-disable-next-line no-restricted-syntax + for await (const message of events(response)) { + if (message.event === ServerEvents.Message) { + const post = JSON.parse(message.data) as TranslateEvent; + if (queryType === 'feed') { + updateFeed(post); + } else { + updatePost(post); + } + } + } + }, + [ + accessToken?.token, + flags?.clickbaitShieldEnabled, + isStreamActive, + language, + queryType, + updateFeed, + updatePost, + ], + ); + + useEffect(() => { + abort.current = new AbortController(); + + return () => { + abort.current?.abort(); + }; + }, []); + + return { fetchTranslations }; +}; diff --git a/packages/shared/src/hooks/useBookmarkPost.ts b/packages/shared/src/hooks/useBookmarkPost.ts index 8f71c248b1..62558761b2 100644 --- a/packages/shared/src/hooks/useBookmarkPost.ts +++ b/packages/shared/src/hooks/useBookmarkPost.ts @@ -10,7 +10,7 @@ import LogContext from '../contexts/LogContext'; import { useToastNotification } from './useToastNotification'; import { useRequestProtocol } from './useRequestProtocol'; import AuthContext from '../contexts/AuthContext'; -import { updatePostCache } from './usePostById'; +import { updatePostCache } from '../lib/query'; import { AuthTriggers } from '../lib/auth'; import type { Origin } from '../lib/log'; import { LogEvent } from '../lib/log'; diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 01264ef7e1..a255c8427c 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -1,9 +1,5 @@ import { useCallback, useContext, useMemo } from 'react'; -import type { - InfiniteData, - QueryKey, - UseInfiniteQueryOptions, -} from '@tanstack/react-query'; +import type { QueryKey, UseInfiniteQueryOptions } from '@tanstack/react-query'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import type { ClientError } from 'graphql-request'; import { useRouter } from 'next/router'; @@ -12,6 +8,7 @@ import { POSTS_ENGAGED_SUBSCRIPTION } from '../graphql/posts'; import AuthContext from '../contexts/AuthContext'; import useSubscription from './useSubscription'; import { + findIndexOfPostInData, getNextPageParam, removeCachedPagePost, RequestKey, @@ -29,9 +26,11 @@ import { featureFeedAdTemplate } from '../lib/featureManagement'; import { cloudinaryPostImageCoverPlaceholder } from '../lib/image'; import { AD_PLACEHOLDER_SOURCE_ID } from '../lib/constants'; import { SharedFeedPage } from '../components/utilities'; +import { useTranslation } from './translation/useTranslation'; interface FeedItemBase { type: T; + dataUpdatedAt: number; } interface AdItem extends FeedItemBase { @@ -72,22 +71,6 @@ export type FeedReturnType = { isError: boolean; }; -const findIndexOfPostInData = ( - data: InfiniteData, - id: string, -): { pageIndex: number; index: number } => { - for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex += 1) { - const page = data.pages[pageIndex]; - for (let index = 0; index < page.page.edges.length; index += 1) { - const item = page.page.edges[index]; - if (item.node.id === id) { - return { pageIndex, index }; - } - } - } - return { pageIndex: -1, index: -1 }; -}; - type UseFeedSettingParams = { adPostLength?: number; disableAds?: boolean; @@ -120,6 +103,10 @@ export default function useFeed( const { user, tokenRefreshed } = useContext(AuthContext); const { isPlus } = usePlusSubscription(); const queryClient = useQueryClient(); + const { fetchTranslations } = useTranslation({ + queryKey: feedQueryKey, + queryType: 'feed', + }); const isFeedPreview = feedQueryKey?.[0] === RequestKey.FeedPreview; const avoidRetry = params?.settings?.feedName === SharedFeedPage.Custom && !isPlus; @@ -127,7 +114,7 @@ export default function useFeed( const feedQuery = useInfiniteQuery({ queryKey: feedQueryKey, queryFn: async ({ pageParam }) => { - const res = await gqlClient.request(query, { + const res = await gqlClient.request(query, { ...variables, first: pageSize, after: pageParam, @@ -151,6 +138,8 @@ export default function useFeed( } } + fetchTranslations(res.page.edges.map(({ node }) => node)); + return res; }, refetchOnMount: false, @@ -306,6 +295,7 @@ export default function useFeed( post: node, page: pageIndex, index, + dataUpdatedAt: feedQuery.dataUpdatedAt, }); }); @@ -321,6 +311,7 @@ export default function useFeed( }, [ feedQuery.data, feedQuery.isFetching, + feedQuery.dataUpdatedAt, settings.marketingCta, settings.showAcquisitionForm, placeholdersPerPage, diff --git a/packages/shared/src/hooks/useLanguage.ts b/packages/shared/src/hooks/useLanguage.ts index 847a76b4f4..8e4b75b476 100644 --- a/packages/shared/src/hooks/useLanguage.ts +++ b/packages/shared/src/hooks/useLanguage.ts @@ -49,7 +49,7 @@ export const useLanguage = (): UseLanguage => { RequestKey.Squad, RequestKey.FeedPreview, RequestKey.FeedPreviewCustom, - RequestKey.PostKey, + RequestKey.Post, RequestKey.Bookmarks, RequestKey.ReadingHistory, RequestKey.RelatedPosts, diff --git a/packages/shared/src/hooks/usePostById.ts b/packages/shared/src/hooks/usePostById.ts index 25e760f48b..fe65def3a7 100644 --- a/packages/shared/src/hooks/usePostById.ts +++ b/packages/shared/src/hooks/usePostById.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import type { QueryClient, - QueryKey, QueryObserverOptions, UseQueryResult, } from '@tanstack/react-query'; @@ -13,7 +12,9 @@ import type { PostCommentsData } from '../graphql/comments'; import type { RequestKey } from '../lib/query'; import { getAllCommentsQuery, + getPostByIdKey, StaleTime, + updatePostCache, updatePostContentPreference, } from '../lib/query'; import type { Connection } from '../graphql/common'; @@ -25,6 +26,7 @@ import { mutationKeyToContentPreferenceStatusMap, } from './contentPreference/types'; import type { PropsParameters } from '../types'; +import { useTranslation } from './translation/useTranslation'; interface UsePostByIdProps { id: string; @@ -36,10 +38,6 @@ interface UsePostById extends Pick { relatedCollectionPosts?: Connection; } -export const POST_KEY = 'post'; - -export const getPostByIdKey = (id: string): QueryKey => [POST_KEY, id]; - export const invalidatePostCacheById = ( client: QueryClient, id: string, @@ -51,35 +49,6 @@ export const invalidatePostCacheById = ( } }; -export const updatePostCache = ( - client: QueryClient, - id: string, - postUpdate: - | Partial> - | ((current: Post) => Partial>), -): PostData => { - const currentPost = client.getQueryData(getPostByIdKey(id)); - - if (!currentPost?.post) { - return currentPost; - } - - return client.setQueryData(getPostByIdKey(id), (node) => { - const update = - typeof postUpdate === 'function' ? postUpdate(node.post) : postUpdate; - const updatedPost = { ...node.post, ...update } as Post; - const bookmark = updatedPost.bookmark ?? { createdAt: new Date() }; - - return { - post: { - ...updatedPost, - id: node.post.id, - bookmark: !updatedPost.bookmarked ? null : bookmark, - }, - }; - }); -}; - export const removePostComments = ( client: QueryClient, post: Post, @@ -126,13 +95,24 @@ const usePostById = ({ id, options = {} }: UsePostByIdProps): UsePostById => { const { initialData, ...restOptions } = options; const { tokenRefreshed } = useAuthContext(); const key = getPostByIdKey(id); + const { fetchTranslations } = useTranslation({ + queryKey: key, + queryType: 'post', + }); const { data: postById, isError, isPending, } = useQuery({ queryKey: key, - queryFn: () => gqlClient.request(POST_BY_ID_QUERY, { id }), + queryFn: async () => { + const res = await gqlClient.request(POST_BY_ID_QUERY, { id }); + if (!res.post?.translation?.title) { + fetchTranslations([res.post]); + } + + return res; + }, ...restOptions, staleTime: StaleTime.Default, enabled: !!id && tokenRefreshed, diff --git a/packages/shared/src/hooks/usePostContent.ts b/packages/shared/src/hooks/usePostContent.ts index ee6fc3d6f3..a6b52dc658 100644 --- a/packages/shared/src/hooks/usePostContent.ts +++ b/packages/shared/src/hooks/usePostContent.ts @@ -7,7 +7,7 @@ import { postLogEvent } from '../lib/feed'; import useOnPostClick from './useOnPostClick'; import useSubscription from './useSubscription'; import type { PostOrigin } from './log/useLogContextData'; -import { updatePostCache } from './usePostById'; +import { updatePostCache } from '../lib/query'; import { ReferralCampaignKey } from '../lib'; import { useGetShortUrl } from './utils/useGetShortUrl'; import { ShareProvider } from '../lib/share'; diff --git a/packages/shared/src/hooks/usePostFeedback.ts b/packages/shared/src/hooks/usePostFeedback.ts index 1dc7a59a7c..d2263bad47 100644 --- a/packages/shared/src/hooks/usePostFeedback.ts +++ b/packages/shared/src/hooks/usePostFeedback.ts @@ -3,8 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { Post } from '../graphql/posts'; import { dismissPostFeedback, UserVote } from '../graphql/posts'; import { optimisticPostUpdateInFeed } from '../lib/feed'; -import { updatePostCache } from './usePostById'; -import { updateCachedPagePost } from '../lib/query'; +import { updateCachedPagePost, updatePostCache } from '../lib/query'; import { ActiveFeedContext } from '../contexts/ActiveFeedContext'; import { SharedFeedPage } from '../components/utilities'; import type { EmptyResponse } from '../graphql/emptyResponse'; diff --git a/packages/shared/src/hooks/useUpdatePost.ts b/packages/shared/src/hooks/useUpdatePost.ts index 5e2d43cf14..0a7f4d974c 100644 --- a/packages/shared/src/hooks/useUpdatePost.ts +++ b/packages/shared/src/hooks/useUpdatePost.ts @@ -1,7 +1,7 @@ import { useQueryClient } from '@tanstack/react-query'; import type { Post, PostData } from '../graphql/posts'; import type { MutateFunc } from '../lib/query'; -import { getPostByIdKey } from './usePostById'; +import { getPostByIdKey } from '../lib/query'; type UpdateData = { id: string; update?: Partial }; type UseBookmarkPostRet = { diff --git a/packages/shared/src/hooks/vote/useFeedVotePost.ts b/packages/shared/src/hooks/vote/useFeedVotePost.ts index 76951f261c..329c26dc19 100644 --- a/packages/shared/src/hooks/vote/useFeedVotePost.ts +++ b/packages/shared/src/hooks/vote/useFeedVotePost.ts @@ -8,7 +8,7 @@ import type { UseVotePost, UseVoteMutationProps } from './types'; import { voteMutationMatcher, voteMutationHandlers } from './types'; import { useVotePost } from './useVotePost'; import { mutateVoteFeedPost } from './utils'; -import { updatePostCache } from '../usePostById'; +import { updatePostCache } from '../../lib/query'; export type UseFeedVotePostProps = { feedName: string; diff --git a/packages/shared/src/hooks/vote/useVotePost.ts b/packages/shared/src/hooks/vote/useVotePost.ts index 1a8545c60f..113369f3d6 100644 --- a/packages/shared/src/hooks/vote/useVotePost.ts +++ b/packages/shared/src/hooks/vote/useVotePost.ts @@ -11,7 +11,7 @@ import { postLogEvent } from '../../lib/feed'; import { getPostByIdKey, updatePostCache as updateSinglePostCache, -} from '../usePostById'; +} from '../../lib/query'; import type { UseVotePostProps, UseVotePost, ToggleVoteProps } from './types'; import { voteMutationHandlers, UserVoteEntity } from './types'; import { useVote } from './useVote'; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 3f0a2fbc70..93db70870a 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -11,7 +11,13 @@ import { GARMR_ERROR } from '../graphql/common'; import type { PageInfo, Connection } from '../graphql/common'; import type { EmptyObjectLiteral } from './kratos'; import type { LoggedUser } from './user'; -import type { FeedData, Post, ReadHistoryPost } from '../graphql/posts'; +import { PostType } from '../graphql/posts'; +import type { + FeedData, + Post, + PostData, + ReadHistoryPost, +} from '../graphql/posts'; import type { ReadHistoryInfiniteData } from '../hooks/useInfiniteReadingHistory'; import type { SharedFeedPage } from '../components/utilities'; import type { @@ -128,7 +134,7 @@ export enum RequestKey { FeedPreview = 'feedPreview', FeedPreviewCustom = 'feedPreviewCustom', ReferredUsers = 'referred', - PostKey = 'post', + Post = 'post', Prompt = 'prompt', Comment = 'comment', SquadTour = 'squad_tour', @@ -189,6 +195,8 @@ export enum RequestKey { FetchedOriginalTitle = 'fetched_original_title', } +export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; + export type HasConnection< TEntity, TKey extends keyof TEntity = keyof TEntity, @@ -471,3 +479,56 @@ export const getAllCommentsQuery = (postId: string): QueryKeyReturnType[] => { return sorting; }; + +export const findIndexOfPostInData = ( + data: InfiniteData, + id: string, + findBySharedPost = false, +): { pageIndex: number; index: number } => { + for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex += 1) { + const page = data.pages[pageIndex]; + for (let index = 0; index < page.page.edges.length; index += 1) { + const item = page.page.edges[index]; + if (item.node.id === id) { + return { pageIndex, index }; + } + if ( + findBySharedPost && + item.node.type === PostType.Share && + item.node.sharedPost.id === id + ) { + return { pageIndex, index }; + } + } + } + return { pageIndex: -1, index: -1 }; +}; + +export const updatePostCache = ( + client: QueryClient, + id: string, + postUpdate: + | Partial> + | ((current: Post) => Partial>), +): PostData => { + const currentPost = client.getQueryData(getPostByIdKey(id)); + + if (!currentPost?.post) { + return currentPost; + } + + return client.setQueryData(getPostByIdKey(id), (node) => { + const update = + typeof postUpdate === 'function' ? postUpdate(node.post) : postUpdate; + const updatedPost = { ...node.post, ...update } as Post; + const bookmark = updatedPost.bookmark ?? { createdAt: new Date() }; + + return { + post: { + ...updatedPost, + id: node.post.id, + bookmark: !updatedPost.bookmarked ? null : bookmark, + }, + }; + }); +}; diff --git a/packages/webapp/__tests__/setup.ts b/packages/webapp/__tests__/setup.ts index 6074bf1fab..40a855c517 100644 --- a/packages/webapp/__tests__/setup.ts +++ b/packages/webapp/__tests__/setup.ts @@ -38,6 +38,15 @@ Object.defineProperty(global, 'open', { value: jest.fn(), }); +Object.defineProperty(global, 'TransformStream', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + backpressure: jest.fn(), + readable: jest.fn(), + writable: jest.fn(), + })), +}); + jest.mock('next/router', () => ({ useRouter: jest.fn().mockImplementation( () => diff --git a/packages/webapp/package.json b/packages/webapp/package.json index f517f4ef84..46e0d79413 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -24,6 +24,7 @@ "date-fns": "^2.28.0", "date-fns-tz": "1.2.2", "dompurify": "^2.5.4", + "fetch-event-stream": "^0.1.5", "focus-visible": "^5.2.1", "graphql": "^16.9.0", "graphql-request": "^3.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32b325621c..5f40b6460f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: dompurify: specifier: ^2.5.4 version: 2.5.7 + fetch-event-stream: + specifier: ^0.1.5 + version: 0.1.5 focus-visible: specifier: ^5.2.1 version: 5.2.1 @@ -394,6 +397,9 @@ importers: classnames: specifier: ^2.3.1 version: 2.5.1 + fetch-event-stream: + specifier: ^0.1.5 + version: 0.1.5 graphql-ws: specifier: ^5.5.5 version: 5.16.0(graphql@16.9.0) @@ -810,6 +816,9 @@ importers: dompurify: specifier: ^2.5.4 version: 2.5.7 + fetch-event-stream: + specifier: ^0.1.5 + version: 0.1.5 focus-visible: specifier: ^5.2.1 version: 5.2.1 @@ -5021,6 +5030,9 @@ packages: picomatch: optional: true + fetch-event-stream@0.1.5: + resolution: {integrity: sha512-V1PWovkspxQfssq/NnxoEyQo1DV+MRK/laPuPblIZmSjMN8P5u46OhlFQznSr9p/t0Sp8Uc6SbM3yCMfr0KU8g==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -13373,6 +13385,8 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fetch-event-stream@0.1.5: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0