Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

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

feat: collapse Also liked posts

- added settings screen for it (overall visibility, collapsing by default)
- remove CORS proxy
- try to change the header from 'Post' to 'Posts also liked' on scroll. (it sucks tho. trying to add a scroll to top button broke so I reverted that)

+540 -239
-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 - }
+6
src/Navigation.tsx
··· 129 129 import {PetLabelSettingsScreen} from '#/screens/Settings/PetLabelSettings' 130 130 import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' 131 131 import {RunesSettingsScreen} from '#/screens/Settings/RunesSettings' 132 + import {RunesDisplayAlsoLikedSettingsScreen} from '#/screens/Settings/RunesSettings/AlsoLikedSettings' 132 133 import {RunesBadgesSettingsScreen} from '#/screens/Settings/RunesSettings/BadgesSettings' 133 134 import {RunesDisplaySettingsScreen} from '#/screens/Settings/RunesSettings/DisplaySettings' 134 135 import {RunesExtraSettingsScreen} from '#/screens/Settings/RunesSettings/ExtraSettings' ··· 456 457 name="RunesDisplaySettings" 457 458 getComponent={() => RunesDisplaySettingsScreen} 458 459 options={{title: title(msg`Display`), requireAuth: true}} 460 + /> 461 + <Stack.Screen 462 + name="RunesDisplayAlsoLikedSettings" 463 + getComponent={() => RunesDisplayAlsoLikedSettingsScreen} 464 + options={{title: title(msg`Also liked`), requireAuth: true}} 459 465 /> 460 466 <Stack.Screen 461 467 name="RunesInfrastructureSettings"
+1
src/lib/routes/types.ts
··· 57 57 RunesImpressionsSettings: undefined 58 58 RunesUsabilitySettings: undefined 59 59 RunesDisplaySettings: undefined 60 + RunesDisplayAlsoLikedSettings: undefined 60 61 RunesInfrastructureSettings: undefined 61 62 RunesExtraSettings: undefined 62 63 AccountSettings: undefined
+1
src/routes.ts
··· 54 54 RunesImpressionsSettings: '/settings/runes/impressions', 55 55 RunesUsabilitySettings: '/settings/runes/usability', 56 56 RunesDisplaySettings: '/settings/runes/display', 57 + RunesDisplayAlsoLikedSettings: '/settings/runes/display/also-liked', 57 58 RunesInfrastructureSettings: '/settings/runes/infrastructure', 58 59 RunesExtraSettings: '/settings/runes/extra', 59 60 AppearanceSettings: '/settings/appearance',
+106 -56
src/screens/PostThread/components/ThreadAlsoLiked.tsx
··· 1 - import {useCallback} from 'react' 1 + import {type RefObject, useCallback} from 'react' 2 2 import {View} from 'react-native' 3 3 import {type AppBskyFeedDefs} from '@atproto/api' 4 4 import {Trans, useLingui} from '@lingui/react/macro' ··· 13 13 import {Post} from '#/view/com/post/Post' 14 14 import {PostLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 15 15 import {atoms as a, useTheme} from '#/alf' 16 - import {Button, ButtonText} from '#/components/Button' 16 + import * as Button from '#/components/Button' 17 + import { 18 + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 19 + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 20 + } from '#/components/icons/Chevron' 17 21 import {Text} from '#/components/Typography' 18 22 19 23 export function ThreadAlsoLiked({ 20 24 posts, 21 - enabled, 22 - isLoading, 25 + visible, 26 + collapsed, 27 + isLoading: _isLoading, 28 + showLoadingState, 23 29 isFetchingNextPage, 24 30 error, 25 31 onRetry, 32 + headerRef, 33 + onToggleCollapsed, 26 34 spacerHeight, 27 35 isTombstoneView, 28 36 }: { 29 37 posts: AppBskyFeedDefs.PostView[] 30 - enabled: boolean 38 + visible: boolean 39 + collapsed: boolean 31 40 isLoading: boolean 41 + showLoadingState: boolean 32 42 isFetchingNextPage: boolean 33 43 error: unknown 34 44 onRetry: () => void 45 + headerRef: RefObject<View | null> 46 + onToggleCollapsed: () => void 35 47 spacerHeight: number | undefined 36 48 isTombstoneView: boolean 37 49 }) { 38 50 const {t: l} = useLingui() 39 51 const t = useTheme() 40 52 const queryClient = useQueryClient() 41 - const hasSection = 42 - enabled && (posts.length > 0 || isLoading || Boolean(error)) 53 + const hasSection = visible 43 54 const onBeforePress = useCallback( 44 55 (post: AppBskyFeedDefs.PostView) => { 45 56 unstableCacheProfileView(queryClient, post.author) ··· 54 65 <View> 55 66 {hasSection && ( 56 67 <View style={[a.border_t, t.atoms.border_contrast_low]}> 57 - <View style={[a.px_lg, a.pt_xl, a.pb_md, a.gap_2xs]}> 58 - <Text style={[a.text_xl, a.font_bold]}> 59 - <Trans>Also liked</Trans> 60 - </Text> 61 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 62 - <Trans>Posts liked by people who liked this post</Trans> 63 - </Text> 64 - </View> 65 - 66 - {posts.map((post, index) => ( 67 - <Post 68 - key={post.uri} 69 - post={post} 70 - hideTopBorder={index === 0} 71 - onBeforePress={() => onBeforePress(post)} 72 - /> 73 - ))} 68 + <Button.Button 69 + ref={headerRef} 70 + label={ 71 + collapsed 72 + ? l`Expand also liked posts` 73 + : l`Collapse also liked posts` 74 + } 75 + onPress={onToggleCollapsed} 76 + style={[a.w_full]}> 77 + {({hovered, pressed}) => ( 78 + <View 79 + style={[ 80 + a.w_full, 81 + a.flex_row, 82 + a.align_center, 83 + a.justify_between, 84 + a.gap_sm, 85 + a.px_lg, 86 + a.py_md, 87 + (hovered || pressed) && t.atoms.bg_contrast_25, 88 + ]}> 89 + <View style={[a.flex_1, a.gap_2xs]}> 90 + <Text style={[a.text_xl, a.font_bold]}> 91 + <Trans>Also liked</Trans> 92 + </Text> 93 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 94 + <Trans>Posts liked by people who liked this post</Trans> 95 + </Text> 96 + </View> 97 + {collapsed ? ( 98 + <ChevronDownIcon 99 + size="sm" 100 + style={t.atoms.text_contrast_medium} 101 + /> 102 + ) : ( 103 + <ChevronUpIcon 104 + size="sm" 105 + style={t.atoms.text_contrast_medium} 106 + /> 107 + )} 108 + </View> 109 + )} 110 + </Button.Button> 74 111 75 - {isLoading && posts.length === 0 && ( 112 + {!collapsed && ( 76 113 <> 77 - <PostLoadingPlaceholder 78 - style={[a.border_t, t.atoms.border_contrast_low]} 79 - /> 80 - <PostLoadingPlaceholder 81 - style={[a.border_t, t.atoms.border_contrast_low]} 82 - /> 83 - </> 84 - )} 114 + {posts.map((post, index) => ( 115 + <Post 116 + key={post.uri} 117 + post={post} 118 + hideTopBorder={index === 0} 119 + onBeforePress={() => onBeforePress(post)} 120 + /> 121 + ))} 122 + 123 + {showLoadingState && posts.length === 0 && ( 124 + <> 125 + <PostLoadingPlaceholder 126 + style={[a.border_t, t.atoms.border_contrast_low]} 127 + /> 128 + <PostLoadingPlaceholder 129 + style={[a.border_t, t.atoms.border_contrast_low]} 130 + /> 131 + </> 132 + )} 85 133 86 - {isFetchingNextPage && ( 87 - <PostLoadingPlaceholder 88 - style={[a.border_t, t.atoms.border_contrast_low]} 89 - /> 90 - )} 134 + {isFetchingNextPage && ( 135 + <PostLoadingPlaceholder 136 + style={[a.border_t, t.atoms.border_contrast_low]} 137 + /> 138 + )} 91 139 92 - {Boolean(error) && !isLoading && !isFetchingNextPage && ( 93 - <View style={[a.px_lg, a.pb_xl, a.gap_md]}> 94 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 95 - {cleanError(error)} 96 - </Text> 97 - <View style={[a.flex_row]}> 98 - <Button 99 - label={l`Retry loading also liked posts`} 100 - onPress={onRetry} 101 - variant="solid" 102 - color="secondary_inverted" 103 - size="small"> 104 - <ButtonText> 105 - <Trans>Retry</Trans> 106 - </ButtonText> 107 - </Button> 108 - </View> 109 - </View> 140 + {Boolean(error) && !showLoadingState && !isFetchingNextPage && ( 141 + <View style={[a.px_lg, a.pb_xl, a.gap_md]}> 142 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 143 + {cleanError(error)} 144 + </Text> 145 + <View style={[a.flex_row]}> 146 + <Button.Button 147 + label={l`Retry loading also liked posts`} 148 + onPress={onRetry} 149 + variant="solid" 150 + color="secondary_inverted" 151 + size="small"> 152 + <Button.ButtonText> 153 + <Trans>Retry</Trans> 154 + </Button.ButtonText> 155 + </Button.Button> 156 + </View> 157 + </View> 158 + )} 159 + </> 110 160 )} 111 161 </View> 112 162 )}
+154 -3
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 {useAlsoLikedCollapseByDefault} from '#/state/preferences/also-liked-collapse-by-default' 18 19 import {useAlsoLikedFeedEnabled} from '#/state/preferences/also-liked-feed-enabled' 19 20 import {usePostAlsoLikedQuery} from '#/state/queries/post-also-liked' 20 21 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' ··· 151 152 const isRoot = !!anchor && anchor.value.post.record.reply === undefined 152 153 const canReply = !anchor?.value.post?.viewer?.replyDisabled 153 154 const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() 155 + const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() 154 156 const alsoLikedAnchorUri = 155 157 anchor?.type === 'threadPost' && isRoot ? anchor.value.post.uri : undefined 156 158 const [deferParents, setDeferParents] = useState(true) 157 - const alsoLikedEnabled = 159 + const [alsoLikedCollapsed, setAlsoLikedCollapsed] = useState( 160 + alsoLikedCollapseByDefault, 161 + ) 162 + useEffect(() => { 163 + setAlsoLikedCollapsed(alsoLikedCollapseByDefault) 164 + }, [alsoLikedAnchorUri, alsoLikedCollapseByDefault]) 165 + const alsoLikedVisible = 158 166 Boolean(alsoLikedAnchorUri) && 159 167 alsoLikedFeedEnabled && 160 168 !thread.state.isPlaceholderData && 161 169 !deferParents 170 + const alsoLikedEnabled = alsoLikedVisible && !alsoLikedCollapsed 162 171 const alsoLiked = usePostAlsoLikedQuery(alsoLikedAnchorUri, { 163 172 enabled: alsoLikedEnabled, 164 173 }) ··· 179 188 const listRef = useRef<ListMethods>(null) 180 189 const anchorRef = useRef<View | null>(null) 181 190 const headerRef = useRef<View | null>(null) 191 + const alsoLikedHeaderRef = useRef<View | null>(null) 192 + const currentScrollOffsetRef = useRef(0) 193 + const scrollStateRequestIdRef = useRef(0) 194 + const [isAlsoLikedFocused, setIsAlsoLikedFocused] = useState(false) 195 + 196 + useEffect(() => { 197 + if (!alsoLikedVisible || alsoLikedCollapsed) { 198 + setIsAlsoLikedFocused(false) 199 + } 200 + }, [alsoLikedCollapsed, alsoLikedVisible]) 182 201 183 202 /* 184 203 * On a cold load, parents are not prepended until the anchor post has ··· 583 602 void alsoLiked.refetch() 584 603 } 585 604 }, [alsoLiked, alsoLikedPosts.length]) 605 + const toggleAlsoLikedCollapsed = useCallback(() => { 606 + setAlsoLikedCollapsed(current => !current) 607 + }, []) 608 + const handleScrollOffsetChange = useNonReactiveCallback((offsetY: number) => { 609 + currentScrollOffsetRef.current = offsetY 610 + if (offsetY <= 1) { 611 + scrollStateRequestIdRef.current += 1 612 + setIsAlsoLikedFocused(false) 613 + return 614 + } 615 + scrollStateRequestIdRef.current += 1 616 + updateAlsoLikedScrollState(offsetY, scrollStateRequestIdRef.current) 617 + }) 618 + const updateAlsoLikedScrollState = useNonReactiveCallback( 619 + (offsetY: number, requestId: number) => { 620 + if ( 621 + !alsoLikedVisible || 622 + alsoLikedCollapsed || 623 + !alsoLikedHeaderRef.current || 624 + !headerRef.current 625 + ) { 626 + setIsAlsoLikedFocused(false) 627 + return 628 + } 629 + 630 + measureViewRect(headerRef.current, headerRect => { 631 + if (requestId !== scrollStateRequestIdRef.current) return 632 + if (!headerRect) { 633 + setIsAlsoLikedFocused(false) 634 + return 635 + } 636 + 637 + measureViewRect(alsoLikedHeaderRef.current, alsoLikedRect => { 638 + if (requestId !== scrollStateRequestIdRef.current) return 639 + if (!alsoLikedRect) { 640 + setIsAlsoLikedFocused(false) 641 + return 642 + } 643 + 644 + const headerBottom = headerRect.y + headerRect.height 645 + const focused = alsoLikedRect.y <= headerBottom 646 + 647 + setIsAlsoLikedFocused(current => 648 + current === focused ? current : focused, 649 + ) 650 + }) 651 + }) 652 + }, 653 + ) 654 + 655 + useEffect(() => { 656 + scrollStateRequestIdRef.current += 1 657 + updateAlsoLikedScrollState( 658 + currentScrollOffsetRef.current, 659 + scrollStateRequestIdRef.current, 660 + ) 661 + }, [ 662 + alsoLikedCollapsed, 663 + alsoLikedPosts.length, 664 + alsoLikedVisible, 665 + updateAlsoLikedScrollState, 666 + ]) 586 667 587 668 return ( 588 669 <PostThreadContextProvider context={thread.context}> ··· 590 671 <Layout.Header.BackButton /> 591 672 <Layout.Header.Content> 592 673 <Layout.Header.TitleText> 593 - <Trans context="description">Post</Trans> 674 + {isAlsoLikedFocused ? ( 675 + <Trans>Posts also liked</Trans> 676 + ) : ( 677 + <Trans context="description">Post</Trans> 678 + )} 594 679 </Layout.Header.TitleText> 595 680 </Layout.Header.Content> 596 681 <Layout.Header.Slot> ··· 622 707 onEndReached={onEndReached} 623 708 onEndReachedThreshold={4} 624 709 onStartReachedThreshold={1} 710 + onScrollOffsetChange={handleScrollOffsetChange} 625 711 onItemSeen={item => { 626 712 // Track post:view for parent posts and replies (non-anchor posts) 627 713 if (item.type === 'threadPost' && item.depth !== 0) { ··· 638 724 ListFooterComponent={ 639 725 <ThreadAlsoLiked 640 726 posts={alsoLikedPosts} 641 - enabled={alsoLikedEnabled} 727 + visible={alsoLikedVisible} 728 + collapsed={alsoLikedCollapsed} 642 729 isLoading={alsoLiked.isLoading} 730 + showLoadingState={ 731 + !alsoLikedCollapsed && 732 + !alsoLiked.error && 733 + alsoLikedPosts.length === 0 && 734 + (!alsoLiked.isFetched || alsoLiked.isLoading) 735 + } 643 736 isFetchingNextPage={alsoLiked.isFetchingNextPage} 644 737 error={alsoLiked.error} 645 738 onRetry={retryAlsoLiked} 739 + headerRef={alsoLikedHeaderRef} 740 + onToggleCollapsed={toggleAlsoLikedCollapsed} 646 741 spacerHeight={platform({ 647 742 web: defaultListFooterHeight, 648 743 default: deferParents ··· 696 791 const keyExtractor = (item: ThreadItem) => { 697 792 return item.key 698 793 } 794 + 795 + type ViewRect = { 796 + x: number 797 + y: number 798 + width: number 799 + height: number 800 + } 801 + 802 + function measureViewRect( 803 + view: View | null, 804 + cb: (rect: ViewRect | null) => void, 805 + ) { 806 + const target = view as any 807 + if (!target) { 808 + cb(null) 809 + return 810 + } 811 + 812 + if (typeof target.measureInWindow === 'function') { 813 + target.measureInWindow( 814 + (x: number, y: number, width: number, height: number) => { 815 + cb({x, y, width, height}) 816 + }, 817 + ) 818 + return 819 + } 820 + 821 + if (typeof target.getBoundingClientRect === 'function') { 822 + const rect = target.getBoundingClientRect() 823 + cb({ 824 + x: rect.left, 825 + y: rect.top, 826 + width: rect.width, 827 + height: rect.height, 828 + }) 829 + return 830 + } 831 + 832 + if (typeof target.measure === 'function') { 833 + target.measure( 834 + ( 835 + _x: number, 836 + _y: number, 837 + width: number, 838 + height: number, 839 + pageX: number, 840 + pageY: number, 841 + ) => { 842 + cb({x: pageX, y: pageY, width, height}) 843 + }, 844 + ) 845 + return 846 + } 847 + 848 + cb(null) 849 + }
+76
src/screens/Settings/RunesSettings/AlsoLikedSettings.tsx
··· 1 + import {Trans, useLingui} from '@lingui/react/macro' 2 + 3 + import { 4 + useAlsoLikedCollapseByDefault, 5 + useAlsoLikedFeedEnabled, 6 + useSetAlsoLikedCollapseByDefault, 7 + useSetAlsoLikedFeedEnabled, 8 + } from '#/state/preferences' 9 + import * as SettingsList from '#/screens/Settings/components/SettingsList' 10 + import {atoms as a} from '#/alf' 11 + import {Admonition} from '#/components/Admonition' 12 + import * as Toggle from '#/components/forms/Toggle' 13 + import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' 14 + import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' 15 + import {SimpleInlineLinkText} from '#/components/Link' 16 + import {RunesScreenLayout} from './components/RunesScreenLayout' 17 + 18 + export function RunesDisplayAlsoLikedSettingsScreen() { 19 + const {t: l} = useLingui() 20 + 21 + const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() 22 + const setAlsoLikedFeedEnabled = useSetAlsoLikedFeedEnabled() 23 + 24 + const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() 25 + const setAlsoLikedCollapseByDefault = useSetAlsoLikedCollapseByDefault() 26 + 27 + return ( 28 + <RunesScreenLayout titleText={l`Also liked`}> 29 + <Toggle.Item 30 + name="also_liked_feed" 31 + label={l`Show "Also liked" recommendations under post replies`} 32 + value={alsoLikedFeedEnabled} 33 + onChange={value => setAlsoLikedFeedEnabled(value)}> 34 + <SettingsList.Item> 35 + <SettingsList.ItemIcon icon={HeartIcon} /> 36 + <SettingsList.ItemText> 37 + <Trans>Show "Also liked" recommendations under post replies</Trans> 38 + </SettingsList.ItemText> 39 + <Toggle.Platform /> 40 + </SettingsList.Item> 41 + </Toggle.Item> 42 + <Toggle.Item 43 + name="also_liked_collapsed_by_default" 44 + label={l`Collapse "Also liked" by default`} 45 + value={alsoLikedCollapseByDefault} 46 + onChange={value => setAlsoLikedCollapseByDefault(value)}> 47 + <SettingsList.Item> 48 + <SettingsList.ItemIcon icon={ChevronDownIcon} /> 49 + <SettingsList.ItemText> 50 + <Trans>Collapse "Also liked" by default</Trans> 51 + </SettingsList.ItemText> 52 + <Toggle.Platform /> 53 + </SettingsList.Item> 54 + </Toggle.Item> 55 + <SettingsList.Item> 56 + <Admonition type="info" style={[a.flex_1]}> 57 + <Trans> 58 + Powered by the{' '} 59 + <SimpleInlineLinkText 60 + to="/profile/spacecowboy17.bsky.social/feed/for-you" 61 + label={l`For You`}> 62 + For You 63 + </SimpleInlineLinkText>{' '} 64 + feed. Posts must have likes, reposts, or have been created in the 65 + last 90 days to appear. Learn more at{' '} 66 + <SimpleInlineLinkText 67 + to="https://foryou.club/" 68 + label={l`for you dot club`}> 69 + foryou.club 70 + </SimpleInlineLinkText> 71 + </Trans> 72 + </Admonition> 73 + </SettingsList.Item> 74 + </RunesScreenLayout> 75 + ) 76 + }
+37 -16
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 + useAlsoLikedCollapseByDefault, 10 11 useAlsoLikedFeedEnabled, 11 - useSetAlsoLikedFeedEnabled, 12 - } from '#/state/preferences/also-liked-feed-enabled' 12 + } from '#/state/preferences' 13 13 import { 14 14 useHighQualityImages, 15 15 useSetHighQualityImages, ··· 39 39 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 40 40 import {Text} from '#/components/Typography' 41 41 import {IS_WEB} from '#/env' 42 + import {ItemTextWithSubtitle} from '../NotificationSettings/components/ItemTextWithSubtitle' 42 43 import {RunesScreenLayout} from './components/RunesScreenLayout' 43 44 44 45 export function RunesDisplaySettingsScreen() { ··· 48 49 const setRepostCarouselEnabled = useSetRepostCarouselEnabled() 49 50 50 51 const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() 51 - const setAlsoLikedFeedEnabled = useSetAlsoLikedFeedEnabled() 52 + const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() 52 53 53 54 const highQualityImages = useHighQualityImages() 54 55 const setHighQualityImages = useSetHighQualityImages() ··· 60 61 61 62 return ( 62 63 <RunesScreenLayout titleText={l`Display`}> 64 + <SettingsList.LinkItem 65 + to="/settings/runes/display/also-liked" 66 + label={l`Also liked`} 67 + contentContainerStyle={[a.align_start]}> 68 + <SettingsList.ItemIcon icon={HeartIcon} /> 69 + <ItemTextWithSubtitle 70 + titleText={<Trans>Also liked</Trans>} 71 + subtitleText={ 72 + <AlsoLikedDeclaration 73 + enabled={alsoLikedFeedEnabled} 74 + collapseByDefault={alsoLikedCollapseByDefault} 75 + /> 76 + } 77 + /> 78 + </SettingsList.LinkItem> 63 79 <Toggle.Item 64 80 name="repost_carousel" 65 81 label={l`Combine reposts into a horizontal carousel`} ··· 74 90 </SettingsList.Item> 75 91 </Toggle.Item> 76 92 <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> 85 - </SettingsList.ItemText> 86 - <Toggle.Platform /> 87 - </SettingsList.Item> 88 - </Toggle.Item> 89 - <Toggle.Item 90 93 name="high_quality_images" 91 94 label={l`Display images in higher quality`} 92 95 value={highQualityImages} ··· 133 136 <PostReplacementDialog control={setPostReplacementDialogControl} /> 134 137 </RunesScreenLayout> 135 138 ) 139 + } 140 + 141 + function AlsoLikedDeclaration({ 142 + enabled, 143 + collapseByDefault, 144 + }: { 145 + enabled: boolean 146 + collapseByDefault: boolean 147 + }) { 148 + if (!enabled) { 149 + return <Trans>Hidden in thread views</Trans> 150 + } 151 + 152 + if (collapseByDefault) { 153 + return <Trans>Shown in thread views, collapsed by default</Trans> 154 + } 155 + 156 + return <Trans>Shown in thread views, expanded by default</Trans> 136 157 } 137 158 138 159 function PostReplacementDialog({
+2
src/state/persisted/schema.ts
··· 173 173 noDiscoverFallback: z.boolean().optional(), 174 174 repostCarouselEnabled: z.boolean().optional(), 175 175 alsoLikedFeedEnabled: z.boolean().optional(), 176 + alsoLikedCollapseByDefault: z.boolean().optional(), 176 177 constellationInstance: z.string().optional(), 177 178 showLinkInHandle: z.boolean().optional(), 178 179 showLinkInHandleOnlyOnWorkingLinks: z.boolean().optional(), ··· 306 307 noDiscoverFallback: false, 307 308 repostCarouselEnabled: false, 308 309 alsoLikedFeedEnabled: true, 310 + alsoLikedCollapseByDefault: true, 309 311 constellationInstance: 'https://constellation.microcosm.blue/', 310 312 showLinkInHandle: true, 311 313 showLinkInHandleOnlyOnWorkingLinks: true,
+49
src/state/preferences/also-liked-collapse-by-default.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.alsoLikedCollapseByDefault), 17 + ) 18 + const setContext = createContext<SetContext>((_: boolean) => {}) 19 + 20 + export function Provider({children}: {children: ReactNode}) { 21 + const [state, setState] = useState( 22 + Boolean(persisted.get('alsoLikedCollapseByDefault')), 23 + ) 24 + 25 + const setStateWrapped = useCallback( 26 + (value: persisted.Schema['alsoLikedCollapseByDefault']) => { 27 + setState(Boolean(value)) 28 + persisted.write('alsoLikedCollapseByDefault', value) 29 + }, 30 + [setState], 31 + ) 32 + 33 + useEffect(() => { 34 + return persisted.onUpdate('alsoLikedCollapseByDefault', 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 useAlsoLikedCollapseByDefault = () => useContext(stateContext) 49 + export const useSetAlsoLikedCollapseByDefault = () => useContext(setContext)
+64 -57
src/state/preferences/index.tsx
··· 1 1 import {type PropsWithChildren} from 'react' 2 2 3 + import {Provider as AlsoLikedCollapseByDefaultProvider} from './also-liked-collapse-by-default' 3 4 import {Provider as AlsoLikedFeedProvider} from './also-liked-feed-enabled' 4 5 import {Provider as AltTextRequiredProvider} from './alt-text-required' 5 6 import {Provider as AutoLikeOnRepostProvider} from './auto-like-on-repost' ··· 58 59 import {Provider as UseHandleInLinksProvider} from './use-handle-in-links' 59 60 import {Provider as UsedStarterPacksProvider} from './used-starter-packs' 60 61 62 + export { 63 + useAlsoLikedCollapseByDefault, 64 + useSetAlsoLikedCollapseByDefault, 65 + } from './also-liked-collapse-by-default' 61 66 export { 62 67 useAlsoLikedFeedEnabled, 63 68 useSetAlsoLikedFeedEnabled, ··· 149 154 <TrendingSettingsProvider> 150 155 <RepostCarouselProvider> 151 156 <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> 157 + <AlsoLikedCollapseByDefaultProvider> 158 + <KawaiiProvider> 159 + <HideFeedsPromoTabProvider> 160 + <DisableViaRepostNotificationProvider> 161 + <DisableLikesMetricsProvider> 162 + <DisableRepostsMetricsProvider> 163 + <DisableQuotesMetricsProvider> 164 + <DisableSavesMetricsProvider> 165 + <DisableReplyMetricsProvider> 166 + <DisableFollowersMetricsProvider> 167 + <DisableFollowingMetricsProvider> 168 + <DisableFollowedByMetricsProvider> 169 + <DisablePostsMetricsProvider> 170 + <ShowFollowsYouBadgeProvider> 171 + <HideSimilarAccountsRecommProvider> 172 + <HideScaryFollowButtonsProvider> 173 + <HideUnreplyablePostsProvider> 174 + <EnableSquareAvatarsProvider> 175 + <EnableSquareButtonsProvider> 176 + <ShowViaClientProvider> 177 + <PostNameReplacementProvider> 178 + <DisableVerifyEmailReminderProvider> 179 + <TranslationServicePreferenceProvider> 180 + <OpenRouterProvider> 181 + <DisableComposerPromptProvider> 182 + <DisableTopOfFeedButtonProvider> 183 + <DiscoverContextEnabledProvider> 184 + <OmitViaFieldProvider> 185 + { 186 + children 187 + } 188 + </OmitViaFieldProvider> 189 + </DiscoverContextEnabledProvider> 190 + </DisableTopOfFeedButtonProvider> 191 + </DisableComposerPromptProvider> 192 + </OpenRouterProvider> 193 + </TranslationServicePreferenceProvider> 194 + </DisableVerifyEmailReminderProvider> 195 + </PostNameReplacementProvider> 196 + </ShowViaClientProvider> 197 + </EnableSquareButtonsProvider> 198 + </EnableSquareAvatarsProvider> 199 + </HideUnreplyablePostsProvider> 200 + </HideScaryFollowButtonsProvider> 201 + </HideSimilarAccountsRecommProvider> 202 + </ShowFollowsYouBadgeProvider> 203 + </DisablePostsMetricsProvider> 204 + </DisableFollowedByMetricsProvider> 205 + </DisableFollowingMetricsProvider> 206 + </DisableFollowersMetricsProvider> 207 + </DisableReplyMetricsProvider> 208 + </DisableSavesMetricsProvider> 209 + </DisableQuotesMetricsProvider> 210 + </DisableRepostsMetricsProvider> 211 + </DisableLikesMetricsProvider> 212 + </DisableViaRepostNotificationProvider> 213 + </HideFeedsPromoTabProvider> 214 + </KawaiiProvider> 215 + </AlsoLikedCollapseByDefaultProvider> 209 216 </AlsoLikedFeedProvider> 210 217 </RepostCarouselProvider> 211 218 </TrendingSettingsProvider>
+27 -56
src/state/queries/post-also-liked.ts
··· 8 8 import {STALE} from '#/state/queries' 9 9 import {precachePost} from '#/state/queries/post' 10 10 import {useAgent} from '#/state/session' 11 - import {IS_WEB} from '#/env' 12 11 13 12 const ALSO_LIKED_URL = 'https://foryou.club/also-liked' 14 - const ALSO_LIKED_PROXY_PATH = '/api/also-liked' 15 - const ALSO_LIKED_PROXY_HOST = 'https://witchsky.app' 16 13 const ALSO_LIKED_PAGE_SIZE = 10 17 14 18 15 type AlsoLikedSkeletonResponse = { ··· 29 26 postUri: string, 30 27 pageParam: string | undefined, 31 28 ) { 32 - const urls = getAlsoLikedUrls(postUri, pageParam) 33 - let lastError: Error | undefined 29 + try { 30 + const res = await fetch(getAlsoLikedUrl(postUri, pageParam).toString()) 31 + if (!res.ok) { 32 + throw new Error( 33 + `Failed to load also liked recommendations (${res.status})`, 34 + ) 35 + } 34 36 35 - for (const url of urls) { 36 - try { 37 - const res = await fetch(url.toString()) 38 - if (!res.ok) { 39 - lastError = new Error( 40 - `Failed to load also liked recommendations (${res.status})`, 41 - ) 42 - continue 43 - } 37 + const contentType = res.headers.get('content-type') || '' 38 + if (!contentType.includes('application/json')) { 39 + const body = await res.text() 40 + throw new Error( 41 + body.startsWith('<!DOCTYPE') || body.startsWith('<!doctype') 42 + ? 'Also liked is unavailable from this web environment right now.' 43 + : 'Also liked returned an unexpected response.', 44 + ) 45 + } 44 46 45 - const contentType = res.headers.get('content-type') || '' 46 - if (!contentType.includes('application/json')) { 47 - const body = await res.text() 48 - lastError = new Error( 49 - body.startsWith('<!DOCTYPE') || body.startsWith('<!doctype') 50 - ? 'Also liked is unavailable from this web environment right now.' 51 - : 'Also liked returned an unexpected response.', 52 - ) 53 - continue 54 - } 55 - 56 - return (await res.json()) as AlsoLikedSkeletonResponse 57 - } catch (err) { 58 - lastError = err instanceof Error ? err : new Error(String(err)) 59 - } 47 + return (await res.json()) as AlsoLikedSkeletonResponse 48 + } catch (err) { 49 + throw err instanceof Error ? err : new Error(String(err)) 60 50 } 61 - 62 - throw lastError || new Error('Failed to load also liked recommendations') 63 51 } 64 52 65 - function getAlsoLikedUrls(postUri: string, pageParam: string | undefined) { 66 - const urls: URL[] = [] 67 - const appendParams = (url: URL) => { 68 - url.searchParams.set('format', 'json') 69 - url.searchParams.set('post', postUri) 70 - url.searchParams.set('limit', String(ALSO_LIKED_PAGE_SIZE)) 71 - if (pageParam) { 72 - url.searchParams.set('cursor', pageParam) 73 - } 74 - return url 53 + function getAlsoLikedUrl(postUri: string, pageParam: string | undefined) { 54 + const url = new URL(ALSO_LIKED_URL) 55 + url.searchParams.set('format', 'json') 56 + url.searchParams.set('post', postUri) 57 + url.searchParams.set('limit', String(ALSO_LIKED_PAGE_SIZE)) 58 + if (pageParam) { 59 + url.searchParams.set('cursor', pageParam) 75 60 } 76 - 77 - if (IS_WEB) { 78 - urls.push( 79 - appendParams(new URL(ALSO_LIKED_PROXY_PATH, window.location.origin)), 80 - ) 81 - 82 - const hostedProxyUrl = new URL(ALSO_LIKED_PROXY_PATH, ALSO_LIKED_PROXY_HOST) 83 - if (hostedProxyUrl.origin !== window.location.origin) { 84 - urls.push(appendParams(hostedProxyUrl)) 85 - } 86 - } else { 87 - urls.push(appendParams(new URL(ALSO_LIKED_URL))) 88 - } 89 - 90 - return urls 61 + return url 91 62 } 92 63 93 64 export const RQKEY_ROOT = 'post-also-liked'
+11
src/view/com/util/List.tsx
··· 30 30 | 'progressViewOffset' // Can't be an animated value 31 31 > & { 32 32 onScrolledDownChange?: (isScrolledDown: boolean) => void 33 + onScrollOffsetChange?: (offsetY: number) => void 33 34 headerOffset?: number 34 35 refreshing?: boolean 35 36 onRefresh?: () => void ··· 48 49 ( 49 50 { 50 51 onScrolledDownChange, 52 + onScrollOffsetChange, 51 53 refreshing, 52 54 onRefresh, 53 55 onItemSeen, ··· 69 71 onScrolledDownChange?.(didScrollDown) 70 72 }, 71 73 ) 74 + const handleScrollOffsetChange = useNonReactiveCallback( 75 + (offsetY: number) => { 76 + onScrollOffsetChange?.(offsetY) 77 + }, 78 + ) 72 79 73 80 // Intentionally destructured outside the main thread closure. 74 81 // See https://github.com/bluesky-social/social-app/pull/4108. ··· 95 102 if (onScrolledDownChange != null) { 96 103 runOnJS(handleScrolledDownChange)(didScrollDown) 97 104 } 105 + } 106 + 107 + if (onScrollOffsetChange != null) { 108 + runOnJS(handleScrollOffsetChange)(e.contentOffset.y) 98 109 } 99 110 100 111 if (IS_IOS) {
+6 -1
src/view/com/util/List.web.tsx
··· 31 31 | 'contentOffset' // Pass headerOffset instead. 32 32 > & { 33 33 onScrolledDownChange?: (isScrolledDown: boolean) => void 34 + onScrollOffsetChange?: (offsetY: number) => void 34 35 headerOffset?: number 35 36 refreshing?: boolean 36 37 onRefresh?: () => void ··· 68 69 onEndReachedThreshold = 2, 69 70 onRefresh: _unsupportedOnRefresh, 70 71 onScrolledDownChange, 72 + onScrollOffsetChange, 71 73 onContentSizeChange, 72 74 onItemSeen, 73 75 renderItem, ··· 235 237 if (!isInsideVisibleTree) return 236 238 237 239 const element = getScrollableNode() 240 + const offsetY = Math.max(0, element?.scrollY ?? 0) 238 241 contextScrollHandlers.onScroll?.( 239 242 { 240 243 contentOffset: { 241 244 x: Math.max(0, element?.scrollX ?? 0), 242 - y: Math.max(0, element?.scrollY ?? 0), 245 + y: offsetY, 243 246 }, 244 247 layoutMeasurement: { 245 248 width: element?.clientWidth, ··· 259 262 >, 260 263 null as any, 261 264 ) 265 + 266 + onScrollOffsetChange?.(offsetY) 262 267 }) 263 268 264 269 useEffect(() => {