Added image upload button

This commit is contained in:
Alex Benzer
2025-11-25 16:56:52 -08:00
parent 91297e2ac7
commit 9f72a22df3
2 changed files with 227 additions and 23 deletions
+217 -23
View File
@@ -1,15 +1,27 @@
import React, {useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import React, {useCallback, useState} from 'react'
import {Keyboard, Pressable, StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
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 {createComposerImage} from '#/state/gallery'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {useComposerControls} from '#/state/shell/composer'
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'
@@ -17,23 +29,181 @@ export function FeedComposerPrompt() {
const {_} = useLingui()
const t = useTheme()
const {openComposer} = useOpenComposer()
const {closeComposer} = useComposerControls()
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('postComposer:click', {})
openComposer({})
}, [openComposer])
const onPressImage = useCallback(async () => {
logger.metric('postComposer:click', {})
// Open the composer first so it's ready
openComposer({})
// Use a small delay to ensure composer starts rendering before opening picker
setTimeout(async () => {
try {
if (isNative) {
const [photoAccess, videoAccess] = await Promise.all([
requestPhotoAccessIfNeeded(),
requestVideoAccessIfNeeded(),
])
if (!photoAccess && !videoAccess) {
return
}
}
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
const selectionCountRemaining = MAX_IMAGES
const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}),
)
if (canceled) return
if (assets.length > 0) {
// Process images and add them to the composer
const images = await Promise.all(
assets
.filter(asset => asset.mimeType?.startsWith('image/'))
.slice(0, MAX_IMAGES)
.map(async image => {
try {
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(
(img): img is NonNullable<typeof img> => img !== null,
)
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)
}, [
openComposer,
closeComposer,
requestPhotoAccessIfNeeded,
requestVideoAccessIfNeeded,
sheetWrapper,
])
const onPressCamera = useCallback(async () => {
logger.metric('postComposer:click', {})
// Open the composer first so it's ready
openComposer({})
// 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 (!profile) {
return null
}
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<Pressable
onPress={onPress}
@@ -72,28 +242,17 @@ export function FeedComposerPrompt() {
size={40}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1, a.ml_md]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.mb_sm]}>
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}>
{displayName}
</Text>
<Text
style={[a.text_md, t.atoms.text_contrast_medium, a.flex_shrink]}
numberOfLines={1}>
{handle}
</Text>
</View>
<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.px_md,
{
height: 40,
borderRadius: 20,
justifyContent: 'space-between',
},
t.atoms.bg_contrast_50,
]}>
@@ -108,6 +267,41 @@ export function FeedComposerPrompt() {
]}>
{_(msg`What's up?`)}
</Text>
<View style={[a.flex_row, a.gap_xs]}>
{isNative && (
<Button
onPress={e => {
e.stopPropagation()
onPressCamera()
}}
label={_(msg`Open camera`)}
accessibilityHint={_(msg`Opens device camera`)}
variant="ghost"
shape="round">
<CameraIcon size="lg" />
</Button>
)}
<Button
onPress={e => {
e.stopPropagation()
onPressImage()
}}
label={_(msg`Add image`)}
accessibilityHint={_(msg`Opens image picker`)}
variant="ghost"
shape="round">
{({hovered}) => (
<ImageIcon
size="lg"
style={{
color: hovered
? t.palette.primary_500
: t.palette.contrast_400,
}}
/>
)}
</Button>
</View>
</View>
</View>
</Pressable>
+10
View File
@@ -531,6 +531,16 @@ let PostFeed = ({
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
})
}
} else if (feedKind === 'following') {
if (sliceIndex === 0) {
// Add composer prompt for Following feed
if (hasSession) {
arr.push({
type: 'composerPrompt',
key: 'composerPrompt-' + sliceIndex,
})
}
}
} else if (feedKind === 'profile') {
if (sliceIndex === 5) {
arr.push({