Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src-tauri/capabilities/android.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"platforms": ["android"],
"windows": ["main"],
"permissions": [
"android-fs:allow-show-open-file-picker",
"android-fs:allow-get-metadata",
"android-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",
Expand Down
14 changes: 13 additions & 1 deletion src-tauri/capabilities/ios.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,17 @@
"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",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }]
}
]
}
68 changes: 40 additions & 28 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -163,7 +163,6 @@ import {
dropzoneIcon,
File as FileIcon,
Gif,
Image as ImageIcon,
ListBullets,
MapPinPlusIcon,
menuIcon,
Expand Down Expand Up @@ -217,6 +216,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 }))
Expand Down Expand Up @@ -431,22 +431,27 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, []);

const handleFiles = useCallback(
async (files: File[], audioMeta?: { waveform: number[]; audioDuration: number }) => {
async (
files: File[],
audioMeta?: { waveform: number[]; audioDuration: number },
options?: { alreadyInMemory?: boolean }
) => {
setUploadBoard(true);
const safeFiles = await Promise.all(files.map(safeUploadFile));
// Eager-read to avoid Android content URI expiry after SAF picker
const blobbedFiles = isMobileOrTablet()
? await Promise.all(
safeFiles.map(async (f) => {
try {
const buf = await f.arrayBuffer();
return new File([buf], f.name, { type: f.type, lastModified: f.lastModified });
} catch {
return f;
}
})
)
: safeFiles;
const blobbedFiles =
isMobileOrTablet() && !options?.alreadyInMemory
? await Promise.all(
safeFiles.map(async (f) => {
try {
const buf = await f.arrayBuffer();
return new File([buf], f.name, { type: f.type, lastModified: f.lastModified });
} catch {
return f;
}
})
)
: safeFiles;
const makeMetadata = () => ({
markedAsSpoiler: false,
waveform: audioMeta?.waveform,
Expand Down Expand Up @@ -492,6 +497,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[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, (source, error) => {
log.warn('Native attachment file error:', source, error);
});
if (files.length > 0) await handleFiles(files, undefined, { alreadyInMemory: true });
} 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);
Expand Down Expand Up @@ -2091,10 +2114,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{() => (
<AttachmentContent
onPickPhotos={() => {
pickFile('image/*,.tgs');
void pickAttachment('media', 'image/*,video/*,.tgs');
}}
onPickFile={() => {
pickFile('*');
void pickAttachment('document', '*');
}}
onPickPoll={() => {
setShowPollPicker(true);
Expand Down Expand Up @@ -2148,17 +2171,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Text size="B300">Add Location</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
onClick={() => {
pickFile('image/*,.tgs');
setAddMenuAnchor(undefined);
}}
before={menuIcon(ImageIcon)}
>
<Text size="B300">Photos</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
Expand Down
227 changes: 227 additions & 0 deletions src/app/features/room/nativeFilePicker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { open } from '@tauri-apps/plugin-dialog';
import { readFile, remove } from '@tauri-apps/plugin-fs';
import { pickNativeFile } from './nativeFilePicker';

type AndroidUri = { uri: string; documentTopTreeUri: string | null };
type AndroidMetadata =
| { type: 'File'; name: string; lastModified: Date; byteLength: number; mimeType: string }
| { type: 'Dir'; name: string; lastModified: Date };

const mocks = vi.hoisted(() => ({
open: vi.fn<
(options: {
pickerMode: 'media' | 'document';
multiple: true;
}) => Promise<string | string[] | null>
>(),
readFile: vi.fn<(path: string) => Promise<Uint8Array>>(),
remove: vi.fn<(path: string) => Promise<void>>(),
androidFs: {
showOpenFilePicker:
vi.fn<
(options: {
pickerType: 'Gallery' | 'FilePicker';
mimeTypes: string[];
multiple: boolean;
}) => Promise<AndroidUri[]>
>(),
getMetadata: vi.fn<(uri: AndroidUri) => Promise<AndroidMetadata>>(),
readFile: vi.fn<(uri: AndroidUri) => Promise<Uint8Array>>(),
},
isAndroidTauri: vi.fn<() => boolean>(),
}));

vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.open }));
vi.mock('@tauri-apps/plugin-fs', () => ({ readFile: mocks.readFile, remove: mocks.remove }));
vi.mock('tauri-plugin-android-fs-api', () => ({ AndroidFs: mocks.androidFs }));
vi.mock('$utils/platform', () => ({ isAndroidTauri: mocks.isAndroidTauri }));

const androidUri = (uri: string): AndroidUri => ({ uri, documentTopTreeUri: null });

describe('pickNativeFile', () => {
beforeEach(() => {
mocks.isAndroidTauri.mockReturnValue(false);
mocks.open.mockResolvedValue(null);
mocks.readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
mocks.remove.mockResolvedValue(undefined);
mocks.androidFs.showOpenFilePicker.mockResolvedValue([]);
mocks.androidFs.readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
});

afterEach(() => {
vi.clearAllMocks();
});

describe('on iOS', () => {
it('opens the native media picker and converts selected files', async () => {
mocks.open.mockResolvedValue([
'file:///var/mobile/Library/Caches/My%20photo.JPG',
'file:///var/mobile/Library/Caches/clip.mp4',
]);

const files = await pickNativeFile('media');

expect(open).toHaveBeenCalledWith({ pickerMode: 'media', multiple: true });
expect(files.map(({ name, type }) => ({ name, type }))).toEqual([
{ name: 'My photo.JPG', type: 'image/jpeg' },
{ name: 'clip.mp4', type: 'video/mp4' },
]);
});

it('deletes the sandbox copies the picker handed over', async () => {
mocks.open.mockResolvedValue(['/Caches/a.png', '/Caches/b.pdf']);

await pickNativeFile('document');

expect(readFile).toHaveBeenCalledWith('/Caches/a.png');
expect(remove).toHaveBeenCalledWith('/Caches/a.png');
expect(remove).toHaveBeenCalledWith('/Caches/b.pdf');
});

it('reports read failures, keeps readable files and still deletes the copies', async () => {
const failure = new Error('permission denied');
mocks.open.mockResolvedValue(['/Caches/readable.png', '/Caches/unreadable.jpg']);
mocks.readFile.mockResolvedValueOnce(new Uint8Array([1])).mockRejectedValueOnce(failure);
const onFileFailure = vi.fn<(source: string, error: unknown) => void>();

const files = await pickNativeFile('media', onFileFailure);

expect(files).toHaveLength(1);
expect(files[0]?.name).toBe('readable.png');
expect(onFileFailure).toHaveBeenCalledWith('/Caches/unreadable.jpg', failure);
expect(remove).toHaveBeenCalledWith('/Caches/unreadable.jpg');
});

it('reports a failed cleanup without dropping the file', async () => {
const failure = new Error('cleanup failed');
mocks.open.mockResolvedValue(['/Caches/photo.png']);
mocks.remove.mockRejectedValue(failure);
const onFileFailure = vi.fn<(source: string, error: unknown) => void>();

const files = await pickNativeFile('media', onFileFailure);

expect(files).toHaveLength(1);
expect(onFileFailure).toHaveBeenCalledWith('/Caches/photo.png', failure);
});

it('does not read files after picker cancellation', async () => {
const files = await pickNativeFile('media');

expect(files).toEqual([]);
expect(readFile).not.toHaveBeenCalled();
expect(remove).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 for documents', async () => {
mocks.open.mockResolvedValue('/documents/report.pdf');

const files = await pickNativeFile('document');

expect(open).toHaveBeenCalledWith({ pickerMode: 'document', multiple: true });
expect(files.map(({ name, type }) => ({ name, type }))).toEqual([
{ name: 'report.pdf', type: 'application/pdf' },
]);
});
});

describe('on Android', () => {
beforeEach(() => {
mocks.isAndroidTauri.mockReturnValue(true);
});

it('picks media through the gallery and takes name and mime from the provider', async () => {
const uri = androidUri('content://media/external/images/media/1000000034');
mocks.androidFs.showOpenFilePicker.mockResolvedValue([uri]);
mocks.androidFs.getMetadata.mockResolvedValue({
type: 'File',
name: '1000000034.png',
lastModified: new Date(1700000000000),
byteLength: 3,
mimeType: 'image/png',
});

const files = await pickNativeFile('media');

expect(mocks.androidFs.showOpenFilePicker).toHaveBeenCalledWith({
pickerType: 'Gallery',
mimeTypes: ['image/*', 'video/*'],
multiple: true,
});
expect(mocks.androidFs.readFile).toHaveBeenCalledWith(uri);
expect(open).not.toHaveBeenCalled();
expect(files.map(({ name, type, lastModified }) => ({ name, type, lastModified }))).toEqual([
{ name: '1000000034.png', type: 'image/png', lastModified: 1700000000000 },
]);
});

it('picks documents through the file picker', async () => {
mocks.androidFs.showOpenFilePicker.mockResolvedValue([androidUri('content://docs/1')]);
mocks.androidFs.getMetadata.mockResolvedValue({
type: 'File',
name: 'report.pdf',
lastModified: new Date(1700000000000),
byteLength: 3,
mimeType: 'application/pdf',
});

const files = await pickNativeFile('document');

expect(mocks.androidFs.showOpenFilePicker).toHaveBeenCalledWith({
pickerType: 'FilePicker',
mimeTypes: [],
multiple: true,
});
expect(files.map(({ name, type }) => ({ name, type }))).toEqual([
{ name: 'report.pdf', type: 'application/pdf' },
]);
});

it('falls back to the extension when the provider reports a generic mime type', async () => {
mocks.androidFs.showOpenFilePicker.mockResolvedValue([androidUri('content://docs/2')]);
mocks.androidFs.getMetadata.mockResolvedValue({
type: 'File',
name: 'sticker.tgs',
lastModified: new Date(1700000000000),
byteLength: 3,
mimeType: 'application/octet-stream',
});

const files = await pickNativeFile('document');

expect(files[0]?.type).toBe('application/x-tgsticker');
});

it('reports failures per file and skips directories', async () => {
const failure = new Error('no read permission');
const dir = androidUri('content://docs/dir');
const broken = androidUri('content://docs/broken');
mocks.androidFs.showOpenFilePicker.mockResolvedValue([dir, broken]);
mocks.androidFs.getMetadata
.mockResolvedValueOnce({ type: 'Dir', name: 'folder', lastModified: new Date(0) })
.mockRejectedValueOnce(failure);
const onFileFailure = vi.fn<(source: string, error: unknown) => void>();

const files = await pickNativeFile('document', onFileFailure);

expect(files).toEqual([]);
expect(onFileFailure).toHaveBeenCalledWith('content://docs/broken', failure);
expect(mocks.androidFs.readFile).not.toHaveBeenCalled();
});

it('returns no files when the picker is cancelled', async () => {
const files = await pickNativeFile('media');

expect(files).toEqual([]);
expect(mocks.androidFs.getMetadata).not.toHaveBeenCalled();
});
});
});
Loading