Просмотр исходного кода

Neo: start a post from the feed with photos or videos (#5807)

* Neo: start a post from the feed with photos or videos

The feed composer gets a round "Add Photos or Videos" button next to "Create
new …". It opens the photo picker straight away (on web the file dialog needs
the click's user gesture), then opens the post form with the picked files
queued for its media field, which uploads them when it mounts, as if they had
been picked inside the form. The button only shows when the first form has a
`video` (images and videos) or `photo` (images) files field.

The "Create new …" trigger is now a plain Pressable instead of a NeoButton:
text only on phones, fading while pressed, and a capsule on tablet and
desktop, in the muted foreground colour. It is not a form input, so it doesn't
reuse one.

The picker options and permission prompt move from the files field to
lib/media/pick-media.ts so the composer and the field share them;
lib/form/pending-field-assets.ts hands the picked files to the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: say when composer picks aren't supported

- pickLibraryMedia returns how many picks it dropped, and treats a picker
  error as an unsupported pick: on web expo-image-picker rejects the whole
  selection ("Unsupported file type") instead of returning the files, which
  left an unhandled rejection and no feedback.
- The composer opens the form even when every pick was dropped, and the hand-off
  queue carries the count, so the files field shows its own "Some files are not
  supported." message, as a pick inside the form does.
- The files field rendered nothing but the attachment list in list_only mode,
  which is how the feed composer mounts it, so that message never showed in
  the composer at all. It renders its Msg there too.
- Native Msg called Alert.alert during render, so every re-render while the
  message was up (an uploading files field) raised another alert. It alerts
  once per show, from an effect.
- The trigger's and the media button's form opening is awaited or marked
  `void` (Sonar S9383).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: show the picker's error alert when the composer's pick fails

pickLibraryMedia also catches a failing permission request, and only web's
"Unsupported file type" rejection counts as an unsupported pick. Any other
failure shows the files field's "Upload error" alert, now shared as
showPickerError, instead of the "not supported" message or an unhandled
rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 неделя назад
Родитель
Сommit
d393c044b2

+ 69 - 10
neo/packages/app/components/elements/multi-post-form.js

@@ -1,4 +1,5 @@
-import { Row, View } from 'app/design/view'
+import { Row, View, Pressable } from 'app/design/view'
+import { Text } from 'app/design/typography'
 import { NeoButton } from 'app/design/controls'
 import { useState, useEffect, useMemo } from 'react'
 import { useWindowDimensions } from 'react-native'
@@ -9,6 +10,36 @@ import { CardList } from 'app/ui/molecules/page/card'
 import { useTranslation } from 'react-i18next'
 import FormModal, { handleFormModal, getFormModal } from 'app/ui/molecules/dialogs/form-modal';
 import { BlockWrapper } from 'app/components/block-wrapper'
+import { useBreakpointName } from 'app/context/measure'
+import { pickLibraryMedia } from 'app/lib/media/pick-media'
+import { queueFieldAssets } from 'app/lib/form/pending-field-assets'
+
+// The first form's media field, as its own "Add Photos or Videos" button in the
+// composer toolbar would open it (forms/feed.js getAttachmentMenuItems).
+function getMediaField(formPage) {
+    const findInputs = (node) => {
+        if (!node || typeof node !== 'object') return null;
+        if (node.inputs && typeof node.inputs === 'object') return node.inputs;
+        for (const key of Object.keys(node)) {
+            const inputs = findInputs(node[key]);
+            if (inputs) return inputs;
+        }
+        return null;
+    };
+    const inputs = findInputs(formPage?.elements);
+    if (inputs?.video?.type === 'files')
+        return { name: 'video', mediaTypes: ['images', 'videos'], icon: 'ImagePlay', title: 'Add Photos or Videos' };
+    if (inputs?.photo?.type === 'files')
+        return { name: 'photo', mediaTypes: ['images'], icon: 'Image', title: 'Add Photos' };
+    return null;
+}
+
+// The "Create new …" trigger is not a form input (nothing is typed here), so it
+// is a plain Pressable rather than a NeoButton or a TextInput look-alike: text
+// only on phones, fading while pressed; a capsule on tablet and desktop.
+const TRIGGER_CLASS = 'flex-1 min-w-0 h-11 justify-center rounded-full active:opacity-50 '
+    + 'sm:px-4 sm:bg-button sm:hover:bg-button-hover web:transition-[background-color,opacity] web:duration-150';
+const TRIGGER_TEXT_CLASS = 'text-base font-medium tracking-tight text-muted-foreground';
 
 export default function MultiPostForm({ data, blockWrapperProps }) {
     const { currentUser } = useCurrentUser();
@@ -19,6 +50,8 @@ export default function MultiPostForm({ data, blockWrapperProps }) {
     // `display: none` subtree of native (Expo UI) buttons trips Fabric's
     // layout ownership assert in debug builds (facebook/react-native#52349).
     const showOtherTypes = isWeb || !!getBreakpoint(windowWidth);
+    // Phones get a borderless media button (hydration-safe: bordered until hydrated on web).
+    const mediaButtonStyle = useBreakpointName() === '' ? 'borderless' : 'bordered';
     const [pageData, setPageData] = useState(false);
     const [pageDataDef, setPageDataDef] = useState(false);
     const menu_add_items = menuItemsByNameNew('', data.menu, currentUser).filter(item => item.name != 'more-auto');
@@ -43,6 +76,8 @@ export default function MultiPostForm({ data, blockWrapperProps }) {
     // Keep showing until user opens the post form
 
 
+    const mediaField = useMemo(() => getMediaField(pageDataDef), [pageDataDef]);
+
     const getFirstForm = async () => {
         if (pageDataDef) {
             setPageData({ ...cloneObject(pageDataDef), ts: Date.now() });
@@ -58,10 +93,24 @@ export default function MultiPostForm({ data, blockWrapperProps }) {
 
 
 
+    const triggerLabel = t('Create new ') + firstForm.title.toLowerCase();
+
     const handleTriggerPress = () => {
         // User clicked to create post - permanently dismiss tooltip
 
-        getFirstForm();
+        void getFirstForm();
+    };
+
+    // Fast track to "Add Photos or Videos": pick first (on web the file dialog
+    // needs the click's user gesture), then open the form with the files queued
+    // for its media field, which uploads them on mount. Unsupported picks still
+    // open the form, where the field says so, like a pick inside the form.
+    const handleMediaPress = async () => {
+        if (!mediaField) return;
+        const picked = await pickLibraryMedia(mediaField.mediaTypes);
+        if (!picked) return;
+        queueFieldAssets(mediaField.name, picked.assets, picked.unsupported);
+        await getFirstForm();
     };
 
     return (
@@ -70,15 +119,25 @@ export default function MultiPostForm({ data, blockWrapperProps }) {
                 <View className="flex-none">
                     <Profile {...profileData} displaySize="base" displayType="unit_wo_info" />
                 </View>
-                <NeoButton
-                    style="bordered"
-                    borderShape="capsule"
-                    controlSize="regular"
-                    align="start"
-                    width="fill"
-                    label={t('Create new ') + firstForm.title.toLowerCase()}
+                <Pressable
+                    className={TRIGGER_CLASS}
+                    accessibilityRole="button"
+                    accessibilityLabel={triggerLabel}
                     onPress={handleTriggerPress}
-                />
+                >
+                    <Text className={TRIGGER_TEXT_CLASS} numberOfLines={1}>{triggerLabel}</Text>
+                </Pressable>
+                {mediaField && (
+                    <NeoButton
+                        style={mediaButtonStyle}
+                        borderShape="circle"
+                        controlSize="regular"
+                        image={mediaField.icon}
+                        tooltip={t(mediaField.title)}
+                        accessibilityLabel={t(mediaField.title)}
+                        onPress={handleMediaPress}
+                    />
+                )}
 
                 <FormModal key={pageData?.ts} pageData={pageData} setPageData={setPageData} />
                 {menu_add_items.length > 0 && showOtherTypes && <Row className="hidden sm:flex gap-3 flex-none">

+ 17 - 43
neo/packages/app/components/form-fields/files.js

@@ -12,7 +12,7 @@ import { useFieldController } from 'app/lib/form/use-form-field';
 import { filesFieldValue } from 'app/lib/form/field-initial-values';
 import { uploadImage, md5, getStoragePickerKind, isExtAllowed, splitExtList } from 'app/lib/util';
 import { Text } from 'app/design/typography'
-import { Image as ImageNative, Alert, Linking, Platform } from 'react-native';
+import { Image as ImageNative, Platform } from 'react-native';
 import { Image as ImageRN } from 'react-native';
 import Video from 'app/ui/atoms/video';
 import Msg from 'app/ui/molecules/dialogs/msg';
@@ -31,25 +31,8 @@ import { trackFormUploadStart, trackFormUploadEnd, formResponseHasFieldErrors, F
 import { isInlineImagePaste, revokePastedBlobUri } from 'app/lib/editor/editor-paste-images';
 import { setUploadProgress, clearUploadProgress } from 'app/lib/upload-progress';
 import UploadProgress from 'app/ui/atoms/upload-progress';
-
-function showPermissionAlert(type, canAskAgain) {
-    const isCamera = type === 'camera';
-    const title = isCamera ? i18n.t('media_permission_camera_title') : i18n.t('media_permission_library_title');
-    const message = canAskAgain === false
-        ? i18n.t('media_permission_denied')
-        : i18n.t('media_permission_required');
-
-    const buttons = [{ text: i18n.t('Cancel'), style: 'cancel' }];
-
-    if (canAskAgain === false || Platform.OS === 'ios') {
-        buttons.push({
-            text: i18n.t('Open Settings'),
-            onPress: () => Linking.openSettings(),
-        });
-    }
-
-    Alert.alert(title, message, buttons, { cancelable: true });
-}
+import { getImagePickerOptions, showPermissionAlert, showPickerError } from 'app/lib/media/pick-media';
+import { takeFieldAssets } from 'app/lib/form/pending-field-assets';
 
 function resolvePickerSource(source, fallback = 'library') {
     if (source === 'camera' || source === 'library') {
@@ -124,27 +107,6 @@ function resolvePickerMediaTypes(fieldName, extAllow, source) {
     return ['images', 'videos'];
 }
 
-function getImagePickerOptions(mediaTypes, bMultiple) {
-    const types = mediaTypes?.length ? mediaTypes : ['images'];
-    const includesVideo = types.includes('videos');
-    const options = {
-        mediaTypes: types,
-        quality: 1,
-        allowsMultipleSelection: Boolean(bMultiple),
-    };
-
-    if (Platform.OS === 'ios') {
-        options.preferredAssetRepresentationMode = includesVideo
-            ? ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible
-            : ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Current;
-        if (includesVideo) {
-            options.shouldDownloadFromNetwork = true;
-        }
-    }
-
-    return options;
-}
-
 /** Survives ImagePicker remounts of the files field (same as abort maps). */
 const fieldImagesByKey = new Map();
 
@@ -552,6 +514,13 @@ export default function (props) {
         setImageSourceN(k, bMultiple);
     }, [cropSession, bMultiple]);
 
+    // Media the feed composer picked before opening this form (pending-field-assets).
+    useEffect(() => {
+        const queued = takeFieldAssets(name);
+        if (queued?.unsupported) setMessage('Some files are not supported.');
+        if (queued?.assets.length) void finishPickedAssets(queued.assets);
+    }, [name, finishPickedAssets]);
+
     const resumedUploads = useRef(false);
     useEffect(() => {
         if (!persistKey || resumedUploads.current || !images?.length) return;
@@ -686,7 +655,7 @@ export default function (props) {
             }
         } catch (err) {
             console.error('[files] selectImage failed:', err);
-            Alert.alert(i18n.t('Upload error'), err?.message ?? i18n.t('Could not open media picker.'));
+            showPickerError(err);
         }
     }, [name, props.ext_deny, props.ext_allow, props.source, hasPermissionCamera, hasPermissionLibrary, requestPermissionCamera, requestPermissionLibrary, selectImage1]);
 
@@ -826,7 +795,12 @@ export default function (props) {
         </>
     }
     if (props.list_only){
-        return GhostsList(imageSource.images, bMultiple, handleDelete, props);
+        // The composer renders its attachments this way; keep the field's
+        // "not supported" message, or the composer drops bad picks silently.
+        return <>
+            <Msg onVisible={message} title={message} handleOk={() => { setMessage(false) }} />
+            {GhostsList(imageSource.images, bMultiple, handleDelete, props)}
+        </>;
     }
     return (
         <Field {...props} error={uploadError || props.error} error2={formContext.formState.errors[name]}>

+ 29 - 0
neo/packages/app/lib/form/pending-field-assets.ts

@@ -0,0 +1,29 @@
+/**
+ * Hands media picked before a form exists to that form's files field.
+ *
+ * The feed composer's media button picks files first and then opens the post
+ * form; the form's files field takes them when it mounts and uploads them as if
+ * they had been picked inside the form. A field that is already mounted never
+ * looks, so an entry only reaches a field the composer is about to open.
+ * `unsupported` counts picks that were dropped, so the field can say so.
+ */
+const QUEUE_TTL_MS = 30_000;
+
+export type PendingFieldAssets<T = unknown> = { assets: T[]; unsupported: number };
+
+const pendingByField = new Map<string, PendingFieldAssets & { at: number }>();
+
+export function queueFieldAssets(fieldName: string, assets: unknown[], unsupported = 0) {
+    if (assets.length || unsupported)
+        pendingByField.set(fieldName, { assets, unsupported, at: Date.now() });
+}
+
+export function takeFieldAssets<T = unknown>(fieldName: string): PendingFieldAssets<T> | null {
+    const entry = pendingByField.get(fieldName);
+    if (!entry)
+        return null;
+    pendingByField.delete(fieldName);
+    if (Date.now() - entry.at > QUEUE_TTL_MS)
+        return null;
+    return { assets: entry.assets as T[], unsupported: entry.unsupported };
+}

+ 92 - 0
neo/packages/app/lib/media/pick-media.ts

@@ -0,0 +1,92 @@
+import * as ImagePicker from 'expo-image-picker';
+import { Alert, Linking, Platform } from 'react-native';
+import i18n from 'i18next';
+
+export type PickerMediaType = 'images' | 'videos';
+
+export function showPermissionAlert(type: 'camera' | 'library', canAskAgain?: boolean) {
+    const isCamera = type === 'camera';
+    const title = isCamera ? i18n.t('media_permission_camera_title') : i18n.t('media_permission_library_title');
+    const message = canAskAgain === false
+        ? i18n.t('media_permission_denied')
+        : i18n.t('media_permission_required');
+
+    const buttons: { text: string; style?: 'cancel'; onPress?: () => void }[] = [{ text: i18n.t('Cancel'), style: 'cancel' }];
+
+    if (canAskAgain === false || Platform.OS === 'ios') {
+        buttons.push({
+            text: i18n.t('Open Settings'),
+            onPress: () => Linking.openSettings(),
+        });
+    }
+
+    Alert.alert(title, message, buttons, { cancelable: true });
+}
+
+/** The alert the files field shows when the picker itself fails. */
+export function showPickerError(err: unknown) {
+    const message = err instanceof Error ? err.message : undefined;
+    Alert.alert(i18n.t('Upload error'), message ?? i18n.t('Could not open media picker.'));
+}
+
+export function getImagePickerOptions(mediaTypes: PickerMediaType[] | undefined, bMultiple: boolean): ImagePicker.ImagePickerOptions {
+    const types = mediaTypes?.length ? mediaTypes : ['images' as const];
+    const includesVideo = types.includes('videos');
+    const options: ImagePicker.ImagePickerOptions = {
+        mediaTypes: types,
+        quality: 1,
+        allowsMultipleSelection: Boolean(bMultiple),
+    };
+
+    if (Platform.OS === 'ios') {
+        options.preferredAssetRepresentationMode = includesVideo
+            ? ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible
+            : ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Current;
+        if (includesVideo) {
+            options.shouldDownloadFromNetwork = true;
+        }
+    }
+
+    return options;
+}
+
+export type PickedMedia = {
+    assets: ImagePicker.ImagePickerAsset[];
+    /** Picks dropped as unreadable (`data:application/octet-stream`, as the files field drops them), or 1 when the picker rejected the selection. */
+    unsupported: number;
+};
+
+/**
+ * Opens the photo library outside a files field. Returns `null` when the user
+ * cancels, denies access or the picker fails (the user gets the files field's
+ * alert). Call it straight from a press handler: on web the file dialog only
+ * opens while the click still counts as a user gesture, so nothing may be
+ * awaited before it.
+ */
+export async function pickLibraryMedia(mediaTypes: PickerMediaType[], bMultiple = true): Promise<PickedMedia | null> {
+    let result: ImagePicker.ImagePickerResult;
+    try {
+        if (Platform.OS !== 'web') {
+            const current = await ImagePicker.getMediaLibraryPermissionsAsync();
+            const permission = current.granted ? current : await ImagePicker.requestMediaLibraryPermissionsAsync();
+            if (!permission.granted) {
+                showPermissionAlert('library', permission.canAskAgain);
+                return null;
+            }
+        }
+        result = await ImagePicker.launchImageLibraryAsync(getImagePickerOptions(mediaTypes, bMultiple));
+    } catch (err) {
+        // Web rejects the whole pick when any file isn't an image or video;
+        // report that like the dropped picks below. Anything else is a failure.
+        if (Platform.OS === 'web' && err instanceof Error && /unsupported file type/i.test(err.message))
+            return { assets: [], unsupported: 1 };
+        console.warn('[pick-media] library pick failed:', err);
+        showPickerError(err);
+        return null;
+    }
+    if (result.canceled || !result.assets?.length)
+        return null;
+
+    const assets = result.assets.filter((asset) => !asset.uri.startsWith('data:application/octet-stream'));
+    return { assets, unsupported: result.assets.length - assets.length };
+}

+ 9 - 2
neo/packages/app/ui/molecules/dialogs/msg.js

@@ -1,3 +1,4 @@
+import { useEffect, useEffectEvent } from 'react';
 import { Alert } from 'react-native';
 import { useTranslation } from 'react-i18next';
 
@@ -8,11 +9,17 @@ export default function (props) {
         props.handleOk();
     }
 
-    if (props.onVisible) {
+    const showAlert = useEffectEvent(() => {
         Alert.alert(props.title, props.text, [
             {text: t('OK'), onPress: () => handleOk()},
           ]);
-    }
+    });
+
+    // Once per show, not on every render: a caller that re-renders while the
+    // message is up (an uploading files field) would otherwise stack alerts.
+    useEffect(() => {
+        if (props.onVisible) showAlert();
+    }, [props.onVisible, props.title, props.text]);
 
     return <></>
 }