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\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;

View File

@ -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
*/

View File

@ -1,7 +1,7 @@
<template>
<Modal ref="modal" @close="cleanup" size="normal" v-if="show">
<template #title>
{{ t('memories', 'Share File') }}
{{ n('memories', 'Share File', 'Share Files', photos?.length ?? 0) }}
</template>
<div class="loading-icon fill-block" v-if="loading > 0">
@ -10,25 +10,21 @@
<ul class="options" v-else>
<NcListItem
v-if="canShareNative && canShareTranscode"
v-if="canShareNative && canShareLowRes"
:title="t('memories', 'Reduced Size')"
:bold="false"
@click.prevent="sharePreview()"
@click.prevent="shareLowRes()"
>
<template #icon>
<PhotoIcon class="avatar" :size="24" />
</template>
<template #subtitle>
{{
isVideo
? t('memories', 'Share the video as a low quality MP4')
: t('memories', 'Share a lower resolution image preview')
}}
{{ t('memories', 'Share in lower quality (small file size)') }}
</template>
</NcListItem>
<NcListItem
v-if="canShareNative && canShareTranscode"
v-if="canShareNative && canShareHighRes"
:title="t('memories', 'High Resolution')"
:bold="false"
@click.prevent="shareHighRes()"
@ -37,11 +33,7 @@
<LargePhotoIcon class="avatar" :size="24" />
</template>
<template #subtitle>
{{
isVideo
? t('memories', 'Share the video as a high quality MP4')
: t('memories', 'Share the image as a high quality JPEG')
}}
{{ t('memories', 'Share in high quality (large file size)') }}
</template>
</NcListItem>
@ -55,7 +47,7 @@
<FileIcon class="avatar" :size="24" />
</template>
<template #subtitle>
{{ t('memories', 'Share the original image / video file') }}
{{ n('memories', 'Share the original file', 'Share the original files', photos?.length ?? 0) }}
</template>
</NcListItem>
@ -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<T>(cb: () => Promise<T>): Promise<T> {
@ -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"

View File

@ -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 */

2
src/globals.d.ts vendored
View File

@ -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<void>;
moveToFolder: (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 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)

View File

@ -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());
}

View File

@ -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
*