From 9f72a22df3905b6d5a6e9e3e8ea57d4ed05f800c Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Tue, 25 Nov 2025 16:56:52 -0800 Subject: [PATCH] Added image upload button --- src/view/com/feeds/FeedComposerPrompt.tsx | 240 +++++++++++++++++++--- src/view/com/posts/PostFeed.tsx | 10 + 2 files changed, 227 insertions(+), 23 deletions(-) diff --git a/src/view/com/feeds/FeedComposerPrompt.tsx b/src/view/com/feeds/FeedComposerPrompt.tsx index 16f41a7d4b..12cf1568f5 100644 --- a/src/view/com/feeds/FeedComposerPrompt.tsx +++ b/src/view/com/feeds/FeedComposerPrompt.tsx @@ -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 => 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 ( - - - - {displayName} - - - {handle} - - + @@ -108,6 +267,41 @@ export function FeedComposerPrompt() { ]}> {_(msg`What's up?`)} + + {isNative && ( + + )} + + diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 094a0365a3..ffcb512290 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -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({