Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

yolo (#7499)

* tweaks to constants (#7478)

* add did

* use correct did

* typo

* tweak

* Prevent Drawer gesture conflicting with Suggestions scroll (#7468)

* Extract BlockDrawerGeesture

* Block drawer when scrolling interstitials

(cherry picked from commit 9e3f2f43745eed9c71cb985e48135b7363d91aa9)

* yolo interstitial

* yolo mode

* right swipe

* fix nav gesture

* vibe controls

* collapsible post text

* rm blurview, cover for tall videos

* smarter video source handling

* use thumbnails, improve perf significantly

* better android loading

* improve aspect ratio

* optimize source changes

* rm spinner on ios

* whoops, remove debug `false`

* FIX WRONG VIDEOS SHOWING UP

* don't spring on way down

* release video players when leaving screen

* remove jank animation

* Add grid

* improve contract, fix double tap

* Filter out posts without videos

* Only do grid on native

* Pipe through feedSourceUri and link to feed

* Handle passed through params

* Partial revert, just filter posts to start at index

* Clean up cards, remove entry interstitial

* Tweak handle

* Change constant name

* Rename some things

* Make types legit

* Clean up more naming

* Add placeholder for grid view

* Handle web, set up new organization

* Begin work on Header

* Replace types

* Squashed commit of the following:

commit 3d1be4c0f19789dd3c5a3572ec1acd744a2edb80
Author: Samuel Newman <mozzius@protonmail.com>
Date: Fri Jan 17 01:08:05 2025 +0000

extend animation

commit c9f199413b018efcbd9d8d2a58dd05eb41e7acb7
Author: Samuel Newman <mozzius@protonmail.com>
Date: Fri Jan 17 01:01:24 2025 +0000

fix gap

commit 22e520795f50efda176f21a5e967cb27d0cdd907
Author: Samuel Newman <mozzius@protonmail.com>
Date: Fri Jan 17 00:50:16 2025 +0000

thinner bar, format time

commit c32427f21405294ed3567545629a2964c4af59fe
Author: Samuel Newman <mozzius@protonmail.com>
Date: Fri Jan 17 00:47:57 2025 +0000

fix 2 in 3 screens

commit cbf84c08d64ca0a08ba9070ef5db918f89aa4296
Author: Samuel Newman <mozzius@protonmail.com>
Date: Fri Jan 17 00:45:46 2025 +0000

rm unneeded var

commit 7e0e100177bb1cd0e64c0841bb7685c7f1eb857f
Author: Samuel Newman <mozzius@protonmail.com>
Date: Fri Jan 17 00:41:18 2025 +0000

scrubberrrrr

* use white with opacity rather than gray

* Simultaneous gesture

* cleanup attempt

* fix jank

* link to profile on press

* fix jitter fr this time

* mostly fix android flicker

* Maybe fix row generation

* Add content hider to video card

* emoji in post text

* reduce update rate

* fix type error

* Fix grid layout trailing single item

* Add Discover interstitial, settings, includes pin for now

* Explore interstitial, handle dimissal, pinning, compact card

* Only use grid placeholder on native

* Update events

* Add feature gate

* android nav bar fixes + lower update speed

* fix interval + decel rate on interstitials

* attempt to fix broken scrub on android (not working)

* follow button

* Part out the interstitials for perf, add view more

* Remove prod web route

* Wrap interstitials with BlockDrawerGesture

* Bring video cropping in line with images (#7462)

* Mimic image cropping for videos on web

* Same on native

* Rename variables for clarity

* Fix Android scrubbing

* Add FeedFeedbackProvider

* Remove swipe gesture

* fix light status bar behaviour

* bump

* feedback

* Copy pasta to new location

* Copy pasta part deux

* Filter only videos

* Make whole text clickable to expand

(cherry picked from commit 4cf31120779f4e06eb4c296b3d4b53814d432b07)

* move scrubber to own file

* end card

* add icon to end card

* add min view time to viewability config

* play haptic on like

* tweak feedback

* tweak feedback again

* Moderation

(cherry picked from commit 6b6b471cfb363031284b3e7a1f6e0ade3ac4ae47)

* remove bad check

* fix feedback for new video grid

* change prop name to items as well

* Simplify logic

* Fix mod footer

* Give scrubber more space on android

* Add subtle track behind scrubber, adjust opacity

* wire in feed context again...

* Add better a11y desc to card

* Fix key issue

* Update a11y copy

* Fix scrubber height

* improve scrubber animation

* Make follow button more obvious

* Make header back button more clear

* Disable interactions with actual video el

* keep content away from the bottom safe area

* fix blur

* fix moderation issue

* improve contrast on mod screen

* Make moderation static per item

* Memoize rows

* Optimizations

* Take video moderation into account

* Only blur titles for list blur

* Change copy

* Bump blur radius

* animate text in both directions

* Rm unused field

* Filter by root early

* Refactor for clarity

* add compose prompt to scrubber

* rm log

* tweak gradient

* Bump SDK, use contentMode to power video feed

* Ensure ProfileFeed view also supports video feed

* improve scrubber on android

* rm border from footer

* Update prod video feed did

* Separate caches

* Add lil hover to View More

* Fix undefined logic, remove header for interstitial

* Ungate

* Fix stuckness

* remove extra useless map

* Fix effect cleanup

* Send seen without cleanup

* Simplify react stuff

* Earlier early return to avoid loading flash

* remove scrubber placeholder

* Remove opacity hack

* Render useEvent conditionally

* Fix Android flash

---------

Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>

authored by

Hailey
dan
Samuel Newman
Eric Bailey
and committed by
GitHub
34582edf cb020655

+3164 -125
+2 -2
package.json
··· 1 1 { 2 2 "name": "bsky.app", 3 - "version": "1.96.5", 3 + "version": "1.96.6", 4 4 "private": true, 5 5 "engines": { 6 6 "node": ">=20" ··· 54 54 "icons:optimize": "svgo -f ./assets/icons" 55 55 }, 56 56 "dependencies": { 57 - "@atproto/api": "^0.13.21", 57 + "@atproto/api": "^0.13.28", 58 58 "@braintree/sanitize-url": "^6.0.2", 59 59 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 60 60 "@emoji-mart/react": "^1.1.1",
+9
src/Navigation.tsx
··· 86 86 StarterPackScreenShort, 87 87 } from '#/screens/StarterPack/StarterPackScreen' 88 88 import {Wizard} from '#/screens/StarterPack/Wizard' 89 + import {VideoFeed} from '#/screens/VideoFeed' 89 90 import {useTheme} from '#/alf' 90 91 import {router} from '#/routes' 91 92 import {Referrer} from '../modules/expo-bluesky-swiss-army' ··· 421 422 name="StarterPackEdit" 422 423 getComponent={() => Wizard} 423 424 options={{title: title(msg`Edit your starter pack`), requireAuth: true}} 425 + /> 426 + <Stack.Screen 427 + name="VideoFeed" 428 + getComponent={() => VideoFeed} 429 + options={{ 430 + title: title(msg`Video Feed`), 431 + requireAuth: true, 432 + }} 424 433 /> 425 434 </> 426 435 )
+1 -1
src/alf/themes.ts
··· 497 497 color: dimPalette.contrast_400, 498 498 }, 499 499 text_contrast_medium: { 500 - color: dimPalette.contrast_700, 500 + color: dimPalette.contrast_600, 501 501 }, 502 502 text_contrast_high: { 503 503 color: dimPalette.contrast_900,
+3 -1
src/components/Dialog/index.tsx
··· 27 27 import {useDialogStateControlContext} from '#/state/dialogs' 28 28 import {List, ListMethods, ListProps} from '#/view/com/util/List' 29 29 import {atoms as a, useTheme} from '#/alf' 30 + import {useThemeName} from '#/alf/util/useColorModeTheme' 30 31 import {Context, useDialogContext} from '#/components/Dialog/context' 31 32 import { 32 33 DialogControlProps, ··· 55 56 nativeOptions, 56 57 testID, 57 58 }: React.PropsWithChildren<DialogOuterProps>) { 58 - const t = useTheme() 59 + const themeName = useThemeName() 60 + const t = useTheme(themeName) 59 61 const ref = React.useRef<BottomSheetNativeComponent>(null) 60 62 const closeCallbacks = React.useRef<(() => void)[]>([]) 61 63 const {setDialogIsOpen, setFullyExpandedCount} =
+59
src/components/Grid.tsx
··· 1 + import {createContext, useContext, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {atoms as a, ViewStyleProp} from '#/alf' 5 + 6 + const Context = createContext({ 7 + gap: 0, 8 + }) 9 + 10 + export function Row({ 11 + children, 12 + gap = 0, 13 + style, 14 + }: ViewStyleProp & { 15 + children: React.ReactNode 16 + gap?: number 17 + }) { 18 + return ( 19 + <Context.Provider value={useMemo(() => ({gap}), [gap])}> 20 + <View 21 + style={[ 22 + a.flex_row, 23 + a.flex_1, 24 + { 25 + marginLeft: -gap / 2, 26 + marginRight: -gap / 2, 27 + }, 28 + style, 29 + ]}> 30 + {children} 31 + </View> 32 + </Context.Provider> 33 + ) 34 + } 35 + 36 + export function Col({ 37 + children, 38 + width = 1, 39 + style, 40 + }: ViewStyleProp & { 41 + children: React.ReactNode 42 + width?: number 43 + }) { 44 + const {gap} = useContext(Context) 45 + return ( 46 + <View 47 + style={[ 48 + a.flex_col, 49 + { 50 + paddingLeft: gap / 2, 51 + paddingRight: gap / 2, 52 + width: `${width * 100}%`, 53 + }, 54 + style, 55 + ]}> 56 + {children} 57 + </View> 58 + ) 59 + }
+5 -1
src/components/Layout/Header/index.tsx
··· 122 122 shape="square" 123 123 onPress={onPressBack} 124 124 hitSlop={HITSLOP_30} 125 - style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]} 125 + style={[ 126 + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, 127 + a.bg_transparent, 128 + style, 129 + ]} 126 130 {...props}> 127 131 <ButtonIcon icon={ArrowLeft} size="lg" /> 128 132 </Button>
+10 -4
src/components/LinearGradientBackground.tsx
··· 6 6 7 7 export function LinearGradientBackground({ 8 8 style, 9 + gradient = 'sky', 9 10 children, 11 + start, 12 + end, 10 13 }: { 11 - style: StyleProp<ViewStyle> 12 - children: React.ReactNode 14 + style?: StyleProp<ViewStyle> 15 + gradient?: keyof typeof gradients 16 + children?: React.ReactNode 17 + start?: [number, number] 18 + end?: [number, number] 13 19 }) { 14 - const gradient = gradients.sky.values.map(([_, color]) => { 20 + const colors = gradients[gradient].values.map(([_, color]) => { 15 21 return color 16 22 }) as [string, string, ...string[]] 17 23 ··· 20 26 } 21 27 22 28 return ( 23 - <LinearGradient colors={gradient} style={style}> 29 + <LinearGradient colors={colors} style={style} start={start} end={end}> 24 30 {children} 25 31 </LinearGradient> 26 32 )
+9 -3
src/components/Lists.tsx
··· 20 20 style, 21 21 showEndMessage = false, 22 22 endMessageText, 23 + renderEndMessage, 23 24 }: { 24 25 isFetchingNextPage?: boolean 25 26 hasNextPage?: boolean ··· 29 30 style?: StyleProp<ViewStyle> 30 31 showEndMessage?: boolean 31 32 endMessageText?: string 33 + renderEndMessage?: () => React.ReactNode 32 34 }) { 33 35 const t = useTheme() 34 36 ··· 48 50 ) : error ? ( 49 51 <ListFooterMaybeError error={error} onRetry={onRetry} /> 50 52 ) : !hasNextPage && showEndMessage ? ( 51 - <Text style={[a.text_sm, t.atoms.text_contrast_low]}> 52 - {endMessageText ?? <Trans>You have reached the end</Trans>} 53 - </Text> 53 + renderEndMessage ? ( 54 + renderEndMessage() 55 + ) : ( 56 + <Text style={[a.text_sm, t.atoms.text_contrast_low]}> 57 + {endMessageText ?? <Trans>You have reached the end</Trans>} 58 + </Text> 59 + ) 54 60 ) : null} 55 61 </View> 56 62 )
+9 -1
src/components/RichText.tsx
··· 19 19 const WORD_WRAP = {wordWrap: 1} 20 20 21 21 export type RichTextProps = TextStyleProp & 22 - Pick<TextProps, 'selectable'> & { 22 + Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & { 23 23 value: RichTextAPI | string 24 24 testID?: string 25 25 numberOfLines?: number ··· 43 43 onLinkPress, 44 44 interactiveStyle, 45 45 emojiMultiplier = 1.85, 46 + onLayout, 47 + onTextLayout, 46 48 }: RichTextProps) { 47 49 const richText = React.useMemo( 48 50 () => ··· 70 72 selectable={selectable} 71 73 testID={testID} 72 74 style={[plainStyles, {fontSize}]} 75 + onLayout={onLayout} 76 + onTextLayout={onTextLayout} 73 77 // @ts-ignore web only -prf 74 78 dataSet={WORD_WRAP}> 75 79 {text} ··· 83 87 testID={testID} 84 88 style={plainStyles} 85 89 numberOfLines={numberOfLines} 90 + onLayout={onLayout} 91 + onTextLayout={onTextLayout} 86 92 // @ts-ignore web only -prf 87 93 dataSet={WORD_WRAP}> 88 94 {text} ··· 163 169 testID={testID} 164 170 style={plainStyles} 165 171 numberOfLines={numberOfLines} 172 + onLayout={onLayout} 173 + onTextLayout={onTextLayout} 166 174 // @ts-ignore web only -prf 167 175 dataSet={WORD_WRAP}> 168 176 {els}
+540
src/components/VideoPostCard.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import { 6 + AppBskyActorDefs, 7 + AppBskyEmbedVideo, 8 + AppBskyFeedDefs, 9 + AppBskyFeedPost, 10 + ModerationDecision, 11 + } from '@atproto/api' 12 + import {msg} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 14 + 15 + import {sanitizeHandle} from '#/lib/strings/handles' 16 + import {formatCount} from '#/view/com/util/numeric/format' 17 + import {UserAvatar} from '#/view/com/util/UserAvatar' 18 + import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' 19 + import {atoms as a, useTheme} from '#/alf' 20 + import {BLUE_HUE} from '#/alf/util/colorGeneration' 21 + import {select} from '#/alf/util/themeSelector' 22 + import {useInteractionState} from '#/components/hooks/useInteractionState' 23 + import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' 24 + import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2' 25 + import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 26 + import {Link} from '#/components/Link' 27 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 28 + import * as Hider from '#/components/moderation/Hider' 29 + import {Text} from '#/components/Typography' 30 + 31 + function getBlackColor(t: ReturnType<typeof useTheme>) { 32 + return select(t.name, { 33 + light: t.palette.black, 34 + dark: t.atoms.bg_contrast_25.backgroundColor, 35 + dim: `hsl(${BLUE_HUE}, 28%, 6%)`, 36 + }) 37 + } 38 + 39 + export function VideoPostCard({ 40 + post, 41 + sourceContext, 42 + moderation, 43 + onInteract, 44 + }: { 45 + post: AppBskyFeedDefs.PostView 46 + sourceContext: VideoFeedSourceContext 47 + moderation: ModerationDecision 48 + /** 49 + * Callback for metrics etc 50 + */ 51 + onInteract?: () => void 52 + }) { 53 + const t = useTheme() 54 + const {_, i18n} = useLingui() 55 + const embed = post.embed 56 + const { 57 + state: pressed, 58 + onIn: onPressIn, 59 + onOut: onPressOut, 60 + } = useInteractionState() 61 + 62 + const listModUi = moderation.ui('contentList') 63 + 64 + const mergedModui = useMemo(() => { 65 + const modui = moderation.ui('contentList') 66 + const mediaModui = moderation.ui('contentMedia') 67 + modui.alerts = [...modui.alerts, ...mediaModui.alerts] 68 + modui.blurs = [...modui.blurs, ...mediaModui.blurs] 69 + modui.filters = [...modui.filters, ...mediaModui.filters] 70 + modui.informs = [...modui.informs, ...mediaModui.informs] 71 + return modui 72 + }, [moderation]) 73 + 74 + /** 75 + * Filtering should be done at a higher level, such as `PostFeed` or 76 + * `PostFeedVideoGridRow`, but we need to protect here as well. 77 + */ 78 + if (!AppBskyEmbedVideo.isView(embed)) return null 79 + 80 + const author = post.author 81 + const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : '' 82 + const likeCount = post?.likeCount ?? 0 83 + const repostCount = post?.repostCount ?? 0 84 + const {thumbnail} = embed 85 + const black = getBlackColor(t) 86 + 87 + const textAndAuthor = ( 88 + <View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}> 89 + {text && ( 90 + <Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji> 91 + {text} 92 + </Text> 93 + )} 94 + <View style={[a.flex_row, a.gap_xs, a.align_center]}> 95 + <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 96 + <UserAvatar type="user" size={20} avatar={post.author.avatar} /> 97 + <MediaInsetBorder /> 98 + </View> 99 + <Text 100 + style={[ 101 + a.flex_1, 102 + a.text_sm, 103 + a.leading_tight, 104 + t.atoms.text_contrast_medium, 105 + ]} 106 + numberOfLines={1}> 107 + {sanitizeHandle(post.author.handle, '@')} 108 + </Text> 109 + </View> 110 + </View> 111 + ) 112 + 113 + return ( 114 + <Link 115 + accessibilityHint={_(msg`Tap to view video in immersive mode.`)} 116 + label={_(msg`Video from ${author.handle}: ${text}`)} 117 + to={{ 118 + screen: 'VideoFeed', 119 + params: { 120 + ...sourceContext, 121 + initialPostUri: post.uri, 122 + }, 123 + }} 124 + onPress={() => { 125 + onInteract?.() 126 + }} 127 + onPressIn={onPressIn} 128 + onPressOut={onPressOut} 129 + style={[ 130 + a.flex_col, 131 + { 132 + alignItems: undefined, 133 + justifyContent: undefined, 134 + }, 135 + ]}> 136 + <Hider.Outer modui={mergedModui}> 137 + <Hider.Mask> 138 + <View 139 + style={[ 140 + a.justify_center, 141 + a.rounded_md, 142 + a.overflow_hidden, 143 + { 144 + backgroundColor: black, 145 + aspectRatio: 9 / 16, 146 + }, 147 + ]}> 148 + <Image 149 + source={{uri: thumbnail}} 150 + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 151 + accessibilityIgnoresInvertColors 152 + blurRadius={100} 153 + /> 154 + <MediaInsetBorder /> 155 + <View 156 + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 157 + <View 158 + style={[ 159 + a.absolute, 160 + a.inset_0, 161 + a.justify_center, 162 + a.align_center, 163 + { 164 + backgroundColor: 'black', 165 + opacity: 0.2, 166 + }, 167 + ]} 168 + /> 169 + <View style={[a.align_center, a.gap_xs]}> 170 + <Eye size="lg" fill="white" /> 171 + <Text style={[a.text_sm, {color: 'white'}]}> 172 + {_(msg`Hidden`)} 173 + </Text> 174 + </View> 175 + </View> 176 + </View> 177 + {listModUi.blur ? ( 178 + <VideoPostCardTextPlaceholder author={post.author} /> 179 + ) : ( 180 + textAndAuthor 181 + )} 182 + </Hider.Mask> 183 + <Hider.Content> 184 + <View 185 + style={[ 186 + a.justify_center, 187 + a.rounded_md, 188 + a.overflow_hidden, 189 + { 190 + backgroundColor: black, 191 + aspectRatio: 9 / 16, 192 + }, 193 + ]}> 194 + <Image 195 + source={{uri: thumbnail}} 196 + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 197 + accessibilityIgnoresInvertColors 198 + /> 199 + <MediaInsetBorder /> 200 + 201 + <View style={[a.absolute, a.inset_0]}> 202 + <View 203 + style={[ 204 + a.absolute, 205 + a.inset_0, 206 + a.pt_2xl, 207 + { 208 + top: 'auto', 209 + }, 210 + ]}> 211 + <LinearGradient 212 + colors={[black, 'rgba(0, 0, 0, 0)']} 213 + locations={[0.02, 1]} 214 + start={{x: 0, y: 1}} 215 + end={{x: 0, y: 0}} 216 + style={[a.absolute, a.inset_0, {opacity: 0.9}]} 217 + /> 218 + 219 + <View 220 + style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}> 221 + {likeCount > 0 && ( 222 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 223 + <Heart size="sm" fill="white" /> 224 + <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> 225 + {formatCount(i18n, likeCount)} 226 + </Text> 227 + </View> 228 + )} 229 + {repostCount > 0 && ( 230 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 231 + <Repost size="sm" fill="white" /> 232 + <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> 233 + {formatCount(i18n, repostCount)} 234 + </Text> 235 + </View> 236 + )} 237 + </View> 238 + </View> 239 + </View> 240 + </View> 241 + {textAndAuthor} 242 + </Hider.Content> 243 + </Hider.Outer> 244 + </Link> 245 + ) 246 + } 247 + 248 + export function VideoPostCardPlaceholder() { 249 + const t = useTheme() 250 + const black = getBlackColor(t) 251 + 252 + return ( 253 + <View style={[a.flex_1]}> 254 + <View 255 + style={[ 256 + a.rounded_md, 257 + a.overflow_hidden, 258 + { 259 + backgroundColor: black, 260 + aspectRatio: 9 / 16, 261 + }, 262 + ]}> 263 + <MediaInsetBorder /> 264 + </View> 265 + <VideoPostCardTextPlaceholder /> 266 + </View> 267 + ) 268 + } 269 + 270 + export function VideoPostCardTextPlaceholder({ 271 + author, 272 + }: { 273 + author?: AppBskyActorDefs.ProfileViewBasic 274 + }) { 275 + const t = useTheme() 276 + 277 + return ( 278 + <View style={[a.flex_1]}> 279 + <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}> 280 + <View 281 + style={[ 282 + a.w_full, 283 + a.rounded_xs, 284 + t.atoms.bg_contrast_50, 285 + { 286 + height: 14, 287 + }, 288 + ]} 289 + /> 290 + <View 291 + style={[ 292 + a.w_full, 293 + a.rounded_xs, 294 + t.atoms.bg_contrast_50, 295 + { 296 + height: 14, 297 + width: '70%', 298 + }, 299 + ]} 300 + /> 301 + {author ? ( 302 + <View style={[a.flex_row, a.gap_xs, a.align_center]}> 303 + <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 304 + <UserAvatar type="user" size={20} avatar={author.avatar} /> 305 + <MediaInsetBorder /> 306 + </View> 307 + <Text 308 + style={[ 309 + a.flex_1, 310 + a.text_sm, 311 + a.leading_tight, 312 + t.atoms.text_contrast_medium, 313 + ]} 314 + numberOfLines={1}> 315 + {sanitizeHandle(author.handle, '@')} 316 + </Text> 317 + </View> 318 + ) : ( 319 + <View style={[a.flex_row, a.gap_xs, a.align_center]}> 320 + <View 321 + style={[ 322 + a.rounded_full, 323 + t.atoms.bg_contrast_50, 324 + { 325 + width: 20, 326 + height: 20, 327 + }, 328 + ]} 329 + /> 330 + <View 331 + style={[ 332 + a.rounded_xs, 333 + t.atoms.bg_contrast_25, 334 + { 335 + height: 12, 336 + width: '75%', 337 + }, 338 + ]} 339 + /> 340 + </View> 341 + )} 342 + </View> 343 + </View> 344 + ) 345 + } 346 + 347 + export function CompactVideoPostCard({ 348 + post, 349 + sourceContext, 350 + moderation, 351 + onInteract, 352 + }: { 353 + post: AppBskyFeedDefs.PostView 354 + sourceContext: VideoFeedSourceContext 355 + moderation: ModerationDecision 356 + /** 357 + * Callback for metrics etc 358 + */ 359 + onInteract?: () => void 360 + }) { 361 + const t = useTheme() 362 + const {_, i18n} = useLingui() 363 + const embed = post.embed 364 + const { 365 + state: pressed, 366 + onIn: onPressIn, 367 + onOut: onPressOut, 368 + } = useInteractionState() 369 + 370 + const mergedModui = useMemo(() => { 371 + const modui = moderation.ui('contentList') 372 + const mediaModui = moderation.ui('contentMedia') 373 + modui.alerts = [...modui.alerts, ...mediaModui.alerts] 374 + modui.blurs = [...modui.blurs, ...mediaModui.blurs] 375 + modui.filters = [...modui.filters, ...mediaModui.filters] 376 + modui.informs = [...modui.informs, ...mediaModui.informs] 377 + return modui 378 + }, [moderation]) 379 + 380 + /** 381 + * Filtering should be done at a higher level, such as `PostFeed` or 382 + * `PostFeedVideoGridRow`, but we need to protect here as well. 383 + */ 384 + if (!AppBskyEmbedVideo.isView(embed)) return null 385 + 386 + const likeCount = post?.likeCount ?? 0 387 + const {thumbnail} = embed 388 + const black = getBlackColor(t) 389 + 390 + return ( 391 + <Link 392 + label={_(msg`View video`)} 393 + to={{ 394 + screen: 'VideoFeed', 395 + params: { 396 + ...sourceContext, 397 + initialPostUri: post.uri, 398 + }, 399 + }} 400 + onPress={() => { 401 + onInteract?.() 402 + }} 403 + onPressIn={onPressIn} 404 + onPressOut={onPressOut} 405 + style={[ 406 + a.flex_col, 407 + { 408 + alignItems: undefined, 409 + justifyContent: undefined, 410 + }, 411 + ]}> 412 + <Hider.Outer modui={mergedModui}> 413 + <Hider.Mask> 414 + <View 415 + style={[ 416 + a.justify_center, 417 + a.rounded_md, 418 + a.overflow_hidden, 419 + { 420 + backgroundColor: black, 421 + aspectRatio: 9 / 16, 422 + }, 423 + ]}> 424 + <Image 425 + source={{uri: thumbnail}} 426 + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 427 + accessibilityIgnoresInvertColors 428 + blurRadius={100} 429 + /> 430 + <MediaInsetBorder /> 431 + <View 432 + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 433 + <View 434 + style={[ 435 + a.absolute, 436 + a.inset_0, 437 + a.justify_center, 438 + a.align_center, 439 + { 440 + backgroundColor: 'black', 441 + opacity: 0.2, 442 + }, 443 + ]} 444 + /> 445 + <View style={[a.align_center, a.gap_xs]}> 446 + <Eye size="lg" fill="white" /> 447 + <Text style={[a.text_sm, {color: 'white'}]}> 448 + {_(msg`Hidden`)} 449 + </Text> 450 + </View> 451 + </View> 452 + </View> 453 + </Hider.Mask> 454 + <Hider.Content> 455 + <View 456 + style={[ 457 + a.justify_center, 458 + a.rounded_md, 459 + a.overflow_hidden, 460 + { 461 + backgroundColor: black, 462 + aspectRatio: 9 / 16, 463 + }, 464 + ]}> 465 + <Image 466 + source={{uri: thumbnail}} 467 + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 468 + accessibilityIgnoresInvertColors 469 + /> 470 + <MediaInsetBorder /> 471 + 472 + <View style={[a.absolute, a.inset_0]}> 473 + <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}> 474 + <View 475 + style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 476 + <UserAvatar 477 + type="user" 478 + size={20} 479 + avatar={post.author.avatar} 480 + /> 481 + <MediaInsetBorder /> 482 + </View> 483 + </View> 484 + <View 485 + style={[ 486 + a.absolute, 487 + a.inset_0, 488 + a.pt_2xl, 489 + { 490 + top: 'auto', 491 + }, 492 + ]}> 493 + <LinearGradient 494 + colors={[black, 'rgba(0, 0, 0, 0)']} 495 + locations={[0.02, 1]} 496 + start={{x: 0, y: 1}} 497 + end={{x: 0, y: 0}} 498 + style={[a.absolute, a.inset_0, {opacity: 0.9}]} 499 + /> 500 + 501 + <View 502 + style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}> 503 + {likeCount > 0 && ( 504 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 505 + <Heart size="sm" fill="white" /> 506 + <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> 507 + {formatCount(i18n, likeCount)} 508 + </Text> 509 + </View> 510 + )} 511 + </View> 512 + </View> 513 + </View> 514 + </View> 515 + </Hider.Content> 516 + </Hider.Outer> 517 + </Link> 518 + ) 519 + } 520 + 521 + export function CompactVideoPostCardPlaceholder() { 522 + const t = useTheme() 523 + const black = getBlackColor(t) 524 + 525 + return ( 526 + <View style={[a.flex_1]}> 527 + <View 528 + style={[ 529 + a.rounded_md, 530 + a.overflow_hidden, 531 + { 532 + backgroundColor: black, 533 + aspectRatio: 9 / 16, 534 + }, 535 + ]}> 536 + <MediaInsetBorder /> 537 + </View> 538 + </View> 539 + ) 540 + }
+67
src/components/feeds/PostFeedVideoGridRow.tsx
··· 1 + import {View} from 'react-native' 2 + import {AppBskyEmbedVideo} from '@atproto/api' 3 + 4 + import {logEvent} from '#/lib/statsig/statsig' 5 + import {FeedPostSliceItem} from '#/state/queries/post-feed' 6 + import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' 7 + import {atoms as a, useGutters} from '#/alf' 8 + import * as Grid from '#/components/Grid' 9 + import { 10 + VideoPostCard, 11 + VideoPostCardPlaceholder, 12 + } from '#/components/VideoPostCard' 13 + 14 + export function PostFeedVideoGridRow({ 15 + items: slices, 16 + sourceContext, 17 + }: { 18 + items: FeedPostSliceItem[] 19 + sourceContext: VideoFeedSourceContext 20 + }) { 21 + const gutters = useGutters(['base', 'base', 0, 'base']) 22 + const posts = slices 23 + .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed)) 24 + .map(slice => ({ 25 + post: slice.post, 26 + moderation: slice.moderation, 27 + })) 28 + 29 + /** 30 + * This should not happen because we should be filtering out posts without 31 + * videos within the `PostFeed` component. 32 + */ 33 + if (posts.length !== slices.length) return null 34 + 35 + return ( 36 + <View style={[gutters]}> 37 + <View style={[a.flex_row, a.gap_sm]}> 38 + <Grid.Row gap={a.gap_sm.gap}> 39 + {posts.map(post => ( 40 + <Grid.Col key={post.post.uri} width={1 / 2}> 41 + <VideoPostCard 42 + post={post.post} 43 + sourceContext={sourceContext} 44 + moderation={post.moderation} 45 + onInteract={() => { 46 + logEvent('videoCard:click', {context: 'feed'}) 47 + }} 48 + /> 49 + </Grid.Col> 50 + ))} 51 + </Grid.Row> 52 + </View> 53 + </View> 54 + ) 55 + } 56 + 57 + export function PostFeedVideoGridRowPlaceholder() { 58 + const gutters = useGutters(['base', 'base', 0, 'base']) 59 + return ( 60 + <View style={[gutters]}> 61 + <View style={[a.flex_row, a.gap_sm]}> 62 + <VideoPostCardPlaceholder /> 63 + <VideoPostCardPlaceholder /> 64 + </View> 65 + </View> 66 + ) 67 + }
+231
src/components/interstitials/TrendingVideos.tsx
··· 1 + import React, {useEffect} from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import {AppBskyEmbedVideo, AtUri} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useQueryClient} from '@tanstack/react-query' 7 + 8 + import {VIDEO_FEED_URI} from '#/lib/constants' 9 + import {makeCustomFeedLink} from '#/lib/routes/links' 10 + import {logEvent} from '#/lib/statsig/statsig' 11 + import {useTrendingSettingsApi} from '#/state/preferences/trending' 12 + import {usePostFeedQuery} from '#/state/queries/post-feed' 13 + import {RQKEY} from '#/state/queries/post-feed' 14 + import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 15 + import {atoms as a, useGutters, useTheme} from '#/alf' 16 + import {Button, ButtonIcon} from '#/components/Button' 17 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 18 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 19 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 20 + import {Link} from '#/components/Link' 21 + import * as Prompt from '#/components/Prompt' 22 + import {Text} from '#/components/Typography' 23 + import { 24 + CompactVideoPostCard, 25 + CompactVideoPostCardPlaceholder, 26 + } from '#/components/VideoPostCard' 27 + 28 + const CARD_WIDTH = 100 29 + 30 + const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` 31 + const FEED_PARAMS: { 32 + feedCacheKey: 'discover' 33 + } = { 34 + feedCacheKey: 'discover', 35 + } 36 + 37 + export function TrendingVideos() { 38 + const t = useTheme() 39 + const {_} = useLingui() 40 + const gutters = useGutters([0, 'base']) 41 + const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) 42 + 43 + // Refetch on unmount if nothing else is using this query. 44 + const queryClient = useQueryClient() 45 + useEffect(() => { 46 + return () => { 47 + const query = queryClient 48 + .getQueryCache() 49 + .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) 50 + if (query && query.getObserversCount() <= 1) { 51 + query.fetch() 52 + } 53 + } 54 + }, [queryClient]) 55 + 56 + const {setTrendingVideoDisabled} = useTrendingSettingsApi() 57 + const trendingPrompt = Prompt.usePromptControl() 58 + 59 + const onConfirmHide = React.useCallback(() => { 60 + setTrendingVideoDisabled(true) 61 + logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) 62 + }, [setTrendingVideoDisabled]) 63 + 64 + if (error) { 65 + return null 66 + } 67 + 68 + return ( 69 + <View 70 + style={[ 71 + a.pt_lg, 72 + a.pb_lg, 73 + a.border_t, 74 + t.atoms.border_contrast_low, 75 + t.atoms.bg_contrast_25, 76 + ]}> 77 + <View 78 + style={[ 79 + gutters, 80 + a.pb_sm, 81 + a.flex_row, 82 + a.align_center, 83 + a.justify_between, 84 + ]}> 85 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}> 86 + <Graph /> 87 + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> 88 + <Trans>Trending Videos</Trans> 89 + </Text> 90 + </View> 91 + <Button 92 + label={_(msg`Dismiss this section`)} 93 + size="tiny" 94 + variant="ghost" 95 + color="secondary" 96 + shape="round" 97 + onPress={() => trendingPrompt.open()}> 98 + <ButtonIcon icon={X} /> 99 + </Button> 100 + </View> 101 + 102 + <BlockDrawerGesture> 103 + <ScrollView 104 + horizontal 105 + showsHorizontalScrollIndicator={false} 106 + decelerationRate="fast" 107 + snapToInterval={CARD_WIDTH + a.gap_sm.gap}> 108 + <View 109 + style={[ 110 + a.flex_row, 111 + a.gap_sm, 112 + { 113 + paddingLeft: gutters.paddingLeft, 114 + paddingRight: gutters.paddingRight, 115 + }, 116 + ]}> 117 + {isLoading ? ( 118 + Array(10) 119 + .fill(0) 120 + .map((_, i) => ( 121 + <View key={i} style={[{width: CARD_WIDTH}]}> 122 + <CompactVideoPostCardPlaceholder /> 123 + </View> 124 + )) 125 + ) : error || !data ? ( 126 + <Text> 127 + <Trans>Whoops! Trending videos failed to load.</Trans> 128 + </Text> 129 + ) : ( 130 + <VideoCards data={data} /> 131 + )} 132 + </View> 133 + </ScrollView> 134 + </BlockDrawerGesture> 135 + 136 + <Prompt.Basic 137 + control={trendingPrompt} 138 + title={_(msg`Hide trending videos?`)} 139 + description={_(msg`You can update this later from your settings.`)} 140 + confirmButtonCta={_(msg`Hide`)} 141 + onConfirm={onConfirmHide} 142 + /> 143 + </View> 144 + ) 145 + } 146 + 147 + function VideoCards({ 148 + data, 149 + }: { 150 + data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> 151 + }) { 152 + const t = useTheme() 153 + const {_} = useLingui() 154 + const items = React.useMemo(() => { 155 + return data.pages 156 + .flatMap(page => page.slices) 157 + .map(slice => slice.items[0]) 158 + .filter(Boolean) 159 + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) 160 + .slice(0, 8) 161 + }, [data]) 162 + const href = React.useMemo(() => { 163 + const urip = new AtUri(VIDEO_FEED_URI) 164 + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover') 165 + }, []) 166 + 167 + return ( 168 + <> 169 + {items.map(item => ( 170 + <View key={item.post.uri} style={[{width: CARD_WIDTH}]}> 171 + <CompactVideoPostCard 172 + post={item.post} 173 + moderation={item.moderation} 174 + sourceContext={{ 175 + type: 'feedgen', 176 + uri: VIDEO_FEED_URI, 177 + sourceInterstitial: 'discover', 178 + }} 179 + onInteract={() => { 180 + logEvent('videoCard:click', { 181 + context: 'interstitial:discover', 182 + }) 183 + }} 184 + /> 185 + </View> 186 + ))} 187 + 188 + <View style={[{width: CARD_WIDTH * 2}]}> 189 + <Link 190 + to={href} 191 + label={_(msg`View more`)} 192 + style={[ 193 + a.justify_center, 194 + a.align_center, 195 + a.flex_1, 196 + a.rounded_md, 197 + t.atoms.bg, 198 + ]}> 199 + {({pressed}) => ( 200 + <View 201 + style={[ 202 + a.flex_row, 203 + a.align_center, 204 + a.gap_md, 205 + { 206 + opacity: pressed ? 0.6 : 1, 207 + }, 208 + ]}> 209 + <Text style={[a.text_md]}> 210 + <Trans>View more</Trans> 211 + </Text> 212 + <View 213 + style={[ 214 + a.align_center, 215 + a.justify_center, 216 + a.rounded_full, 217 + { 218 + width: 34, 219 + height: 34, 220 + backgroundColor: t.palette.primary_500, 221 + }, 222 + ]}> 223 + <ButtonIcon icon={ChevronRight} /> 224 + </View> 225 + </View> 226 + )} 227 + </Link> 228 + </View> 229 + </> 230 + ) 231 + }
+5
src/lib/constants.ts
··· 124 124 125 125 export const DISCOVER_FEED_URI = 126 126 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' 127 + export const VIDEO_FEED_URI = 128 + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids' 129 + export const STAGING_VIDEO_FEED_URI = 130 + 'at://did:plc:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/thevids' 131 + export const VIDEO_FEED_URIS = [VIDEO_FEED_URI, STAGING_VIDEO_FEED_URI] 127 132 export const DISCOVER_SAVED_FEED = { 128 133 type: 'feed', 129 134 value: DISCOVER_FEED_URI,
+6 -2
src/lib/routes/links.ts
··· 19 19 export function makeCustomFeedLink( 20 20 did: string, 21 21 rkey: string, 22 - ...segments: string[] 22 + segment?: string | undefined, 23 + feedCacheKey?: 'discover' | 'explore' | undefined, 23 24 ) { 24 - return [`/profile`, did, 'feed', rkey, ...segments].join('/') 25 + return ( 26 + [`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') + 27 + (feedCacheKey ? `?feedCacheKey=${encodeURIComponent(feedCacheKey)}` : '') 28 + ) 25 29 } 26 30 27 31 export function makeListLink(did: string, rkey: string, ...segments: string[]) {
+8 -1
src/lib/routes/types.ts
··· 1 1 import {NavigationState, PartialState} from '@react-navigation/native' 2 2 import type {NativeStackNavigationProp} from '@react-navigation/native-stack' 3 3 4 + import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' 5 + 4 6 export type {NativeStackScreenProps} from '@react-navigation/native-stack' 5 7 6 8 export type CommonNavigatorParams = { ··· 20 22 PostLikedBy: {name: string; rkey: string} 21 23 PostRepostedBy: {name: string; rkey: string} 22 24 PostQuotes: {name: string; rkey: string} 23 - ProfileFeed: {name: string; rkey: string} 25 + ProfileFeed: { 26 + name: string 27 + rkey: string 28 + feedCacheKey?: 'discover' | 'explore' | undefined 29 + } 24 30 ProfileFeedLikedBy: {name: string; rkey: string} 25 31 ProfileLabelerLikedBy: {name: string} 26 32 Debug: undefined ··· 57 63 StarterPackShort: {code: string} 58 64 StarterPackWizard: undefined 59 65 StarterPackEdit: {rkey?: string} 66 + VideoFeed: VideoFeedSourceContext 60 67 } 61 68 62 69 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+15 -4
src/lib/statsig/events.ts
··· 131 131 doesPosterFollowLiker: boolean | undefined 132 132 likerClout: number | undefined 133 133 postClout: number | undefined 134 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 134 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 135 135 } 136 136 'post:repost': { 137 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 137 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 138 138 } 139 139 'post:unlike': { 140 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 140 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 141 141 } 142 142 'post:unrepost': { 143 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 143 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 144 144 } 145 145 'post:mute': {} 146 146 'post:unmute': {} ··· 163 163 | 'FeedInterstitial' 164 164 | 'ProfileHeaderSuggestedFollows' 165 165 | 'PostOnboardingFindFollows' 166 + | 'ImmersiveVideo' 166 167 } 167 168 'profile:unfollow': { 168 169 logContext: ··· 179 180 | 'FeedInterstitial' 180 181 | 'ProfileHeaderSuggestedFollows' 181 182 | 'PostOnboardingFindFollows' 183 + | 'ImmersiveVideo' 182 184 } 183 185 'chat:create': { 184 186 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' ··· 248 250 } 249 251 'recommendedTopic:click': { 250 252 context: 'explore' 253 + } 254 + 'trendingVideos:show': { 255 + context: 'settings' 256 + } 257 + 'trendingVideos:hide': { 258 + context: 'settings' | 'interstitial:discover' | 'interstitial:explore' 259 + } 260 + 'videoCard:click': { 261 + context: 'interstitial:discover' | 'interstitial:explore' | 'feed' 251 262 } 252 263 253 264 'progressGuide:hide': {}
+1
src/routes.ts
··· 64 64 StarterPack: '/starter-pack/:name/:rkey', 65 65 StarterPackShort: '/starter-pack-short/:code', 66 66 StarterPackWizard: '/starter-pack/create', 67 + VideoFeed: '/video-feed', 67 68 })
+32 -3
src/screens/Profile/ProfileFeed/index.tsx
··· 1 1 import React, {useCallback, useMemo} from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {useAnimatedRef} from 'react-native-reanimated' 4 + import {AppBskyFeedDefs} from '@atproto/api' 4 5 import {msg, Trans} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' 6 7 import {useIsFocused, useNavigation} from '@react-navigation/native' 7 8 import {NativeStackScreenProps} from '@react-navigation/native-stack' 8 9 import {useQueryClient} from '@tanstack/react-query' 9 10 11 + import {VIDEO_FEED_URIS} from '#/lib/constants' 10 12 import {usePalette} from '#/lib/hooks/usePalette' 11 13 import {useSetTitle} from '#/lib/hooks/useSetTitle' 12 14 import {ComposeIcon2} from '#/lib/icons' ··· 18 20 import {listenSoftReset} from '#/state/events' 19 21 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 20 22 import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 21 - import {FeedDescriptor} from '#/state/queries/post-feed' 23 + import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 22 24 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 23 25 import { 24 26 usePreferencesQuery, ··· 46 48 export function ProfileFeedScreen(props: Props) { 47 49 const {rkey, name: handleOrDid} = props.route.params 48 50 51 + const feedParams: FeedParams | undefined = props.route.params.feedCacheKey 52 + ? { 53 + feedCacheKey: props.route.params.feedCacheKey, 54 + } 55 + : undefined 49 56 const pal = usePalette('default') 50 57 const {_} = useLingui() 51 58 const navigation = useNavigation<NavigationProp>() ··· 96 103 97 104 return resolvedUri ? ( 98 105 <Layout.Screen> 99 - <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> 106 + <ProfileFeedScreenIntermediate 107 + feedUri={resolvedUri.uri} 108 + feedParams={feedParams} 109 + /> 100 110 </Layout.Screen> 101 111 ) : ( 102 112 <Layout.Screen> ··· 108 118 ) 109 119 } 110 120 111 - function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { 121 + function ProfileFeedScreenIntermediate({ 122 + feedUri, 123 + feedParams, 124 + }: { 125 + feedUri: string 126 + feedParams: FeedParams | undefined 127 + }) { 112 128 const {data: preferences} = usePreferencesQuery() 113 129 const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) 114 130 ··· 125 141 <ProfileFeedScreenInner 126 142 preferences={preferences} 127 143 feedInfo={info as FeedSourceFeedInfo} 144 + feedParams={feedParams} 128 145 /> 129 146 ) 130 147 } 131 148 132 149 export function ProfileFeedScreenInner({ 133 150 feedInfo, 151 + feedParams, 134 152 }: { 135 153 preferences: UsePreferencesQueryResponse 136 154 feedInfo: FeedSourceFeedInfo 155 + feedParams: FeedParams | undefined 137 156 }) { 138 157 const {_} = useLingui() 139 158 const {hasSession} = useSession() ··· 170 189 return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> 171 190 }, [_]) 172 191 192 + const isVideoFeed = React.useMemo(() => { 193 + const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) 194 + const feedIsVideoMode = 195 + feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO 196 + const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode 197 + return isNative && _isVideoFeed 198 + }, [feedInfo]) 199 + 173 200 return ( 174 201 <> 175 202 <ProfileFeedHeader info={feedInfo} /> ··· 177 204 <FeedFeedbackProvider value={feedFeedback}> 178 205 <PostFeed 179 206 feed={feed} 207 + feedParams={feedParams} 180 208 pollInterval={60e3} 181 209 disablePoll={hasNew} 182 210 onHasNew={setHasNew} 183 211 scrollElRef={scrollElRef} 184 212 onScrolledDownChange={setIsScrolledDown} 185 213 renderEmptyState={renderPostsEmpty} 214 + isVideoFeed={isVideoFeed} 186 215 /> 187 216 </FeedFeedbackProvider> 188 217
+271
src/screens/Search/components/ExploreTrendingVideos.tsx
··· 1 + import React from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import {AppBskyEmbedVideo, AtUri} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useFocusEffect} from '@react-navigation/native' 7 + import {useQueryClient} from '@tanstack/react-query' 8 + 9 + import {VIDEO_FEED_URI} from '#/lib/constants' 10 + import {makeCustomFeedLink} from '#/lib/routes/links' 11 + import {logEvent} from '#/lib/statsig/statsig' 12 + import {isWeb} from '#/platform/detection' 13 + import {useSavedFeeds} from '#/state/queries/feed' 14 + import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 15 + import {useAddSavedFeedsMutation} from '#/state/queries/preferences' 16 + import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 17 + import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import {GradientFill} from '#/components/GradientFill' 20 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 21 + import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' 22 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 23 + import {Link} from '#/components/Link' 24 + import {Text} from '#/components/Typography' 25 + import { 26 + CompactVideoPostCard, 27 + CompactVideoPostCardPlaceholder, 28 + } from '#/components/VideoPostCard' 29 + 30 + const CARD_WIDTH = 100 31 + 32 + const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` 33 + const FEED_PARAMS: { 34 + feedCacheKey: 'explore' 35 + } = { 36 + feedCacheKey: 'explore', 37 + } 38 + 39 + export function ExploreTrendingVideos() { 40 + const t = useTheme() 41 + const {_} = useLingui() 42 + const gutters = useGutters([0, 'base']) 43 + const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) 44 + 45 + // Refetch on tab change if nothing else is using this query. 46 + const queryClient = useQueryClient() 47 + useFocusEffect(() => { 48 + return () => { 49 + const query = queryClient 50 + .getQueryCache() 51 + .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) 52 + if (query && query.getObserversCount() <= 1) { 53 + query.fetch() 54 + } 55 + } 56 + }) 57 + 58 + const {data: saved} = useSavedFeeds() 59 + const isSavedAlready = React.useMemo(() => { 60 + return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) 61 + }, [saved]) 62 + 63 + const {mutateAsync: addSavedFeeds, isPending: isPinPending} = 64 + useAddSavedFeedsMutation() 65 + const pinFeed = React.useCallback( 66 + (e: any) => { 67 + e.preventDefault() 68 + 69 + addSavedFeeds([ 70 + { 71 + type: 'feed', 72 + value: VIDEO_FEED_URI, 73 + pinned: true, 74 + }, 75 + ]) 76 + 77 + // prevent navigation 78 + return false 79 + }, 80 + [addSavedFeeds], 81 + ) 82 + 83 + if (error) { 84 + return null 85 + } 86 + 87 + return ( 88 + <View style={[a.pb_xl]}> 89 + <View 90 + style={[ 91 + a.flex_row, 92 + isWeb 93 + ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 94 + : [a.p_lg, a.pt_xl, a.gap_md], 95 + a.border_b, 96 + t.atoms.border_contrast_low, 97 + ]}> 98 + <View style={[a.flex_1, a.gap_sm]}> 99 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 100 + <Graph 101 + size="lg" 102 + fill={t.palette.primary_500} 103 + style={{marginLeft: -2}} 104 + /> 105 + <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> 106 + <Trans>Trending Videos</Trans> 107 + </Text> 108 + <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> 109 + <GradientFill gradient={tokens.gradients.primary} /> 110 + <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> 111 + <Trans>BETA</Trans> 112 + </Text> 113 + </View> 114 + </View> 115 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 116 + <Trans>Popular videos in your network.</Trans> 117 + </Text> 118 + </View> 119 + </View> 120 + 121 + <BlockDrawerGesture> 122 + <ScrollView 123 + horizontal 124 + showsHorizontalScrollIndicator={false} 125 + decelerationRate="fast" 126 + snapToInterval={CARD_WIDTH + tokens.space.sm}> 127 + <View 128 + style={[ 129 + a.pt_lg, 130 + a.flex_row, 131 + a.gap_sm, 132 + { 133 + paddingLeft: gutters.paddingLeft, 134 + paddingRight: gutters.paddingRight, 135 + }, 136 + ]}> 137 + {isLoading ? ( 138 + Array(10) 139 + .fill(0) 140 + .map((_, i) => ( 141 + <View key={i} style={[{width: CARD_WIDTH}]}> 142 + <CompactVideoPostCardPlaceholder /> 143 + </View> 144 + )) 145 + ) : error || !data ? ( 146 + <Text> 147 + <Trans>Whoops! Trending videos failed to load.</Trans> 148 + </Text> 149 + ) : ( 150 + <VideoCards data={data} /> 151 + )} 152 + </View> 153 + </ScrollView> 154 + </BlockDrawerGesture> 155 + 156 + {!isSavedAlready && ( 157 + <View 158 + style={[ 159 + gutters, 160 + a.pt_lg, 161 + a.flex_row, 162 + a.align_center, 163 + a.justify_between, 164 + a.gap_xl, 165 + ]}> 166 + <Text style={[a.flex_1, a.text_sm, a.leading_snug]}> 167 + <Trans> 168 + Pin the trending videos feed to your home screen for easy access 169 + </Trans> 170 + </Text> 171 + <Button 172 + disabled={isPinPending} 173 + label={_(msg`Pin`)} 174 + size="small" 175 + variant="outline" 176 + color="secondary" 177 + onPress={pinFeed}> 178 + <ButtonText>{_(msg`Pin`)}</ButtonText> 179 + <ButtonIcon icon={Pin} position="right" /> 180 + </Button> 181 + </View> 182 + )} 183 + </View> 184 + ) 185 + } 186 + 187 + function VideoCards({ 188 + data, 189 + }: { 190 + data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> 191 + }) { 192 + const t = useTheme() 193 + const {_} = useLingui() 194 + const items = React.useMemo(() => { 195 + return data.pages 196 + .flatMap(page => page.slices) 197 + .map(slice => slice.items[0]) 198 + .filter(Boolean) 199 + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) 200 + .slice(0, 8) 201 + }, [data]) 202 + const href = React.useMemo(() => { 203 + const urip = new AtUri(VIDEO_FEED_URI) 204 + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') 205 + }, []) 206 + 207 + return ( 208 + <> 209 + {items.map(item => ( 210 + <View key={item.post.uri} style={[{width: CARD_WIDTH}]}> 211 + <CompactVideoPostCard 212 + post={item.post} 213 + moderation={item.moderation} 214 + sourceContext={{ 215 + type: 'feedgen', 216 + uri: VIDEO_FEED_URI, 217 + sourceInterstitial: 'explore', 218 + }} 219 + onInteract={() => { 220 + logEvent('videoCard:click', { 221 + context: 'interstitial:discover', 222 + }) 223 + }} 224 + /> 225 + </View> 226 + ))} 227 + 228 + <View style={[{width: CARD_WIDTH * 2}]}> 229 + <Link 230 + to={href} 231 + label={_(msg`View more`)} 232 + style={[ 233 + a.justify_center, 234 + a.align_center, 235 + a.flex_1, 236 + a.rounded_md, 237 + t.atoms.bg_contrast_25, 238 + ]}> 239 + {({pressed}) => ( 240 + <View 241 + style={[ 242 + a.flex_row, 243 + a.align_center, 244 + a.gap_md, 245 + { 246 + opacity: pressed ? 0.6 : 1, 247 + }, 248 + ]}> 249 + <Text style={[a.text_md]}> 250 + <Trans>View more</Trans> 251 + </Text> 252 + <View 253 + style={[ 254 + a.align_center, 255 + a.justify_center, 256 + a.rounded_full, 257 + { 258 + width: 34, 259 + height: 34, 260 + backgroundColor: t.palette.primary_500, 261 + }, 262 + ]}> 263 + <ButtonIcon icon={ChevronRight} /> 264 + </View> 265 + </View> 266 + )} 267 + </Link> 268 + </View> 269 + </> 270 + ) 271 + }
+24 -2
src/screens/Settings/ContentAndMediaSettings.tsx
··· 37 37 const inAppBrowserPref = useInAppBrowser() 38 38 const setUseInAppBrowser = useSetInAppBrowser() 39 39 const {enabled: trendingEnabled} = useTrendingConfig() 40 - const {trendingDisabled} = useTrendingSettings() 41 - const {setTrendingDisabled} = useTrendingSettingsApi() 40 + const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() 41 + const {setTrendingDisabled, setTrendingVideoDisabled} = 42 + useTrendingSettingsApi() 42 43 43 44 return ( 44 45 <Layout.Screen> ··· 134 135 <SettingsList.ItemIcon icon={Graph} /> 135 136 <SettingsList.ItemText> 136 137 <Trans>Enable trending topics</Trans> 138 + </SettingsList.ItemText> 139 + <Toggle.Platform /> 140 + </SettingsList.Item> 141 + </Toggle.Item> 142 + <Toggle.Item 143 + name="show_trending_videos" 144 + label={_(msg`Enable trending videos in your Discover feed.`)} 145 + value={!trendingVideoDisabled} 146 + onChange={value => { 147 + const hide = Boolean(!value) 148 + if (hide) { 149 + logEvent('trendingVideos:hide', {context: 'settings'}) 150 + } else { 151 + logEvent('trendingVideos:show', {context: 'settings'}) 152 + } 153 + setTrendingVideoDisabled(hide) 154 + }}> 155 + <SettingsList.Item> 156 + <SettingsList.ItemIcon icon={Graph} /> 157 + <SettingsList.ItemText> 158 + <Trans>Enable trending videos in your Discover feed</Trans> 137 159 </SettingsList.ItemText> 138 160 <Toggle.Platform /> 139 161 </SettingsList.Item>
+180
src/screens/VideoFeed/components/Header.tsx
··· 1 + import {useCallback} from 'react' 2 + import {GestureResponderEvent, View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useNavigation} from '@react-navigation/native' 6 + 7 + import {HITSLOP_30} from '#/lib/constants' 8 + import {NavigationProp} from '#/lib/routes/types' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {useFeedSourceInfoQuery} from '#/state/queries/feed' 11 + import {UserAvatar} from '#/view/com/util/UserAvatar' 12 + import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' 13 + import {atoms as a, useBreakpoints} from '#/alf' 14 + import {Button, ButtonProps} from '#/components/Button' 15 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' 16 + import * as Layout from '#/components/Layout' 17 + import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const' 18 + import {Text} from '#/components/Typography' 19 + 20 + export function HeaderPlaceholder() { 21 + return ( 22 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 23 + <View 24 + style={[ 25 + a.rounded_sm, 26 + { 27 + width: 36, 28 + height: 36, 29 + backgroundColor: 'white', 30 + opacity: 0.8, 31 + }, 32 + ]} 33 + /> 34 + 35 + <View style={[a.flex_1, a.gap_xs]}> 36 + <View 37 + style={[ 38 + a.w_full, 39 + a.rounded_xs, 40 + { 41 + backgroundColor: 'white', 42 + height: 14, 43 + width: 80, 44 + opacity: 0.8, 45 + }, 46 + ]} 47 + /> 48 + <View 49 + style={[ 50 + a.w_full, 51 + a.rounded_xs, 52 + { 53 + backgroundColor: 'white', 54 + height: 10, 55 + width: 140, 56 + opacity: 0.6, 57 + }, 58 + ]} 59 + /> 60 + </View> 61 + </View> 62 + ) 63 + } 64 + 65 + export function Header({ 66 + sourceContext, 67 + }: { 68 + sourceContext: VideoFeedSourceContext 69 + }) { 70 + let content = null 71 + switch (sourceContext.type) { 72 + case 'feedgen': { 73 + content = <FeedHeader sourceContext={sourceContext} /> 74 + break 75 + } 76 + case 'author': 77 + // TODO 78 + default: { 79 + break 80 + } 81 + } 82 + 83 + return ( 84 + <Layout.Header.Outer noBottomBorder> 85 + <BackButton /> 86 + <Layout.Header.Content align="left">{content}</Layout.Header.Content> 87 + </Layout.Header.Outer> 88 + ) 89 + } 90 + 91 + export function FeedHeader({ 92 + sourceContext, 93 + }: { 94 + sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}> 95 + }) { 96 + const {gtMobile} = useBreakpoints() 97 + 98 + const { 99 + data: info, 100 + isLoading, 101 + error, 102 + } = useFeedSourceInfoQuery({uri: sourceContext.uri}) 103 + 104 + if (sourceContext.sourceInterstitial !== undefined) { 105 + // For now, don't show the header if coming from an interstitial. 106 + return null 107 + } 108 + 109 + if (isLoading) { 110 + return <HeaderPlaceholder /> 111 + } else if (error || !info) { 112 + return null 113 + } 114 + 115 + return ( 116 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 117 + {info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />} 118 + 119 + <View style={[a.flex_1]}> 120 + <Text 121 + style={[ 122 + a.text_md, 123 + a.font_heavy, 124 + a.leading_tight, 125 + gtMobile && a.text_lg, 126 + ]} 127 + numberOfLines={2}> 128 + {info.displayName} 129 + </Text> 130 + <View style={[a.flex_row, {gap: 6}]}> 131 + <Text 132 + style={[a.flex_shrink, a.text_sm, a.leading_snug]} 133 + numberOfLines={1}> 134 + {sanitizeHandle(info.creatorHandle, '@')} 135 + </Text> 136 + </View> 137 + </View> 138 + </View> 139 + ) 140 + } 141 + 142 + // TODO: This customization should be a part of the layout component 143 + export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) { 144 + const {_} = useLingui() 145 + const navigation = useNavigation<NavigationProp>() 146 + 147 + const onPressBack = useCallback( 148 + (evt: GestureResponderEvent) => { 149 + onPress?.(evt) 150 + if (evt.defaultPrevented) return 151 + if (navigation.canGoBack()) { 152 + navigation.goBack() 153 + } else { 154 + navigation.navigate('Home') 155 + } 156 + }, 157 + [onPress, navigation], 158 + ) 159 + 160 + return ( 161 + <Layout.Header.Slot> 162 + <Button 163 + label={_(msg`Go back`)} 164 + size="small" 165 + variant="ghost" 166 + color="secondary" 167 + shape="round" 168 + onPress={onPressBack} 169 + hitSlop={HITSLOP_30} 170 + style={[ 171 + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, 172 + a.bg_transparent, 173 + style, 174 + ]} 175 + {...props}> 176 + <ArrowLeft size="lg" fill="white" /> 177 + </Button> 178 + </Layout.Header.Slot> 179 + ) 180 + }
+265
src/screens/VideoFeed/components/Scrubber.tsx
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + Gesture, 5 + GestureDetector, 6 + NativeGesture, 7 + } from 'react-native-gesture-handler' 8 + import Animated, { 9 + interpolate, 10 + runOnJS, 11 + runOnUI, 12 + SharedValue, 13 + useAnimatedReaction, 14 + useAnimatedStyle, 15 + useSharedValue, 16 + withTiming, 17 + } from 'react-native-reanimated' 18 + import { 19 + useSafeAreaFrame, 20 + useSafeAreaInsets, 21 + } from 'react-native-safe-area-context' 22 + import {useEventListener} from 'expo' 23 + import {VideoPlayer} from 'expo-video' 24 + 25 + import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils' 26 + import {tokens} from '#/alf' 27 + import {atoms as a} from '#/alf' 28 + import {Text} from '#/components/Typography' 29 + 30 + // magic number that is roughly the min height of the write reply button 31 + // we inset the video by this amount 32 + export const VIDEO_PLAYER_BOTTOM_INSET = 57 33 + 34 + export function Scrubber({ 35 + active, 36 + player, 37 + seekingAnimationSV, 38 + scrollGesture, 39 + children, 40 + }: { 41 + active: boolean 42 + player?: VideoPlayer 43 + seekingAnimationSV: SharedValue<number> 44 + scrollGesture: NativeGesture 45 + children?: React.ReactNode 46 + }) { 47 + const {width: screenWidth} = useSafeAreaFrame() 48 + const insets = useSafeAreaInsets() 49 + const currentTimeSV = useSharedValue(0) 50 + const durationSV = useSharedValue(0) 51 + const [currentSeekTime, setCurrentSeekTime] = useState(0) 52 + const [duration, setDuration] = useState(0) 53 + 54 + const updateTime = (currentTime: number, duration: number) => { 55 + 'worklet' 56 + currentTimeSV.set(currentTime) 57 + if (duration !== 0) { 58 + durationSV.set(duration) 59 + } 60 + } 61 + 62 + const isSeekingSV = useSharedValue(false) 63 + const seekProgressSV = useSharedValue(0) 64 + 65 + useAnimatedReaction( 66 + () => Math.round(seekProgressSV.get()), 67 + (progress, prevProgress) => { 68 + if (progress !== prevProgress) { 69 + runOnJS(setCurrentSeekTime)(progress) 70 + } 71 + }, 72 + ) 73 + 74 + const seekBy = useCallback( 75 + (time: number) => { 76 + player?.seekBy(time) 77 + 78 + setTimeout(() => { 79 + runOnUI(() => { 80 + 'worklet' 81 + isSeekingSV.set(false) 82 + seekingAnimationSV.set(withTiming(0, {duration: 500})) 83 + })() 84 + }, 50) 85 + }, 86 + [player, isSeekingSV, seekingAnimationSV], 87 + ) 88 + 89 + const scrubPanGesture = useMemo(() => { 90 + return Gesture.Pan() 91 + .blocksExternalGesture(scrollGesture) 92 + .activeOffsetX([-10, 10]) 93 + .failOffsetY([-10, 10]) 94 + .onStart(() => { 95 + 'worklet' 96 + seekProgressSV.set(currentTimeSV.get()) 97 + isSeekingSV.set(true) 98 + seekingAnimationSV.set(withTiming(1, {duration: 500})) 99 + }) 100 + .onUpdate(evt => { 101 + 'worklet' 102 + const progress = evt.x / screenWidth 103 + seekProgressSV.set( 104 + clamp(progress * durationSV.get(), 0, durationSV.get()), 105 + ) 106 + }) 107 + .onEnd(evt => { 108 + 'worklet' 109 + isSeekingSV.get() 110 + 111 + const progress = evt.x / screenWidth 112 + const newTime = clamp(progress * durationSV.get(), 0, durationSV.get()) 113 + 114 + // optimisically set the progress bar 115 + seekProgressSV.set(newTime) 116 + 117 + // it's seek by, so offset by the current time 118 + // seekBy sets isSeekingSV back to false, so no need to do that here 119 + runOnJS(seekBy)(newTime - currentTimeSV.get()) 120 + }) 121 + }, [ 122 + scrollGesture, 123 + seekingAnimationSV, 124 + seekBy, 125 + screenWidth, 126 + currentTimeSV, 127 + durationSV, 128 + isSeekingSV, 129 + seekProgressSV, 130 + ]) 131 + 132 + const timeStyle = useAnimatedStyle(() => { 133 + return { 134 + display: seekingAnimationSV.get() === 0 ? 'none' : 'flex', 135 + opacity: seekingAnimationSV.get(), 136 + } 137 + }) 138 + 139 + const barStyle = useAnimatedStyle(() => { 140 + const currentTime = isSeekingSV.get() 141 + ? seekProgressSV.get() 142 + : currentTimeSV.get() 143 + const progress = currentTime === 0 ? 0 : currentTime / durationSV.get() 144 + const isSeeking = seekingAnimationSV.get() 145 + return { 146 + height: isSeeking * 3 + 1, 147 + opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]), 148 + width: `${progress * 100}%`, 149 + } 150 + }) 151 + const trackStyle = useAnimatedStyle(() => { 152 + return { 153 + height: seekingAnimationSV.get() * 3 + 1, 154 + } 155 + }) 156 + const childrenStyle = useAnimatedStyle(() => { 157 + return { 158 + opacity: 1 - seekingAnimationSV.get(), 159 + } 160 + }) 161 + 162 + return ( 163 + <> 164 + {player && active && ( 165 + <PlayerListener 166 + player={player} 167 + setDuration={setDuration} 168 + updateTime={updateTime} 169 + /> 170 + )} 171 + <Animated.View 172 + style={[ 173 + a.absolute, 174 + { 175 + left: 0, 176 + right: 0, 177 + bottom: insets.bottom + 80, 178 + }, 179 + timeStyle, 180 + ]} 181 + pointerEvents="none"> 182 + <Text style={[a.text_center, a.font_bold]}> 183 + <Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}> 184 + {formatTime(currentSeekTime)} 185 + </Text> 186 + <Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text> 187 + <Text 188 + style={[ 189 + a.text_5xl, 190 + {opacity: 0.8}, 191 + {fontVariant: ['tabular-nums']}, 192 + ]}> 193 + {formatTime(duration)} 194 + </Text> 195 + </Text> 196 + </Animated.View> 197 + 198 + <GestureDetector gesture={scrubPanGesture}> 199 + <View 200 + style={[ 201 + a.relative, 202 + a.w_full, 203 + a.justify_end, 204 + { 205 + paddingBottom: insets.bottom, 206 + minHeight: 207 + // bottom padding 208 + insets.bottom + 209 + // scrubber height 210 + tokens.space.lg + 211 + // write reply height 212 + VIDEO_PLAYER_BOTTOM_INSET, 213 + }, 214 + a.z_10, 215 + ]}> 216 + <View style={[a.w_full, a.relative]}> 217 + <Animated.View 218 + style={[ 219 + a.w_full, 220 + {backgroundColor: 'white', opacity: 0.2}, 221 + trackStyle, 222 + ]} 223 + /> 224 + <Animated.View 225 + style={[ 226 + a.absolute, 227 + {top: 0, left: 0, backgroundColor: 'white'}, 228 + barStyle, 229 + ]} 230 + /> 231 + </View> 232 + <Animated.View 233 + style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}> 234 + {children} 235 + </Animated.View> 236 + </View> 237 + </GestureDetector> 238 + </> 239 + ) 240 + } 241 + 242 + function PlayerListener({ 243 + player, 244 + setDuration, 245 + updateTime, 246 + }: { 247 + player: VideoPlayer 248 + setDuration: (duration: number) => void 249 + updateTime: (currentTime: number, duration: number) => void 250 + }) { 251 + useEventListener(player, 'timeUpdate', evt => { 252 + const duration = player.duration 253 + if (duration !== 0) { 254 + setDuration(Math.round(duration)) 255 + } 256 + runOnUI(updateTime)(evt.currentTime, duration) 257 + }) 258 + 259 + return null 260 + } 261 + 262 + function clamp(num: number, min: number, max: number) { 263 + 'worklet' 264 + return Math.min(Math.max(num, min), max) 265 + }
+1093
src/screens/VideoFeed/index.tsx
··· 1 + import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 + import { 3 + LayoutAnimation, 4 + ListRenderItem, 5 + Pressable, 6 + ScrollView, 7 + View, 8 + ViewabilityConfig, 9 + ViewToken, 10 + } from 'react-native' 11 + import { 12 + Gesture, 13 + GestureDetector, 14 + NativeGesture, 15 + } from 'react-native-gesture-handler' 16 + import Animated, { 17 + useAnimatedStyle, 18 + useSharedValue, 19 + } from 'react-native-reanimated' 20 + import { 21 + useSafeAreaFrame, 22 + useSafeAreaInsets, 23 + } from 'react-native-safe-area-context' 24 + import {useEventListener} from 'expo' 25 + import {Image, ImageStyle} from 'expo-image' 26 + import {LinearGradient} from 'expo-linear-gradient' 27 + import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video' 28 + import { 29 + AppBskyEmbedVideo, 30 + AppBskyFeedDefs, 31 + AppBskyFeedPost, 32 + AtUri, 33 + ModerationDecision, 34 + RichText as RichTextAPI, 35 + } from '@atproto/api' 36 + import {msg, Trans} from '@lingui/macro' 37 + import {useLingui} from '@lingui/react' 38 + import { 39 + RouteProp, 40 + useFocusEffect, 41 + useIsFocused, 42 + useNavigation, 43 + useRoute, 44 + } from '@react-navigation/native' 45 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 46 + 47 + import {HITSLOP_20} from '#/lib/constants' 48 + import {useHaptics} from '#/lib/haptics' 49 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 50 + import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 51 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 52 + import {cleanError} from '#/lib/strings/errors' 53 + import {sanitizeHandle} from '#/lib/strings/handles' 54 + import {isAndroid} from '#/platform/detection' 55 + import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 56 + import {useProfileShadow} from '#/state/cache/profile-shadow' 57 + import { 58 + FeedFeedbackProvider, 59 + useFeedFeedbackContext, 60 + } from '#/state/feed-feedback' 61 + import {useFeedFeedback} from '#/state/feed-feedback' 62 + import {usePostLikeMutationQueue} from '#/state/queries/post' 63 + import { 64 + AuthorFilter, 65 + FeedPostSliceItem, 66 + usePostFeedQuery, 67 + } from '#/state/queries/post-feed' 68 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 69 + import {useSession} from '#/state/session' 70 + import {useComposerControls, useSetMinimalShellMode} from '#/state/shell' 71 + import {useSetLightStatusBar} from '#/state/shell/light-status-bar' 72 + import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' 73 + import {List} from '#/view/com/util/List' 74 + import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' 75 + import {UserAvatar} from '#/view/com/util/UserAvatar' 76 + import {Header} from '#/screens/VideoFeed/components/Header' 77 + import {atoms as a, platform, ThemeProvider, useTheme} from '#/alf' 78 + import {setNavigationBar} from '#/alf/util/navigationBar' 79 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 80 + import {Divider} from '#/components/Divider' 81 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 82 + import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 83 + import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' 84 + import {Leaf_Stroke2_Corner0_Rounded as LeafIcon} from '#/components/icons/Leaf' 85 + import * as Layout from '#/components/Layout' 86 + import {Link} from '#/components/Link' 87 + import {ListFooter} from '#/components/Lists' 88 + import * as Hider from '#/components/moderation/Hider' 89 + import {RichText} from '#/components/RichText' 90 + import {Text} from '#/components/Typography' 91 + import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' 92 + 93 + function createThreeVideoPlayers( 94 + sources?: [string, string, string], 95 + ): [VideoPlayer, VideoPlayer, VideoPlayer] { 96 + // android is typically slower and can't keep up with a 0.1 interval 97 + const eventInterval = platform({ 98 + ios: 0.2, 99 + android: 0.5, 100 + default: 0, 101 + }) 102 + const p1 = createVideoPlayer(sources?.[0] ?? '') 103 + p1.loop = true 104 + p1.timeUpdateEventInterval = eventInterval 105 + const p2 = createVideoPlayer(sources?.[1] ?? '') 106 + p2.loop = true 107 + p2.timeUpdateEventInterval = eventInterval 108 + const p3 = createVideoPlayer(sources?.[2] ?? '') 109 + p3.loop = true 110 + p3.timeUpdateEventInterval = eventInterval 111 + return [p1, p2, p3] 112 + } 113 + 114 + export function VideoFeed({}: NativeStackScreenProps< 115 + CommonNavigatorParams, 116 + 'VideoFeed' 117 + >) { 118 + const {top} = useSafeAreaInsets() 119 + const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>() 120 + 121 + const t = useTheme() 122 + const setMinShellMode = useSetMinimalShellMode() 123 + useFocusEffect( 124 + useCallback(() => { 125 + setMinShellMode(true) 126 + setNavigationBar('lightbox', t) 127 + return () => { 128 + setMinShellMode(false) 129 + setNavigationBar('theme', t) 130 + } 131 + }, [setMinShellMode, t]), 132 + ) 133 + 134 + const isFocused = useIsFocused() 135 + useSetLightStatusBar(isFocused) 136 + 137 + return ( 138 + <ThemeProvider theme="dark"> 139 + <Layout.Screen noInsetTop style={{backgroundColor: 'black'}}> 140 + <View 141 + style={[ 142 + a.absolute, 143 + a.z_30, 144 + {top: 0, left: 0, right: 0, paddingTop: top}, 145 + ]}> 146 + <Header sourceContext={params} /> 147 + </View> 148 + <Feed /> 149 + </Layout.Screen> 150 + </ThemeProvider> 151 + ) 152 + } 153 + 154 + const viewabilityConfig = { 155 + itemVisiblePercentThreshold: 100, 156 + minimumViewTime: 0, 157 + } satisfies ViewabilityConfig 158 + 159 + type CurrentSource = { 160 + source: string 161 + } | null 162 + 163 + type VideoItem = { 164 + moderation: ModerationDecision 165 + post: AppBskyFeedDefs.PostView 166 + feedContext: string | undefined 167 + } 168 + 169 + function Feed() { 170 + const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>() 171 + const isFocused = useIsFocused() 172 + const {hasSession} = useSession() 173 + const {height} = useSafeAreaFrame() 174 + 175 + const feedDesc = useMemo(() => { 176 + switch (params.type) { 177 + case 'feedgen': 178 + return `feedgen|${params.uri as string}` as const 179 + case 'author': 180 + return `author|${params.did as string}|${ 181 + params.filter as AuthorFilter 182 + }` as const 183 + default: 184 + throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) 185 + } 186 + }, [params]) 187 + const feedFeedback = useFeedFeedback(feedDesc, hasSession) 188 + const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} = 189 + usePostFeedQuery( 190 + feedDesc, 191 + params.type === 'feedgen' && params.sourceInterstitial !== 'none' 192 + ? {feedCacheKey: params.sourceInterstitial} 193 + : undefined, 194 + ) 195 + 196 + const videos = useMemo(() => { 197 + let vids = 198 + data?.pages 199 + .flatMap(page => { 200 + const items: { 201 + _reactKey: string 202 + moderation: ModerationDecision 203 + post: AppBskyFeedDefs.PostView 204 + feedContext: string | undefined 205 + }[] = [] 206 + for (const slice of page.slices) { 207 + for (const i of slice.items) { 208 + items.push({ 209 + _reactKey: i._reactKey, 210 + moderation: i.moderation, 211 + post: i.post, 212 + feedContext: slice.feedContext, 213 + }) 214 + } 215 + } 216 + return items 217 + }) 218 + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) || [] 219 + const startingVideoIndex = vids?.findIndex(video => { 220 + return video.post.uri === params.initialPostUri 221 + }) 222 + if (vids && startingVideoIndex && startingVideoIndex > -1) { 223 + vids = vids.slice(startingVideoIndex) 224 + } 225 + return vids 226 + }, [data, params.initialPostUri]) 227 + 228 + const [currentSources, setCurrentSources] = useState< 229 + [CurrentSource, CurrentSource, CurrentSource] 230 + >([null, null, null]) 231 + 232 + const [players, setPlayers] = useState< 233 + [VideoPlayer, VideoPlayer, VideoPlayer] | null 234 + >(null) 235 + 236 + const [currentIndex, setCurrentIndex] = useState(0) 237 + 238 + const scrollGesture = useMemo(() => Gesture.Native(), []) 239 + 240 + const renderItem: ListRenderItem<VideoItem> = useCallback( 241 + ({item, index}) => { 242 + const {post} = item 243 + 244 + // filtered above, here for TS 245 + if (!post.embed || !AppBskyEmbedVideo.isView(post.embed)) { 246 + return null 247 + } 248 + 249 + const player = players?.[index % 3] 250 + const currentSource = currentSources[index % 3] 251 + 252 + return ( 253 + <VideoItem 254 + player={player} 255 + post={post} 256 + embed={post.embed} 257 + active={ 258 + isFocused && 259 + index === currentIndex && 260 + currentSource?.source === post.embed.playlist 261 + } 262 + moderation={item.moderation} 263 + scrollGesture={scrollGesture} 264 + feedContext={item.feedContext} 265 + /> 266 + ) 267 + }, 268 + [players, currentIndex, isFocused, currentSources, scrollGesture], 269 + ) 270 + 271 + const updateVideoState = useCallback( 272 + (index?: number) => { 273 + if (!videos.length) return 274 + 275 + if (index === undefined) { 276 + index = currentIndex 277 + } else { 278 + setCurrentIndex(index) 279 + } 280 + 281 + const prevSlice = videos.at(index - 1) 282 + const prevPost = prevSlice?.post 283 + const prevEmbed = prevPost?.embed 284 + const prevVideo = 285 + prevEmbed && AppBskyEmbedVideo.isView(prevEmbed) 286 + ? prevEmbed.playlist 287 + : null 288 + const currSlice = videos.at(index) 289 + const currPost = currSlice?.post 290 + const currEmbed = currPost?.embed 291 + const currVideo = 292 + currEmbed && AppBskyEmbedVideo.isView(currEmbed) 293 + ? currEmbed.playlist 294 + : null 295 + const currVideoModeration = currSlice?.moderation 296 + const nextSlice = videos.at(index + 1) 297 + const nextPost = nextSlice?.post 298 + const nextEmbed = nextPost?.embed 299 + const nextVideo = 300 + nextEmbed && AppBskyEmbedVideo.isView(nextEmbed) 301 + ? nextEmbed.playlist 302 + : null 303 + 304 + const prevPlayerCurrentSource = currentSources[(index + 2) % 3] 305 + const currPlayerCurrentSource = currentSources[index % 3] 306 + const nextPlayerCurrentSource = currentSources[(index + 1) % 3] 307 + 308 + if (!players) { 309 + const args = ['', '', ''] satisfies [string, string, string] 310 + if (prevVideo) args[(index + 2) % 3] = prevVideo 311 + if (currVideo) args[index % 3] = currVideo 312 + if (nextVideo) args[(index + 1) % 3] = nextVideo 313 + const [player1, player2, player3] = createThreeVideoPlayers(args) 314 + 315 + setPlayers([player1, player2, player3]) 316 + 317 + if (currVideo) { 318 + const currPlayer = [player1, player2, player3][index % 3] 319 + currPlayer.play() 320 + } 321 + } else { 322 + const [player1, player2, player3] = players 323 + 324 + const prevPlayer = [player1, player2, player3][(index + 2) % 3] 325 + const currPlayer = [player1, player2, player3][index % 3] 326 + const nextPlayer = [player1, player2, player3][(index + 1) % 3] 327 + 328 + if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) { 329 + prevPlayer.replace(prevVideo) 330 + } 331 + prevPlayer.pause() 332 + 333 + if (currVideo) { 334 + if (currVideo !== currPlayerCurrentSource?.source) { 335 + currPlayer.replace(currVideo) 336 + } 337 + if ( 338 + currVideoModeration && 339 + (currVideoModeration.ui('contentView').blur || 340 + currVideoModeration.ui('contentMedia').blur) 341 + ) { 342 + currPlayer.pause() 343 + } else { 344 + currPlayer.play() 345 + } 346 + } 347 + 348 + if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) { 349 + nextPlayer.replace(nextVideo) 350 + } 351 + nextPlayer.pause() 352 + } 353 + 354 + const updatedSources: [CurrentSource, CurrentSource, CurrentSource] = [ 355 + ...currentSources, 356 + ] 357 + if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) { 358 + updatedSources[(index + 2) % 3] = { 359 + source: prevVideo, 360 + } 361 + } 362 + if (currVideo && currVideo !== currPlayerCurrentSource?.source) { 363 + updatedSources[index % 3] = { 364 + source: currVideo, 365 + } 366 + } 367 + if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) { 368 + updatedSources[(index + 1) % 3] = { 369 + source: nextVideo, 370 + } 371 + } 372 + 373 + if ( 374 + updatedSources[0]?.source !== currentSources[0]?.source || 375 + updatedSources[1]?.source !== currentSources[1]?.source || 376 + updatedSources[2]?.source !== currentSources[2]?.source 377 + ) { 378 + setCurrentSources(updatedSources) 379 + } 380 + }, 381 + [videos, currentSources, currentIndex, players], 382 + ) 383 + 384 + const updateVideoStateInitially = useNonReactiveCallback(() => { 385 + updateVideoState() 386 + }) 387 + 388 + useFocusEffect( 389 + useCallback(() => { 390 + if (!players) { 391 + // create players, set sources, start playing 392 + updateVideoStateInitially() 393 + } 394 + return () => { 395 + if (players) { 396 + // manually release players when offscreen 397 + players.forEach(p => p.release()) 398 + setPlayers(null) 399 + } 400 + } 401 + }, [players, updateVideoStateInitially]), 402 + ) 403 + 404 + const onViewableItemsChanged = useCallback( 405 + ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { 406 + if (viewableItems[0] && viewableItems[0].index !== null) { 407 + updateVideoState(viewableItems[0].index) 408 + } 409 + }, 410 + [updateVideoState], 411 + ) 412 + 413 + const renderEndMessage = useCallback(() => <EndMessage />, []) 414 + 415 + return ( 416 + <FeedFeedbackProvider value={feedFeedback}> 417 + <GestureDetector gesture={scrollGesture}> 418 + <List 419 + data={videos} 420 + renderItem={renderItem} 421 + keyExtractor={keyExtractor} 422 + initialNumToRender={3} 423 + maxToRenderPerBatch={3} 424 + windowSize={6} 425 + pagingEnabled={true} 426 + ListFooterComponent={ 427 + <ListFooter 428 + hasNextPage={hasNextPage} 429 + isFetchingNextPage={isFetchingNextPage} 430 + error={cleanError(error)} 431 + onRetry={fetchNextPage} 432 + height={height} 433 + showEndMessage 434 + renderEndMessage={renderEndMessage} 435 + style={[a.justify_center, a.border_0]} 436 + /> 437 + } 438 + onEndReached={() => { 439 + if (hasNextPage && !isFetchingNextPage) { 440 + fetchNextPage() 441 + } 442 + }} 443 + showsVerticalScrollIndicator={false} 444 + onViewableItemsChanged={onViewableItemsChanged} 445 + viewabilityConfig={viewabilityConfig} 446 + /> 447 + </GestureDetector> 448 + </FeedFeedbackProvider> 449 + ) 450 + } 451 + 452 + function keyExtractor(item: FeedPostSliceItem) { 453 + return item._reactKey 454 + } 455 + 456 + let VideoItem = ({ 457 + player, 458 + post, 459 + embed, 460 + active, 461 + scrollGesture, 462 + moderation, 463 + feedContext, 464 + }: { 465 + player?: VideoPlayer 466 + post: AppBskyFeedDefs.PostView 467 + embed: AppBskyEmbedVideo.View 468 + active: boolean 469 + scrollGesture: NativeGesture 470 + moderation?: ModerationDecision 471 + feedContext: string | undefined 472 + }): React.ReactNode => { 473 + const postShadow = usePostShadow(post) 474 + const {width, height} = useSafeAreaFrame() 475 + const {sendInteraction} = useFeedFeedbackContext() 476 + 477 + useEffect(() => { 478 + if (active) { 479 + sendInteraction({ 480 + item: post.uri, 481 + event: 'app.bsky.feed.defs#interactionSeen', 482 + feedContext, 483 + }) 484 + } 485 + }, [active, post.uri, feedContext, sendInteraction]) 486 + 487 + return ( 488 + <View style={[a.relative, {height, width}]}> 489 + {postShadow === POST_TOMBSTONE ? ( 490 + <View 491 + style={[ 492 + a.absolute, 493 + a.inset_0, 494 + a.z_20, 495 + a.align_center, 496 + a.justify_center, 497 + {backgroundColor: 'rgba(0, 0, 0, 0.8)'}, 498 + ]}> 499 + <Text 500 + style={[ 501 + a.text_2xl, 502 + a.font_heavy, 503 + a.text_center, 504 + a.leading_tight, 505 + a.mx_xl, 506 + ]}> 507 + <Trans>Post has been deleted</Trans> 508 + </Text> 509 + </View> 510 + ) : ( 511 + <> 512 + <VideoItemPlaceholder embed={embed} /> 513 + {active && player && <VideoItemInner player={player} embed={embed} />} 514 + {moderation && ( 515 + <Overlay 516 + player={player} 517 + post={postShadow} 518 + embed={embed} 519 + active={active} 520 + scrollGesture={scrollGesture} 521 + moderation={moderation} 522 + feedContext={feedContext} 523 + /> 524 + )} 525 + </> 526 + )} 527 + </View> 528 + ) 529 + } 530 + VideoItem = memo(VideoItem) 531 + 532 + function VideoItemInner({ 533 + player, 534 + embed, 535 + }: { 536 + player: VideoPlayer 537 + embed: AppBskyEmbedVideo.View 538 + }) { 539 + const {bottom} = useSafeAreaInsets() 540 + const [isReady, setIsReady] = useState(!isAndroid) 541 + 542 + useEventListener(player, 'timeUpdate', evt => { 543 + if (isAndroid && !isReady && evt.currentTime >= 0.05) { 544 + setIsReady(true) 545 + } 546 + }) 547 + 548 + return ( 549 + <VideoView 550 + accessible={false} 551 + style={[ 552 + a.absolute, 553 + { 554 + top: 0, 555 + left: 0, 556 + right: 0, 557 + bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET, 558 + }, 559 + !isReady && {opacity: 0}, 560 + ]} 561 + player={player} 562 + nativeControls={false} 563 + contentFit={isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'} 564 + accessibilityIgnoresInvertColors 565 + /> 566 + ) 567 + } 568 + 569 + function ModerationOverlay({ 570 + embed, 571 + onPressShow, 572 + }: { 573 + embed: AppBskyEmbedVideo.View 574 + onPressShow: () => void 575 + }) { 576 + const {_} = useLingui() 577 + const hider = Hider.useHider() 578 + const {bottom} = useSafeAreaInsets() 579 + 580 + const onShow = useCallback(() => { 581 + hider.setIsContentVisible(true) 582 + onPressShow() 583 + }, [hider, onPressShow]) 584 + 585 + return ( 586 + <View style={[a.absolute, a.inset_0, a.z_20]}> 587 + <VideoItemPlaceholder blur embed={embed} /> 588 + <View 589 + style={[ 590 + a.absolute, 591 + a.inset_0, 592 + a.z_20, 593 + a.justify_center, 594 + a.align_center, 595 + {backgroundColor: 'rgba(0, 0, 0, 0.8)'}, 596 + ]}> 597 + <View style={[a.align_center, a.gap_sm]}> 598 + <Eye width={36} fill="white" /> 599 + <Text style={[a.text_center, a.leading_snug, a.pb_xs]}> 600 + <Trans>Hidden by your moderation settings.</Trans> 601 + </Text> 602 + <Button 603 + label={_(msg`Show anyway`)} 604 + size="small" 605 + variant="solid" 606 + color="secondary_inverted" 607 + onPress={onShow}> 608 + <ButtonText> 609 + <Trans>Show anyway</Trans> 610 + </ButtonText> 611 + </Button> 612 + </View> 613 + <View 614 + style={[ 615 + a.absolute, 616 + a.inset_0, 617 + a.px_xl, 618 + a.pt_4xl, 619 + { 620 + top: 'auto', 621 + paddingBottom: bottom, 622 + }, 623 + ]}> 624 + <LinearGradient 625 + colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.4)']} 626 + style={[a.absolute, a.inset_0]} 627 + /> 628 + <Divider style={{borderColor: 'white'}} /> 629 + <View> 630 + <Button 631 + label={_(msg`View details`)} 632 + onPress={() => { 633 + hider.showInfoDialog() 634 + }} 635 + style={[ 636 + a.w_full, 637 + { 638 + height: 60, 639 + }, 640 + ]}> 641 + {({pressed}) => ( 642 + <Text 643 + style={[ 644 + a.text_sm, 645 + a.font_bold, 646 + a.text_center, 647 + {opacity: pressed ? 0.5 : 1}, 648 + ]}> 649 + <Trans>View details</Trans> 650 + </Text> 651 + )} 652 + </Button> 653 + </View> 654 + </View> 655 + </View> 656 + </View> 657 + ) 658 + } 659 + 660 + function Overlay({ 661 + player, 662 + post, 663 + embed, 664 + active, 665 + scrollGesture, 666 + moderation, 667 + feedContext, 668 + }: { 669 + player?: VideoPlayer 670 + post: Shadow<AppBskyFeedDefs.PostView> 671 + embed: AppBskyEmbedVideo.View 672 + active: boolean 673 + scrollGesture: NativeGesture 674 + moderation: ModerationDecision 675 + feedContext: string | undefined 676 + }) { 677 + const {_} = useLingui() 678 + const t = useTheme() 679 + const {openComposer} = useComposerControls() 680 + const navigation = useNavigation<NavigationProp>() 681 + const seekingAnimationSV = useSharedValue(0) 682 + 683 + const profile = useProfileShadow(post.author) 684 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 685 + profile, 686 + 'ImmersiveVideo', 687 + ) 688 + 689 + const rkey = new AtUri(post.uri).rkey 690 + const record = AppBskyFeedPost.isRecord(post.record) ? post.record : undefined 691 + const richText = new RichTextAPI({ 692 + text: record?.text || '', 693 + facets: record?.facets, 694 + }) 695 + 696 + const animatedStyle = useAnimatedStyle(() => ({ 697 + opacity: 1 - seekingAnimationSV.get(), 698 + })) 699 + 700 + const onPressShow = useCallback(() => { 701 + player?.play() 702 + }, [player]) 703 + 704 + const mergedModui = useMemo(() => { 705 + const modui = moderation.ui('contentView') 706 + const mediaModui = moderation.ui('contentMedia') 707 + modui.alerts = [...modui.alerts, ...mediaModui.alerts] 708 + modui.blurs = [...modui.blurs, ...mediaModui.blurs] 709 + modui.filters = [...modui.filters, ...mediaModui.filters] 710 + modui.informs = [...modui.informs, ...mediaModui.informs] 711 + return modui 712 + }, [moderation]) 713 + 714 + const onPressReply = useCallback(() => { 715 + openComposer({ 716 + replyTo: { 717 + uri: post.uri, 718 + cid: post.cid, 719 + text: record?.text || '', 720 + author: post.author, 721 + embed: post.embed, 722 + }, 723 + }) 724 + }, [openComposer, post, record]) 725 + 726 + return ( 727 + <Hider.Outer modui={mergedModui}> 728 + <Hider.Mask> 729 + <ModerationOverlay embed={embed} onPressShow={onPressShow} /> 730 + </Hider.Mask> 731 + <Hider.Content> 732 + <View style={[a.absolute, a.inset_0, a.z_20]}> 733 + <View style={[a.flex_1]}> 734 + <PlayPauseTapArea 735 + player={player} 736 + post={post} 737 + feedContext={feedContext} 738 + /> 739 + </View> 740 + 741 + <LinearGradient 742 + colors={[ 743 + 'rgba(0,0,0,0)', 744 + 'rgba(0,0,0,0.7)', 745 + 'rgba(0,0,0,0.95)', 746 + 'rgba(0,0,0,0.95)', 747 + ]} 748 + style={[a.w_full, a.pt_md]}> 749 + <Animated.View style={[a.px_xl, animatedStyle]}> 750 + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}> 751 + <Link 752 + label={_( 753 + msg`View ${sanitizeDisplayName( 754 + post.author.displayName || post.author.handle, 755 + )}'s profile`, 756 + )} 757 + to={{ 758 + screen: 'Profile', 759 + params: {name: post.author.did}, 760 + }} 761 + style={[a.flex_1, a.flex_row, a.gap_md, a.align_center]}> 762 + <UserAvatar 763 + type="user" 764 + avatar={post.author.avatar} 765 + size={32} 766 + /> 767 + <View style={[a.flex_1]}> 768 + <Text 769 + style={[a.text_md, a.font_heavy]} 770 + emoji 771 + numberOfLines={1}> 772 + {sanitizeDisplayName( 773 + post.author.displayName || post.author.handle, 774 + )} 775 + </Text> 776 + <Text 777 + style={[a.text_sm, t.atoms.text_contrast_high]} 778 + numberOfLines={1}> 779 + {sanitizeHandle(post.author.handle, '@')} 780 + </Text> 781 + </View> 782 + </Link> 783 + {/* show button based on non-reactive version, so it doesn't hide on press */} 784 + {!post.author.viewer?.following && ( 785 + <Button 786 + label={ 787 + profile.viewer?.following 788 + ? _(msg`Following`) 789 + : _(msg`Follow`) 790 + } 791 + accessibilityHint={ 792 + profile.viewer?.following ? _(msg`Unfollow user`) : '' 793 + } 794 + size="small" 795 + variant="solid" 796 + color="secondary_inverted" 797 + style={[a.mb_xs]} 798 + onPress={() => 799 + profile.viewer?.following 800 + ? queueUnfollow() 801 + : queueFollow() 802 + }> 803 + {!!profile.viewer?.following && ( 804 + <ButtonIcon icon={CheckIcon} /> 805 + )} 806 + <ButtonText> 807 + {profile.viewer?.following ? ( 808 + <Trans>Following</Trans> 809 + ) : ( 810 + <Trans>Follow</Trans> 811 + )} 812 + </ButtonText> 813 + </Button> 814 + )} 815 + </View> 816 + {record?.text?.trim() && ( 817 + <ExpandableRichTextView 818 + value={richText} 819 + authorHandle={post.author.handle} 820 + /> 821 + )} 822 + {record && ( 823 + <View style={[{left: -5}]}> 824 + <PostCtrls 825 + richText={richText} 826 + post={post} 827 + record={record} 828 + logContext="FeedItem" 829 + onPressReply={() => 830 + navigation.navigate('PostThread', { 831 + name: post.author.did, 832 + rkey, 833 + }) 834 + } 835 + big 836 + /> 837 + </View> 838 + )} 839 + </Animated.View> 840 + <Scrubber 841 + active={active} 842 + player={player} 843 + seekingAnimationSV={seekingAnimationSV} 844 + scrollGesture={scrollGesture}> 845 + <PostThreadComposePrompt onPressCompose={onPressReply} /> 846 + </Scrubber> 847 + </LinearGradient> 848 + </View> 849 + {/* 850 + {isAndroid && status === 'loading' && ( 851 + <View 852 + style={[ 853 + a.absolute, 854 + a.inset_0, 855 + a.align_center, 856 + a.justify_center, 857 + a.z_10, 858 + ]} 859 + pointerEvents="none"> 860 + <Loader size="2xl" /> 861 + </View> 862 + )} 863 + */} 864 + </Hider.Content> 865 + </Hider.Outer> 866 + ) 867 + } 868 + 869 + function ExpandableRichTextView({ 870 + value, 871 + authorHandle, 872 + }: { 873 + value: RichTextAPI 874 + authorHandle?: string 875 + }) { 876 + const {height: screenHeight} = useSafeAreaFrame() 877 + const [expanded, setExpanded] = useState(false) 878 + const [hasBeenExpanded, setHasBeenExpanded] = useState(false) 879 + const [constrained, setConstrained] = useState(false) 880 + const [contentHeight, setContentHeight] = useState(0) 881 + const {_} = useLingui() 882 + 883 + if (expanded && !hasBeenExpanded) { 884 + setHasBeenExpanded(true) 885 + } 886 + 887 + return ( 888 + <ScrollView 889 + scrollEnabled={expanded} 890 + onContentSizeChange={(_w, h) => { 891 + if (hasBeenExpanded) { 892 + LayoutAnimation.configureNext({ 893 + duration: 500, 894 + update: {type: 'spring', springDamping: 0.6}, 895 + }) 896 + } 897 + setContentHeight(h) 898 + }} 899 + style={{height: Math.min(contentHeight, screenHeight * 0.5)}} 900 + contentContainerStyle={[ 901 + a.py_sm, 902 + a.gap_xs, 903 + expanded ? [a.align_start] : a.flex_row, 904 + ]}> 905 + <RichText 906 + value={value} 907 + style={[a.text_sm, a.flex_1, a.leading_normal]} 908 + authorHandle={authorHandle} 909 + enableTags 910 + numberOfLines={expanded ? undefined : constrained ? 2 : 2} 911 + onTextLayout={evt => { 912 + if (!constrained && evt.nativeEvent.lines.length > 1) { 913 + setConstrained(true) 914 + } 915 + }} 916 + /> 917 + {constrained && ( 918 + <Pressable 919 + accessibilityHint={_(msg`Tap to expand or collapse post text.`)} 920 + accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)} 921 + hitSlop={HITSLOP_20} 922 + onPress={() => setExpanded(prev => !prev)} 923 + style={[a.absolute, a.inset_0]} 924 + /> 925 + )} 926 + </ScrollView> 927 + ) 928 + } 929 + 930 + function VideoItemPlaceholder({ 931 + embed, 932 + style, 933 + blur, 934 + }: { 935 + embed: AppBskyEmbedVideo.View 936 + style?: ImageStyle 937 + blur?: boolean 938 + }) { 939 + const {bottom} = useSafeAreaInsets() 940 + const src = embed.thumbnail 941 + let contentFit = isTallAspectRatio(embed.aspectRatio) 942 + ? ('cover' as const) 943 + : ('contain' as const) 944 + if (blur) { 945 + contentFit = 'cover' as const 946 + } 947 + return src ? ( 948 + <Image 949 + accessibilityIgnoresInvertColors 950 + source={{uri: src}} 951 + style={[ 952 + a.absolute, 953 + blur 954 + ? a.inset_0 955 + : { 956 + top: 0, 957 + left: 0, 958 + right: 0, 959 + bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET, 960 + }, 961 + style, 962 + ]} 963 + contentFit={contentFit} 964 + blurRadius={blur ? 100 : 0} 965 + /> 966 + ) : null 967 + } 968 + 969 + function PlayPauseTapArea({ 970 + player, 971 + post, 972 + feedContext, 973 + }: { 974 + player?: VideoPlayer 975 + post: Shadow<AppBskyFeedDefs.PostView> 976 + feedContext: string | undefined 977 + }) { 978 + const {_} = useLingui() 979 + const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null) 980 + const playHaptic = useHaptics() 981 + const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo') 982 + const {sendInteraction} = useFeedFeedbackContext() 983 + 984 + const togglePlayPause = () => { 985 + if (!player) return 986 + doubleTapRef.current = null 987 + if (player.playing) { 988 + player.pause() 989 + } else { 990 + player.play() 991 + } 992 + } 993 + 994 + const onPress = () => { 995 + if (doubleTapRef.current) { 996 + clearTimeout(doubleTapRef.current) 997 + doubleTapRef.current = null 998 + playHaptic('Light') 999 + queueLike() 1000 + sendInteraction({ 1001 + item: post.uri, 1002 + event: 'app.bsky.feed.defs#interactionLike', 1003 + feedContext, 1004 + }) 1005 + } else { 1006 + doubleTapRef.current = setTimeout(togglePlayPause, 200) 1007 + } 1008 + } 1009 + 1010 + return ( 1011 + <Button 1012 + disabled={!player} 1013 + label={_(`Tap to play or pause the video`)} 1014 + accessibilityHint={_(msg`Double tap to like`)} 1015 + onPress={onPress} 1016 + style={[a.absolute, a.inset_0]}> 1017 + <View /> 1018 + </Button> 1019 + ) 1020 + } 1021 + 1022 + function EndMessage() { 1023 + const navigation = useNavigation<NavigationProp>() 1024 + const {_} = useLingui() 1025 + const t = useTheme() 1026 + return ( 1027 + <View 1028 + style={[ 1029 + a.w_full, 1030 + a.gap_3xl, 1031 + a.px_lg, 1032 + a.mx_auto, 1033 + a.align_center, 1034 + {maxWidth: 350}, 1035 + ]}> 1036 + <View 1037 + style={[ 1038 + {height: 100, width: 100}, 1039 + a.rounded_full, 1040 + t.atoms.bg_contrast_700, 1041 + a.align_center, 1042 + a.justify_center, 1043 + ]}> 1044 + <LeafIcon width={64} fill="black" /> 1045 + </View> 1046 + <View style={[a.w_full, a.gap_md]}> 1047 + <Text style={[a.text_3xl, a.text_center, a.font_heavy]}> 1048 + <Trans>That's everything!</Trans> 1049 + </Text> 1050 + <Text 1051 + style={[ 1052 + a.text_lg, 1053 + a.text_center, 1054 + t.atoms.text_contrast_high, 1055 + a.leading_snug, 1056 + ]}> 1057 + <Trans> 1058 + You've run out of videos to watch. Maybe it's a good time to take a 1059 + break? 1060 + </Trans> 1061 + </Text> 1062 + </View> 1063 + <Button 1064 + testID="videoFeedGoBackButton" 1065 + onPress={() => { 1066 + if (navigation.canGoBack()) { 1067 + navigation.goBack() 1068 + } else { 1069 + navigation.navigate('Home') 1070 + } 1071 + }} 1072 + variant="solid" 1073 + color="secondary_inverted" 1074 + size="small" 1075 + label={_(msg`Go back`)} 1076 + accessibilityHint={_(msg`Returns to previous page`)}> 1077 + <ButtonIcon icon={ArrowLeftIcon} /> 1078 + <ButtonText> 1079 + <Trans>Go back</Trans> 1080 + </ButtonText> 1081 + </Button> 1082 + </View> 1083 + ) 1084 + } 1085 + 1086 + /* 1087 + * If the video is taller than 9:16 1088 + */ 1089 + function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) { 1090 + const videoAspectRatio = 1091 + (aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1) 1092 + return videoAspectRatio <= 9 / 16 1093 + }
+3
src/screens/VideoFeed/index.web.tsx
··· 1 + export function VideoScreen() { 2 + return null 3 + }
+18
src/screens/VideoFeed/types.ts
··· 1 + import {AuthorFilter} from '#/state/queries/post-feed' 2 + 3 + /** 4 + * Kind of like `FeedDescriptor` but not 5 + */ 6 + export type VideoFeedSourceContext = 7 + | { 8 + type: 'feedgen' 9 + uri: string 10 + sourceInterstitial: 'discover' | 'explore' | 'none' 11 + initialPostUri?: string 12 + } 13 + | { 14 + type: 'author' 15 + did: string 16 + filter: AuthorFilter 17 + initialPostUri?: string 18 + }
+4 -7
src/state/feed-feedback.tsx
··· 7 7 import {logEvent} from '#/lib/statsig/statsig' 8 8 import {logger} from '#/logger' 9 9 import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed' 10 - import {getFeedPostSlice} from '#/view/com/posts/PostFeed' 10 + import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 11 11 import {useAgent} from './session' 12 12 13 13 type StateContext = { ··· 102 102 if (!enabled) { 103 103 return 104 104 } 105 - const slice = getFeedPostSlice(feedItem) 106 - if (slice === null) { 107 - return 108 - } 109 - for (const postItem of slice.items) { 105 + const items = getItemsForFeedback(feedItem) 106 + for (const {item: postItem, feedContext} of items) { 110 107 if (!history.current.has(postItem)) { 111 108 history.current.add(postItem) 112 109 queue.current.add( 113 110 toString({ 114 111 item: postItem.uri, 115 112 event: 'app.bsky.feed.defs#interactionSeen', 116 - feedContext: slice.feedContext, 113 + feedContext, 117 114 }), 118 115 ) 119 116 sendToFeed()
+2
src/state/persisted/schema.ts
··· 126 126 /** @deprecated */ 127 127 mutedThreads: z.array(z.string()), 128 128 trendingDisabled: z.boolean().optional(), 129 + trendingVideoDisabled: z.boolean().optional(), 129 130 }) 130 131 export type Schema = z.infer<typeof schema> 131 132 ··· 172 173 hasCheckedForStarterPack: false, 173 174 subtitlesEnabled: true, 174 175 trendingDisabled: false, 176 + trendingVideoDisabled: false, 175 177 } 176 178 177 179 export function tryParse(rawData: string): Schema | undefined {
+17 -3
src/state/preferences/trending.tsx
··· 4 4 5 5 type StateContext = { 6 6 trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined> 7 + trendingVideoDisabled: Exclude< 8 + persisted.Schema['trendingVideoDisabled'], 9 + undefined 10 + > 7 11 } 8 12 type ApiContext = { 9 13 setTrendingDisabled( 10 14 hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>, 11 15 ): void 16 + setTrendingVideoDisabled( 17 + hidden: Exclude<persisted.Schema['trendingVideoDisabled'], undefined>, 18 + ): void 12 19 } 13 20 14 21 const StateContext = React.createContext<StateContext>({ 15 22 trendingDisabled: Boolean(persisted.defaults.trendingDisabled), 23 + trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled), 16 24 }) 17 25 const ApiContext = React.createContext<ApiContext>({ 18 26 setTrendingDisabled() {}, 27 + setTrendingVideoDisabled() {}, 19 28 }) 20 29 21 30 function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) { ··· 43 52 export function Provider({children}: React.PropsWithChildren<{}>) { 44 53 const [trendingDisabled, setTrendingDisabled] = 45 54 usePersistedBooleanValue('trendingDisabled') 55 + const [trendingVideoDisabled, setTrendingVideoDisabled] = 56 + usePersistedBooleanValue('trendingVideoDisabled') 46 57 47 58 /* 48 59 * Context 49 60 */ 50 - const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled]) 61 + const state = React.useMemo( 62 + () => ({trendingDisabled, trendingVideoDisabled}), 63 + [trendingDisabled, trendingVideoDisabled], 64 + ) 51 65 const api = React.useMemo( 52 - () => ({setTrendingDisabled}), 53 - [setTrendingDisabled], 66 + () => ({setTrendingDisabled, setTrendingVideoDisabled}), 67 + [setTrendingDisabled, setTrendingVideoDisabled], 54 68 ) 55 69 56 70 return (
+6
src/state/queries/feed.ts
··· 48 48 creatorHandle: string 49 49 likeCount: number | undefined 50 50 likeUri: string | undefined 51 + contentMode: AppBskyFeedDefs.GeneratorView['contentMode'] 51 52 } 52 53 53 54 export type FeedSourceListInfo = { ··· 65 66 description: RichText 66 67 creatorDid: string 67 68 creatorHandle: string 69 + contentMode: undefined 68 70 } 69 71 70 72 export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo ··· 111 113 creatorHandle: view.creator.handle, 112 114 likeCount: view.likeCount, 113 115 likeUri: view.viewer?.like, 116 + contentMode: view.contentMode, 114 117 } 115 118 } 116 119 ··· 141 144 displayName: view.name 142 145 ? sanitizeDisplayName(view.name) 143 146 : `User List by ${sanitizeHandle(view.creator.handle, '@')}`, 147 + contentMode: undefined, 144 148 } 145 149 } 146 150 ··· 399 403 id: 'pwi-discover', 400 404 ...DISCOVER_SAVED_FEED, 401 405 }, 406 + contentMode: undefined, 402 407 } 403 408 404 409 const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos' ··· 485 490 likeCount: 0, 486 491 likeUri: '', 487 492 savedFeed: pinnedItem, 493 + contentMode: undefined, 488 494 }) 489 495 } 490 496 }
+2 -1
src/state/queries/post-feed.ts
··· 44 44 } from './util' 45 45 46 46 type ActorDid = string 47 - type AuthorFilter = 47 + export type AuthorFilter = 48 48 | 'posts_with_replies' 49 49 | 'posts_no_replies' 50 50 | 'posts_and_author_threads' ··· 61 61 export interface FeedParams { 62 62 mergeFeedEnabled?: boolean 63 63 mergeFeedSources?: string[] 64 + feedCacheKey?: 'discover' | 'explore' | undefined 64 65 } 65 66 66 67 type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
+13 -1
src/view/com/feeds/FeedPage.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs} from '@atproto/api' 3 + import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import {NavigationProp, useNavigation} from '@react-navigation/native' 7 7 import {useQueryClient} from '@tanstack/react-query' 8 8 9 + import {VIDEO_FEED_URIS} from '#/lib/constants' 9 10 import {ComposeIcon2} from '#/lib/icons' 10 11 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' 11 12 import {AllNavigatorParams} from '#/lib/routes/types' ··· 15 16 import {listenSoftReset} from '#/state/events' 16 17 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 17 18 import {useSetHomeBadge} from '#/state/home-badge' 19 + import {SavedFeedSourceInfo} from '#/state/queries/feed' 18 20 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 19 21 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 20 22 import {truncateAndInvalidate} from '#/state/queries/util' ··· 39 41 renderEmptyState, 40 42 renderEndOfFeed, 41 43 savedFeedConfig, 44 + feedInfo, 42 45 }: { 43 46 testID?: string 44 47 feed: FeedDescriptor ··· 48 51 renderEmptyState: () => JSX.Element 49 52 renderEndOfFeed?: () => JSX.Element 50 53 savedFeedConfig?: AppBskyActorDefs.SavedFeed 54 + feedInfo: SavedFeedSourceInfo 51 55 }) { 52 56 const {hasSession} = useSession() 53 57 const {_} = useLingui() ··· 61 65 const scrollElRef = React.useRef<ListMethods>(null) 62 66 const [hasNew, setHasNew] = React.useState(false) 63 67 const setHomeBadge = useSetHomeBadge() 68 + const isVideoFeed = React.useMemo(() => { 69 + const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) 70 + const feedIsVideoMode = 71 + feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO 72 + const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode 73 + return isNative && _isVideoFeed 74 + }, [feedInfo]) 64 75 65 76 React.useEffect(() => { 66 77 if (isPageFocused) { ··· 134 145 renderEndOfFeed={renderEndOfFeed} 135 146 headerOffset={headerOffset} 136 147 savedFeedConfig={savedFeedConfig} 148 + isVideoFeed={isVideoFeed} 137 149 /> 138 150 </FeedFeedbackProvider> 139 151 </MainScrollProvider>
-1
src/view/com/post-thread/PostThreadComposePrompt.tsx
··· 40 40 t.atoms.border_contrast_low, 41 41 t.atoms.bg, 42 42 ]} 43 - onPressIn={ios(() => playHaptic('Light'))} 44 43 onPress={() => { 45 44 onPressCompose() 46 45 playHaptic('Light')
+196 -75
src/view/com/posts/PostFeed.tsx
··· 9 9 View, 10 10 ViewStyle, 11 11 } from 'react-native' 12 - import {AppBskyActorDefs} from '@atproto/api' 12 + import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' 13 13 import {msg} from '@lingui/macro' 14 14 import {useLingui} from '@lingui/react' 15 15 import {useQueryClient} from '@tanstack/react-query' ··· 20 20 import {logEvent} from '#/lib/statsig/statsig' 21 21 import {useTheme} from '#/lib/ThemeContext' 22 22 import {logger} from '#/logger' 23 - import {isIOS, isWeb} from '#/platform/detection' 23 + import {isIOS, isNative, isWeb} from '#/platform/detection' 24 24 import {listenPostCreated} from '#/state/events' 25 25 import {useFeedFeedbackContext} from '#/state/feed-feedback' 26 26 import {useTrendingSettings} from '#/state/preferences/trending' ··· 29 29 FeedDescriptor, 30 30 FeedParams, 31 31 FeedPostSlice, 32 + FeedPostSliceItem, 32 33 pollLatest, 33 34 RQKEY, 34 35 usePostFeedQuery, 35 36 } from '#/state/queries/post-feed' 36 37 import {useSession} from '#/state/session' 37 38 import {useProgressGuide} from '#/state/shell/progress-guide' 39 + import {List, ListRef} from '#/view/com/util/List' 40 + import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 41 + import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 38 42 import {useBreakpoints} from '#/alf' 39 43 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' 44 + import { 45 + PostFeedVideoGridRow, 46 + PostFeedVideoGridRowPlaceholder, 47 + } from '#/components/feeds/PostFeedVideoGridRow' 40 48 import {TrendingInterstitial} from '#/components/interstitials/Trending' 41 - import {List, ListRef} from '../util/List' 42 - import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 43 - import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 49 + import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 44 50 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 45 51 import {FeedShutdownMsg} from './FeedShutdownMsg' 46 52 import {PostFeedErrorMessage} from './PostFeedErrorMessage' ··· 69 75 key: string 70 76 } 71 77 | { 72 - type: 'slice' 78 + type: 'slice' // TODO can we remove? 73 79 key: string 74 80 slice: FeedPostSlice 75 81 } ··· 81 87 showReplyTo: boolean 82 88 } 83 89 | { 90 + type: 'videoGridRowPlaceholder' 91 + key: string 92 + } 93 + | { 94 + type: 'videoGridRow' 95 + key: string 96 + items: FeedPostSliceItem[] 97 + sourceFeedUri: string 98 + feedContexts: (string | undefined)[] 99 + } 100 + | { 84 101 type: 'sliceViewFullThread' 85 102 key: string 86 103 uri: string ··· 97 114 type: 'interstitialTrending' 98 115 key: string 99 116 } 117 + | { 118 + type: 'interstitialTrendingVideos' 119 + key: string 120 + } 100 121 101 - export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { 122 + export function getItemsForFeedback(feedRow: FeedRow): 123 + | { 124 + item: FeedPostSliceItem 125 + feedContext: string | undefined 126 + }[] { 102 127 if (feedRow.type === 'sliceItem') { 103 - return feedRow.slice 128 + return feedRow.slice.items.map(item => ({ 129 + item, 130 + feedContext: feedRow.slice.feedContext, 131 + })) 132 + } else if (feedRow.type === 'videoGridRow') { 133 + return feedRow.items.map((item, i) => ({ 134 + item, 135 + feedContext: feedRow.feedContexts[i], 136 + })) 104 137 } else { 105 - return null 138 + return [] 106 139 } 107 140 } 108 141 ··· 131 164 extraData, 132 165 savedFeedConfig, 133 166 initialNumToRender: initialNumToRenderOverride, 167 + isVideoFeed = false, 134 168 }: { 135 169 feed: FeedDescriptor 136 170 feedParams?: FeedParams ··· 152 186 extraData?: any 153 187 savedFeedConfig?: AppBskyActorDefs.SavedFeed 154 188 initialNumToRender?: number 189 + isVideoFeed?: boolean 155 190 }): React.ReactNode => { 156 191 const theme = useTheme() 157 192 const {_} = useLingui() ··· 163 198 const checkForNewRef = React.useRef<(() => void) | null>(null) 164 199 const lastFetchRef = React.useRef<number>(Date.now()) 165 200 const [feedType, feedUri, feedTab] = feed.split('|') 166 - const {gtTablet} = useBreakpoints() 201 + const {gtMobile, gtTablet} = useBreakpoints() 202 + const areVideoFeedsEnabled = isNative 167 203 204 + const feedCacheKey = feedParams?.feedCacheKey 168 205 const opts = React.useMemo( 169 206 () => ({enabled, ignoreFilterFor}), 170 207 [enabled, ignoreFilterFor], ··· 267 304 const showProgressIntersitial = 268 305 (followProgressGuide || followAndLikeProgressGuide) && !isDesktop 269 306 270 - const {trendingDisabled} = useTrendingSettings() 307 + const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() 271 308 272 309 const feedItems: FeedRow[] = React.useMemo(() => { 273 - let feedKind: 'following' | 'discover' | 'profile' | undefined 310 + let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined 274 311 if (feedType === 'following') { 275 312 feedKind = 'following' 276 313 } else if (feedUri === DISCOVER_FEED_URI) { ··· 303 340 }) 304 341 } else if (data) { 305 342 let sliceIndex = -1 306 - for (const page of data?.pages) { 307 - for (const slice of page.slices) { 343 + 344 + if (isVideoFeed) { 345 + const videos: { 346 + item: FeedPostSliceItem 347 + feedContext: string | undefined 348 + }[] = [] 349 + for (const page of data.pages) { 350 + for (const slice of page.slices) { 351 + const item = slice.items.at(0) 352 + if (item && AppBskyEmbedVideo.isView(item.post.embed)) { 353 + videos.push({item, feedContext: slice.feedContext}) 354 + } 355 + } 356 + } 357 + 358 + const rows: { 359 + item: FeedPostSliceItem 360 + feedContext: string | undefined 361 + }[][] = [] 362 + for (let i = 0; i < videos.length; i++) { 363 + const video = videos[i] 364 + const item = video.item 365 + const cols = gtMobile ? 3 : 2 366 + const rowItem = {item, feedContext: video.feedContext} 367 + if (i % cols === 0) { 368 + rows.push([rowItem]) 369 + } else { 370 + rows[rows.length - 1].push(rowItem) 371 + } 372 + } 373 + 374 + for (const row of rows) { 308 375 sliceIndex++ 376 + arr.push({ 377 + type: 'videoGridRow', 378 + key: row.map(r => r.item._reactKey).join('-'), 379 + items: row.map(r => r.item), 380 + sourceFeedUri: feedUri, 381 + feedContexts: row.map(r => r.feedContext), 382 + }) 383 + } 384 + } else { 385 + for (const page of data?.pages) { 386 + for (const slice of page.slices) { 387 + sliceIndex++ 309 388 310 - if (hasSession) { 311 - if (feedKind === 'discover') { 312 - if (sliceIndex === 0) { 313 - if (showProgressIntersitial) { 389 + if (hasSession) { 390 + if (feedKind === 'discover') { 391 + if (sliceIndex === 0) { 392 + if (showProgressIntersitial) { 393 + arr.push({ 394 + type: 'interstitialProgressGuide', 395 + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 396 + }) 397 + } 398 + if (!gtTablet && !trendingDisabled) { 399 + arr.push({ 400 + type: 'interstitialTrending', 401 + key: 402 + 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, 403 + }) 404 + } 405 + } else if (sliceIndex === 15) { 406 + if (areVideoFeedsEnabled && !trendingVideoDisabled) { 407 + arr.push({ 408 + type: 'interstitialTrendingVideos', 409 + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 410 + }) 411 + } 412 + } else if (sliceIndex === 30) { 314 413 arr.push({ 315 - type: 'interstitialProgressGuide', 414 + type: 'interstitialFollows', 316 415 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 317 416 }) 318 417 } 319 - if (!gtTablet && !trendingDisabled) { 418 + } else if (feedKind === 'profile') { 419 + if (sliceIndex === 5) { 320 420 arr.push({ 321 - type: 'interstitialTrending', 322 - key: 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, 421 + type: 'interstitialFollows', 422 + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 323 423 }) 324 424 } 325 - } else if (sliceIndex === 30) { 326 - arr.push({ 327 - type: 'interstitialFollows', 328 - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 329 - }) 330 - } 331 - } else if (feedKind === 'profile') { 332 - if (sliceIndex === 5) { 333 - arr.push({ 334 - type: 'interstitialFollows', 335 - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 336 - }) 337 425 } 338 426 } 339 - } 340 427 341 - if (slice.isIncompleteThread && slice.items.length >= 3) { 342 - const beforeLast = slice.items.length - 2 343 - const last = slice.items.length - 1 344 - arr.push({ 345 - type: 'sliceItem', 346 - key: slice.items[0]._reactKey, 347 - slice: slice, 348 - indexInSlice: 0, 349 - showReplyTo: false, 350 - }) 351 - arr.push({ 352 - type: 'sliceViewFullThread', 353 - key: slice._reactKey + '-viewFullThread', 354 - uri: slice.items[0].uri, 355 - }) 356 - arr.push({ 357 - type: 'sliceItem', 358 - key: slice.items[beforeLast]._reactKey, 359 - slice: slice, 360 - indexInSlice: beforeLast, 361 - showReplyTo: 362 - slice.items[beforeLast].parentAuthor?.did !== 363 - slice.items[beforeLast].post.author.did, 364 - }) 365 - arr.push({ 366 - type: 'sliceItem', 367 - key: slice.items[last]._reactKey, 368 - slice: slice, 369 - indexInSlice: last, 370 - showReplyTo: false, 371 - }) 372 - } else { 373 - for (let i = 0; i < slice.items.length; i++) { 428 + if (slice.isIncompleteThread && slice.items.length >= 3) { 429 + const beforeLast = slice.items.length - 2 430 + const last = slice.items.length - 1 431 + arr.push({ 432 + type: 'sliceItem', 433 + key: slice.items[0]._reactKey, 434 + slice: slice, 435 + indexInSlice: 0, 436 + showReplyTo: false, 437 + }) 438 + arr.push({ 439 + type: 'sliceViewFullThread', 440 + key: slice._reactKey + '-viewFullThread', 441 + uri: slice.items[0].uri, 442 + }) 443 + arr.push({ 444 + type: 'sliceItem', 445 + key: slice.items[beforeLast]._reactKey, 446 + slice: slice, 447 + indexInSlice: beforeLast, 448 + showReplyTo: 449 + slice.items[beforeLast].parentAuthor?.did !== 450 + slice.items[beforeLast].post.author.did, 451 + }) 374 452 arr.push({ 375 453 type: 'sliceItem', 376 - key: slice.items[i]._reactKey, 454 + key: slice.items[last]._reactKey, 377 455 slice: slice, 378 - indexInSlice: i, 379 - showReplyTo: i === 0, 456 + indexInSlice: last, 457 + showReplyTo: false, 380 458 }) 459 + } else { 460 + for (let i = 0; i < slice.items.length; i++) { 461 + arr.push({ 462 + type: 'sliceItem', 463 + key: slice.items[i]._reactKey, 464 + slice: slice, 465 + indexInSlice: i, 466 + showReplyTo: i === 0, 467 + }) 468 + } 381 469 } 382 470 } 383 471 } ··· 390 478 }) 391 479 } 392 480 } else { 393 - arr.push({ 394 - type: 'loading', 395 - key: 'loading', 396 - }) 481 + if (isVideoFeed) { 482 + arr.push({ 483 + type: 'videoGridRowPlaceholder', 484 + key: 'videoGridRowPlaceholder', 485 + }) 486 + } else { 487 + arr.push({ 488 + type: 'loading', 489 + key: 'loading', 490 + }) 491 + } 397 492 } 398 493 399 494 return arr ··· 409 504 hasSession, 410 505 showProgressIntersitial, 411 506 trendingDisabled, 507 + trendingVideoDisabled, 412 508 gtTablet, 509 + gtMobile, 510 + isVideoFeed, 511 + areVideoFeedsEnabled, 413 512 ]) 414 513 415 514 // events ··· 498 597 return <ProgressGuide /> 499 598 } else if (row.type === 'interstitialTrending') { 500 599 return <TrendingInterstitial /> 600 + } else if (row.type === 'interstitialTrendingVideos') { 601 + return <TrendingVideosInterstitial /> 501 602 } else if (row.type === 'sliceItem') { 502 603 const slice = row.slice 503 604 if (slice.isFallbackMarker) { ··· 532 633 ) 533 634 } else if (row.type === 'sliceViewFullThread') { 534 635 return <ViewFullThread uri={row.uri} /> 636 + } else if (row.type === 'videoGridRowPlaceholder') { 637 + return ( 638 + <View> 639 + <PostFeedVideoGridRowPlaceholder /> 640 + <PostFeedVideoGridRowPlaceholder /> 641 + <PostFeedVideoGridRowPlaceholder /> 642 + </View> 643 + ) 644 + } else if (row.type === 'videoGridRow') { 645 + return ( 646 + <PostFeedVideoGridRow 647 + items={row.items} 648 + sourceContext={{ 649 + type: 'feedgen', 650 + uri: row.sourceFeedUri, 651 + sourceInterstitial: feedCacheKey ?? 'none', 652 + }} 653 + /> 654 + ) 535 655 } else { 536 656 return null 537 657 } ··· 545 665 _, 546 666 onPressRetryLoadMore, 547 667 feedUri, 668 + feedCacheKey, 548 669 ], 549 670 ) 550 671
+3 -3
src/view/com/util/List.tsx
··· 152 152 153 153 return ( 154 154 <FlatList_INTERNAL 155 + showsVerticalScrollIndicator={!isAndroid} // overridable 156 + onViewableItemsChanged={onViewableItemsChanged} 157 + viewabilityConfig={viewabilityConfig} 155 158 {...props} 156 159 automaticallyAdjustsScrollIndicatorInsets={ 157 160 automaticallyAdjustsScrollIndicatorInsets ··· 166 169 onScroll={scrollHandler} 167 170 scrollsToTop={!activeLightbox} 168 171 scrollEventThrottle={1} 169 - onViewableItemsChanged={onViewableItemsChanged} 170 - viewabilityConfig={viewabilityConfig} 171 - showsVerticalScrollIndicator={!isAndroid} 172 172 style={style} 173 173 // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn 174 174 ref={ref}
+1 -1
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 69 69 style?: StyleProp<ViewStyle> 70 70 onPressReply: () => void 71 71 onPostReply?: (postUri: string | undefined) => void 72 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 72 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 73 73 threadgateRecord?: AppBskyFeedThreadgate.Record 74 74 }): React.ReactNode => { 75 75 const t = useTheme()
+2
src/view/screens/Home.tsx
··· 234 234 feedParams={homeFeedParams} 235 235 renderEmptyState={renderFollowingEmptyState} 236 236 renderEndOfFeed={FollowingEndOfFeed} 237 + feedInfo={feedInfo} 237 238 /> 238 239 ) 239 240 } ··· 247 248 feed={feed} 248 249 renderEmptyState={renderCustomFeedEmptyState} 249 250 savedFeedConfig={savedFeedConfig} 251 + feedInfo={feedInfo} 250 252 /> 251 253 ) 252 254 })
+16 -1
src/view/screens/Search/Explore.tsx
··· 12 12 13 13 import {cleanError} from '#/lib/strings/errors' 14 14 import {logger} from '#/logger' 15 - import {isWeb} from '#/platform/detection' 15 + import {isNative, isWeb} from '#/platform/detection' 16 16 import {useModerationOpts} from '#/state/preferences/moderation-opts' 17 17 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 18 18 import {usePreferencesQuery} from '#/state/queries/preferences' ··· 26 26 import {UserAvatar} from '#/view/com/util/UserAvatar' 27 27 import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' 28 28 import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' 29 + import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos' 29 30 import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 30 31 import {Button} from '#/components/Button' 31 32 import * as FeedCard from '#/components/FeedCard' ··· 247 248 key: string 248 249 } 249 250 | { 251 + type: 'trendingVideos' 252 + key: string 253 + } 254 + | { 250 255 type: 'recommendations' 251 256 key: string 252 257 } ··· 343 348 key: `trending-topics`, 344 349 }) 345 350 351 + if (isNative) { 352 + i.push({ 353 + type: 'trendingVideos', 354 + key: `trending-videos`, 355 + }) 356 + } 357 + 346 358 i.push({ 347 359 type: 'recommendations', 348 360 key: `recommendations`, ··· 513 525 } 514 526 case 'trendingTopics': { 515 527 return <ExploreTrendingTopics /> 528 + } 529 + case 'trendingVideos': { 530 + return <ExploreTrendingVideos /> 516 531 } 517 532 case 'recommendations': { 518 533 return <ExploreRecommendations />
+36 -7
yarn.lock
··· 72 72 tlds "^1.234.0" 73 73 zod "^3.23.8" 74 74 75 - "@atproto/api@^0.13.21": 76 - version "0.13.21" 77 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc" 78 - integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA== 75 + "@atproto/api@^0.13.28": 76 + version "0.13.28" 77 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.28.tgz#b36d4ad9485724ec030e7292599f048ab62a9fcc" 78 + integrity sha512-qBuEI5aNe2/KjmtmtLMilnpZc+FRAsAM3/5nFOQPEudUk388ctNsmKdz2Nti4OvCebn+50EB6V3lju596CTUNA== 79 79 dependencies: 80 - "@atproto/common-web" "^0.3.1" 81 - "@atproto/lexicon" "^0.4.4" 80 + "@atproto/common-web" "^0.3.2" 81 + "@atproto/lexicon" "^0.4.5" 82 82 "@atproto/syntax" "^0.3.1" 83 - "@atproto/xrpc" "^0.6.5" 83 + "@atproto/xrpc" "^0.6.6" 84 84 await-lock "^2.2.2" 85 85 multiformats "^9.9.0" 86 86 tlds "^1.234.0" ··· 169 169 uint8arrays "3.0.0" 170 170 zod "^3.23.8" 171 171 172 + "@atproto/common-web@^0.3.2": 173 + version "0.3.2" 174 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.2.tgz#4cf78ad4d24fed801882f3d35afc39bceccdff51" 175 + integrity sha512-Vx0JtL1/CssJbFAb0UOdvTrkbUautsDfHNOXNTcX2vyPIxH9xOameSqLLunM1hZnOQbJwyjmQCt6TV+bhnanDg== 176 + dependencies: 177 + graphemer "^1.4.0" 178 + multiformats "^9.9.0" 179 + uint8arrays "3.0.0" 180 + zod "^3.23.8" 181 + 172 182 "@atproto/common@0.1.0": 173 183 version "0.1.0" 174 184 resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" ··· 278 288 integrity sha512-QFEmr3rpj/RoAmfX9ALU/asBG/rsVtQZnw+9nOB1/AuIwoxXd+ZyndR6lVUc2+DL4GEjl6W2yvBru5xbQIZWyA== 279 289 dependencies: 280 290 "@atproto/common-web" "^0.3.1" 291 + "@atproto/syntax" "^0.3.1" 292 + iso-datestring-validator "^2.2.2" 293 + multiformats "^9.9.0" 294 + zod "^3.23.8" 295 + 296 + "@atproto/lexicon@^0.4.5": 297 + version "0.4.5" 298 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.5.tgz#4fcf3731193c674286e9e8d677bbab5dd530b817" 299 + integrity sha512-fljWqMGKn+XWtTprBcS3F1hGBREnQYh6qYHv2sjENucc7REms1gtmZXSerB9N6pVeHVNOnXiILdukeAcic5OEw== 300 + dependencies: 301 + "@atproto/common-web" "^0.3.2" 281 302 "@atproto/syntax" "^0.3.1" 282 303 iso-datestring-validator "^2.2.2" 283 304 multiformats "^9.9.0" ··· 450 471 integrity sha512-t6u8iPEVbWge5RhzKZDahSzNDYIAxUtop6Q/X/apAZY1rgreVU0/1sSvvRoRFH19d3UIKjYdLuwFqMi9w8nY3Q== 451 472 dependencies: 452 473 "@atproto/lexicon" "^0.4.4" 474 + zod "^3.23.8" 475 + 476 + "@atproto/xrpc@^0.6.6": 477 + version "0.6.6" 478 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.6.tgz#28f58270ef4a8056f7f718bd52512e74bcd3702f" 479 + integrity sha512-umXEYVMo9/pyIBoKmIAIi64RXDW9tSXY+wqztlQ6I2GZtjLfNZqmAWU+wADk3SxUe54mvjxxGyA4TtyGtDMfhA== 480 + dependencies: 481 + "@atproto/lexicon" "^0.4.5" 453 482 zod "^3.23.8" 454 483 455 484 "@aws-crypto/crc32@3.0.0":