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
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/capabilities/android.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src-tauri/capabilities/ios.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,13 @@
"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/**/*" }]
}
]
}
5 changes: 5 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
37 changes: 22 additions & 15 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 @@ -492,6 +492,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, (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);
Expand Down Expand Up @@ -2091,10 +2109,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 +2166,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
89 changes: 89 additions & 0 deletions src/app/features/room/nativeFilePicker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { open } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { pickNativeFile } from './nativeFilePicker';

const mocks = vi.hoisted(() => ({
open: vi.fn<
(options: {
pickerMode: 'media' | 'document';
multiple: true;
}) => Promise<string | string[] | null>
>(),
readFile: vi.fn<(path: string) => Promise<Uint8Array>>(),
}));

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('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');

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' },
]);
});
});
93 changes: 93 additions & 0 deletions src/app/features/room/nativeFilePicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createLogger } from '$utils/debug';
import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes';

const log = createLogger('nativeFilePicker');

const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
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: 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();
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<File[]> => {
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) {
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(
paths.map(async (path, index): Promise<File | undefined> => {
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);
};
22 changes: 21 additions & 1 deletion src/app/utils/platform.test.ts
Original file line number Diff line number Diff line change
@@ -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>(),
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/app/utils/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading