diff --git a/functions/api/also-liked.ts b/functions/api/also-liked.ts new file mode 100644 index 000000000..74f65132c --- /dev/null +++ b/functions/api/also-liked.ts @@ -0,0 +1,50 @@ +const ALSO_LIKED_URL = 'https://foryou.club/also-liked' + +function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { + 'access-control-allow-origin': '*', + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + ...init?.headers, + }, + }) +} + +export async function onRequestGet(context: {request: Request}) { + const url = new URL(context.request.url) + const post = url.searchParams.get('post') + + if (!post) { + return json({error: 'Missing post parameter'}, {status: 400}) + } + + const upstreamUrl = new URL(ALSO_LIKED_URL) + upstreamUrl.searchParams.set('format', 'json') + upstreamUrl.searchParams.set('post', post) + + const limit = url.searchParams.get('limit') + if (limit) { + upstreamUrl.searchParams.set('limit', limit) + } + + const cursor = url.searchParams.get('cursor') + if (cursor) { + upstreamUrl.searchParams.set('cursor', cursor) + } + + const response = await fetch(upstreamUrl.toString()) + const body = await response.text() + + return new Response(body, { + status: response.status, + headers: { + 'access-control-allow-origin': '*', + 'cache-control': 'no-store', + 'content-type': + response.headers.get('content-type') || + 'application/json; charset=utf-8', + }, + }) +} diff --git a/src/screens/PostThread/components/ThreadAlsoLiked.tsx b/src/screens/PostThread/components/ThreadAlsoLiked.tsx new file mode 100644 index 000000000..ec72381af --- /dev/null +++ b/src/screens/PostThread/components/ThreadAlsoLiked.tsx @@ -0,0 +1,91 @@ +import {View} from 'react-native' +import {type AppBskyFeedDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {cleanError} from '#/lib/strings/errors' +import {Post} from '#/view/com/post/Post' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function ThreadAlsoLiked({ + posts, + enabled, + isLoading, + isFetchingNextPage, + error, + onRetry, + spacerHeight, + isTombstoneView, +}: { + posts: AppBskyFeedDefs.PostView[] + enabled: boolean + isLoading: boolean + isFetchingNextPage: boolean + error: unknown + onRetry: () => void + spacerHeight: number | undefined + isTombstoneView: boolean +}) { + const {t: l} = useLingui() + const t = useTheme() + const hasSection = + enabled && (posts.length > 0 || isLoading || Boolean(error)) + + return ( + + {hasSection && ( + + + + Also liked + + + Posts liked by people who liked this post + + + + {posts.map((post, index) => ( + + ))} + + {(isLoading || isFetchingNextPage) && ( + + + + )} + + {Boolean(error) && !isLoading && !isFetchingNextPage && ( + + + {cleanError(error)} + + + + + + )} + + )} + + + + ) +} diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 954d9e836..287b5e040 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -15,6 +15,8 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {useFeedFeedback} from '#/state/feed-feedback' +import {useAlsoLikedFeedEnabled} from '#/state/preferences/also-liked-feed-enabled' +import {usePostAlsoLikedQuery} from '#/state/queries/post-also-liked' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' import { PostThreadContextProvider, @@ -27,6 +29,7 @@ import {useShellLayout} from '#/state/shell/shell-layout' import {useUnstablePostSource} from '#/state/unstable-post-source' import {List, type ListMethods} from '#/view/com/util/List' import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' +import {ThreadAlsoLiked} from '#/screens/PostThread/components/ThreadAlsoLiked' import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' import {ThreadError} from '#/screens/PostThread/components/ThreadError' import { @@ -50,7 +53,6 @@ import { } from '#/screens/PostThread/components/ThreadItemTreePost' import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' import * as Layout from '#/components/Layout' -import {ListFooter} from '#/components/Lists' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' @@ -148,6 +150,22 @@ export function PostThread({uri}: {uri: string}) { const isRoot = !!anchor && anchor.value.post.record.reply === undefined const canReply = !anchor?.value.post?.viewer?.replyDisabled + const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() + const alsoLikedAnchorUri = + anchor?.type === 'threadPost' && isRoot ? anchor.value.post.uri : undefined + const alsoLiked = usePostAlsoLikedQuery(alsoLikedAnchorUri, { + enabled: Boolean(alsoLikedAnchorUri) && alsoLikedFeedEnabled, + }) + const alsoLikedPosts = useMemo(() => { + const seen = new Set() + return (alsoLiked.data?.pages ?? []) + .flatMap(page => page.posts) + .filter(post => { + if (seen.has(post.uri)) return false + seen.add(post.uri) + return true + }) + }, [alsoLiked.data]) const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) const totalParentCount = useRef(0) // recomputed below @@ -331,9 +349,19 @@ export function PostThread({uri}: {uri: string}) { if (thread.state.isFetching) return // can be true after `prepareForParamsUpdate` is called if (deferParents) return - // prevent any state mutations if we know we're done - if (maxChildrenCount >= totalChildrenCount.current) return - setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) + if (maxChildrenCount < totalChildrenCount.current) { + setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) + return + } + if ( + alsoLikedAnchorUri && + alsoLikedFeedEnabled && + !alsoLiked.isLoading && + !alsoLiked.isFetchingNextPage && + alsoLiked.hasNextPage + ) { + void alsoLiked.fetchNextPage() + } } const slices = useMemo(() => { @@ -543,6 +571,13 @@ export function PostThread({uri}: {uri: string}) { ) const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined + const retryAlsoLiked = useCallback(() => { + if (alsoLikedPosts.length > 0) { + void alsoLiked.fetchNextPage() + } else { + void alsoLiked.refetch() + } + }, [alsoLiked, alsoLikedPosts.length]) return ( @@ -596,23 +631,20 @@ export function PostThread({uri}: {uri: string}) { desktopFixedHeight sideBorders={false} ListFooterComponent={ - } initialNumToRender={initialNumToRender} diff --git a/src/screens/Settings/RunesSettings/DisplaySettings.tsx b/src/screens/Settings/RunesSettings/DisplaySettings.tsx index 2e1f5ec3e..cd522de51 100644 --- a/src/screens/Settings/RunesSettings/DisplaySettings.tsx +++ b/src/screens/Settings/RunesSettings/DisplaySettings.tsx @@ -6,6 +6,10 @@ import {usePalette} from '#/lib/hooks/usePalette' import {dynamicActivate} from '#/locale/i18n' import {dynamicActivate as dynamicActivateWeb} from '#/locale/i18n.web' import {type AppLanguage} from '#/locale/languages' +import { + useAlsoLikedFeedEnabled, + useSetAlsoLikedFeedEnabled, +} from '#/state/preferences/also-liked-feed-enabled' import { useHighQualityImages, useSetHighQualityImages, @@ -28,6 +32,7 @@ import {Admonition} from '#/components/Admonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as Toggle from '#/components/forms/Toggle' +import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' @@ -42,6 +47,9 @@ export function RunesDisplaySettingsScreen() { const repostCarouselEnabled = useRepostCarouselEnabled() const setRepostCarouselEnabled = useSetRepostCarouselEnabled() + const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() + const setAlsoLikedFeedEnabled = useSetAlsoLikedFeedEnabled() + const highQualityImages = useHighQualityImages() const setHighQualityImages = useSetHighQualityImages() @@ -65,6 +73,19 @@ export function RunesDisplaySettingsScreen() { + setAlsoLikedFeedEnabled(value)}> + + + + Show "Also liked" recommendations under post replies + + + + void + +const stateContext = createContext( + Boolean(persisted.defaults.alsoLikedFeedEnabled), +) +const setContext = createContext((_: boolean) => {}) + +export function Provider({children}: {children: ReactNode}) { + const [state, setState] = useState( + Boolean(persisted.get('alsoLikedFeedEnabled')), + ) + + const setStateWrapped = useCallback( + (value: persisted.Schema['alsoLikedFeedEnabled']) => { + setState(Boolean(value)) + persisted.write('alsoLikedFeedEnabled', value) + }, + [setState], + ) + + useEffect(() => { + return persisted.onUpdate('alsoLikedFeedEnabled', nextValue => { + setState(Boolean(nextValue)) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export const useAlsoLikedFeedEnabled = () => useContext(stateContext) +export const useSetAlsoLikedFeedEnabled = () => useContext(setContext) diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 08d25514d..1bfae5ee4 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,5 +1,6 @@ import {type PropsWithChildren} from 'react' +import {Provider as AlsoLikedFeedProvider} from './also-liked-feed-enabled' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoLikeOnRepostProvider} from './auto-like-on-repost' import {Provider as AutoplayProvider} from './autoplay' @@ -57,6 +58,10 @@ import {Provider as TrendingSettingsProvider} from './trending' import {Provider as UseHandleInLinksProvider} from './use-handle-in-links' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' +export { + useAlsoLikedFeedEnabled, + useSetAlsoLikedFeedEnabled, +} from './also-liked-feed-enabled' export { useRequireAltTextEnabled, useSetRequireAltTextEnabled, @@ -143,63 +148,65 @@ export function Provider({children}: PropsWithChildren<{}>) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - children - } - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + children + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/state/queries/post-also-liked.ts b/src/state/queries/post-also-liked.ts new file mode 100644 index 000000000..fe5777e4c --- /dev/null +++ b/src/state/queries/post-also-liked.ts @@ -0,0 +1,144 @@ +import {type AppBskyFeedDefs} from '@atproto/api' +import {type InfiniteData, useInfiniteQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' +import {IS_WEB} from '#/env' + +const ALSO_LIKED_URL = 'https://foryou.club/also-liked' +const ALSO_LIKED_PROXY_PATH = '/api/also-liked' +const ALSO_LIKED_PROXY_HOST = 'https://witchsky.app' +const ALSO_LIKED_PAGE_SIZE = 10 + +type AlsoLikedSkeletonResponse = { + cursor?: string + feed?: Array<{post?: string}> +} + +type AlsoLikedPage = { + cursor?: string + posts: AppBskyFeedDefs.PostView[] +} + +async function fetchAlsoLikedSkeleton( + postUri: string, + pageParam: string | undefined, +) { + const urls = getAlsoLikedUrls(postUri, pageParam) + let lastError: Error | undefined + + for (const url of urls) { + try { + const res = await fetch(url.toString()) + if (!res.ok) { + lastError = new Error( + `Failed to load also liked recommendations (${res.status})`, + ) + continue + } + + const contentType = res.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { + const body = await res.text() + lastError = new Error( + body.startsWith(' { + url.searchParams.set('format', 'json') + url.searchParams.set('post', postUri) + url.searchParams.set('limit', String(ALSO_LIKED_PAGE_SIZE)) + if (pageParam) { + url.searchParams.set('cursor', pageParam) + } + return url + } + + if (IS_WEB) { + urls.push( + appendParams(new URL(ALSO_LIKED_PROXY_PATH, window.location.origin)), + ) + + const hostedProxyUrl = new URL(ALSO_LIKED_PROXY_PATH, ALSO_LIKED_PROXY_HOST) + if (hostedProxyUrl.origin !== window.location.origin) { + urls.push(appendParams(hostedProxyUrl)) + } + } else { + urls.push(appendParams(new URL(ALSO_LIKED_URL))) + } + + return urls +} + +export const RQKEY_ROOT = 'post-also-liked' +export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] as [string, string] + +export function usePostAlsoLikedQuery( + uri: string | undefined, + opts?: {enabled?: boolean}, +) { + const agent = useAgent() + + return useInfiniteQuery< + AlsoLikedPage, + Error, + InfiniteData, + [string, string], + string | undefined + >({ + enabled: Boolean(uri) && opts?.enabled !== false, + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY(uri || ''), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.cursor || undefined, + async queryFn({pageParam}): Promise { + if (!uri) { + throw new Error('No post URI provided for also liked query') + } + + const data = await fetchAlsoLikedSkeleton(uri, pageParam) + const uris = Array.from( + new Set( + (data.feed || []) + .map(item => item.post) + .filter((post): post is string => Boolean(post)), + ), + ).slice(0, 25) + + if (!uris.length) { + return {cursor: undefined, posts: []} + } + + const postsRes = await agent.getPosts({uris}) + if (!postsRes.success) { + throw new Error('Failed to hydrate also liked recommendations') + } + + const postsByUri = new Map( + postsRes.data.posts.map(post => [post.uri, post] as const), + ) + + return { + cursor: data.cursor, + posts: uris + .map(postUri => postsByUri.get(postUri)) + .filter((post): post is AppBskyFeedDefs.PostView => Boolean(post)), + } + }, + }) +}