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>
This commit is contained in:
Alex Benzer
2025-12-04 15:39:56 -08:00
committed by GitHub
parent 55eb6c5628
commit bc39b553f9
10 changed files with 308 additions and 2 deletions
+1
View File
@@ -9,5 +9,6 @@ export type Gate =
| 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks'
| 'remove_show_latest_button'
| 'show_composer_prompt'
| 'test_gate_1'
| 'test_gate_2'
+4
View File
@@ -203,6 +203,10 @@ export type MetricEvents = {
'composer:gif:open': {}
'composer:gif:select': {}
'postComposer:click': {}
'composerPrompt:press': {}
'composerPrompt:camera:press': {}
'composerPrompt:gallery:press': {}
'composer:threadgate:open': {
nudged: boolean
+1
View File
@@ -43,6 +43,7 @@ export interface ComposerOpts {
text?: string
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number}
openGallery?: boolean
}
type StateContext = ComposerOpts | undefined
+5
View File
@@ -172,6 +172,7 @@ export const ComposePost = ({
text: initText,
imageUris: initImageUris,
videoUri: initVideoUri,
openGallery,
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef | null>
@@ -721,6 +722,7 @@ export const ComposePost = ({
}}
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
openGallery={openGallery}
/>
</>
)
@@ -1334,6 +1336,7 @@ function ComposerFooter({
onAddPost,
currentLanguages,
onSelectLanguage,
openGallery,
}: {
post: PostDraft
dispatch: (action: PostAction) => void
@@ -1344,6 +1347,7 @@ function ComposerFooter({
onAddPost: () => void
currentLanguages: string[]
onSelectLanguage?: (language: string) => void
openGallery?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -1463,6 +1467,7 @@ function ComposerFooter({
allowedAssetTypes={selectedAssetsType}
selectedAssetsCount={selectedAssetsCount}
onSelectAssets={onSelectAssets}
autoOpen={openGallery}
/>
<OpenCameraBtn
disabled={media?.type === 'images' ? isMaxImages : !!media}
+14 -1
View File
@@ -1,4 +1,4 @@
import {useCallback} from 'react'
import {useCallback, useEffect, useRef} from 'react'
import {Keyboard} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {msg, plural} from '@lingui/macro'
@@ -31,6 +31,10 @@ export type SelectMediaButtonProps = {
assets: ImagePickerAsset[]
errors: string[]
}) => void
/**
* If true, automatically open the media picker when the component mounts.
*/
autoOpen?: boolean
}
/**
@@ -358,12 +362,14 @@ export function SelectMediaButton({
allowedAssetTypes,
selectedAssetsCount,
onSelectAssets,
autoOpen,
}: SelectMediaButtonProps) {
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const t = useTheme()
const hasAutoOpened = useRef(false)
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
@@ -460,6 +466,13 @@ export function SelectMediaButton({
selectionCountRemaining,
])
useEffect(() => {
if (autoOpen && !hasAutoOpened.current && !disabled) {
hasAutoOpened.current = true
onPressSelectMedia()
}
}, [autoOpen, disabled, onPressSelectMedia])
return (
<Button
testID="openMediaBtn"
+247
View File
@@ -0,0 +1,247 @@
import React, {useCallback, useState} from 'react'
import {Keyboard, Pressable, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {
useCameraPermission,
usePhotoLibraryPermission,
useVideoLibraryPermission,
} from '#/lib/hooks/usePermissions'
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
export function ComposerPrompt() {
const {_} = useLingui()
const t = useTheme()
const {openComposer} = useOpenComposer()
const profile = useCurrentAccountProfile()
const [hover, setHover] = useState(false)
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const onPress = React.useCallback(() => {
logger.metric('composerPrompt:press', {})
openComposer({})
}, [openComposer])
const onPressImage = useCallback(async () => {
logger.metric('composerPrompt:gallery:press', {})
// On web, open the composer with the gallery picker auto-opening
if (!isNative) {
openComposer({openGallery: true})
return
}
try {
const [photoAccess, videoAccess] = await Promise.all([
requestPhotoAccessIfNeeded(),
requestVideoAccessIfNeeded(),
])
if (!photoAccess && !videoAccess) {
return
}
if (Keyboard.isVisible()) {
Keyboard.dismiss()
}
const selectionCountRemaining = MAX_IMAGES
const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}),
)
if (canceled) {
return
}
if (assets.length > 0) {
const imageUris = assets
.filter(asset => asset.mimeType?.startsWith('image/'))
.slice(0, MAX_IMAGES)
.map(asset => ({
uri: asset.uri,
width: asset.width,
height: asset.height,
}))
if (imageUris.length > 0) {
openComposer({imageUris})
}
}
} catch (err: any) {
if (!String(err).toLowerCase().includes('cancel')) {
logger.warn('Error opening image picker', {error: err})
}
}
}, [
openComposer,
requestPhotoAccessIfNeeded,
requestVideoAccessIfNeeded,
sheetWrapper,
])
const onPressCamera = useCallback(async () => {
logger.metric('composerPrompt:camera:press', {})
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
const image = await openCamera({
mediaTypes: 'images',
})
const imageUris = [
{
uri: image.path,
width: image.width,
height: image.height,
},
]
openComposer({
imageUris: isNative ? imageUris : undefined,
})
} catch (err: any) {
if (!String(err).toLowerCase().includes('cancel')) {
logger.warn('Error opening camera', {error: err})
}
}
}, [openComposer, requestCameraAccessIfNeeded])
if (!profile) {
return null
}
return (
<Pressable
onPress={onPress}
android_ripple={null}
accessibilityRole="button"
accessibilityLabel={_(msg`Compose new post`)}
accessibilityHint={_(msg`Opens the post composer`)}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
style={({pressed}) => [
a.relative,
a.flex_row,
a.align_start,
a.border_t,
t.atoms.border_contrast_low,
{
paddingLeft: 18,
paddingRight: 15,
},
a.py_md,
native({
paddingTop: 10,
paddingBottom: 10,
}),
web({
cursor: 'pointer',
outline: 'none',
}),
pressed && web({outline: 'none'}),
]}>
<SubtleHover hover={hover} />
<UserAvatar
avatar={profile.avatar}
size={40}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1, a.ml_md, a.flex_row, a.align_center, a.gap_xs]}>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.rounded_full,
t.atoms.bg_contrast_50,
{
height: 40,
},
]}>
<Text
style={[
t.atoms.text_contrast_low,
a.text_md,
a.pl_xs,
{
includeFontPadding: false,
},
]}>
{_(msg`What's up?`)}
</Text>
<View style={[a.flex_row, a.gap_md, a.mr_xs]}>
{isNative && (
<Button
onPress={e => {
e.stopPropagation()
onPressCamera()
}}
label={_(msg`Open camera`)}
accessibilityHint={_(msg`Opens device camera`)}
variant="ghost"
shape="round">
{({hovered}) => (
<CameraIcon
size="md"
style={{
color: hovered
? t.palette.primary_500
: t.palette.contrast_300,
}}
/>
)}
</Button>
)}
<Button
onPress={e => {
e.stopPropagation()
onPressImage()
}}
label={_(msg`Add image`)}
accessibilityHint={_(msg`Opens image picker`)}
variant="ghost"
shape="round">
{({hovered}) => (
<ImageIcon
size="md"
style={{
color: hovered
? t.palette.primary_500
: t.palette.contrast_300,
}}
/>
)}
</Button>
</View>
</View>
</View>
</Pressable>
)
}
+33 -1
View File
@@ -31,7 +31,7 @@ import {isStatusStillActive, validateStatus} from '#/lib/actor-status'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logEvent} from '#/lib/statsig/statsig'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isIOS, isNative, isWeb} from '#/platform/detection'
@@ -70,6 +70,7 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {ComposerPrompt} from '../feeds/ComposerPrompt'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg'
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
@@ -150,6 +151,10 @@ type FeedRow =
type: 'ageAssuranceBanner'
key: string
}
| {
type: 'composerPrompt'
key: string
}
export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem
@@ -225,6 +230,7 @@ let PostFeed = ({
const {_} = useLingui()
const queryClient = useQueryClient()
const {currentAccount, hasSession} = useSession()
const gate = useGate()
const initialNumToRender = useInitialNumToRender()
const feedFeedback = useFeedFeedbackContext()
const [isPTRing, setIsPTRing] = useState(false)
@@ -511,6 +517,18 @@ let PostFeed = ({
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
})
}
// Show composer prompt for Discover and Following feeds
if (
hasSession &&
(feedUriOrActorDid === DISCOVER_FEED_URI ||
feed === 'following') &&
gate('show_composer_prompt')
) {
arr.push({
type: 'composerPrompt',
key: 'composerPrompt-' + sliceIndex,
})
}
} else if (sliceIndex === 15) {
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
arr.push({
@@ -524,6 +542,16 @@ let PostFeed = ({
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
})
}
} else if (feedKind === 'following') {
if (sliceIndex === 0) {
// Show composer prompt for Following feed
if (hasSession && gate('show_composer_prompt')) {
arr.push({
type: 'composerPrompt',
key: 'composerPrompt-' + sliceIndex,
})
}
}
} else if (feedKind === 'profile') {
if (sliceIndex === 5) {
arr.push({
@@ -638,6 +666,7 @@ let PostFeed = ({
isEmpty,
lastFetchedAt,
data,
feed,
feedType,
feedUriOrActorDid,
feedTab,
@@ -652,6 +681,7 @@ let PostFeed = ({
hasPressedShowLessUris,
ageAssuranceBannerState,
isCurrentFeedAtStartupSelected,
gate,
blockedOrMutedAuthors,
])
@@ -743,6 +773,8 @@ let PostFeed = ({
return <AgeAssuranceDismissibleFeedBanner />
} else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial />
} else if (row.type === 'composerPrompt') {
return <ComposerPrompt />
} else if (row.type === 'interstitialTrendingVideos') {
return <TrendingVideosInterstitial />
} else if (row.type === 'fallbackMarker') {
+1
View File
@@ -45,6 +45,7 @@ export function Composer({}: {winHeight: number}) {
text={state?.text}
imageUris={state?.imageUris}
videoUri={state?.videoUri}
openGallery={state?.openGallery}
/>
</TooltipSheetCompatProvider>
</View>
+1
View File
@@ -55,6 +55,7 @@ export function Composer({winHeight}: {winHeight: number}) {
text={state.text}
imageUris={state.imageUris}
videoUri={state.videoUri}
openGallery={state.openGallery}
/>
</Animated.View>
)
+1
View File
@@ -110,6 +110,7 @@ function Inner({state}: {state: ComposerOpts}) {
openEmojiPicker={onOpenPicker}
text={state.text}
imageUris={state.imageUris}
openGallery={state.openGallery}
/>
</View>
<EmojiPicker state={pickerState} close={onClosePicker} />