Skip to content

Commit

Permalink
nx: implement multi-share (fix #901)
Browse files Browse the repository at this point in the history
Signed-off-by: Varun Patil <radialapps@gmail.com>
  • Loading branch information
pulsejet committed Nov 14, 2023
1 parent fe74b9f commit 14a8907
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 120 deletions.
24 changes: 19 additions & 5 deletions lib/Controller/ImageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
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;

Expand All @@ -56,11 +55,22 @@ public function preview(
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;
Expand Down Expand Up @@ -293,13 +303,17 @@ public function decodable(string $id): Http\Response
/** @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;
Expand Down
14 changes: 14 additions & 0 deletions src/components/SelectionManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down
172 changes: 85 additions & 87 deletions src/components/modal/ShareModal.vue
Original file line number Diff line number Diff line change
@@ -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">
Expand All @@ -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()"
Expand All @@ -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>

Expand All @@ -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>

Expand Down Expand Up @@ -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> {
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/components/viewer/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 14a8907

Please sign in to comment.