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)),
+ }
+ },
+ })
+}