diff --git a/functions/api/also-liked.ts b/functions/api/also-liked.ts deleted file mode 100644 --- a/functions/api/also-liked.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/Navigation.tsx b/src/Navigation.tsx --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -129,6 +129,7 @@ import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings' import {PetLabelSettingsScreen} from '#/screens/Settings/PetLabelSettings' import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' import {RunesSettingsScreen} from '#/screens/Settings/RunesSettings' +import {RunesDisplayAlsoLikedSettingsScreen} from '#/screens/Settings/RunesSettings/AlsoLikedSettings' import {RunesBadgesSettingsScreen} from '#/screens/Settings/RunesSettings/BadgesSettings' import {RunesDisplaySettingsScreen} from '#/screens/Settings/RunesSettings/DisplaySettings' import {RunesExtraSettingsScreen} from '#/screens/Settings/RunesSettings/ExtraSettings' @@ -456,6 +457,11 @@ RunesDisplaySettingsScreen} options={{title: title(msg`Display`), requireAuth: true}} + /> + RunesDisplayAlsoLikedSettingsScreen} + options={{title: title(msg`Also liked`), requireAuth: true}} /> void + headerRef: RefObject + onToggleCollapsed: () => void spacerHeight: number | undefined isTombstoneView: boolean }) { const {t: l} = useLingui() const t = useTheme() const queryClient = useQueryClient() - const hasSection = - enabled && (posts.length > 0 || isLoading || Boolean(error)) + const hasSection = visible const onBeforePress = useCallback( (post: AppBskyFeedDefs.PostView) => { unstableCacheProfileView(queryClient, post.author) @@ -54,59 +65,98 @@ return ( {hasSection && ( - - - Also liked - - - Posts liked by people who liked this post - - - - {posts.map((post, index) => ( - onBeforePress(post)} - /> - ))} + + {({hovered, pressed}) => ( + + + + Also liked + + + Posts liked by people who liked this post + + + {collapsed ? ( + + ) : ( + + )} + + )} + - {isLoading && posts.length === 0 && ( + {!collapsed && ( <> - - - - )} + {posts.map((post, index) => ( + onBeforePress(post)} + /> + ))} + + {showLoadingState && posts.length === 0 && ( + <> + + + + )} - {isFetchingNextPage && ( - - )} + {isFetchingNextPage && ( + + )} - {Boolean(error) && !isLoading && !isFetchingNextPage && ( - - - {cleanError(error)} - - - - - + {Boolean(error) && !showLoadingState && !isFetchingNextPage && ( + + + {cleanError(error)} + + + + + Retry + + + + + )} + )} )} diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -15,6 +15,7 @@ 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 {useAlsoLikedCollapseByDefault} from '#/state/preferences/also-liked-collapse-by-default' 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' @@ -151,14 +152,22 @@ const isRoot = !!anchor && anchor.value.post.record.reply === undefined const canReply = !anchor?.value.post?.viewer?.replyDisabled const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() + const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() const alsoLikedAnchorUri = anchor?.type === 'threadPost' && isRoot ? anchor.value.post.uri : undefined const [deferParents, setDeferParents] = useState(true) - const alsoLikedEnabled = + const [alsoLikedCollapsed, setAlsoLikedCollapsed] = useState( + alsoLikedCollapseByDefault, + ) + useEffect(() => { + setAlsoLikedCollapsed(alsoLikedCollapseByDefault) + }, [alsoLikedAnchorUri, alsoLikedCollapseByDefault]) + const alsoLikedVisible = Boolean(alsoLikedAnchorUri) && alsoLikedFeedEnabled && !thread.state.isPlaceholderData && !deferParents + const alsoLikedEnabled = alsoLikedVisible && !alsoLikedCollapsed const alsoLiked = usePostAlsoLikedQuery(alsoLikedAnchorUri, { enabled: alsoLikedEnabled, }) @@ -179,6 +188,16 @@ const totalChildrenCount = useRef(thread.data.items.length) // recomputed below const listRef = useRef(null) const anchorRef = useRef(null) const headerRef = useRef(null) + const alsoLikedHeaderRef = useRef(null) + const currentScrollOffsetRef = useRef(0) + const scrollStateRequestIdRef = useRef(0) + const [isAlsoLikedFocused, setIsAlsoLikedFocused] = useState(false) + + useEffect(() => { + if (!alsoLikedVisible || alsoLikedCollapsed) { + setIsAlsoLikedFocused(false) + } + }, [alsoLikedCollapsed, alsoLikedVisible]) /* * On a cold load, parents are not prepended until the anchor post has @@ -583,6 +602,68 @@ } else { void alsoLiked.refetch() } }, [alsoLiked, alsoLikedPosts.length]) + const toggleAlsoLikedCollapsed = useCallback(() => { + setAlsoLikedCollapsed(current => !current) + }, []) + const handleScrollOffsetChange = useNonReactiveCallback((offsetY: number) => { + currentScrollOffsetRef.current = offsetY + if (offsetY <= 1) { + scrollStateRequestIdRef.current += 1 + setIsAlsoLikedFocused(false) + return + } + scrollStateRequestIdRef.current += 1 + updateAlsoLikedScrollState(offsetY, scrollStateRequestIdRef.current) + }) + const updateAlsoLikedScrollState = useNonReactiveCallback( + (offsetY: number, requestId: number) => { + if ( + !alsoLikedVisible || + alsoLikedCollapsed || + !alsoLikedHeaderRef.current || + !headerRef.current + ) { + setIsAlsoLikedFocused(false) + return + } + + measureViewRect(headerRef.current, headerRect => { + if (requestId !== scrollStateRequestIdRef.current) return + if (!headerRect) { + setIsAlsoLikedFocused(false) + return + } + + measureViewRect(alsoLikedHeaderRef.current, alsoLikedRect => { + if (requestId !== scrollStateRequestIdRef.current) return + if (!alsoLikedRect) { + setIsAlsoLikedFocused(false) + return + } + + const headerBottom = headerRect.y + headerRect.height + const focused = alsoLikedRect.y <= headerBottom + + setIsAlsoLikedFocused(current => + current === focused ? current : focused, + ) + }) + }) + }, + ) + + useEffect(() => { + scrollStateRequestIdRef.current += 1 + updateAlsoLikedScrollState( + currentScrollOffsetRef.current, + scrollStateRequestIdRef.current, + ) + }, [ + alsoLikedCollapsed, + alsoLikedPosts.length, + alsoLikedVisible, + updateAlsoLikedScrollState, + ]) return ( @@ -590,7 +671,11 @@ - Post + {isAlsoLikedFocused ? ( + Posts also liked + ) : ( + Post + )} @@ -622,6 +707,7 @@ onStartReached={onStartReached} onEndReached={onEndReached} onEndReachedThreshold={4} onStartReachedThreshold={1} + onScrollOffsetChange={handleScrollOffsetChange} onItemSeen={item => { // Track post:view for parent posts and replies (non-anchor posts) if (item.type === 'threadPost' && item.depth !== 0) { @@ -638,11 +724,20 @@ sideBorders={false} ListFooterComponent={ { return item.key } + +type ViewRect = { + x: number + y: number + width: number + height: number +} + +function measureViewRect( + view: View | null, + cb: (rect: ViewRect | null) => void, +) { + const target = view as any + if (!target) { + cb(null) + return + } + + if (typeof target.measureInWindow === 'function') { + target.measureInWindow( + (x: number, y: number, width: number, height: number) => { + cb({x, y, width, height}) + }, + ) + return + } + + if (typeof target.getBoundingClientRect === 'function') { + const rect = target.getBoundingClientRect() + cb({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }) + return + } + + if (typeof target.measure === 'function') { + target.measure( + ( + _x: number, + _y: number, + width: number, + height: number, + pageX: number, + pageY: number, + ) => { + cb({x: pageX, y: pageY, width, height}) + }, + ) + return + } + + cb(null) +} diff --git a/src/screens/Settings/RunesSettings/AlsoLikedSettings.tsx b/src/screens/Settings/RunesSettings/AlsoLikedSettings.tsx new file mode 100644 --- /dev/null +++ b/src/screens/Settings/RunesSettings/AlsoLikedSettings.tsx @@ -0,0 +1,76 @@ +import {Trans, useLingui} from '@lingui/react/macro' + +import { + useAlsoLikedCollapseByDefault, + useAlsoLikedFeedEnabled, + useSetAlsoLikedCollapseByDefault, + useSetAlsoLikedFeedEnabled, +} from '#/state/preferences' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' +import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' +import {SimpleInlineLinkText} from '#/components/Link' +import {RunesScreenLayout} from './components/RunesScreenLayout' + +export function RunesDisplayAlsoLikedSettingsScreen() { + const {t: l} = useLingui() + + const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() + const setAlsoLikedFeedEnabled = useSetAlsoLikedFeedEnabled() + + const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() + const setAlsoLikedCollapseByDefault = useSetAlsoLikedCollapseByDefault() + + return ( + + setAlsoLikedFeedEnabled(value)}> + + + + Show "Also liked" recommendations under post replies + + + + + setAlsoLikedCollapseByDefault(value)}> + + + + Collapse "Also liked" by default + + + + + + + + Powered by the{' '} + + For You + {' '} + feed. Posts must have likes, reposts, or have been created in the + last 90 days to appear. Learn more at{' '} + + foryou.club + + + + + + ) +} diff --git a/src/screens/Settings/RunesSettings/DisplaySettings.tsx b/src/screens/Settings/RunesSettings/DisplaySettings.tsx --- a/src/screens/Settings/RunesSettings/DisplaySettings.tsx +++ b/src/screens/Settings/RunesSettings/DisplaySettings.tsx @@ -7,9 +7,9 @@ import {dynamicActivate} from '#/locale/i18n' import {dynamicActivate as dynamicActivateWeb} from '#/locale/i18n.web' import {type AppLanguage} from '#/locale/languages' import { + useAlsoLikedCollapseByDefault, useAlsoLikedFeedEnabled, - useSetAlsoLikedFeedEnabled, -} from '#/state/preferences/also-liked-feed-enabled' +} from '#/state/preferences' import { useHighQualityImages, useSetHighQualityImages, @@ -39,6 +39,7 @@ import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' +import {ItemTextWithSubtitle} from '../NotificationSettings/components/ItemTextWithSubtitle' import {RunesScreenLayout} from './components/RunesScreenLayout' export function RunesDisplaySettingsScreen() { @@ -48,7 +49,7 @@ const repostCarouselEnabled = useRepostCarouselEnabled() const setRepostCarouselEnabled = useSetRepostCarouselEnabled() const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() - const setAlsoLikedFeedEnabled = useSetAlsoLikedFeedEnabled() + const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() const highQualityImages = useHighQualityImages() const setHighQualityImages = useSetHighQualityImages() @@ -60,6 +61,21 @@ const setPostReplacementDialogControl = Dialog.useDialogControl() return ( + + + Also liked} + subtitleText={ + + } + /> + setAlsoLikedFeedEnabled(value)}> - - - - Show "Also liked" recommendations under post replies - - - - - ) +} + +function AlsoLikedDeclaration({ + enabled, + collapseByDefault, +}: { + enabled: boolean + collapseByDefault: boolean +}) { + if (!enabled) { + return Hidden in thread views + } + + if (collapseByDefault) { + return Shown in thread views, collapsed by default + } + + return Shown in thread views, expanded by default } function PostReplacementDialog({ diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -173,6 +173,7 @@ noAppLabelers: z.boolean().optional(), noDiscoverFallback: z.boolean().optional(), repostCarouselEnabled: z.boolean().optional(), alsoLikedFeedEnabled: z.boolean().optional(), + alsoLikedCollapseByDefault: z.boolean().optional(), constellationInstance: z.string().optional(), showLinkInHandle: z.boolean().optional(), showLinkInHandleOnlyOnWorkingLinks: z.boolean().optional(), @@ -306,6 +307,7 @@ noAppLabelers: false, noDiscoverFallback: false, repostCarouselEnabled: false, alsoLikedFeedEnabled: true, + alsoLikedCollapseByDefault: true, constellationInstance: 'https://constellation.microcosm.blue/', showLinkInHandle: true, showLinkInHandleOnlyOnWorkingLinks: true, diff --git a/src/state/preferences/also-liked-collapse-by-default.tsx b/src/state/preferences/also-liked-collapse-by-default.tsx new file mode 100644 --- /dev/null +++ b/src/state/preferences/also-liked-collapse-by-default.tsx @@ -0,0 +1,49 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = createContext( + Boolean(persisted.defaults.alsoLikedCollapseByDefault), +) +const setContext = createContext((_: boolean) => {}) + +export function Provider({children}: {children: ReactNode}) { + const [state, setState] = useState( + Boolean(persisted.get('alsoLikedCollapseByDefault')), + ) + + const setStateWrapped = useCallback( + (value: persisted.Schema['alsoLikedCollapseByDefault']) => { + setState(Boolean(value)) + persisted.write('alsoLikedCollapseByDefault', value) + }, + [setState], + ) + + useEffect(() => { + return persisted.onUpdate('alsoLikedCollapseByDefault', nextValue => { + setState(Boolean(nextValue)) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export const useAlsoLikedCollapseByDefault = () => useContext(stateContext) +export const useSetAlsoLikedCollapseByDefault = () => useContext(setContext) diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,5 +1,6 @@ import {type PropsWithChildren} from 'react' +import {Provider as AlsoLikedCollapseByDefaultProvider} from './also-liked-collapse-by-default' 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' @@ -58,6 +59,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 { + useAlsoLikedCollapseByDefault, + useSetAlsoLikedCollapseByDefault, +} from './also-liked-collapse-by-default' export { useAlsoLikedFeedEnabled, useSetAlsoLikedFeedEnabled, @@ -149,63 +154,65 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - children - } - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + children + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/state/queries/post-also-liked.ts b/src/state/queries/post-also-liked.ts --- a/src/state/queries/post-also-liked.ts +++ b/src/state/queries/post-also-liked.ts @@ -8,11 +8,8 @@ import {STALE} from '#/state/queries' import {precachePost} from '#/state/queries/post' 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 = { @@ -29,65 +26,39 @@ async function fetchAlsoLikedSkeleton( postUri: string, pageParam: string | undefined, ) { - const urls = getAlsoLikedUrls(postUri, pageParam) - let lastError: Error | undefined + try { + const res = await fetch(getAlsoLikedUrl(postUri, pageParam).toString()) + if (!res.ok) { + throw new Error( + `Failed to load also liked recommendations (${res.status})`, + ) + } - 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() + throw 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 +function getAlsoLikedUrl(postUri: string, pageParam: string | undefined) { + const url = new URL(ALSO_LIKED_URL) + 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) } - - 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 + return url } export const RQKEY_ROOT = 'post-also-liked' diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -30,6 +30,7 @@ | 'contentOffset' // Pass headerOffset instead. | 'progressViewOffset' // Can't be an animated value > & { onScrolledDownChange?: (isScrolledDown: boolean) => void + onScrollOffsetChange?: (offsetY: number) => void headerOffset?: number refreshing?: boolean onRefresh?: () => void @@ -48,6 +49,7 @@ let List = forwardRef( ( { onScrolledDownChange, + onScrollOffsetChange, refreshing, onRefresh, onItemSeen, @@ -69,6 +71,11 @@ (didScrollDown: boolean) => { onScrolledDownChange?.(didScrollDown) }, ) + const handleScrollOffsetChange = useNonReactiveCallback( + (offsetY: number) => { + onScrollOffsetChange?.(offsetY) + }, + ) // Intentionally destructured outside the main thread closure. // See https://github.com/bluesky-social/social-app/pull/4108. @@ -95,6 +102,10 @@ isScrolledDown.set(didScrollDown) if (onScrolledDownChange != null) { runOnJS(handleScrolledDownChange)(didScrollDown) } + } + + if (onScrollOffsetChange != null) { + runOnJS(handleScrollOffsetChange)(e.contentOffset.y) } if (IS_IOS) { diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -31,6 +31,7 @@ | 'refreshControl' // Pass refreshing and/or onRefresh instead. | 'contentOffset' // Pass headerOffset instead. > & { onScrolledDownChange?: (isScrolledDown: boolean) => void + onScrollOffsetChange?: (offsetY: number) => void headerOffset?: number refreshing?: boolean onRefresh?: () => void @@ -68,6 +69,7 @@ onEndReached, onEndReachedThreshold = 2, onRefresh: _unsupportedOnRefresh, onScrolledDownChange, + onScrollOffsetChange, onContentSizeChange, onItemSeen, renderItem, @@ -235,11 +237,12 @@ const handleScroll = useNonReactiveCallback(() => { if (!isInsideVisibleTree) return const element = getScrollableNode() + const offsetY = Math.max(0, element?.scrollY ?? 0) contextScrollHandlers.onScroll?.( { contentOffset: { x: Math.max(0, element?.scrollX ?? 0), - y: Math.max(0, element?.scrollY ?? 0), + y: offsetY, }, layoutMeasurement: { width: element?.clientWidth, @@ -259,6 +262,8 @@ | 'contentInset' >, null as any, ) + + onScrollOffsetChange?.(offsetY) }) useEffect(() => {