From 38c5974e2fbe08e28a9a2cda3d0c938b68a2fd94 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 28 Jul 2026 17:17:52 +0200 Subject: [PATCH 1/5] 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 c8c27dac8..b0fa29a23 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" @@ -146,7 +147,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 1db10918d..ad7f6bece 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 d081c7e22..f76bae4ca 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'; @@ -163,9 +163,6 @@ import { dropzoneIcon, File as FileIcon, Gif, - Image as ImageIcon, - ListBullets, - MapPinPlusIcon, menuIcon, Microphone, PaperPlaneTilt, @@ -217,6 +214,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 })) @@ -312,7 +310,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'); @@ -492,6 +489,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); @@ -508,7 +523,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); @@ -2063,7 +2077,7 @@ export const RoomInput = forwardRef( } before={ <> - {isMobileOrTablet() ? ( + {isMobileOrTablet() && ( <> { @@ -2091,10 +2105,10 @@ export const RoomInput = forwardRef( {() => ( { - pickFile('image/*,.tgs'); + void pickAttachment('media', 'image/*,video/*,.tgs'); }} onPickFile={() => { - pickFile('*'); + void pickAttachment('document', '*'); }} onPickPoll={() => { setShowPollPicker(true); @@ -2108,90 +2122,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 75ffcd4e245816248bd8ccc5c644592dae85df80 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 31 Jul 2026 10:58:16 +0200 Subject: [PATCH 2/5] 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/**/*" }] + } ] } From 4f9169442447966fee3d426afe81a5fd965ccc60 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 11:22:17 +0200 Subject: [PATCH 3/5] fix(composer): restore desktop add menu without the Photos item --- src/app/features/room/RoomInput.tsx | 79 ++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f76bae4ca..d01a75b3e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -163,6 +163,8 @@ import { dropzoneIcon, File as FileIcon, Gif, + ListBullets, + MapPinPlusIcon, menuIcon, Microphone, PaperPlaneTilt, @@ -310,6 +312,7 @@ 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'); @@ -523,6 +526,7 @@ 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); @@ -2077,7 +2081,7 @@ export const RoomInput = forwardRef( } before={ <> - {isMobileOrTablet() && ( + {isMobileOrTablet() ? ( <> { @@ -2122,6 +2126,79 @@ 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('*'); + 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 && ( Date: Sat, 1 Aug 2026 11:22:18 +0200 Subject: [PATCH 4/5] fix(mobile): surface native picker failures instead of swallowing them --- .../features/room/nativeFilePicker.test.ts | 7 ++++++ src/app/features/room/nativeFilePicker.ts | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/nativeFilePicker.test.ts b/src/app/features/room/nativeFilePicker.test.ts index 9f42bc785..e479ccc92 100644 --- a/src/app/features/room/nativeFilePicker.test.ts +++ b/src/app/features/room/nativeFilePicker.test.ts @@ -68,6 +68,13 @@ describe('pickNativeFile', () => { expect(readFile).not.toHaveBeenCalled(); }); + it('throws instead of silently dropping entries that are not paths', async () => { + mocks.open.mockResolvedValue([{ relative: 'file:///photos/img.HEIC' }] as never); + + await expect(pickNativeFile('media')).rejects.toThrow('unusable entries'); + expect(readFile).not.toHaveBeenCalled(); + }); + it('opens the native document picker and converts selected paths to Files', async () => { mocks.open.mockResolvedValue('/documents/report.pdf'); diff --git a/src/app/features/room/nativeFilePicker.ts b/src/app/features/room/nativeFilePicker.ts index 2b5d5a369..b2ab8fec5 100644 --- a/src/app/features/room/nativeFilePicker.ts +++ b/src/app/features/room/nativeFilePicker.ts @@ -1,5 +1,8 @@ +import { createLogger } from '$utils/debug'; import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes'; +const log = createLogger('nativeFilePicker'); + const MIME_TYPES_BY_EXTENSION: Record = { apng: 'image/apng', avif: 'image/avif', @@ -28,8 +31,19 @@ const MIME_TYPES_BY_EXTENSION: Record = { 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 normalizeSelectedPaths = (selected: unknown): string[] => { + if (selected === null || selected === undefined) return []; + + const values = Array.isArray(selected) ? (selected as unknown[]) : [selected]; + const paths = values.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ); + if (paths.length !== values.length) { + throw new Error(`Native picker returned unusable entries: ${JSON.stringify(selected)}`); + } + + return paths; +}; const getFileName = (path: string, index: number): string => { const pathName = path.split(/[\\/]/).pop(); @@ -53,8 +67,13 @@ export const pickNativeFile = async ( ): Promise => { const { open } = await import('@tauri-apps/plugin-dialog'); const selected = await open({ pickerMode, multiple: true }); + log.log('picker returned', pickerMode, typeof selected, selected); + const paths = normalizeSelectedPaths(selected); - if (paths.length === 0) return []; + if (paths.length === 0) { + log.warn('picker resolved without any path (cancelled, or a swallowed native failure)'); + return []; + } const { readFile } = await import('@tauri-apps/plugin-fs'); const files = await Promise.all( From f2c624832a7f498f9cc2bedf6e8fa9f61aea7ce7 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 16:09:52 +0200 Subject: [PATCH 5/5] fix(tauri): keep desktop message menus as popouts --- src/app/utils/platform.test.ts | 22 +++++++++++++++++++++- src/app/utils/platform.ts | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/app/utils/platform.test.ts b/src/app/utils/platform.test.ts index 263793cee..1ba9f3351 100644 --- a/src/app/utils/platform.test.ts +++ b/src/app/utils/platform.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { getAppOrigin, getWindowOrigin } from './platform'; +import { getAppOrigin, getWindowOrigin, isMobileOrTablet, ua } from './platform'; import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn<() => boolean>(), @@ -45,6 +46,25 @@ describe('getAppOrigin', () => { }); }); +describe('isMobileOrTablet', () => { + const originalDeviceType = ua.device.type; + const originalOsName = ua.os.name; + + afterEach(() => { + ua.device.type = originalDeviceType; + ua.os.name = originalOsName; + }); + + it('uses the desktop Tauri OS instead of a mobile-looking WebView user agent', () => { + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(osType).mockReturnValue('windows'); + ua.device.type = 'mobile'; + ua.os.name = 'Android'; + + expect(isMobileOrTablet()).toBe(false); + }); +}); + describe('getWindowOrigin', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts index 9c278c885..30ac40b51 100644 --- a/src/app/utils/platform.ts +++ b/src/app/utils/platform.ts @@ -27,6 +27,7 @@ const DESKTOP_TAURI_OS = ['linux', 'macos', 'windows'] as const; export function isMobileOrTablet(): boolean { const tauriOS = getTauriOS(); if (tauriOS && (MOBILE_TAURI_OS as readonly string[]).includes(tauriOS)) return true; + if (tauriOS && (DESKTOP_TAURI_OS as readonly string[]).includes(tauriOS)) return false; const { os, device } = ua; if (device.type === 'mobile' || device.type === 'tablet') return true;