Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ export const ImageViewer = as<'div', ImageViewerProps>(
showToast(`Failed to download file: ${message}`);
return;
}
await saveFileToDevice(fileContent, downloadFilename);
await saveFileToDevice(
fileContent,
downloadFilename,
galleryMimeType || fileContent.type || undefined
);
};

const menu = useMenuAnchor<HTMLElement>();
Expand Down
58 changes: 58 additions & 0 deletions src/app/utils/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,24 @@ describe('saveFileToDevice', () => {
expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'file.txt');
});

it('uses authenticated media transport before saving a URL in the browser', async () => {
vi.mocked(isTauri).mockReturnValue(false);
const blob = new Blob(['data'], { type: 'image/png' });
mocks.fetchMediaBlob.mockResolvedValue(blob);

await expect(
saveFileToDevice(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo',
'photo.png'
)
).resolves.toBe('saved');

expect(mocks.fetchMediaBlob).toHaveBeenCalledWith(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo'
);
expect(FileSaver.saveAs).toHaveBeenCalledWith(blob, 'photo.png');
});

it('uses authenticated media transport when saving a URL on Android', async () => {
const blob = new Blob(['data'], { type: 'image/png' });
mocks.fetchMediaBlob.mockResolvedValue(blob);
Expand All @@ -137,6 +155,26 @@ describe('saveFileToDevice', () => {
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo'
);
});

it('uses authenticated media transport when saving a URL on desktop', async () => {
vi.mocked(osType).mockReturnValue('linux');
mocks.fetchMediaBlob.mockResolvedValue(new Blob(['data'], { type: 'image/png' }));

await expect(
saveFileToDevice(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo',
'photo.png'
)
).resolves.toBe('saved');

expect(mocks.fetchMediaBlob).toHaveBeenCalledWith(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo'
);
expect(invoke).toHaveBeenCalledWith('save_download', {
filename: 'photo.png',
bytes: [100, 97, 116, 97],
});
});
});

describe('downloadJsonFile', () => {
Expand Down Expand Up @@ -270,6 +308,26 @@ describe('saveMediaToGallery', () => {
expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled();
});

it('uses authenticated media transport before saving a URL to iOS Photos', async () => {
vi.mocked(osType).mockReturnValue('ios');
mocks.fetchMediaBlob.mockResolvedValue(new Blob(['data'], { type: 'image/png' }));

await saveMediaToGallery(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo',
'photo.png',
'image/png'
);

expect(mocks.fetchMediaBlob).toHaveBeenCalledWith(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo'
);
expect(invoke).toHaveBeenCalledWith('save_media_to_photos', {
filename: 'photo.png',
mimeType: 'image/png',
bytes: [100, 97, 116, 97],
});
});

it('shows a failure toast when the iOS Photos command rejects', async () => {
vi.mocked(osType).mockReturnValue('ios');
vi.mocked(invoke).mockRejectedValue(new Error('photos unavailable'));
Expand Down
23 changes: 15 additions & 8 deletions src/app/utils/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const BIDI_CONTROL_CHARS = /[\u202a-\u202e\u2066-\u2069]/g;
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
const MAX_FILENAME_LENGTH = 255;

const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);

const nonEmptyString = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
Expand Down Expand Up @@ -99,8 +102,7 @@ export async function saveMediaToGallery(
showToast('Saved to Gallery');
} catch (error) {
if (uri) await AndroidFs.removeFile(uri).catch(() => undefined);
const message = error instanceof Error ? error.message : 'unknown error';
showToast(`Failed to save to gallery: ${message}`);
showToast(`Failed to save to gallery: ${getErrorMessage(error)}`);
}
return;
}
Expand All @@ -116,8 +118,7 @@ export async function saveMediaToGallery(
});
showToast('Saved to Photos');
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
showToast(`Failed to save to photos: ${message}`);
showToast(`Failed to save to photos: ${getErrorMessage(error)}`);
}
}

Expand All @@ -126,9 +127,16 @@ export async function saveFileToDevice(
filename: string,
mimeType?: string
): Promise<'saved' | 'cancelled' | 'failed'> {
let blob: Blob;
try {
blob = await resolveBlob(input);
} catch (error) {
showToast(`Failed to save file: ${getErrorMessage(error)}`);
return 'failed';
}

if (isTauri()) {
try {
const blob = await resolveBlob(input);
const bytes = new Uint8Array(await blob.arrayBuffer());

if (osType() === 'android') {
Expand Down Expand Up @@ -173,13 +181,12 @@ export async function saveFileToDevice(
if (saved) showToast('File saved');
return saved ? 'saved' : 'cancelled';
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
showToast(`Failed to save file: ${message}`);
showToast(`Failed to save file: ${getErrorMessage(error)}`);
return 'failed';
}
}

FileSaver.saveAs(input, filename);
FileSaver.saveAs(blob, filename);
return 'saved';
}

Expand Down
Loading