Skip to content
Closed
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 @@ -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",
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
116 changes: 23 additions & 93 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 @@ -161,9 +161,6 @@ import {
dropzoneIcon,
File as FileIcon,
Gif,
Image as ImageIcon,
ListBullets,
MapPinPlusIcon,
menuIcon,
Microphone,
PaperPlaneTilt,
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -310,7 +308,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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');
Expand Down Expand Up @@ -487,6 +484,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 All @@ -503,7 +518,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom(
roomIdToEditingScheduledDelayIdAtomFamily(roomId)
);
const [AddMenuAnchor, setAddMenuAnchor] = useState<RectCords>();
const [showAttachmentSheet, setShowAttachmentSheet] = useState(false);
const attachmentSkipReturnFocusRef = useRef(false);
const [showPollPicker, setShowPollPicker] = useState(false);
Expand Down Expand Up @@ -2047,7 +2061,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
before={
<>
{isMobileOrTablet() ? (
{isMobileOrTablet() && (
<>
<IconButton
onClick={() => {
Expand Down Expand Up @@ -2075,10 +2089,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 All @@ -2092,90 +2106,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</MobileSwipeDownModal>
)}
</>
) : (
<>
<PopOut
anchor={AddMenuAnchor}
position="Top"
align="Start"
offset={5}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAddMenuAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
onClick={() => {
setAddMenuAnchor(undefined);
setShowPollPicker(true);
}}
before={menuIcon(ListBullets)}
>
<Text size="B300">Create Poll</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
onClick={() => {
setAddMenuAnchor(undefined);
setShowLocationPicker(true);
}}
before={menuIcon(MapPinPlusIcon)}
>
<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"
onClick={() => {
pickFile('*');
setAddMenuAnchor(undefined);
}}
before={menuIcon(PlusCircle)}
>
<Text size="B300">Add File</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
<IconButton
onClick={(evt) =>
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)}
</IconButton>
</>
)}
{pmpPickerEnable && (
<PersonaPicker
Expand Down
82 changes: 82 additions & 0 deletions src/app/features/room/nativeFilePicker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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('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' },
]);
});
});
74 changes: 74 additions & 0 deletions src/app/features/room/nativeFilePicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes';

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: 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<File[]> => {
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<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);
};
Loading