From d06d73ccfbb783e46acbc757ae92aa7f0e598f96 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 28 Jul 2026 17:17:52 +0200 Subject: [PATCH 1/2] feat(mobile): use native attachment pickers --- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/android.json | 2 + src-tauri/capabilities/ios.json | 7 +- src-tauri/src/lib.rs | 5 + src/app/features/room/RoomInput.tsx | 116 ++++-------------- .../features/room/nativeFilePicker.test.ts | 82 +++++++++++++ src/app/features/room/nativeFilePicker.ts | 74 +++++++++++ 7 files changed, 193 insertions(+), 95 deletions(-) create mode 100644 src/app/features/room/nativeFilePicker.test.ts create mode 100644 src/app/features/room/nativeFilePicker.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee6cfdbb3..5255c1285 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -124,6 +124,7 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } +tauri-plugin-fs = "2" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" @@ -140,7 +141,6 @@ objc2-photos = { version = "0.3.2", default-features = false, features = [ "dispatch2", ] } block2 = "0.6" -tauri-plugin-fs = "2" objc2-avf-audio = { version = "0.3", default-features = false, features = [ "AVAudioSession", "AVAudioSessionTypes", diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json index fc3fa717e..973636d65 100644 --- a/src-tauri/capabilities/android.json +++ b/src-tauri/capabilities/android.json @@ -4,6 +4,8 @@ "platforms": ["android"], "windows": ["main"], "permissions": [ + "dialog:allow-open", + "fs:allow-read-file", "android-fs:allow-check-public-files-permission", "android-fs:allow-create-new-public-file", "android-fs:allow-create-new-public-image-file", diff --git a/src-tauri/capabilities/ios.json b/src-tauri/capabilities/ios.json index c02f6dc52..9f65e10f5 100644 --- a/src-tauri/capabilities/ios.json +++ b/src-tauri/capabilities/ios.json @@ -3,5 +3,10 @@ "identifier": "ios-capability", "platforms": ["iOS"], "windows": ["main"], - "permissions": ["dialog:allow-save", "fs:allow-write-file"] + "permissions": [ + "dialog:allow-save", + "dialog:allow-open", + "fs:allow-write-file", + "fs:allow-read-file" + ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7fcfe176c..788a07a57 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -352,6 +352,11 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); + #[cfg(target_os = "android")] + let builder = builder + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()); + #[cfg(mobile)] let builder = builder .plugin(tauri_plugin_edge_to_edge::init()) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 60a037194..6e11a5746 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -115,7 +115,7 @@ import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/ import { buildReplacementContent } from './buildReplacementContent'; import { htmlToMarkdown } from '$plugins/markdown'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; -import { isMobileOrTablet } from '$utils/platform'; +import { isMobileOrTablet, isMobileTauri } from '$utils/platform'; import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { nicknamesAtom } from '$state/nicknames'; @@ -161,9 +161,6 @@ import { dropzoneIcon, File as FileIcon, Gif, - Image as ImageIcon, - ListBullets, - MapPinPlusIcon, menuIcon, Microphone, PaperPlaneTilt, @@ -215,6 +212,7 @@ import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { useClientConfig } from '$hooks/useClientConfig'; import { PersonaPicker, type PersonaPickerTab } from './persona-picker/PersonaPicker.tsx'; +import { pickNativeFile } from './nativeFilePicker'; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -310,7 +308,6 @@ export const RoomInput = forwardRef( const clientConfig = useClientConfig(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); const [editorGifButton] = useSetting(settingsAtom, 'editorGifButton'); const [editorEmojiButton] = useSetting(settingsAtom, 'editorEmojiButton'); const [editorStickerButton] = useSetting(settingsAtom, 'editorStickerButton'); @@ -487,6 +484,24 @@ export const RoomInput = forwardRef( [setSelectedFiles, room] ); const pickFile = useFilePicker(handleFiles, true); + const pickAttachment = useCallback( + async (pickerMode: 'media' | 'document', accept: string) => { + if (!isMobileTauri()) { + await pickFile(accept); + return; + } + + try { + const files = await pickNativeFile(pickerMode, (path, error) => { + log.warn('Failed to read native attachment file:', path, error); + }); + if (files.length > 0) await handleFiles(files); + } catch (error) { + log.error('Failed to open native attachment picker', { roomId }, error); + } + }, + [handleFiles, pickFile, roomId] + ); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const [hasText, setHasText] = useState(false); @@ -503,7 +518,6 @@ export const RoomInput = forwardRef( const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(roomId) ); - const [AddMenuAnchor, setAddMenuAnchor] = useState(); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); const attachmentSkipReturnFocusRef = useRef(false); const [showPollPicker, setShowPollPicker] = useState(false); @@ -2047,7 +2061,7 @@ export const RoomInput = forwardRef( } before={ <> - {isMobileOrTablet() ? ( + {isMobileOrTablet() && ( <> { @@ -2075,10 +2089,10 @@ export const RoomInput = forwardRef( {() => ( { - pickFile('image/*,.tgs'); + void pickAttachment('media', 'image/*,video/*,.tgs'); }} onPickFile={() => { - pickFile('*'); + void pickAttachment('document', '*'); }} onPickPoll={() => { setShowPollPicker(true); @@ -2092,90 +2106,6 @@ export const RoomInput = forwardRef( )} - ) : ( - <> - setAddMenuAnchor(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - - { - setAddMenuAnchor(undefined); - setShowPollPicker(true); - }} - before={menuIcon(ListBullets)} - > - Create Poll - - { - setAddMenuAnchor(undefined); - setShowLocationPicker(true); - }} - before={menuIcon(MapPinPlusIcon)} - > - Add Location - - { - pickFile('image/*,.tgs'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(ImageIcon)} - > - Photos - - { - pickFile('*'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(PlusCircle)} - > - Add File - - - - - } - /> - - editorOldAddFile - ? pickFile('*') - : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) - } - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - title={editorOldAddFile ? 'Upload File' : 'Add'} - aria-label={editorOldAddFile ? 'Upload and attach a File' : 'Add new Item'} - > - {composerIcon(PlusCircle)} - - )} {pmpPickerEnable && ( ({ + open: vi.fn< + (options: { + pickerMode: 'media' | 'document'; + multiple: true; + }) => Promise + >(), + readFile: vi.fn<(path: string) => Promise>(), +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.open })); +vi.mock('@tauri-apps/plugin-fs', () => ({ readFile: mocks.readFile })); + +describe('pickNativeFile', () => { + beforeEach(() => { + mocks.open.mockResolvedValue(null); + mocks.readFile.mockResolvedValue(new Uint8Array([1, 2, 3])); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('opens the native media picker and converts selected paths to Files', async () => { + mocks.open.mockResolvedValue(['/photos/My%20photo.JPG', '/videos/clip.mp4']); + + const files = await pickNativeFile('media'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'media', multiple: true }); + expect(readFile).toHaveBeenCalledWith('/photos/My%20photo.JPG'); + expect(readFile).toHaveBeenCalledWith('/videos/clip.mp4'); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'My photo.JPG', type: 'image/jpeg' }, + { name: 'clip.mp4', type: 'video/mp4' }, + ]); + }); + + it('returns readable files when an individual read fails', async () => { + const failure = new Error('permission denied'); + mocks.open.mockResolvedValue(['/photos/readable.png', '/photos/unreadable.jpg']); + mocks.readFile.mockResolvedValueOnce(new Uint8Array([1])).mockRejectedValueOnce(failure); + const onReadFailure = vi.fn<(path: string, error: unknown) => void>(); + + const files = await pickNativeFile('media', onReadFailure); + + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe('readable.png'); + expect(onReadFailure).toHaveBeenCalledWith('/photos/unreadable.jpg', failure); + }); + + it('does not read files after picker cancellation', async () => { + const files = await pickNativeFile('media'); + + expect(files).toEqual([]); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('propagates picker errors without attempting another picker', async () => { + const error = new Error('picker failed'); + mocks.open.mockRejectedValue(error); + + await expect(pickNativeFile('media')).rejects.toBe(error); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('opens the native document picker and converts selected paths to Files', async () => { + mocks.open.mockResolvedValue('/documents/report.pdf'); + + const files = await pickNativeFile('document'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'document', multiple: true }); + expect(readFile).toHaveBeenCalledWith('/documents/report.pdf'); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'report.pdf', type: 'application/pdf' }, + ]); + }); +}); diff --git a/src/app/features/room/nativeFilePicker.ts b/src/app/features/room/nativeFilePicker.ts new file mode 100644 index 000000000..2b5d5a369 --- /dev/null +++ b/src/app/features/room/nativeFilePicker.ts @@ -0,0 +1,74 @@ +import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes'; + +const MIME_TYPES_BY_EXTENSION: Record = { + apng: 'image/apng', + avif: 'image/avif', + bmp: 'image/bmp', + gif: 'image/gif', + heic: 'image/heic', + heif: 'image/heif', + json: 'application/json', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + md: 'text/markdown', + mov: 'video/quicktime', + mp4: 'video/mp4', + m4v: 'video/mp4', + ogg: 'video/ogg', + ogv: 'video/ogg', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + tgs: TGS_MIMETYPE, + txt: 'text/plain', + webm: 'video/webm', + webp: 'image/webp', +}; + +export type NativePickerMode = 'media' | 'document'; +export type NativeFileReadFailureHandler = (path: string, error: unknown) => void; + +const normalizeSelectedPaths = (selected: string | string[] | null | undefined): string[] => + (typeof selected === 'string' ? [selected] : (selected ?? [])).filter((path) => path.length > 0); + +const getFileName = (path: string, index: number): string => { + const pathName = path.split(/[\\/]/).pop(); + if (!pathName) return `attachment-${index + 1}`; + + try { + return decodeURIComponent(pathName); + } catch { + return pathName; + } +}; + +const getMimeType = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension ? (MIME_TYPES_BY_EXTENSION[extension] ?? FALLBACK_MIMETYPE) : FALLBACK_MIMETYPE; +}; + +export const pickNativeFile = async ( + pickerMode: NativePickerMode, + onReadFailure?: NativeFileReadFailureHandler +): Promise => { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ pickerMode, multiple: true }); + const paths = normalizeSelectedPaths(selected); + if (paths.length === 0) return []; + + const { readFile } = await import('@tauri-apps/plugin-fs'); + const files = await Promise.all( + paths.map(async (path, index): Promise => { + try { + const name = getFileName(path, index); + const contents = await readFile(path); + return new File([contents], name, { type: getMimeType(name) }); + } catch (error) { + onReadFailure?.(path, error); + return undefined; + } + }) + ); + + return files.filter((file): file is File => file !== undefined); +}; From f3d0d24d3c2d15a055b0abf6873533dd6ebbe997 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 31 Jul 2026 10:58:16 +0200 Subject: [PATCH 2/2] fix(mobile): scope iOS fs reads to picker copy directories --- src-tauri/capabilities/ios.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/capabilities/ios.json b/src-tauri/capabilities/ios.json index 9f65e10f5..0d0baad6e 100644 --- a/src-tauri/capabilities/ios.json +++ b/src-tauri/capabilities/ios.json @@ -7,6 +7,9 @@ "dialog:allow-save", "dialog:allow-open", "fs:allow-write-file", - "fs:allow-read-file" + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }] + } ] }