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

This commit is contained in:
Alex Benzer
2025-11-29 18:21:51 -08:00
parent 0aab70677b
commit 62b051d244
9 changed files with 101 additions and 145 deletions
+3
View File
@@ -203,6 +203,9 @@ export type MetricEvents = {
'composer:gif:open': {} 'composer:gif:open': {}
'composer:gif:select': {} 'composer:gif:select': {}
'postComposer:click': {} 'postComposer:click': {}
'composerPrompt:press': {}
'composerPrompt:camera:press': {}
'composerPrompt:gallery:press': {}
'composer:threadgate:open': { 'composer:threadgate:open': {
nudged: boolean nudged: boolean
+1
View File
@@ -43,6 +43,7 @@ export interface ComposerOpts {
text?: string text?: string
imageUris?: {uri: string; width: number; height: number; altText?: string}[] imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number} videoUri?: {uri: string; width: number; height: number}
openGallery?: boolean
} }
type StateContext = ComposerOpts | undefined type StateContext = ComposerOpts | undefined
+5
View File
@@ -172,6 +172,7 @@ export const ComposePost = ({
text: initText, text: initText,
imageUris: initImageUris, imageUris: initImageUris,
videoUri: initVideoUri, videoUri: initVideoUri,
openGallery,
cancelRef, cancelRef,
}: Props & { }: Props & {
cancelRef?: React.RefObject<CancelRef | null> cancelRef?: React.RefObject<CancelRef | null>
@@ -721,6 +722,7 @@ export const ComposePost = ({
}} }}
currentLanguages={currentLanguages} currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage} onSelectLanguage={onSelectLanguage}
openGallery={openGallery}
/> />
</> </>
) )
@@ -1334,6 +1336,7 @@ function ComposerFooter({
onAddPost, onAddPost,
currentLanguages, currentLanguages,
onSelectLanguage, onSelectLanguage,
openGallery,
}: { }: {
post: PostDraft post: PostDraft
dispatch: (action: PostAction) => void dispatch: (action: PostAction) => void
@@ -1344,6 +1347,7 @@ function ComposerFooter({
onAddPost: () => void onAddPost: () => void
currentLanguages: string[] currentLanguages: string[]
onSelectLanguage?: (language: string) => void onSelectLanguage?: (language: string) => void
openGallery?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -1463,6 +1467,7 @@ function ComposerFooter({
allowedAssetTypes={selectedAssetsType} allowedAssetTypes={selectedAssetsType}
selectedAssetsCount={selectedAssetsCount} selectedAssetsCount={selectedAssetsCount}
onSelectAssets={onSelectAssets} onSelectAssets={onSelectAssets}
autoOpen={openGallery}
/> />
<OpenCameraBtn <OpenCameraBtn
disabled={media?.type === 'images' ? isMaxImages : !!media} 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 {Keyboard} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {msg, plural} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
@@ -31,6 +31,10 @@ export type SelectMediaButtonProps = {
assets: ImagePickerAsset[] assets: ImagePickerAsset[]
errors: string[] errors: string[]
}) => void }) => void
/**
* If true, automatically open the media picker when the component mounts.
*/
autoOpen?: boolean
} }
/** /**
@@ -358,12 +362,14 @@ export function SelectMediaButton({
allowedAssetTypes, allowedAssetTypes,
selectedAssetsCount, selectedAssetsCount,
onSelectAssets, onSelectAssets,
autoOpen,
}: SelectMediaButtonProps) { }: SelectMediaButtonProps) {
const {_} = useLingui() const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper() const sheetWrapper = useSheetWrapper()
const t = useTheme() const t = useTheme()
const hasAutoOpened = useRef(false)
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
@@ -460,6 +466,13 @@ export function SelectMediaButton({
selectionCountRemaining, selectionCountRemaining,
]) ])
useEffect(() => {
if (autoOpen && !hasAutoOpened.current && !disabled) {
hasAutoOpened.current = true
onPressSelectMedia()
}
}, [autoOpen, disabled, onPressSelectMedia])
return ( return (
<Button <Button
testID="openMediaBtn" testID="openMediaBtn"
@@ -12,9 +12,7 @@ import {
import {openCamera, openUnifiedPicker} from '#/lib/media/picker' import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {createComposerImage} from '#/state/gallery'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {useComposerControls} from '#/state/shell/composer'
import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {MAX_IMAGES} from '#/view/com/composer/state/composer'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
@@ -25,11 +23,10 @@ import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Ima
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function FeedComposerPrompt() { export function ComposerPrompt() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {closeComposer} = useComposerControls()
const profile = useCurrentAccountProfile() const profile = useCurrentAccountProfile()
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -38,167 +35,101 @@ export function FeedComposerPrompt() {
const sheetWrapper = useSheetWrapper() const sheetWrapper = useSheetWrapper()
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
logger.metric('postComposer:click', {}) logger.metric('composerPrompt:press', {})
openComposer({}) openComposer({})
}, [openComposer]) }, [openComposer])
const onPressImage = useCallback(async () => { const onPressImage = useCallback(async () => {
logger.metric('postComposer:click', {}) logger.metric('composerPrompt:gallery:press', {})
// Open the composer first so it's ready // On web, open the composer with the gallery picker auto-opening
openComposer({}) if (!isNative) {
openComposer({openGallery: true})
return
}
// Use a small delay to ensure composer starts rendering before opening picker try {
setTimeout(async () => { const [photoAccess, videoAccess] = await Promise.all([
try { requestPhotoAccessIfNeeded(),
if (isNative) { requestVideoAccessIfNeeded(),
const [photoAccess, videoAccess] = await Promise.all([ ])
requestPhotoAccessIfNeeded(),
requestVideoAccessIfNeeded(),
])
if (!photoAccess && !videoAccess) { if (!photoAccess && !videoAccess) {
return return
} }
}
if (isNative && Keyboard.isVisible()) { if (Keyboard.isVisible()) {
Keyboard.dismiss() Keyboard.dismiss()
} }
const selectionCountRemaining = MAX_IMAGES const selectionCountRemaining = MAX_IMAGES
const {assets, canceled} = await sheetWrapper( const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}), openUnifiedPicker({selectionCountRemaining}),
) )
if (canceled) return if (canceled) {
return
}
if (assets.length > 0) { if (assets.length > 0) {
// Process images and add them to the composer const imageUris = assets
const images = await Promise.all( .filter(asset => asset.mimeType?.startsWith('image/'))
assets .slice(0, MAX_IMAGES)
.filter(asset => asset.mimeType?.startsWith('image/')) .map(asset => ({
.slice(0, MAX_IMAGES) uri: asset.uri,
.map(async image => { width: asset.width,
try { height: asset.height,
return await createComposerImage({ }))
path: image.uri,
width: image.width,
height: image.height,
mime: image.mimeType!,
})
} catch (e) {
logger.error(`createComposerImage failed`, {
safeMessage: e instanceof Error ? e.message : String(e),
})
return null
}
}),
)
const validImages = images.filter( if (imageUris.length > 0) {
(img): img is NonNullable<typeof img> => img !== null, openComposer({imageUris})
)
if (validImages.length > 0) {
// Convert to imageUris format for the composer
// createComposerImage returns ComposerImageWithoutTransformation which always has source
const imageUris = validImages.map(img => {
const source = img.source
return {
uri: source.path,
width: source.width,
height: source.height,
altText: img.alt,
}
})
// Close the composer first (if it was opened), then reopen with images
// This ensures the composer state is reset and can accept the new imageUris
closeComposer()
// Wait a bit for the picker to fully close and composer to close, then reopen with images
// This follows the same pattern as useIntentHandler
setTimeout(() => {
openComposer({
imageUris: isNative ? imageUris : undefined,
})
}, 100)
}
}
} catch (err: any) {
if (!String(err).toLowerCase().includes('cancel')) {
logger.warn('Error opening image picker', {error: err})
} }
} }
}, 100) } catch (err: any) {
if (!String(err).toLowerCase().includes('cancel')) {
logger.warn('Error opening image picker', {error: err})
}
}
}, [ }, [
openComposer, openComposer,
closeComposer,
requestPhotoAccessIfNeeded, requestPhotoAccessIfNeeded,
requestVideoAccessIfNeeded, requestVideoAccessIfNeeded,
sheetWrapper, sheetWrapper,
]) ])
const onPressCamera = useCallback(async () => { const onPressCamera = useCallback(async () => {
logger.metric('postComposer:click', {}) logger.metric('composerPrompt:camera:press', {})
// Open the composer first so it's ready try {
openComposer({}) if (!(await requestCameraAccessIfNeeded())) {
return
// Use a small delay to ensure composer starts rendering before opening camera
setTimeout(async () => {
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
const image = await openCamera({
mediaTypes: 'images',
})
// Process the image
try {
const composerImage = await createComposerImage({
path: image.path,
width: image.width,
height: image.height,
mime: image.mime,
})
// Convert to imageUris format for the composer
const source = composerImage.source
const imageUris = [
{
uri: source.path,
width: source.width,
height: source.height,
altText: composerImage.alt,
},
]
// Close the composer first (if it was opened), then reopen with image
closeComposer()
setTimeout(() => {
openComposer({
imageUris: isNative ? imageUris : undefined,
})
}, 100)
} catch (e) {
logger.error(`createComposerImage failed`, {
safeMessage: e instanceof Error ? e.message : String(e),
})
}
} catch (err: any) {
if (!String(err).toLowerCase().includes('cancel')) {
logger.warn('Error opening camera', {error: err})
}
} }
}, 100)
}, [openComposer, closeComposer, requestCameraAccessIfNeeded]) 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) { if (!profile) {
return null return null
@@ -264,8 +195,8 @@ export function FeedComposerPrompt() {
{ {
lineHeight: a.text_md.fontSize, lineHeight: a.text_md.fontSize,
includeFontPadding: false, includeFontPadding: false,
top: 1,
}, },
native({top: 1}),
]}> ]}>
{_(msg`What's up?`)} {_(msg`What's up?`)}
</Text> </Text>
+2 -2
View File
@@ -69,7 +69,7 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow' } from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {FeedComposerPrompt} from '../feeds/FeedComposerPrompt' import {ComposerPrompt} from '../feeds/ComposerPrompt'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg' import {FeedShutdownMsg} from './FeedShutdownMsg'
import {PostFeedErrorMessage} from './PostFeedErrorMessage' import {PostFeedErrorMessage} from './PostFeedErrorMessage'
@@ -755,7 +755,7 @@ let PostFeed = ({
} else if (row.type === 'interstitialTrending') { } else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial /> return <TrendingInterstitial />
} else if (row.type === 'composerPrompt') { } else if (row.type === 'composerPrompt') {
return <FeedComposerPrompt /> return <ComposerPrompt />
} else if (row.type === 'interstitialTrendingVideos') { } else if (row.type === 'interstitialTrendingVideos') {
return <TrendingVideosInterstitial /> return <TrendingVideosInterstitial />
} else if (row.type === 'fallbackMarker') { } else if (row.type === 'fallbackMarker') {
+1
View File
@@ -45,6 +45,7 @@ export function Composer({}: {winHeight: number}) {
text={state?.text} text={state?.text}
imageUris={state?.imageUris} imageUris={state?.imageUris}
videoUri={state?.videoUri} videoUri={state?.videoUri}
openGallery={state?.openGallery}
/> />
</TooltipSheetCompatProvider> </TooltipSheetCompatProvider>
</View> </View>
+1
View File
@@ -55,6 +55,7 @@ export function Composer({winHeight}: {winHeight: number}) {
text={state.text} text={state.text}
imageUris={state.imageUris} imageUris={state.imageUris}
videoUri={state.videoUri} videoUri={state.videoUri}
openGallery={state.openGallery}
/> />
</Animated.View> </Animated.View>
) )
+1
View File
@@ -110,6 +110,7 @@ function Inner({state}: {state: ComposerOpts}) {
openEmojiPicker={onOpenPicker} openEmojiPicker={onOpenPicker}
text={state.text} text={state.text}
imageUris={state.imageUris} imageUris={state.imageUris}
openGallery={state.openGallery}
/> />
</View> </View>
<EmojiPicker state={pickerState} close={onClosePicker} /> <EmojiPicker state={pickerState} close={onClosePicker} />