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

Configure Feed

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

Add horizontal repost carousel

authored by

nta 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}