Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Also liked posts below posts

xan.lol 1633c1f9 d42a2808

+469 -73
+50
functions/api/also-liked.ts
··· 1 + const ALSO_LIKED_URL = 'https://foryou.club/also-liked' 2 + 3 + function json(data: unknown, init?: ResponseInit) { 4 + return new Response(JSON.stringify(data), { 5 + ...init, 6 + headers: { 7 + 'access-control-allow-origin': '*', 8 + 'cache-control': 'no-store', 9 + 'content-type': 'application/json; charset=utf-8', 10 + ...init?.headers, 11 + }, 12 + }) 13 + } 14 + 15 + export async function onRequestGet(context: {request: Request}) { 16 + const url = new URL(context.request.url) 17 + const post = url.searchParams.get('post') 18 + 19 + if (!post) { 20 + return json({error: 'Missing post parameter'}, {status: 400}) 21 + } 22 + 23 + const upstreamUrl = new URL(ALSO_LIKED_URL) 24 + upstreamUrl.searchParams.set('format', 'json') 25 + upstreamUrl.searchParams.set('post', post) 26 + 27 + const limit = url.searchParams.get('limit') 28 + if (limit) { 29 + upstreamUrl.searchParams.set('limit', limit) 30 + } 31 + 32 + const cursor = url.searchParams.get('cursor') 33 + if (cursor) { 34 + upstreamUrl.searchParams.set('cursor', cursor) 35 + } 36 + 37 + const response = await fetch(upstreamUrl.toString()) 38 + const body = await response.text() 39 + 40 + return new Response(body, { 41 + status: response.status, 42 + headers: { 43 + 'access-control-allow-origin': '*', 44 + 'cache-control': 'no-store', 45 + 'content-type': 46 + response.headers.get('content-type') || 47 + 'application/json; charset=utf-8', 48 + }, 49 + }) 50 + }
+91
src/screens/PostThread/components/ThreadAlsoLiked.tsx
··· 1 + import {View} from 'react-native' 2 + import {type AppBskyFeedDefs} from '@atproto/api' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + 5 + import {cleanError} from '#/lib/strings/errors' 6 + import {Post} from '#/view/com/post/Post' 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {Button, ButtonText} from '#/components/Button' 9 + import {Loader} from '#/components/Loader' 10 + import {Text} from '#/components/Typography' 11 + 12 + export function ThreadAlsoLiked({ 13 + posts, 14 + enabled, 15 + isLoading, 16 + isFetchingNextPage, 17 + error, 18 + onRetry, 19 + spacerHeight, 20 + isTombstoneView, 21 + }: { 22 + posts: AppBskyFeedDefs.PostView[] 23 + enabled: boolean 24 + isLoading: boolean 25 + isFetchingNextPage: boolean 26 + error: unknown 27 + onRetry: () => void 28 + spacerHeight: number | undefined 29 + isTombstoneView: boolean 30 + }) { 31 + const {t: l} = useLingui() 32 + const t = useTheme() 33 + const hasSection = 34 + enabled && (posts.length > 0 || isLoading || Boolean(error)) 35 + 36 + return ( 37 + <View> 38 + {hasSection && ( 39 + <View style={[a.border_t, t.atoms.border_contrast_low]}> 40 + <View style={[a.px_lg, a.pt_xl, a.pb_md, a.gap_2xs]}> 41 + <Text style={[a.text_xl, a.font_bold]}> 42 + <Trans>Also liked</Trans> 43 + </Text> 44 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 45 + <Trans>Posts liked by people who liked this post</Trans> 46 + </Text> 47 + </View> 48 + 49 + {posts.map((post, index) => ( 50 + <Post key={post.uri} post={post} hideTopBorder={index === 0} /> 51 + ))} 52 + 53 + {(isLoading || isFetchingNextPage) && ( 54 + <View style={[a.align_center, a.py_xl]}> 55 + <Loader size="xl" /> 56 + </View> 57 + )} 58 + 59 + {Boolean(error) && !isLoading && !isFetchingNextPage && ( 60 + <View style={[a.px_lg, a.pb_xl, a.gap_md]}> 61 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 62 + {cleanError(error)} 63 + </Text> 64 + <View style={[a.flex_row]}> 65 + <Button 66 + label={l`Retry loading also liked posts`} 67 + onPress={onRetry} 68 + variant="solid" 69 + color="secondary_inverted" 70 + size="small"> 71 + <ButtonText> 72 + <Trans>Retry</Trans> 73 + </ButtonText> 74 + </Button> 75 + </View> 76 + </View> 77 + )} 78 + </View> 79 + )} 80 + 81 + <View 82 + style={[ 83 + a.w_full, 84 + !hasSection && 85 + !isTombstoneView && [a.border_t, t.atoms.border_contrast_low], 86 + {height: spacerHeight ?? 180}, 87 + ]} 88 + /> 89 + </View> 90 + ) 91 + }
+48 -16
src/screens/PostThread/index.tsx
··· 15 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 16 import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 17 17 import {useFeedFeedback} from '#/state/feed-feedback' 18 + import {useAlsoLikedFeedEnabled} from '#/state/preferences/also-liked-feed-enabled' 19 + import {usePostAlsoLikedQuery} from '#/state/queries/post-also-liked' 18 20 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 19 21 import { 20 22 PostThreadContextProvider, ··· 27 29 import {useUnstablePostSource} from '#/state/unstable-post-source' 28 30 import {List, type ListMethods} from '#/view/com/util/List' 29 31 import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' 32 + import {ThreadAlsoLiked} from '#/screens/PostThread/components/ThreadAlsoLiked' 30 33 import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' 31 34 import {ThreadError} from '#/screens/PostThread/components/ThreadError' 32 35 import { ··· 50 53 } from '#/screens/PostThread/components/ThreadItemTreePost' 51 54 import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 52 55 import * as Layout from '#/components/Layout' 53 - import {ListFooter} from '#/components/Lists' 54 56 import {useAnalytics} from '#/analytics' 55 57 import {IS_NATIVE} from '#/env' 56 58 ··· 148 150 149 151 const isRoot = !!anchor && anchor.value.post.record.reply === undefined 150 152 const canReply = !anchor?.value.post?.viewer?.replyDisabled 153 + const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() 154 + const alsoLikedAnchorUri = 155 + anchor?.type === 'threadPost' && isRoot ? anchor.value.post.uri : undefined 156 + const alsoLiked = usePostAlsoLikedQuery(alsoLikedAnchorUri, { 157 + enabled: Boolean(alsoLikedAnchorUri) && alsoLikedFeedEnabled, 158 + }) 159 + const alsoLikedPosts = useMemo(() => { 160 + const seen = new Set<string>() 161 + return (alsoLiked.data?.pages ?? []) 162 + .flatMap(page => page.posts) 163 + .filter(post => { 164 + if (seen.has(post.uri)) return false 165 + seen.add(post.uri) 166 + return true 167 + }) 168 + }, [alsoLiked.data]) 151 169 const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) 152 170 const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) 153 171 const totalParentCount = useRef(0) // recomputed below ··· 331 349 if (thread.state.isFetching) return 332 350 // can be true after `prepareForParamsUpdate` is called 333 351 if (deferParents) return 334 - // prevent any state mutations if we know we're done 335 - if (maxChildrenCount >= totalChildrenCount.current) return 336 - setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) 352 + if (maxChildrenCount < totalChildrenCount.current) { 353 + setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) 354 + return 355 + } 356 + if ( 357 + alsoLikedAnchorUri && 358 + alsoLikedFeedEnabled && 359 + !alsoLiked.isLoading && 360 + !alsoLiked.isFetchingNextPage && 361 + alsoLiked.hasNextPage 362 + ) { 363 + void alsoLiked.fetchNextPage() 364 + } 337 365 } 338 366 339 367 const slices = useMemo(() => { ··· 543 571 ) 544 572 545 573 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined 574 + const retryAlsoLiked = useCallback(() => { 575 + if (alsoLikedPosts.length > 0) { 576 + void alsoLiked.fetchNextPage() 577 + } else { 578 + void alsoLiked.refetch() 579 + } 580 + }, [alsoLiked, alsoLikedPosts.length]) 546 581 547 582 return ( 548 583 <PostThreadContextProvider context={thread.context}> ··· 596 631 desktopFixedHeight 597 632 sideBorders={false} 598 633 ListFooterComponent={ 599 - <ListFooter 600 - /* 601 - * On native, if `deferParents` is true, we need some extra buffer to 602 - * account for the `on*ReachedThreshold` values. 603 - * 604 - * Otherwise, and on web, this value needs to be the height of 605 - * the viewport _minus_ a sensible min-post height e.g. 200, so 606 - * that there's enough scroll remaining to get the anchor post 607 - * back to the top of the screen when handling scroll. 608 - */ 609 - height={platform({ 634 + <ThreadAlsoLiked 635 + posts={alsoLikedPosts} 636 + enabled={Boolean(alsoLikedAnchorUri) && alsoLikedFeedEnabled} 637 + isLoading={alsoLiked.isLoading} 638 + isFetchingNextPage={alsoLiked.isFetchingNextPage} 639 + error={alsoLiked.error} 640 + onRetry={retryAlsoLiked} 641 + spacerHeight={platform({ 610 642 web: defaultListFooterHeight, 611 643 default: deferParents 612 644 ? windowHeight * 2 613 645 : defaultListFooterHeight, 614 646 })} 615 - style={isTombstoneView ? {borderTopWidth: 0} : undefined} 647 + isTombstoneView={isTombstoneView} 616 648 /> 617 649 } 618 650 initialNumToRender={initialNumToRender}
+21
src/screens/Settings/RunesSettings/DisplaySettings.tsx
··· 7 7 import {dynamicActivate as dynamicActivateWeb} from '#/locale/i18n.web' 8 8 import {type AppLanguage} from '#/locale/languages' 9 9 import { 10 + useAlsoLikedFeedEnabled, 11 + useSetAlsoLikedFeedEnabled, 12 + } from '#/state/preferences/also-liked-feed-enabled' 13 + import { 10 14 useHighQualityImages, 11 15 useSetHighQualityImages, 12 16 } from '#/state/preferences/high-quality-images' ··· 28 32 import {Button, ButtonText} from '#/components/Button' 29 33 import * as Dialog from '#/components/Dialog' 30 34 import * as Toggle from '#/components/forms/Toggle' 35 + import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' 31 36 import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 32 37 import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' 33 38 import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' ··· 42 47 const repostCarouselEnabled = useRepostCarouselEnabled() 43 48 const setRepostCarouselEnabled = useSetRepostCarouselEnabled() 44 49 50 + const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() 51 + const setAlsoLikedFeedEnabled = useSetAlsoLikedFeedEnabled() 52 + 45 53 const highQualityImages = useHighQualityImages() 46 54 const setHighQualityImages = useSetHighQualityImages() 47 55 ··· 61 69 <SettingsList.ItemIcon icon={RepostIcon} /> 62 70 <SettingsList.ItemText> 63 71 <Trans>Combine reposts into a horizontal carousel</Trans> 72 + </SettingsList.ItemText> 73 + <Toggle.Platform /> 74 + </SettingsList.Item> 75 + </Toggle.Item> 76 + <Toggle.Item 77 + name="also_liked_feed" 78 + label={l`Show "Also liked" recommendations under post replies`} 79 + value={alsoLikedFeedEnabled} 80 + onChange={value => setAlsoLikedFeedEnabled(value)}> 81 + <SettingsList.Item> 82 + <SettingsList.ItemIcon icon={HeartIcon} /> 83 + <SettingsList.ItemText> 84 + <Trans>Show "Also liked" recommendations under post replies</Trans> 64 85 </SettingsList.ItemText> 65 86 <Toggle.Platform /> 66 87 </SettingsList.Item>
+2
src/state/persisted/schema.ts
··· 169 169 noAppLabelers: z.boolean().optional(), 170 170 noDiscoverFallback: z.boolean().optional(), 171 171 repostCarouselEnabled: z.boolean().optional(), 172 + alsoLikedFeedEnabled: z.boolean().optional(), 172 173 constellationInstance: z.string().optional(), 173 174 showLinkInHandle: z.boolean().optional(), 174 175 showLinkInHandleOnlyOnWorkingLinks: z.boolean().optional(), ··· 299 300 noAppLabelers: false, 300 301 noDiscoverFallback: false, 301 302 repostCarouselEnabled: false, 303 + alsoLikedFeedEnabled: true, 302 304 constellationInstance: 'https://constellation.microcosm.blue/', 303 305 showLinkInHandle: true, 304 306 showLinkInHandleOnlyOnWorkingLinks: true,
+49
src/state/preferences/also-liked-feed-enabled.tsx
··· 1 + import { 2 + createContext, 3 + type ReactNode, 4 + useCallback, 5 + useContext, 6 + useEffect, 7 + useState, 8 + } from 'react' 9 + 10 + import * as persisted from '#/state/persisted' 11 + 12 + type StateContext = boolean 13 + type SetContext = (v: boolean) => void 14 + 15 + const stateContext = createContext<StateContext>( 16 + Boolean(persisted.defaults.alsoLikedFeedEnabled), 17 + ) 18 + const setContext = createContext<SetContext>((_: boolean) => {}) 19 + 20 + export function Provider({children}: {children: ReactNode}) { 21 + const [state, setState] = useState( 22 + Boolean(persisted.get('alsoLikedFeedEnabled')), 23 + ) 24 + 25 + const setStateWrapped = useCallback( 26 + (value: persisted.Schema['alsoLikedFeedEnabled']) => { 27 + setState(Boolean(value)) 28 + persisted.write('alsoLikedFeedEnabled', value) 29 + }, 30 + [setState], 31 + ) 32 + 33 + useEffect(() => { 34 + return persisted.onUpdate('alsoLikedFeedEnabled', nextValue => { 35 + setState(Boolean(nextValue)) 36 + }) 37 + }, [setStateWrapped]) 38 + 39 + return ( 40 + <stateContext.Provider value={state}> 41 + <setContext.Provider value={setStateWrapped}> 42 + {children} 43 + </setContext.Provider> 44 + </stateContext.Provider> 45 + ) 46 + } 47 + 48 + export const useAlsoLikedFeedEnabled = () => useContext(stateContext) 49 + export const useSetAlsoLikedFeedEnabled = () => useContext(setContext)
+64 -57
src/state/preferences/index.tsx
··· 1 1 import {type PropsWithChildren} from 'react' 2 2 3 + import {Provider as AlsoLikedFeedProvider} from './also-liked-feed-enabled' 3 4 import {Provider as AltTextRequiredProvider} from './alt-text-required' 4 5 import {Provider as AutoLikeOnRepostProvider} from './auto-like-on-repost' 5 6 import {Provider as AutoplayProvider} from './autoplay' ··· 57 58 import {Provider as UseHandleInLinksProvider} from './use-handle-in-links' 58 59 import {Provider as UsedStarterPacksProvider} from './used-starter-packs' 59 60 61 + export { 62 + useAlsoLikedFeedEnabled, 63 + useSetAlsoLikedFeedEnabled, 64 + } from './also-liked-feed-enabled' 60 65 export { 61 66 useRequireAltTextEnabled, 62 67 useSetRequireAltTextEnabled, ··· 143 148 <SubtitlesProvider> 144 149 <TrendingSettingsProvider> 145 150 <RepostCarouselProvider> 146 - <KawaiiProvider> 147 - <HideFeedsPromoTabProvider> 148 - <DisableViaRepostNotificationProvider> 149 - <DisableLikesMetricsProvider> 150 - <DisableRepostsMetricsProvider> 151 - <DisableQuotesMetricsProvider> 152 - <DisableSavesMetricsProvider> 153 - <DisableReplyMetricsProvider> 154 - <DisableFollowersMetricsProvider> 155 - <DisableFollowingMetricsProvider> 156 - <DisableFollowedByMetricsProvider> 157 - <DisablePostsMetricsProvider> 158 - <ShowFollowsYouBadgeProvider> 159 - <HideSimilarAccountsRecommProvider> 160 - <HideScaryFollowButtonsProvider> 161 - <HideUnreplyablePostsProvider> 162 - <EnableSquareAvatarsProvider> 163 - <EnableSquareButtonsProvider> 164 - <ShowViaClientProvider> 165 - <PostNameReplacementProvider> 166 - <DisableVerifyEmailReminderProvider> 167 - <TranslationServicePreferenceProvider> 168 - <OpenRouterProvider> 169 - <DisableComposerPromptProvider> 170 - <DisableTopOfFeedButtonProvider> 171 - <DiscoverContextEnabledProvider> 172 - <OmitViaFieldProvider> 173 - { 174 - children 175 - } 176 - </OmitViaFieldProvider> 177 - </DiscoverContextEnabledProvider> 178 - </DisableTopOfFeedButtonProvider> 179 - </DisableComposerPromptProvider> 180 - </OpenRouterProvider> 181 - </TranslationServicePreferenceProvider> 182 - </DisableVerifyEmailReminderProvider> 183 - </PostNameReplacementProvider> 184 - </ShowViaClientProvider> 185 - </EnableSquareButtonsProvider> 186 - </EnableSquareAvatarsProvider> 187 - </HideUnreplyablePostsProvider> 188 - </HideScaryFollowButtonsProvider> 189 - </HideSimilarAccountsRecommProvider> 190 - </ShowFollowsYouBadgeProvider> 191 - </DisablePostsMetricsProvider> 192 - </DisableFollowedByMetricsProvider> 193 - </DisableFollowingMetricsProvider> 194 - </DisableFollowersMetricsProvider> 195 - </DisableReplyMetricsProvider> 196 - </DisableSavesMetricsProvider> 197 - </DisableQuotesMetricsProvider> 198 - </DisableRepostsMetricsProvider> 199 - </DisableLikesMetricsProvider> 200 - </DisableViaRepostNotificationProvider> 201 - </HideFeedsPromoTabProvider> 202 - </KawaiiProvider> 151 + <AlsoLikedFeedProvider> 152 + <KawaiiProvider> 153 + <HideFeedsPromoTabProvider> 154 + <DisableViaRepostNotificationProvider> 155 + <DisableLikesMetricsProvider> 156 + <DisableRepostsMetricsProvider> 157 + <DisableQuotesMetricsProvider> 158 + <DisableSavesMetricsProvider> 159 + <DisableReplyMetricsProvider> 160 + <DisableFollowersMetricsProvider> 161 + <DisableFollowingMetricsProvider> 162 + <DisableFollowedByMetricsProvider> 163 + <DisablePostsMetricsProvider> 164 + <ShowFollowsYouBadgeProvider> 165 + <HideSimilarAccountsRecommProvider> 166 + <HideScaryFollowButtonsProvider> 167 + <HideUnreplyablePostsProvider> 168 + <EnableSquareAvatarsProvider> 169 + <EnableSquareButtonsProvider> 170 + <ShowViaClientProvider> 171 + <PostNameReplacementProvider> 172 + <DisableVerifyEmailReminderProvider> 173 + <TranslationServicePreferenceProvider> 174 + <OpenRouterProvider> 175 + <DisableComposerPromptProvider> 176 + <DisableTopOfFeedButtonProvider> 177 + <DiscoverContextEnabledProvider> 178 + <OmitViaFieldProvider> 179 + { 180 + children 181 + } 182 + </OmitViaFieldProvider> 183 + </DiscoverContextEnabledProvider> 184 + </DisableTopOfFeedButtonProvider> 185 + </DisableComposerPromptProvider> 186 + </OpenRouterProvider> 187 + </TranslationServicePreferenceProvider> 188 + </DisableVerifyEmailReminderProvider> 189 + </PostNameReplacementProvider> 190 + </ShowViaClientProvider> 191 + </EnableSquareButtonsProvider> 192 + </EnableSquareAvatarsProvider> 193 + </HideUnreplyablePostsProvider> 194 + </HideScaryFollowButtonsProvider> 195 + </HideSimilarAccountsRecommProvider> 196 + </ShowFollowsYouBadgeProvider> 197 + </DisablePostsMetricsProvider> 198 + </DisableFollowedByMetricsProvider> 199 + </DisableFollowingMetricsProvider> 200 + </DisableFollowersMetricsProvider> 201 + </DisableReplyMetricsProvider> 202 + </DisableSavesMetricsProvider> 203 + </DisableQuotesMetricsProvider> 204 + </DisableRepostsMetricsProvider> 205 + </DisableLikesMetricsProvider> 206 + </DisableViaRepostNotificationProvider> 207 + </HideFeedsPromoTabProvider> 208 + </KawaiiProvider> 209 + </AlsoLikedFeedProvider> 203 210 </RepostCarouselProvider> 204 211 </TrendingSettingsProvider> 205 212 </SubtitlesProvider>
+144
src/state/queries/post-also-liked.ts
··· 1 + import {type AppBskyFeedDefs} from '@atproto/api' 2 + import {type InfiniteData, useInfiniteQuery} from '@tanstack/react-query' 3 + 4 + import {STALE} from '#/state/queries' 5 + import {useAgent} from '#/state/session' 6 + import {IS_WEB} from '#/env' 7 + 8 + const ALSO_LIKED_URL = 'https://foryou.club/also-liked' 9 + const ALSO_LIKED_PROXY_PATH = '/api/also-liked' 10 + const ALSO_LIKED_PROXY_HOST = 'https://witchsky.app' 11 + const ALSO_LIKED_PAGE_SIZE = 10 12 + 13 + type AlsoLikedSkeletonResponse = { 14 + cursor?: string 15 + feed?: Array<{post?: string}> 16 + } 17 + 18 + type AlsoLikedPage = { 19 + cursor?: string 20 + posts: AppBskyFeedDefs.PostView[] 21 + } 22 + 23 + async function fetchAlsoLikedSkeleton( 24 + postUri: string, 25 + pageParam: string | undefined, 26 + ) { 27 + const urls = getAlsoLikedUrls(postUri, pageParam) 28 + let lastError: Error | undefined 29 + 30 + for (const url of urls) { 31 + try { 32 + const res = await fetch(url.toString()) 33 + if (!res.ok) { 34 + lastError = new Error( 35 + `Failed to load also liked recommendations (${res.status})`, 36 + ) 37 + continue 38 + } 39 + 40 + const contentType = res.headers.get('content-type') || '' 41 + if (!contentType.includes('application/json')) { 42 + const body = await res.text() 43 + lastError = new Error( 44 + body.startsWith('<!DOCTYPE') || body.startsWith('<!doctype') 45 + ? 'Also liked is unavailable from this web environment right now.' 46 + : 'Also liked returned an unexpected response.', 47 + ) 48 + continue 49 + } 50 + 51 + return (await res.json()) as AlsoLikedSkeletonResponse 52 + } catch (err) { 53 + lastError = err instanceof Error ? err : new Error(String(err)) 54 + } 55 + } 56 + 57 + throw lastError || new Error('Failed to load also liked recommendations') 58 + } 59 + 60 + function getAlsoLikedUrls(postUri: string, pageParam: string | undefined) { 61 + const urls: URL[] = [] 62 + const appendParams = (url: URL) => { 63 + url.searchParams.set('format', 'json') 64 + url.searchParams.set('post', postUri) 65 + url.searchParams.set('limit', String(ALSO_LIKED_PAGE_SIZE)) 66 + if (pageParam) { 67 + url.searchParams.set('cursor', pageParam) 68 + } 69 + return url 70 + } 71 + 72 + if (IS_WEB) { 73 + urls.push( 74 + appendParams(new URL(ALSO_LIKED_PROXY_PATH, window.location.origin)), 75 + ) 76 + 77 + const hostedProxyUrl = new URL(ALSO_LIKED_PROXY_PATH, ALSO_LIKED_PROXY_HOST) 78 + if (hostedProxyUrl.origin !== window.location.origin) { 79 + urls.push(appendParams(hostedProxyUrl)) 80 + } 81 + } else { 82 + urls.push(appendParams(new URL(ALSO_LIKED_URL))) 83 + } 84 + 85 + return urls 86 + } 87 + 88 + export const RQKEY_ROOT = 'post-also-liked' 89 + export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] as [string, string] 90 + 91 + export function usePostAlsoLikedQuery( 92 + uri: string | undefined, 93 + opts?: {enabled?: boolean}, 94 + ) { 95 + const agent = useAgent() 96 + 97 + return useInfiniteQuery< 98 + AlsoLikedPage, 99 + Error, 100 + InfiniteData<AlsoLikedPage>, 101 + [string, string], 102 + string | undefined 103 + >({ 104 + enabled: Boolean(uri) && opts?.enabled !== false, 105 + staleTime: STALE.MINUTES.FIVE, 106 + queryKey: RQKEY(uri || ''), 107 + initialPageParam: undefined as string | undefined, 108 + getNextPageParam: lastPage => lastPage.cursor || undefined, 109 + async queryFn({pageParam}): Promise<AlsoLikedPage> { 110 + if (!uri) { 111 + throw new Error('No post URI provided for also liked query') 112 + } 113 + 114 + const data = await fetchAlsoLikedSkeleton(uri, pageParam) 115 + const uris = Array.from( 116 + new Set( 117 + (data.feed || []) 118 + .map(item => item.post) 119 + .filter((post): post is string => Boolean(post)), 120 + ), 121 + ).slice(0, 25) 122 + 123 + if (!uris.length) { 124 + return {cursor: undefined, posts: []} 125 + } 126 + 127 + const postsRes = await agent.getPosts({uris}) 128 + if (!postsRes.success) { 129 + throw new Error('Failed to hydrate also liked recommendations') 130 + } 131 + 132 + const postsByUri = new Map( 133 + postsRes.data.posts.map(post => [post.uri, post] as const), 134 + ) 135 + 136 + return { 137 + cursor: data.cursor, 138 + posts: uris 139 + .map(postUri => postsByUri.get(postUri)) 140 + .filter((post): post is AppBskyFeedDefs.PostView => Boolean(post)), 141 + } 142 + }, 143 + }) 144 + }