nx: implement multi-share (fix #901)

Signed-off-by: Varun Patil <radialapps@gmail.com>
pulsejet/aio-hw-docs
Varun Patil 2023-11-13 22:39:12 -08:00
parent fe74b9f089
commit 14a890796e
8 changed files with 152 additions and 120 deletions

View File

@ -29,7 +29,6 @@ use OCA\Memories\Exif;
use OCA\Memories\Service; use OCA\Memories\Service;
use OCA\Memories\Util; use OCA\Memories\Util;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\IRootFolder; use OCP\Files\IRootFolder;
@ -56,11 +55,22 @@ class ImageController extends GenericApiController
throw Exceptions::MissingParameter('id, x, y'); throw Exceptions::MissingParameter('id, x, y');
} }
// Get preview for this file
$file = $this->fs->getUserFile($id); $file = $this->fs->getUserFile($id);
$preview = \OC::$server->get(\OCP\IPreview::class)->getPreview($file, $x, $y, !$a, $mode); $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); $response->cacheFor(3600 * 24, false, true);
return $response; return $response;
@ -293,13 +303,17 @@ class ImageController extends GenericApiController
/** @var string Blob of image */ /** @var string Blob of image */
$blob = $file->getContent(); $blob = $file->getContent();
/** @var string Name of file */
$name = $file->getName();
// Convert image to JPEG if required // Convert image to JPEG if required
if (!\in_array($mimetype, ['image/png', 'image/webp', 'image/jpeg', 'image/gif'], true)) { if (!\in_array($mimetype, ['image/png', 'image/webp', 'image/jpeg', 'image/gif'], true)) {
[$blob, $mimetype] = $this->getImageJPEG($blob, $mimetype); [$blob, $mimetype] = $this->getImageJPEG($blob, $mimetype);
$name .= '.jpg';
} }
// Return the image // 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); $response->cacheFor(3600 * 24, false, false);
return $response; return $response;

View File

@ -50,6 +50,7 @@ import * as dav from '@services/dav';
import * as utils from '@services/utils'; import * as utils from '@services/utils';
import * as nativex from '@native'; import * as nativex from '@native';
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue';
import StarIcon from 'vue-material-design-icons/Star.vue'; import StarIcon from 'vue-material-design-icons/Star.vue';
import DownloadIcon from 'vue-material-design-icons/Download.vue'; import DownloadIcon from 'vue-material-design-icons/Download.vue';
import DeleteIcon from 'vue-material-design-icons/TrashCanOutline.vue'; import DeleteIcon from 'vue-material-design-icons/TrashCanOutline.vue';
@ -203,6 +204,12 @@ export default defineComponent({
callback: this.deleteSelection.bind(this), callback: this.deleteSelection.bind(this),
if: () => this.routeIsAlbums, if: () => this.routeIsAlbums,
}, },
{
name: t('memories', 'Share'),
icon: ShareIcon,
callback: this.shareSelection.bind(this),
if: () => !this.routeIsAlbums,
},
{ {
name: t('memories', 'Download'), name: t('memories', 'Download'),
icon: DownloadIcon, 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 * Open the edit date dialog
*/ */

View File

@ -1,7 +1,7 @@
<template> <template>
<Modal ref="modal" @close="cleanup" size="normal" v-if="show"> <Modal ref="modal" @close="cleanup" size="normal" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Share File') }} {{ n('memories', 'Share File', 'Share Files', photos?.length ?? 0) }}
</template> </template>
<div class="loading-icon fill-block" v-if="loading > 0"> <div class="loading-icon fill-block" v-if="loading > 0">
@ -10,25 +10,21 @@
<ul class="options" v-else> <ul class="options" v-else>
<NcListItem <NcListItem
v-if="canShareNative && canShareTranscode" v-if="canShareNative && canShareLowRes"
:title="t('memories', 'Reduced Size')" :title="t('memories', 'Reduced Size')"
:bold="false" :bold="false"
@click.prevent="sharePreview()" @click.prevent="shareLowRes()"
> >
<template #icon> <template #icon>
<PhotoIcon class="avatar" :size="24" /> <PhotoIcon class="avatar" :size="24" />
</template> </template>
<template #subtitle> <template #subtitle>
{{ {{ t('memories', 'Share in lower quality (small file size)') }}
isVideo
? t('memories', 'Share the video as a low quality MP4')
: t('memories', 'Share a lower resolution image preview')
}}
</template> </template>
</NcListItem> </NcListItem>
<NcListItem <NcListItem
v-if="canShareNative && canShareTranscode" v-if="canShareNative && canShareHighRes"
:title="t('memories', 'High Resolution')" :title="t('memories', 'High Resolution')"
:bold="false" :bold="false"
@click.prevent="shareHighRes()" @click.prevent="shareHighRes()"
@ -37,11 +33,7 @@
<LargePhotoIcon class="avatar" :size="24" /> <LargePhotoIcon class="avatar" :size="24" />
</template> </template>
<template #subtitle> <template #subtitle>
{{ {{ t('memories', 'Share in high quality (large file size)') }}
isVideo
? t('memories', 'Share the video as a high quality MP4')
: t('memories', 'Share the image as a high quality JPEG')
}}
</template> </template>
</NcListItem> </NcListItem>
@ -55,7 +47,7 @@
<FileIcon class="avatar" :size="24" /> <FileIcon class="avatar" :size="24" />
</template> </template>
<template #subtitle> <template #subtitle>
{{ t('memories', 'Share the original image / video file') }} {{ n('memories', 'Share the original file', 'Share the original files', photos?.length ?? 0) }}
</template> </template>
</NcListItem> </NcListItem>
@ -112,47 +104,57 @@ export default defineComponent({
mixins: [UserConfig, ModalMixin], mixins: [UserConfig, ModalMixin],
data: () => ({ data: () => ({
photo: null as IPhoto | null, photos: null as IPhoto[] | null,
loading: 0, loading: 0,
}), }),
created() { created() {
console.assert(!_m.modals.sharePhoto, 'ShareModal created twice'); console.assert(!_m.modals.sharePhotos, 'ShareModal created twice');
_m.modals.sharePhoto = this.open; _m.modals.sharePhotos = this.open;
}, },
computed: { computed: {
isVideo(): boolean { isSingle(): boolean {
return !!this.photo && (this.photo.mimetype?.startsWith('video/') || !!(this.photo.flag & this.c.FLAG_IS_VIDEO)); return this.photos?.length === 1 ?? false;
},
hasVideos(): boolean {
return !!this.photos?.some(utils.isVideo);
}, },
canShareNative(): boolean { canShareNative(): boolean {
return 'share' in navigator || nativex.has(); return 'share' in navigator || nativex.has();
}, },
canShareTranscode(): boolean { canShareLowRes(): boolean {
return !this.isLocal && (!this.isVideo || !this.config.vod_disable); // 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 { 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 { hasLocal(): boolean {
return utils.isLocalPhoto(this.photo!); return !!this.photos?.some(utils.isLocalPhoto);
}, },
}, },
methods: { methods: {
open(photo: IPhoto) { open(photos: IPhoto[]) {
this.photo = photo; this.photos = photos;
this.loading = 0; this.loading = 0;
this.show = true; this.show = true;
}, },
cleanup() { cleanup() {
this.show = false; this.show = false;
this.photo = null; this.photos = null;
}, },
async l<T>(cb: () => Promise<T>): Promise<T> { async l<T>(cb: () => Promise<T>): Promise<T> {
@ -164,93 +166,89 @@ export default defineComponent({
} }
}, },
async sharePreview() { async shareLowRes() {
const src = this.isVideo await this.shareWithHref(
? API.VIDEO_TRANSCODE(this.photo!.fileid, '480p.mp4') this.photos!.map((photo) => ({
: utils.getPreviewUrl({ photo: this.photo!, size: 2048 }); auid: String(), // no local
this.shareWithHref(src, true); href: utils.isVideo(photo)
? API.VIDEO_TRANSCODE(photo.fileid, '480p.mp4')
: utils.getPreviewUrl({ photo, size: 2048 }),
})),
);
}, },
async shareHighRes() { async shareHighRes() {
const fileid = this.photo!.fileid; await this.shareWithHref(
const src = this.isVideo this.photos!.map((photo) => ({
? API.VIDEO_TRANSCODE(fileid, '1080p.mp4') auid: String(), // no local
: API.IMAGE_DECODABLE(fileid, this.photo!.etag); href: utils.isVideo(photo)
this.shareWithHref(src, !this.isVideo); ? API.VIDEO_TRANSCODE(photo.fileid, '1080p.mp4')
: API.IMAGE_DECODABLE(photo.fileid, photo.etag),
})),
);
}, },
async shareOriginal() { async shareOriginal() {
if (nativex.has()) { await this.shareWithHref(
try { this.photos!.map((photo) => ({
return await this.l(async () => await nativex.shareLocal(this.photo!)); auid: photo.auid ?? String(),
} catch (e) { href: dav.getDownloadLink(photo),
// 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!));
}, },
async shareLink() { 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 await this.close(); // wait till transition is done
_m.modals.shareNodeLink(fileInfo.filename, true); _m.modals.shareNodeLink(fileInfo.filename, true);
}, },
/** /**
* Download a file and then share the blob. * 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()) { if (nativex.has()) {
return await this.l(async () => nativex.shareBlobFromUrl(href)); return await this.l(async () => nativex.shareBlobs(objects));
} }
let blob: Blob | undefined; // Pull blobs in parallel
await this.l(async () => { const calls = objects.map((obj) => async () => {
const res = await axios.get(href, { responseType: 'blob' }); return await this.l(async () => {
blob = res.data; 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) { // Get all blobs from parallel calls
showError(this.t('memories', 'Failed to download file')); const files: File[] = [];
return; 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] ?? '';
let basename = this.photo?.basename ?? 'blank'; const blob = res!.data;
files.push(new File([blob], filename, { type: blob.type }));
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];
} }
} }
if (!files.length) return;
const data = { // Check if we can share this type of data
files: [ if (!navigator.canShare({ files })) {
new File([blob], basename, {
type: blob.type,
}),
],
};
if (!navigator.canShare(data)) {
showError(this.t('memories', 'Cannot share this type of data')); showError(this.t('memories', 'Cannot share this type of data'));
} }
try { try {
await navigator.share(data); await navigator.share({ files });
} catch (e) { } catch (e) {
// Don't show this error because it's silly stuff // Don't show this error because it's silly stuff
// like "share canceled" // like "share canceled"

View File

@ -986,8 +986,8 @@ export default defineComponent({
}, },
/** Share the current photo externally */ /** Share the current photo externally */
async shareCurrent() { shareCurrent() {
_m.modals.sharePhoto(this.currentPhoto!); _m.modals.sharePhotos([this.currentPhoto!]);
}, },
/** Key press events */ /** Key press events */

2
src/globals.d.ts vendored
View File

@ -40,7 +40,7 @@ declare global {
modals: { modals: {
editMetadata: (photos: IPhoto[], sections?: number[]) => void; editMetadata: (photos: IPhoto[], sections?: number[]) => void;
updateAlbums: (photos: IPhoto[]) => void; updateAlbums: (photos: IPhoto[]) => void;
sharePhoto: (photo: IPhoto) => void; sharePhotos: (photo: IPhoto[]) => void;
shareNodeLink: (path: string, immediate?: boolean) => Promise<void>; shareNodeLink: (path: string, immediate?: boolean) => Promise<void>;
moveToFolder: (photos: IPhoto[]) => void; moveToFolder: (photos: IPhoto[]) => void;
moveToFace: (photos: IPhoto[]) => void; moveToFace: (photos: IPhoto[]) => void;

View File

@ -69,22 +69,15 @@ export const NAPI = {
*/ */
SHARE_URL: (url: string) => `${BASE_URL}/api/share/url/${euc(euc(url))}`, 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 * The native client MUST download the object using a download manager
* and immediately prompt the user to download it. The asynchronous call * and immediately prompt the user to download it. The asynchronous call
* must return only after the object has been downloaded. * must return only after the object has been downloaded.
* @regex ^/api/share/blob/.+$ * @regex ^/api/share/blobs$
* @param url URL to share (double-encoded)
* @returns {void} * @returns {void}
*/ */
SHARE_BLOB: (url: string) => `${BASE_URL}/api/share/blob/${euc(euc(url))}`, SHARE_BLOBS: () => `${BASE_URL}/api/share/blobs`,
/**
* 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}`,
/** /**
* Allow usage of local media (permissions request) * Allow usage of local media (permissions request)
@ -139,6 +132,13 @@ export type NativeX = {
*/ */
downloadFromUrl: (url: string, filename: string) => void; 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). * Play a video from the given AUID or URL(s).
* @param auid AUID of file (will play local if available) * @param auid AUID of file (will play local if available)

View File

@ -1,7 +1,6 @@
import axios from '@nextcloud/axios'; import axios from '@nextcloud/axios';
import { BASE_URL, NAPI, nativex } from './api'; import { NAPI, nativex } from './api';
import { addOrigin } from './basic'; import { addOrigin } from './basic';
import type { IPhoto } from '@typings';
/** /**
* Download a file from the given URL. * 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. * Download a blob from the given URL and share it.
*/ */
export async function shareBlobFromUrl(url: string) { export async function shareBlobs(
if (url.startsWith(BASE_URL)) { objects: {
throw new Error('Cannot share localhost URL'); auid: string;
} href: string;
await axios.get(NAPI.SHARE_BLOB(addOrigin(url))); }[],
} ) {
// Make sure all URLs are absolute
objects.forEach((obj) => (obj.href = addOrigin(obj.href)));
/** // Hand off to native client
* Share a local file with native page. nativex.setShareBlobs(JSON.stringify(objects));
*/ await axios.get(NAPI.SHARE_BLOBS());
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));
} }

View File

@ -107,6 +107,14 @@ export function isLocalPhoto(photo: IPhoto): boolean {
return Boolean(photo?.fileid) && Boolean((photo?.flag ?? 0) & c.FLAG_IS_LOCAL); 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 * Get the URL for the imageInfo of a photo
* *