Bluesky app fork with some witchin' additions 💫

Adds a composer prompt on the home screen to invite users to post (#9464)

* Add a composer prompt on home

* Added image upload button

* Feed composer buttons

* Feed composer styles

* Add client events, auto-open gallery when button is pressed

* Add feature gate

* Move gate check to last

* Small style cleanups

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by Alex Benzer Eric Bailey and committed by GitHub bc39b553 55eb6c56

Changed files
+308 -2
src
+1
src/lib/statsig/gates.ts
··· 9 | 'onboarding_add_video_feed' 10 | 'onboarding_suggested_starterpacks' 11 | 'remove_show_latest_button' 12 | 'test_gate_1' 13 | 'test_gate_2'
··· 9 | 'onboarding_add_video_feed' 10 | 'onboarding_suggested_starterpacks' 11 | 'remove_show_latest_button' 12 + | 'show_composer_prompt' 13 | 'test_gate_1' 14 | 'test_gate_2'
+4
src/logger/metrics.ts
··· 203 204 'composer:gif:open': {} 205 'composer:gif:select': {} 206 207 'composer:threadgate:open': { 208 nudged: boolean
··· 203 204 'composer:gif:open': {} 205 'composer:gif:select': {} 206 + 'postComposer:click': {} 207 + 'composerPrompt:press': {} 208 + 'composerPrompt:camera:press': {} 209 + 'composerPrompt:gallery:press': {} 210 211 'composer:threadgate:open': { 212 nudged: boolean
+1
src/state/shell/composer/index.tsx
··· 43 text?: string 44 imageUris?: {uri: string; width: number; height: number; altText?: string}[] 45 videoUri?: {uri: string; width: number; height: number} 46 } 47 48 type StateContext = ComposerOpts | undefined
··· 43 text?: string 44 imageUris?: {uri: string; width: number; height: number; altText?: string}[] 45 videoUri?: {uri: string; width: number; height: number} 46 + openGallery?: boolean 47 } 48 49 type StateContext = ComposerOpts | undefined
+5
src/view/com/composer/Composer.tsx
··· 172 text: initText, 173 imageUris: initImageUris, 174 videoUri: initVideoUri, 175 cancelRef, 176 }: Props & { 177 cancelRef?: React.RefObject<CancelRef | null> ··· 721 }} 722 currentLanguages={currentLanguages} 723 onSelectLanguage={onSelectLanguage} 724 /> 725 </> 726 ) ··· 1334 onAddPost, 1335 currentLanguages, 1336 onSelectLanguage, 1337 }: { 1338 post: PostDraft 1339 dispatch: (action: PostAction) => void ··· 1344 onAddPost: () => void 1345 currentLanguages: string[] 1346 onSelectLanguage?: (language: string) => void 1347 }) { 1348 const t = useTheme() 1349 const {_} = useLingui() ··· 1463 allowedAssetTypes={selectedAssetsType} 1464 selectedAssetsCount={selectedAssetsCount} 1465 onSelectAssets={onSelectAssets} 1466 /> 1467 <OpenCameraBtn 1468 disabled={media?.type === 'images' ? isMaxImages : !!media}
··· 172 text: initText, 173 imageUris: initImageUris, 174 videoUri: initVideoUri, 175 + openGallery, 176 cancelRef, 177 }: Props & { 178 cancelRef?: React.RefObject<CancelRef | null> ··· 722 }} 723 currentLanguages={currentLanguages} 724 onSelectLanguage={onSelectLanguage} 725 + openGallery={openGallery} 726 /> 727 </> 728 ) ··· 1336 onAddPost, 1337 currentLanguages, 1338 onSelectLanguage, 1339 + openGallery, 1340 }: { 1341 post: PostDraft 1342 dispatch: (action: PostAction) => void ··· 1347 onAddPost: () => void 1348 currentLanguages: string[] 1349 onSelectLanguage?: (language: string) => void 1350 + openGallery?: boolean 1351 }) { 1352 const t = useTheme() 1353 const {_} = useLingui() ··· 1467 allowedAssetTypes={selectedAssetsType} 1468 selectedAssetsCount={selectedAssetsCount} 1469 onSelectAssets={onSelectAssets} 1470 + autoOpen={openGallery} 1471 /> 1472 <OpenCameraBtn 1473 disabled={media?.type === 'images' ? isMaxImages : !!media}
+14 -1
src/view/com/composer/SelectMediaButton.tsx
··· 1 - import {useCallback} from 'react' 2 import {Keyboard} from 'react-native' 3 import {type ImagePickerAsset} from 'expo-image-picker' 4 import {msg, plural} from '@lingui/macro' ··· 31 assets: ImagePickerAsset[] 32 errors: string[] 33 }) => void 34 } 35 36 /** ··· 358 allowedAssetTypes, 359 selectedAssetsCount, 360 onSelectAssets, 361 }: SelectMediaButtonProps) { 362 const {_} = useLingui() 363 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 364 const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 365 const sheetWrapper = useSheetWrapper() 366 const t = useTheme() 367 368 const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount 369 ··· 459 processSelectedAssets, 460 selectionCountRemaining, 461 ]) 462 463 return ( 464 <Button
··· 1 + import {useCallback, useEffect, useRef} from 'react' 2 import {Keyboard} from 'react-native' 3 import {type ImagePickerAsset} from 'expo-image-picker' 4 import {msg, plural} from '@lingui/macro' ··· 31 assets: ImagePickerAsset[] 32 errors: string[] 33 }) => void 34 + /** 35 + * If true, automatically open the media picker when the component mounts. 36 + */ 37 + autoOpen?: boolean 38 } 39 40 /** ··· 362 allowedAssetTypes, 363 selectedAssetsCount, 364 onSelectAssets, 365 + autoOpen, 366 }: SelectMediaButtonProps) { 367 const {_} = useLingui() 368 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 369 const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 370 const sheetWrapper = useSheetWrapper() 371 const t = useTheme() 372 + const hasAutoOpened = useRef(false) 373 374 const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount 375 ··· 465 processSelectedAssets, 466 selectionCountRemaining, 467 ]) 468 + 469 + useEffect(() => { 470 + if (autoOpen && !hasAutoOpened.current && !disabled) { 471 + hasAutoOpened.current = true 472 + onPressSelectMedia() 473 + } 474 + }, [autoOpen, disabled, onPressSelectMedia]) 475 476 return ( 477 <Button
+247
src/view/com/feeds/ComposerPrompt.tsx
···
··· 1 + import React, {useCallback, useState} from 'react' 2 + import {Keyboard, Pressable, View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 + import { 8 + useCameraPermission, 9 + usePhotoLibraryPermission, 10 + useVideoLibraryPermission, 11 + } from '#/lib/hooks/usePermissions' 12 + import {openCamera, openUnifiedPicker} from '#/lib/media/picker' 13 + import {logger} from '#/logger' 14 + import {isNative} from '#/platform/detection' 15 + import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 16 + import {MAX_IMAGES} from '#/view/com/composer/state/composer' 17 + import {UserAvatar} from '#/view/com/util/UserAvatar' 18 + import {atoms as a, native, useTheme, web} from '#/alf' 19 + import {Button} from '#/components/Button' 20 + import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 21 + import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera' 22 + import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 23 + import {SubtleHover} from '#/components/SubtleHover' 24 + import {Text} from '#/components/Typography' 25 + 26 + export function ComposerPrompt() { 27 + const {_} = useLingui() 28 + const t = useTheme() 29 + const {openComposer} = useOpenComposer() 30 + const profile = useCurrentAccountProfile() 31 + const [hover, setHover] = useState(false) 32 + const {requestCameraAccessIfNeeded} = useCameraPermission() 33 + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 34 + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 35 + const sheetWrapper = useSheetWrapper() 36 + 37 + const onPress = React.useCallback(() => { 38 + logger.metric('composerPrompt:press', {}) 39 + openComposer({}) 40 + }, [openComposer]) 41 + 42 + const onPressImage = useCallback(async () => { 43 + logger.metric('composerPrompt:gallery:press', {}) 44 + 45 + // On web, open the composer with the gallery picker auto-opening 46 + if (!isNative) { 47 + openComposer({openGallery: true}) 48 + return 49 + } 50 + 51 + try { 52 + const [photoAccess, videoAccess] = await Promise.all([ 53 + requestPhotoAccessIfNeeded(), 54 + requestVideoAccessIfNeeded(), 55 + ]) 56 + 57 + if (!photoAccess && !videoAccess) { 58 + return 59 + } 60 + 61 + if (Keyboard.isVisible()) { 62 + Keyboard.dismiss() 63 + } 64 + 65 + const selectionCountRemaining = MAX_IMAGES 66 + const {assets, canceled} = await sheetWrapper( 67 + openUnifiedPicker({selectionCountRemaining}), 68 + ) 69 + 70 + if (canceled) { 71 + return 72 + } 73 + 74 + if (assets.length > 0) { 75 + const imageUris = assets 76 + .filter(asset => asset.mimeType?.startsWith('image/')) 77 + .slice(0, MAX_IMAGES) 78 + .map(asset => ({ 79 + uri: asset.uri, 80 + width: asset.width, 81 + height: asset.height, 82 + })) 83 + 84 + if (imageUris.length > 0) { 85 + openComposer({imageUris}) 86 + } 87 + } 88 + } catch (err: any) { 89 + if (!String(err).toLowerCase().includes('cancel')) { 90 + logger.warn('Error opening image picker', {error: err}) 91 + } 92 + } 93 + }, [ 94 + openComposer, 95 + requestPhotoAccessIfNeeded, 96 + requestVideoAccessIfNeeded, 97 + sheetWrapper, 98 + ]) 99 + 100 + const onPressCamera = useCallback(async () => { 101 + logger.metric('composerPrompt:camera:press', {}) 102 + 103 + try { 104 + if (!(await requestCameraAccessIfNeeded())) { 105 + return 106 + } 107 + 108 + if (isNative && Keyboard.isVisible()) { 109 + Keyboard.dismiss() 110 + } 111 + 112 + const image = await openCamera({ 113 + mediaTypes: 'images', 114 + }) 115 + 116 + const imageUris = [ 117 + { 118 + uri: image.path, 119 + width: image.width, 120 + height: image.height, 121 + }, 122 + ] 123 + 124 + openComposer({ 125 + imageUris: isNative ? imageUris : undefined, 126 + }) 127 + } catch (err: any) { 128 + if (!String(err).toLowerCase().includes('cancel')) { 129 + logger.warn('Error opening camera', {error: err}) 130 + } 131 + } 132 + }, [openComposer, requestCameraAccessIfNeeded]) 133 + 134 + if (!profile) { 135 + return null 136 + } 137 + 138 + return ( 139 + <Pressable 140 + onPress={onPress} 141 + android_ripple={null} 142 + accessibilityRole="button" 143 + accessibilityLabel={_(msg`Compose new post`)} 144 + accessibilityHint={_(msg`Opens the post composer`)} 145 + onPointerEnter={() => setHover(true)} 146 + onPointerLeave={() => setHover(false)} 147 + style={({pressed}) => [ 148 + a.relative, 149 + a.flex_row, 150 + a.align_start, 151 + a.border_t, 152 + t.atoms.border_contrast_low, 153 + { 154 + paddingLeft: 18, 155 + paddingRight: 15, 156 + }, 157 + a.py_md, 158 + native({ 159 + paddingTop: 10, 160 + paddingBottom: 10, 161 + }), 162 + web({ 163 + cursor: 'pointer', 164 + outline: 'none', 165 + }), 166 + pressed && web({outline: 'none'}), 167 + ]}> 168 + <SubtleHover hover={hover} /> 169 + <UserAvatar 170 + avatar={profile.avatar} 171 + size={40} 172 + type={profile.associated?.labeler ? 'labeler' : 'user'} 173 + /> 174 + <View style={[a.flex_1, a.ml_md, a.flex_row, a.align_center, a.gap_xs]}> 175 + <View 176 + style={[ 177 + a.flex_1, 178 + a.flex_row, 179 + a.align_center, 180 + a.justify_between, 181 + a.px_md, 182 + a.rounded_full, 183 + t.atoms.bg_contrast_50, 184 + { 185 + height: 40, 186 + }, 187 + ]}> 188 + <Text 189 + style={[ 190 + t.atoms.text_contrast_low, 191 + a.text_md, 192 + a.pl_xs, 193 + { 194 + includeFontPadding: false, 195 + }, 196 + ]}> 197 + {_(msg`What's up?`)} 198 + </Text> 199 + <View style={[a.flex_row, a.gap_md, a.mr_xs]}> 200 + {isNative && ( 201 + <Button 202 + onPress={e => { 203 + e.stopPropagation() 204 + onPressCamera() 205 + }} 206 + label={_(msg`Open camera`)} 207 + accessibilityHint={_(msg`Opens device camera`)} 208 + variant="ghost" 209 + shape="round"> 210 + {({hovered}) => ( 211 + <CameraIcon 212 + size="md" 213 + style={{ 214 + color: hovered 215 + ? t.palette.primary_500 216 + : t.palette.contrast_300, 217 + }} 218 + /> 219 + )} 220 + </Button> 221 + )} 222 + <Button 223 + onPress={e => { 224 + e.stopPropagation() 225 + onPressImage() 226 + }} 227 + label={_(msg`Add image`)} 228 + accessibilityHint={_(msg`Opens image picker`)} 229 + variant="ghost" 230 + shape="round"> 231 + {({hovered}) => ( 232 + <ImageIcon 233 + size="md" 234 + style={{ 235 + color: hovered 236 + ? t.palette.primary_500 237 + : t.palette.contrast_300, 238 + }} 239 + /> 240 + )} 241 + </Button> 242 + </View> 243 + </View> 244 + </View> 245 + </Pressable> 246 + ) 247 + }
+33 -1
src/view/com/posts/PostFeed.tsx
··· 31 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 32 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 33 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 34 - import {logEvent} from '#/lib/statsig/statsig' 35 import {isNetworkError} from '#/lib/strings/errors' 36 import {logger} from '#/logger' 37 import {isIOS, isNative, isWeb} from '#/platform/detection' ··· 70 } from '#/components/feeds/PostFeedVideoGridRow' 71 import {TrendingInterstitial} from '#/components/interstitials/Trending' 72 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 73 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 74 import {FeedShutdownMsg} from './FeedShutdownMsg' 75 import {PostFeedErrorMessage} from './PostFeedErrorMessage' ··· 150 type: 'ageAssuranceBanner' 151 key: string 152 } 153 154 export function getItemsForFeedback(feedRow: FeedRow): { 155 item: FeedPostSliceItem ··· 225 const {_} = useLingui() 226 const queryClient = useQueryClient() 227 const {currentAccount, hasSession} = useSession() 228 const initialNumToRender = useInitialNumToRender() 229 const feedFeedback = useFeedFeedbackContext() 230 const [isPTRing, setIsPTRing] = useState(false) ··· 511 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, 512 }) 513 } 514 } else if (sliceIndex === 15) { 515 if (areVideoFeedsEnabled && !trendingVideoDisabled) { 516 arr.push({ ··· 524 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 525 }) 526 } 527 } else if (feedKind === 'profile') { 528 if (sliceIndex === 5) { 529 arr.push({ ··· 638 isEmpty, 639 lastFetchedAt, 640 data, 641 feedType, 642 feedUriOrActorDid, 643 feedTab, ··· 652 hasPressedShowLessUris, 653 ageAssuranceBannerState, 654 isCurrentFeedAtStartupSelected, 655 blockedOrMutedAuthors, 656 ]) 657 ··· 743 return <AgeAssuranceDismissibleFeedBanner /> 744 } else if (row.type === 'interstitialTrending') { 745 return <TrendingInterstitial /> 746 } else if (row.type === 'interstitialTrendingVideos') { 747 return <TrendingVideosInterstitial /> 748 } else if (row.type === 'fallbackMarker') {
··· 31 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 32 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 33 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 34 + import {logEvent, useGate} from '#/lib/statsig/statsig' 35 import {isNetworkError} from '#/lib/strings/errors' 36 import {logger} from '#/logger' 37 import {isIOS, isNative, isWeb} from '#/platform/detection' ··· 70 } from '#/components/feeds/PostFeedVideoGridRow' 71 import {TrendingInterstitial} from '#/components/interstitials/Trending' 72 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 73 + import {ComposerPrompt} from '../feeds/ComposerPrompt' 74 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 75 import {FeedShutdownMsg} from './FeedShutdownMsg' 76 import {PostFeedErrorMessage} from './PostFeedErrorMessage' ··· 151 type: 'ageAssuranceBanner' 152 key: string 153 } 154 + | { 155 + type: 'composerPrompt' 156 + key: string 157 + } 158 159 export function getItemsForFeedback(feedRow: FeedRow): { 160 item: FeedPostSliceItem ··· 230 const {_} = useLingui() 231 const queryClient = useQueryClient() 232 const {currentAccount, hasSession} = useSession() 233 + const gate = useGate() 234 const initialNumToRender = useInitialNumToRender() 235 const feedFeedback = useFeedFeedbackContext() 236 const [isPTRing, setIsPTRing] = useState(false) ··· 517 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, 518 }) 519 } 520 + // Show composer prompt for Discover and Following feeds 521 + if ( 522 + hasSession && 523 + (feedUriOrActorDid === DISCOVER_FEED_URI || 524 + feed === 'following') && 525 + gate('show_composer_prompt') 526 + ) { 527 + arr.push({ 528 + type: 'composerPrompt', 529 + key: 'composerPrompt-' + sliceIndex, 530 + }) 531 + } 532 } else if (sliceIndex === 15) { 533 if (areVideoFeedsEnabled && !trendingVideoDisabled) { 534 arr.push({ ··· 542 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 543 }) 544 } 545 + } else if (feedKind === 'following') { 546 + if (sliceIndex === 0) { 547 + // Show composer prompt for Following feed 548 + if (hasSession && gate('show_composer_prompt')) { 549 + arr.push({ 550 + type: 'composerPrompt', 551 + key: 'composerPrompt-' + sliceIndex, 552 + }) 553 + } 554 + } 555 } else if (feedKind === 'profile') { 556 if (sliceIndex === 5) { 557 arr.push({ ··· 666 isEmpty, 667 lastFetchedAt, 668 data, 669 + feed, 670 feedType, 671 feedUriOrActorDid, 672 feedTab, ··· 681 hasPressedShowLessUris, 682 ageAssuranceBannerState, 683 isCurrentFeedAtStartupSelected, 684 + gate, 685 blockedOrMutedAuthors, 686 ]) 687 ··· 773 return <AgeAssuranceDismissibleFeedBanner /> 774 } else if (row.type === 'interstitialTrending') { 775 return <TrendingInterstitial /> 776 + } else if (row.type === 'composerPrompt') { 777 + return <ComposerPrompt /> 778 } else if (row.type === 'interstitialTrendingVideos') { 779 return <TrendingVideosInterstitial /> 780 } else if (row.type === 'fallbackMarker') {
+1
src/view/shell/Composer.ios.tsx
··· 45 text={state?.text} 46 imageUris={state?.imageUris} 47 videoUri={state?.videoUri} 48 /> 49 </TooltipSheetCompatProvider> 50 </View>
··· 45 text={state?.text} 46 imageUris={state?.imageUris} 47 videoUri={state?.videoUri} 48 + openGallery={state?.openGallery} 49 /> 50 </TooltipSheetCompatProvider> 51 </View>
+1
src/view/shell/Composer.tsx
··· 55 text={state.text} 56 imageUris={state.imageUris} 57 videoUri={state.videoUri} 58 /> 59 </Animated.View> 60 )
··· 55 text={state.text} 56 imageUris={state.imageUris} 57 videoUri={state.videoUri} 58 + openGallery={state.openGallery} 59 /> 60 </Animated.View> 61 )
+1
src/view/shell/Composer.web.tsx
··· 110 openEmojiPicker={onOpenPicker} 111 text={state.text} 112 imageUris={state.imageUris} 113 /> 114 </View> 115 <EmojiPicker state={pickerState} close={onClosePicker} />
··· 110 openEmojiPicker={onOpenPicker} 111 text={state.text} 112 imageUris={state.imageUris} 113 + openGallery={state.openGallery} 114 /> 115 </View> 116 <EmojiPicker state={pickerState} close={onClosePicker} />