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

Add horizontal repost carousel

authored by ntauthority.me and committed by

Aviva Ruben 360587c4 b6493bb2

+331 -19
+18
src/screens/Settings/DeerSettings.tsx
··· 31 31 useNoDiscoverFallback, 32 32 useSetNoDiscoverFallback, 33 33 } from '#/state/preferences/no-discover-fallback' 34 + import { 35 + useRepostCarouselEnabled, 36 + useSetRepostCarouselEnabled, 37 + } from '#/state/preferences/repost-carousel-enabled' 34 38 import {TextInput} from '#/view/com/modals/util' 35 39 import * as SettingsList from '#/screens/Settings/components/SettingsList' 36 40 import {atoms as a} from '#/alf' ··· 134 138 135 139 const location = useGeolocation() 136 140 const setLocationControl = Dialog.useDialogControl() 141 + 142 + const repostCarouselEnabled = useRepostCarouselEnabled() 143 + const setRepostCarouselEnabled = useSetRepostCarouselEnabled() 137 144 138 145 const [gates, setGatesView] = useState(Object.fromEntries(useGatesCache())) 139 146 const dangerousSetGate = useDangerousSetGate() ··· 283 290 <SettingsList.ItemText> 284 291 <Trans>Tweaks</Trans> 285 292 </SettingsList.ItemText> 293 + <Toggle.Item 294 + name="repost_carousel" 295 + label={_(msg`Combine reposts into a horizontal carousel`)} 296 + value={repostCarouselEnabled} 297 + onChange={value => setRepostCarouselEnabled(value)} 298 + style={[a.w_full]}> 299 + <Toggle.LabelText style={[a.flex_1]}> 300 + <Trans>Combine reposts into a horizontal carousel</Trans> 301 + </Toggle.LabelText> 302 + <Toggle.Platform /> 303 + </Toggle.Item> 286 304 <Toggle.Item 287 305 name="no_discover_fallback" 288 306 label={_(msg`Do not fall back to discover feed`)}
+2
src/state/persisted/schema.ts
··· 130 130 directFetchRecords: z.boolean().optional(), 131 131 noAppLabelers: z.boolean().optional(), 132 132 noDiscoverFallback: z.boolean().optional(), 133 + repostCarouselEnabled: z.boolean().optional(), 133 134 134 135 /** @deprecated */ 135 136 mutedThreads: z.array(z.string()), ··· 189 190 directFetchRecords: false, 190 191 noAppLabelers: false, 191 192 noDiscoverFallback: false, 193 + repostCarouselEnabled: false, 192 194 } 193 195 194 196 export function tryParse(rawData: string): Schema | undefined {
+5 -2
src/state/preferences/index.tsx
··· 14 14 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 15 15 import {Provider as NoAppLabelersProvider} from './no-app-labelers' 16 16 import {Provider as NoDiscoverProvider} from './no-discover-fallback' 17 + import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 17 18 import {Provider as SubtitlesProvider} from './subtitles' 18 19 import {Provider as TrendingSettingsProvider} from './trending' 19 20 import {Provider as UsedStarterPacksProvider} from './used-starter-packs' ··· 52 53 <UsedStarterPacksProvider> 53 54 <SubtitlesProvider> 54 55 <TrendingSettingsProvider> 55 - <KawaiiProvider>{children}</KawaiiProvider> 56 - </TrendingSettingsProvider> 56 + <RepostCarouselProvider> 57 + <KawaiiProvider>{children}</KawaiiProvider> 58 + </RepostCarouselProvider> 59 + </TrendingSettingsProvider> 57 60 </SubtitlesProvider> 58 61 </UsedStarterPacksProvider> 59 62 </AutoplayProvider>
+95 -3
src/view/com/posts/PostFeed.tsx
··· 9 9 View, 10 10 type ViewStyle, 11 11 } from 'react-native' 12 - import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' 12 + import { 13 + type AppBskyActorDefs, 14 + AppBskyEmbedVideo, 15 + AppBskyFeedDefs, 16 + } from '@atproto/api' 13 17 import {msg} from '@lingui/macro' 14 18 import {useLingui} from '@lingui/react' 15 19 import {useQueryClient} from '@tanstack/react-query' ··· 21 25 import {isIOS, isNative, isWeb} from '#/platform/detection' 22 26 import {listenPostCreated} from '#/state/events' 23 27 import {useFeedFeedbackContext} from '#/state/feed-feedback' 28 + import {useRepostCarouselEnabled} from '#/state/preferences/repost-carousel-enabled' 24 29 import {useTrendingSettings} from '#/state/preferences/trending' 25 30 import {STALE} from '#/state/queries' 26 31 import { ··· 51 56 import {FeedShutdownMsg} from './FeedShutdownMsg' 52 57 import {PostFeedErrorMessage} from './PostFeedErrorMessage' 53 58 import {PostFeedItem} from './PostFeedItem' 59 + import {PostFeedItemCarousel} from './PostFeedItemCarousel' 54 60 import {ViewFullThread} from './ViewFullThread' 55 61 56 62 type FeedRow = ··· 84 90 slice: FeedPostSlice 85 91 indexInSlice: number 86 92 showReplyTo: boolean 93 + } 94 + | { 95 + type: 'reposts' 96 + key: string 97 + items: FeedPostSlice[] 87 98 } 88 99 | { 89 100 type: 'videoGridRowPlaceholder' ··· 118 129 key: string 119 130 } 120 131 132 + type FeedPostSliceOrGroup = 133 + | (FeedPostSlice & { 134 + isRepostSlice?: false 135 + }) 136 + | { 137 + isRepostSlice: true 138 + slices: FeedPostSlice[] 139 + } 140 + 121 141 export function getItemsForFeedback(feedRow: FeedRow): 122 142 | { 123 143 item: FeedPostSliceItem ··· 128 148 item, 129 149 feedContext: feedRow.slice.feedContext, 130 150 })) 151 + } else if (feedRow.type === 'reposts') { 152 + return feedRow.items.map((item, i) => ({ 153 + item: item.items[0], 154 + feedContext: feedRow.items[i].feedContext, 155 + })) 131 156 } else if (feedRow.type === 'videoGridRow') { 132 157 return feedRow.items.map((item, i) => ({ 133 158 item, ··· 138 163 } 139 164 } 140 165 166 + // logic from https://github.com/cheeaun/phanpy/blob/d608ee0a7594e3c83cdb087e81002f176d0d7008/src/utils/timeline-utils.js#L9 167 + function groupReposts(values: FeedPostSlice[]) { 168 + let newValues: FeedPostSliceOrGroup[] = [] 169 + const reposts: FeedPostSlice[] = [] 170 + 171 + // serial reposts lain 172 + let serialReposts = 0 173 + 174 + for (const row of values) { 175 + if (AppBskyFeedDefs.isReasonRepost(row.reason)) { 176 + reposts.push(row) 177 + serialReposts++ 178 + continue 179 + } 180 + 181 + newValues.push(row) 182 + if (serialReposts < 3) { 183 + serialReposts = 0 184 + } 185 + } 186 + 187 + // TODO: handle counts for multi-item slices 188 + if ( 189 + values.length > 10 && 190 + (reposts.length > values.length / 4 || serialReposts >= 3) 191 + ) { 192 + // if boostStash is more than 3 quarter of values 193 + if (reposts.length > (values.length * 3) / 4) { 194 + // insert boost array at the end of specialHome list 195 + newValues = [...newValues, {isRepostSlice: true, slices: reposts}] 196 + } else { 197 + // insert boosts array in the middle of specialHome list 198 + const half = Math.floor(newValues.length / 2) 199 + newValues = [ 200 + ...newValues.slice(0, half), 201 + {isRepostSlice: true, slices: reposts}, 202 + ...newValues.slice(half), 203 + ] 204 + } 205 + 206 + return newValues 207 + } 208 + 209 + return values as FeedPostSliceOrGroup[] 210 + } 211 + 141 212 // DISABLED need to check if this is causing random feed refreshes -prf 142 213 // const REFRESH_AFTER = STALE.HOURS.ONE 143 214 const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY ··· 164 235 savedFeedConfig, 165 236 initialNumToRender: initialNumToRenderOverride, 166 237 isVideoFeed = false, 238 + useRepostCarousel = false, 167 239 }: { 168 240 feed: FeedDescriptor 169 241 feedParams?: FeedParams ··· 186 258 savedFeedConfig?: AppBskyActorDefs.SavedFeed 187 259 initialNumToRender?: number 188 260 isVideoFeed?: boolean 261 + useRepostCarousel?: boolean 189 262 }): React.ReactNode => { 190 263 const {_} = useLingui() 191 264 const queryClient = useQueryClient() ··· 320 393 321 394 const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() 322 395 396 + const repostCarouselEnabled = useRepostCarouselEnabled() 397 + 398 + if (feedType === 'following') { 399 + useRepostCarousel = repostCarouselEnabled 400 + } 401 + 323 402 const feedItems: FeedRow[] = React.useMemo(() => { 324 403 let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined 325 404 if (feedType === 'following') { ··· 399 478 } 400 479 } else { 401 480 for (const page of data?.pages) { 402 - for (const slice of page.slices) { 481 + let slices = useRepostCarousel 482 + ? groupReposts(page.slices) 483 + : (page.slices as FeedPostSliceOrGroup[]) 484 + 485 + for (const slice of slices) { 403 486 sliceIndex++ 404 487 405 488 if (hasSession) { ··· 441 524 } 442 525 } 443 526 444 - if (slice.isFallbackMarker) { 527 + if (slice.isRepostSlice) { 528 + arr.push({ 529 + type: 'reposts', 530 + key: slice.slices[0]._reactKey, 531 + items: slice.slices, 532 + }) 533 + } else if (slice.isFallbackMarker) { 445 534 arr.push({ 446 535 type: 'fallbackMarker', 447 536 key: ··· 531 620 gtMobile, 532 621 isVideoFeed, 533 622 areVideoFeedsEnabled, 623 + useRepostCarousel, 534 624 ]) 535 625 536 626 // events ··· 652 742 rootPost={slice.items[0].post} 653 743 /> 654 744 ) 745 + } else if (row.type === 'reposts') { 746 + return <PostFeedItemCarousel items={row.items} /> 655 747 } else if (row.type === 'sliceViewFullThread') { 656 748 return <ViewFullThread uri={row.uri} /> 657 749 } else if (row.type === 'videoGridRowPlaceholder') {
+11 -7
src/view/com/posts/PostFeedItem.tsx
··· 1 1 import React, {memo, useMemo, useState} from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import { 4 - AppBskyActorDefs, 4 + type AppBskyActorDefs, 5 5 AppBskyFeedDefs, 6 6 AppBskyFeedPost, 7 7 AppBskyFeedThreadgate, 8 8 AtUri, 9 - ModerationDecision, 9 + type ModerationDecision, 10 10 RichText as RichTextAPI, 11 11 } from '@atproto/api' 12 12 import { 13 13 FontAwesomeIcon, 14 - FontAwesomeIconStyle, 14 + type FontAwesomeIconStyle, 15 15 } from '@fortawesome/react-native-fontawesome' 16 16 import {msg, Trans} from '@lingui/macro' 17 17 import {useLingui} from '@lingui/react' 18 18 import {useQueryClient} from '@tanstack/react-query' 19 19 20 - import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types' 20 + import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' 21 21 import {MAX_POST_LINES} from '#/lib/constants' 22 22 import {usePalette} from '#/lib/hooks/usePalette' 23 23 import {makeProfileLink} from '#/lib/routes/links' ··· 25 25 import {sanitizeHandle} from '#/lib/strings/handles' 26 26 import {countLines} from '#/lib/strings/helpers' 27 27 import {s} from '#/lib/styles' 28 - import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 28 + import {POST_TOMBSTONE, type Shadow, usePostShadow} from '#/state/cache/post-shadow' 29 29 import {useFeedFeedbackContext} from '#/state/feed-feedback' 30 30 import {precacheProfile} from '#/state/queries/profile' 31 31 import {useSession} from '#/state/session' ··· 43 43 import {ContentHider} from '#/components/moderation/ContentHider' 44 44 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 45 45 import {PostAlerts} from '#/components/moderation/PostAlerts' 46 - import {AppModerationCause} from '#/components/Pills' 46 + import {type AppModerationCause} from '#/components/Pills' 47 47 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 48 48 import {RichText} from '#/components/RichText' 49 49 import {SubtleWebHover} from '#/components/SubtleWebHover' ··· 69 69 hideTopBorder?: boolean 70 70 isParentBlocked?: boolean 71 71 isParentNotFound?: boolean 72 + isCarouselItem?: boolean 72 73 } 73 74 74 75 export function PostFeedItem({ ··· 86 87 isParentBlocked, 87 88 isParentNotFound, 88 89 rootPost, 90 + isCarouselItem, 89 91 }: FeedItemProps & { 90 92 post: AppBskyFeedDefs.PostView 91 93 rootPost: AppBskyFeedDefs.PostView ··· 121 123 hideTopBorder={hideTopBorder} 122 124 isParentBlocked={isParentBlocked} 123 125 isParentNotFound={isParentNotFound} 126 + isCarouselItem={isCarouselItem} 124 127 rootPost={rootPost} 125 128 /> 126 129 ) ··· 143 146 hideTopBorder, 144 147 isParentBlocked, 145 148 isParentNotFound, 149 + isCarouselItem, 146 150 rootPost, 147 151 }: FeedItemProps & { 148 152 richText: RichTextAPI ··· 258 262 }}> 259 263 <SubtleWebHover hover={hover} /> 260 264 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 261 - <View style={{width: 42}}> 265 + <View style={{width: isCarouselItem ? 0 : 42}}> 262 266 {isThreadChild && ( 263 267 <View 264 268 style={[
+148
src/view/com/posts/PostFeedItemCarousel.tsx
··· 1 + import React from 'react' 2 + import {Dimensions, ScrollView, View} from 'react-native' 3 + import {msg, Plural} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {type FeedPostSlice} from '#/state/queries/post-feed' 7 + import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {Button, ButtonIcon} from '#/components/Button' 10 + import { 11 + ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, 12 + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, 13 + } from '#/components/icons/Chevron' 14 + import {Text} from '#/components/Typography' 15 + import {PostFeedItem} from './PostFeedItem' 16 + 17 + const CARD_WIDTH = 320 18 + const CARD_INTERVAL = CARD_WIDTH + a.gap_md.gap 19 + 20 + export function PostFeedItemCarousel({items}: {items: FeedPostSlice[]}) { 21 + const t = useTheme() 22 + const {_} = useLingui() 23 + const ref = React.useRef<ScrollView>(null) 24 + const [scrollX, setScrollX] = React.useState(0) 25 + 26 + const scrollTo = React.useCallback( 27 + (item: number) => { 28 + setScrollX(item) 29 + 30 + ref.current?.scrollTo({ 31 + x: item * CARD_INTERVAL, 32 + y: 0, 33 + animated: true, 34 + }) 35 + }, 36 + [ref], 37 + ) 38 + 39 + const scrollLeft = React.useCallback(() => { 40 + const newPos = scrollX > 0 ? scrollX - 1 : items.length - 1 41 + scrollTo(newPos) 42 + }, [scrollTo, scrollX, items.length]) 43 + 44 + const scrollRight = React.useCallback(() => { 45 + const newPos = scrollX < items.length - 1 ? scrollX + 1 : 0 46 + scrollTo(newPos) 47 + }, [scrollTo, scrollX, items.length]) 48 + 49 + return ( 50 + <View 51 + style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 52 + <View 53 + style={[ 54 + a.py_lg, 55 + a.px_md, 56 + a.pb_xs, 57 + a.flex_row, 58 + a.align_center, 59 + a.justify_between, 60 + ]}> 61 + <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}> 62 + {items.length}{' '} 63 + <Plural value={items.length} one="repost" other="reposts" /> 64 + </Text> 65 + <View style={[a.gap_md, a.flex_row, a.align_end]}> 66 + <Button 67 + label={_(msg`Scroll carousel left`)} 68 + size="tiny" 69 + variant="ghost" 70 + color="secondary" 71 + shape="round" 72 + onPress={() => scrollLeft()}> 73 + <ButtonIcon icon={ChevronLeft} /> 74 + </Button> 75 + <Button 76 + label={_(msg`Scroll carousel right`)} 77 + size="tiny" 78 + variant="ghost" 79 + color="secondary" 80 + shape="round" 81 + onPress={() => scrollRight()}> 82 + <ButtonIcon icon={ChevronRight} /> 83 + </Button> 84 + </View> 85 + </View> 86 + <BlockDrawerGesture> 87 + <View> 88 + <ScrollView 89 + horizontal 90 + snapToInterval={CARD_INTERVAL} 91 + decelerationRate="fast" 92 + /* TODO: figure out how to not get this to break on the last item 93 + onScroll={e => { 94 + setScrollX(Math.floor(e.nativeEvent.contentOffset.x / CARD_INTERVAL)) 95 + }} 96 + */ 97 + ref={ref}> 98 + <View 99 + style={[ 100 + a.px_md, 101 + a.pt_sm, 102 + a.pb_lg, 103 + a.flex_row, 104 + a.gap_md, 105 + a.align_start, 106 + ]}> 107 + {items.map(slice => { 108 + const item = slice.items[0] 109 + 110 + return ( 111 + <View 112 + style={[ 113 + { 114 + maxHeight: Dimensions.get('window').height * 0.65, 115 + width: CARD_WIDTH, 116 + }, 117 + a.rounded_md, 118 + a.border, 119 + t.atoms.bg, 120 + t.atoms.border_contrast_low, 121 + a.flex_shrink_0, 122 + a.overflow_hidden, 123 + ]} 124 + key={item._reactKey}> 125 + <PostFeedItem 126 + post={item.post} 127 + record={item.record} 128 + reason={slice.reason} 129 + feedContext={slice.feedContext} 130 + moderation={item.moderation} 131 + parentAuthor={item.parentAuthor} 132 + isParentBlocked={item.isParentBlocked} 133 + isParentNotFound={item.isParentNotFound} 134 + hideTopBorder={true} 135 + isCarouselItem={true} 136 + rootPost={slice.items[0].post} 137 + showReplyTo={false} 138 + /> 139 + </View> 140 + ) 141 + })} 142 + </View> 143 + </ScrollView> 144 + </View> 145 + </BlockDrawerGesture> 146 + </View> 147 + ) 148 + }
+1 -1
src/view/com/util/PostMeta.tsx
··· 59 59 return ( 60 60 <View 61 61 style={[ 62 - a.flex_1, 62 + isAndroid ? a.flex_1 : a.flex_shrink, 63 63 a.flex_row, 64 64 a.align_center, 65 65 a.pb_xs,
+9 -6
src/view/com/util/images/ImageLayoutGrid.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 - import {AppBskyEmbedImages} from '@atproto/api' 2 + import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' 3 + import {type AppBskyEmbedImages} from '@atproto/api' 4 4 5 - import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' 5 + import {type HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' 6 + import {isAndroid} from '#/platform/detection' 6 7 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' 7 8 import {atoms as a, useBreakpoints} from '#/alf' 8 - import {Dimensions} from '../../lightbox/ImageViewing/@types' 9 + import {type Dimensions} from '../../lightbox/ImageViewing/@types' 9 10 import {GalleryItem} from './Gallery' 10 11 11 12 interface ImageLayoutGridProps { ··· 62 63 const containerRef4 = useHandleRef() 63 64 const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) 64 65 66 + const outerFlex = isAndroid ? a.flex_1 : a.flex_shrink 67 + 65 68 switch (count) { 66 69 case 2: { 67 70 const containerRefs = [containerRef1, containerRef2] 68 71 return ( 69 - <View style={[a.flex_1, a.flex_row, gap]}> 72 + <View style={[outerFlex, a.flex_row, gap]}> 70 73 <View style={[a.flex_1, {aspectRatio: 1}]}> 71 74 <GalleryItem 72 75 {...props} ··· 92 95 case 3: { 93 96 const containerRefs = [containerRef1, containerRef2, containerRef3] 94 97 return ( 95 - <View style={[a.flex_1, a.flex_row, gap]}> 98 + <View style={[outerFlex, a.flex_row, gap]}> 96 99 <View style={[a.flex_1, {aspectRatio: 1}]}> 97 100 <GalleryItem 98 101 {...props}