From 14a890796ef7509de8e0258050a034c9f94659b8 Mon Sep 17 00:00:00 2001 From: Varun Patil Date: Mon, 13 Nov 2023 22:39:12 -0800 Subject: [PATCH] nx: implement multi-share (fix #901) Signed-off-by: Varun Patil --- lib/Controller/ImageController.php | 24 +++- src/components/SelectionManager.vue | 14 +++ src/components/modal/ShareModal.vue | 172 ++++++++++++++-------------- src/components/viewer/Viewer.vue | 4 +- src/globals.d.ts | 2 +- src/native/api.ts | 22 ++-- src/native/share.ts | 26 ++--- src/services/utils/helpers.ts | 8 ++ 8 files changed, 152 insertions(+), 120 deletions(-) diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index 5e2c7a05..4f94c6e2 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -29,7 +29,6 @@ use OCA\Memories\Exif; use OCA\Memories\Service; use OCA\Memories\Util; use OCP\AppFramework\Http; -use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\Files\IRootFolder; @@ -56,11 +55,22 @@ class ImageController extends GenericApiController throw Exceptions::MissingParameter('id, x, y'); } + // Get preview for this file $file = $this->fs->getUserFile($id); $preview = \OC::$server->get(\OCP\IPreview::class)->getPreview($file, $x, $y, !$a, $mode); - $response = new FileDisplayResponse($preview, Http::STATUS_OK, [ - 'Content-Type' => $preview->getMimeType(), - ]); + + // Get the filename. We need to move the extension from + // the preview file to the filename's end if it's not there + // Do the comparison case-insensitive + $filename = $file->getName(); + if ($ext = pathinfo($preview->getName(), PATHINFO_EXTENSION)) { + if (!str_ends_with(strtolower($filename), strtolower('.'.$ext))) { + $filename .= '.'.$ext; + } + } + + // Generate response with proper content-disposition + $response = new Http\DataDownloadResponse($preview->getContent(), $filename, $preview->getMimeType()); $response->cacheFor(3600 * 24, false, true); return $response; @@ -293,13 +303,17 @@ class ImageController extends GenericApiController /** @var string Blob of image */ $blob = $file->getContent(); + /** @var string Name of file */ + $name = $file->getName(); + // Convert image to JPEG if required if (!\in_array($mimetype, ['image/png', 'image/webp', 'image/jpeg', 'image/gif'], true)) { [$blob, $mimetype] = $this->getImageJPEG($blob, $mimetype); + $name .= '.jpg'; } // Return the image - $response = new Http\DataDisplayResponse($blob, Http::STATUS_OK, ['Content-Type' => $mimetype]); + $response = new Http\DataDownloadResponse($blob, $name, $mimetype); $response->cacheFor(3600 * 24, false, false); return $response; diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 9067adde..12cb352b 100644 --- a/src/components/SelectionManager.vue +++ b/src/components/SelectionManager.vue @@ -50,6 +50,7 @@ import * as dav from '@services/dav'; import * as utils from '@services/utils'; import * as nativex from '@native'; +import ShareIcon from 'vue-material-design-icons/ShareVariant.vue'; import StarIcon from 'vue-material-design-icons/Star.vue'; import DownloadIcon from 'vue-material-design-icons/Download.vue'; import DeleteIcon from 'vue-material-design-icons/TrashCanOutline.vue'; @@ -203,6 +204,12 @@ export default defineComponent({ callback: this.deleteSelection.bind(this), if: () => this.routeIsAlbums, }, + { + name: t('memories', 'Share'), + icon: ShareIcon, + callback: this.shareSelection.bind(this), + if: () => !this.routeIsAlbums, + }, { name: t('memories', 'Download'), icon: DownloadIcon, @@ -809,6 +816,13 @@ export default defineComponent({ } }, + /** + * Share the currently selected photos + */ + shareSelection(selection: Selection) { + _m.modals.sharePhotos(selection.photosNoDupFileId()); + }, + /** * Open the edit date dialog */ diff --git a/src/components/modal/ShareModal.vue b/src/components/modal/ShareModal.vue index a597c9a9..5b15901f 100644 --- a/src/components/modal/ShareModal.vue +++ b/src/components/modal/ShareModal.vue @@ -1,7 +1,7 @@ @@ -55,7 +47,7 @@ @@ -112,47 +104,57 @@ export default defineComponent({ mixins: [UserConfig, ModalMixin], data: () => ({ - photo: null as IPhoto | null, + photos: null as IPhoto[] | null, loading: 0, }), created() { - console.assert(!_m.modals.sharePhoto, 'ShareModal created twice'); - _m.modals.sharePhoto = this.open; + console.assert(!_m.modals.sharePhotos, 'ShareModal created twice'); + _m.modals.sharePhotos = this.open; }, computed: { - isVideo(): boolean { - return !!this.photo && (this.photo.mimetype?.startsWith('video/') || !!(this.photo.flag & this.c.FLAG_IS_VIDEO)); + isSingle(): boolean { + return this.photos?.length === 1 ?? false; + }, + + hasVideos(): boolean { + return !!this.photos?.some(utils.isVideo); }, canShareNative(): boolean { return 'share' in navigator || nativex.has(); }, - canShareTranscode(): boolean { - return !this.isLocal && (!this.isVideo || !this.config.vod_disable); + canShareLowRes(): boolean { + // Only allow transcoding videos if a single video is selected + return !this.hasLocal && (!this.hasVideos || (!this.config.vod_disable && this.isSingle)); + }, + + canShareHighRes(): boolean { + // High-CPU operations only permitted for single node + return this.isSingle && this.canShareLowRes; }, canShareLink(): boolean { - return !!this.photo?.imageInfo?.permissions?.includes('S') && !this.routeIsAlbums; + return !this.routeIsAlbums && !!this.photos?.every((p) => p?.imageInfo?.permissions?.includes('S')); }, - isLocal(): boolean { - return utils.isLocalPhoto(this.photo!); + hasLocal(): boolean { + return !!this.photos?.some(utils.isLocalPhoto); }, }, methods: { - open(photo: IPhoto) { - this.photo = photo; + open(photos: IPhoto[]) { + this.photos = photos; this.loading = 0; this.show = true; }, cleanup() { this.show = false; - this.photo = null; + this.photos = null; }, async l(cb: () => Promise): Promise { @@ -164,93 +166,89 @@ export default defineComponent({ } }, - async sharePreview() { - const src = this.isVideo - ? API.VIDEO_TRANSCODE(this.photo!.fileid, '480p.mp4') - : utils.getPreviewUrl({ photo: this.photo!, size: 2048 }); - this.shareWithHref(src, true); + async shareLowRes() { + await this.shareWithHref( + this.photos!.map((photo) => ({ + auid: String(), // no local + href: utils.isVideo(photo) + ? API.VIDEO_TRANSCODE(photo.fileid, '480p.mp4') + : utils.getPreviewUrl({ photo, size: 2048 }), + })), + ); }, async shareHighRes() { - const fileid = this.photo!.fileid; - const src = this.isVideo - ? API.VIDEO_TRANSCODE(fileid, '1080p.mp4') - : API.IMAGE_DECODABLE(fileid, this.photo!.etag); - this.shareWithHref(src, !this.isVideo); + await this.shareWithHref( + this.photos!.map((photo) => ({ + auid: String(), // no local + href: utils.isVideo(photo) + ? API.VIDEO_TRANSCODE(photo.fileid, '1080p.mp4') + : API.IMAGE_DECODABLE(photo.fileid, photo.etag), + })), + ); }, async shareOriginal() { - if (nativex.has()) { - try { - return await this.l(async () => await nativex.shareLocal(this.photo!)); - } catch (e) { - // maybe the file doesn't exist locally - } - - // if it's purel local, we can't share it - if (this.isLocal) return; - } - - await this.shareWithHref(dav.getDownloadLink(this.photo!)); + await this.shareWithHref( + this.photos!.map((photo) => ({ + auid: photo.auid ?? String(), + href: dav.getDownloadLink(photo), + })), + ); }, async shareLink() { - const fileInfo = await this.l(async () => (await dav.getFiles([this.photo!]))[0]); + const fileInfo = await this.l(async () => (await dav.getFiles([this.photos![0]]))[0]); await this.close(); // wait till transition is done _m.modals.shareNodeLink(fileInfo.filename, true); }, /** * Download a file and then share the blob. + * + * If a download object includes AUID then local download + * is allowed when on NativeX. */ - async shareWithHref(href: string, replaceExt = false) { + async shareWithHref( + objects: { + auid: string; + href: string; + }[], + ) { if (nativex.has()) { - return await this.l(async () => nativex.shareBlobFromUrl(href)); + return await this.l(async () => nativex.shareBlobs(objects)); } - let blob: Blob | undefined; - await this.l(async () => { - const res = await axios.get(href, { responseType: 'blob' }); - blob = res.data; + // Pull blobs in parallel + const calls = objects.map((obj) => async () => { + return await this.l(async () => { + try { + return await axios.get(obj.href, { responseType: 'blob' }); + } catch (e) { + showError(this.t('memories', 'Failed to download file {href}', { href: obj.href })); + return null; + } + }); }); - if (!blob) { - showError(this.t('memories', 'Failed to download file')); - return; - } - - let basename = this.photo?.basename ?? 'blank'; - - if (replaceExt) { - // Fix basename extension - let targetExts: string[] = []; - if (blob.type === 'image/png') { - targetExts = ['png']; - } else { - targetExts = ['jpg', 'jpeg']; - } - - // Append extension if not found - const baseExt = basename.split('.').pop()?.toLowerCase() ?? ''; - if (!targetExts.includes(baseExt)) { - basename += '.' + targetExts[0]; + // Get all blobs from parallel calls + const files: File[] = []; + for await (const responses of dav.runInParallel(calls, 8)) { + for (const res of responses.filter(Boolean)) { + const filename = res!.headers['content-disposition']?.match(/filename="(.+)"/)?.[1] ?? ''; + const blob = res!.data; + files.push(new File([blob], filename, { type: blob.type })); } } + if (!files.length) return; - const data = { - files: [ - new File([blob], basename, { - type: blob.type, - }), - ], - }; - - if (!navigator.canShare(data)) { + // Check if we can share this type of data + if (!navigator.canShare({ files })) { showError(this.t('memories', 'Cannot share this type of data')); } try { - await navigator.share(data); + await navigator.share({ files }); } catch (e) { // Don't show this error because it's silly stuff // like "share canceled" diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index c6043479..242504e5 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -986,8 +986,8 @@ export default defineComponent({ }, /** Share the current photo externally */ - async shareCurrent() { - _m.modals.sharePhoto(this.currentPhoto!); + shareCurrent() { + _m.modals.sharePhotos([this.currentPhoto!]); }, /** Key press events */ diff --git a/src/globals.d.ts b/src/globals.d.ts index 774f5a9e..4e4e7b61 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -40,7 +40,7 @@ declare global { modals: { editMetadata: (photos: IPhoto[], sections?: number[]) => void; updateAlbums: (photos: IPhoto[]) => void; - sharePhoto: (photo: IPhoto) => void; + sharePhotos: (photo: IPhoto[]) => void; shareNodeLink: (path: string, immediate?: boolean) => Promise; moveToFolder: (photos: IPhoto[]) => void; moveToFace: (photos: IPhoto[]) => void; diff --git a/src/native/api.ts b/src/native/api.ts index be4e0864..0f1a4606 100644 --- a/src/native/api.ts +++ b/src/native/api.ts @@ -69,22 +69,15 @@ export const NAPI = { */ SHARE_URL: (url: string) => `${BASE_URL}/api/share/url/${euc(euc(url))}`, /** - * Share an object (as blob) natively using a given URL. + * Share an object (as blob) natively. + * The list of objects to share is already set using setShareBlobs * The native client MUST download the object using a download manager * and immediately prompt the user to download it. The asynchronous call * must return only after the object has been downloaded. - * @regex ^/api/share/blob/.+$ - * @param url URL to share (double-encoded) + * @regex ^/api/share/blobs$ * @returns {void} */ - SHARE_BLOB: (url: string) => `${BASE_URL}/api/share/blob/${euc(euc(url))}`, - /** - * Share a local file (as blob) with native page. - * @regex ^/api/share/local/\d+$ - * @param auid AUID of the photo - * @returns {void} - */ - SHARE_LOCAL: (auid: string) => `${BASE_URL}/api/share/local/${auid}`, + SHARE_BLOBS: () => `${BASE_URL}/api/share/blobs`, /** * Allow usage of local media (permissions request) @@ -139,6 +132,13 @@ export type NativeX = { */ downloadFromUrl: (url: string, filename: string) => void; + /** + * Set the list of objects to share with SHARE_BLOB API. + * @param objects List of objects to share (JSON-encoded) + * @details The SHARE_BLOB API must be called immediately after this call. + */ + setShareBlobs: (objects: string) => void; + /** * Play a video from the given AUID or URL(s). * @param auid AUID of file (will play local if available) diff --git a/src/native/share.ts b/src/native/share.ts index 9701c02e..819cc2fd 100644 --- a/src/native/share.ts +++ b/src/native/share.ts @@ -1,7 +1,6 @@ import axios from '@nextcloud/axios'; -import { BASE_URL, NAPI, nativex } from './api'; +import { NAPI, nativex } from './api'; import { addOrigin } from './basic'; -import type { IPhoto } from '@typings'; /** * Download a file from the given URL. @@ -29,17 +28,16 @@ export async function shareUrl(url: string) { /** * Download a blob from the given URL and share it. */ -export async function shareBlobFromUrl(url: string) { - if (url.startsWith(BASE_URL)) { - throw new Error('Cannot share localhost URL'); - } - await axios.get(NAPI.SHARE_BLOB(addOrigin(url))); -} +export async function shareBlobs( + objects: { + auid: string; + href: string; + }[], +) { + // Make sure all URLs are absolute + objects.forEach((obj) => (obj.href = addOrigin(obj.href))); -/** - * Share a local file with native page. - */ -export async function shareLocal(photo: IPhoto) { - if (!photo.auid) throw new Error('Cannot share local file without AUID'); - await axios.get(NAPI.SHARE_LOCAL(photo.auid)); + // Hand off to native client + nativex.setShareBlobs(JSON.stringify(objects)); + await axios.get(NAPI.SHARE_BLOBS()); } diff --git a/src/services/utils/helpers.ts b/src/services/utils/helpers.ts index 417c6ac3..5c577cad 100644 --- a/src/services/utils/helpers.ts +++ b/src/services/utils/helpers.ts @@ -107,6 +107,14 @@ export function isLocalPhoto(photo: IPhoto): boolean { return Boolean(photo?.fileid) && Boolean((photo?.flag ?? 0) & c.FLAG_IS_LOCAL); } +/** + * Check if an object is a video + * @param photo Photo object + */ +export function isVideo(photo: IPhoto): boolean { + return !!photo?.mimetype?.startsWith('video/') || !!(photo.flag & c.FLAG_IS_VIDEO); +} + /** * Get the URL for the imageInfo of a photo *