From 539356023d4b964f41a73cc7b98f201d0ad3e433 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sat, 26 Jul 2025 01:43:19 +0900 Subject: [PATCH] :v: --- .../frontend/src/components/MkPostForm.vue | 8 +- .../src/components/MkUploaderDialog.vue | 4 +- .../frontend/src/composables/use-uploader.ts | 148 +++++++++--------- packages/frontend/src/pages/share.vue | 59 ++++++- packages/frontend/src/types/post-form.ts | 1 + packages/sw/src/sw.ts | 30 +++- 6 files changed, 165 insertions(+), 85 deletions(-) diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 174a73e0fd..9e4cc54dce 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -146,7 +146,7 @@ import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; -import { useUploader } from '@/composables/use-uploader.js'; +import { FileUploader } from '@/composables/use-uploader.js'; const $i = ensureSignin(); @@ -214,7 +214,7 @@ const targetChannel = shallowRef(props.channel); const serverDraftId = ref(null); const postFormActions = getPluginHandlers('post_form_action'); -const uploader = useUploader({ +const uploader = new FileUploader({ multiple: true, }); @@ -223,6 +223,10 @@ uploader.events.on('itemUploaded', ctx => { uploader.removeItem(ctx.item); }); +if (props.initialLocalFiles) { + uploader.addFiles(props.initialLocalFiles); +} + const draftKey = computed((): string => { let key = targetChannel.value ? `channel:${targetChannel.value.id}` : ''; diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index ce098d71e4..372a61b881 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -58,7 +58,7 @@ import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; -import { useUploader } from '@/composables/use-uploader.js'; +import { FileUploader } from '@/composables/use-uploader.js'; import MkUploaderItems from '@/components/MkUploaderItems.vue'; const $i = ensureSignin(); @@ -80,7 +80,7 @@ const emit = defineEmits<{ const dialog = useTemplateRef('dialog'); -const uploader = useUploader({ +const uploader = new FileUploader({ multiple: props.multiple, folderId: props.folderId, features: props.features, diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts index 826d8c5203..63b6a2f437 100644 --- a/packages/frontend/src/composables/use-uploader.ts +++ b/packages/frontend/src/composables/use-uploader.ts @@ -99,31 +99,43 @@ function getCompressionSettings(level: 0 | 1 | 2 | 3) { } } -export function useUploader(options: { - folderId?: string | null; - multiple?: boolean; - features?: UploaderFeatures; -} = {}) { - const $i = ensureSignin(); - - const events = new EventEmitter<{ +export class FileUploader { + private $i: Misskey.entities.MeDetailed; + public events = new EventEmitter<{ 'itemUploaded': (ctx: { item: UploaderItem; }) => void; }>(); - - const uploaderFeatures = computed>(() => { + private uploaderFeatures = computed>(() => { return { - imageEditing: options.features?.imageEditing ?? true, - watermark: options.features?.watermark ?? true, + imageEditing: this.options.features?.imageEditing ?? true, + watermark: this.options.features?.watermark ?? true, }; }); + public items = ref([]); + public uploading = computed(() => this.items.value.some(item => item.uploading)); + public readyForUpload = computed(() => this.items.value.length > 0 && this.items.value.some(item => item.uploaded == null) && !this.items.value.some(item => item.uploading || item.preprocessing)); + public allItemsUploaded = computed(() => this.items.value.every(item => item.uploaded != null)); - const items = ref([]); + constructor( + public options: { + folderId?: string | null; + multiple?: boolean; + features?: UploaderFeatures; + } = {} + ) { + this.$i = ensureSignin(); - function initializeFile(file: File) { + onUnmounted(() => { + for (const item of this.items.value) { + if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); + } + }); + } + + private initializeFile(file: File) { const id = genId(); const filename = file.name ?? 'untitled'; const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - items.value.push({ + this.items.value.push({ id, name: prefer.s.keepOriginalFilename ? filename : id + extension, progress: null, @@ -134,27 +146,27 @@ export function useUploader(options: { uploaded: null, uploadFailed: false, compressionLevel: prefer.s.defaultImageCompressionLevel, - watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null, + watermarkPresetId: this.uploaderFeatures.value.watermark && this.$i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null, file: markRaw(file), }); - const reactiveItem = items.value.at(-1)!; - preprocess(reactiveItem).then(() => { - triggerRef(items); + const reactiveItem = this.items.value.at(-1)!; + this.preprocess(reactiveItem).then(() => { + triggerRef(this.items); }); } - function addFiles(newFiles: File[]) { + public addFiles(newFiles: File[]) { for (const file of newFiles) { - initializeFile(file); + this.initializeFile(file); } } - function removeItem(item: UploaderItem) { + public removeItem(item: UploaderItem) { if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); - items.value.splice(items.value.indexOf(item), 1); + this.items.value.splice(this.items.value.indexOf(item), 1); } - function getMenu(item: UploaderItem): MenuItem[] { + public getMenu(item: UploaderItem): MenuItem[] { const menu: MenuItem[] = []; if ( @@ -206,7 +218,7 @@ export function useUploader(options: { } if ( - uploaderFeatures.value.imageEditing && + this.uploaderFeatures.value.imageEditing && IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && @@ -222,14 +234,14 @@ export function useUploader(options: { action: async () => { const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); - items.value.splice(items.value.indexOf(item), 1, { + this.items.value.splice(this.items.value.indexOf(item), 1, { ...item, file: markRaw(cropped), thumbnail: window.URL.createObjectURL(cropped), }); - const reactiveItem = items.value.find(x => x.id === item.id)!; - preprocess(reactiveItem).then(() => { - triggerRef(items); + const reactiveItem = this.items.value.find(x => x.id === item.id)!; + this.preprocess(reactiveItem).then(() => { + triggerRef(this.items); }); }, }, /*{ @@ -247,14 +259,14 @@ export function useUploader(options: { }, { ok: (file) => { if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); - items.value.splice(items.value.indexOf(item), 1, { + this.items.value.splice(this.items.value.indexOf(item), 1, { ...item, file: markRaw(file), thumbnail: window.URL.createObjectURL(file), }); - const reactiveItem = items.value.find(x => x.id === item.id)!; - preprocess(reactiveItem).then(() => { - triggerRef(items); + const reactiveItem = this.items.value.find(x => x.id === item.id)!; + this.preprocess(reactiveItem).then(() => { + triggerRef(this.items); }); }, closed: () => dispose(), @@ -265,19 +277,19 @@ export function useUploader(options: { } if ( - uploaderFeatures.value.watermark && - $i.policies.watermarkAvailable && + this.uploaderFeatures.value.watermark && + this.$i.policies.watermarkAvailable && WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded ) { - function changeWatermarkPreset(presetId: string | null) { + const changeWatermarkPreset = (presetId: string | null) => { item.watermarkPresetId = presetId; - preprocess(item).then(() => { - triggerRef(items); + this.preprocess(item).then(() => { + triggerRef(this.items); }); - } + }; menu.push({ icon: 'ti ti-copyright', @@ -323,12 +335,12 @@ export function useUploader(options: { !item.uploading && !item.uploaded ) { - function changeCompressionLevel(level: 0 | 1 | 2 | 3) { + const changeCompressionLevel = (level: 0 | 1 | 2 | 3) => { item.compressionLevel = level; - preprocess(item).then(() => { - triggerRef(items); + this.preprocess(item).then(() => { + triggerRef(this.items); }); - } + }; menu.push({ icon: 'ti ti-leaf', @@ -381,14 +393,14 @@ export function useUploader(options: { icon: 'ti ti-upload', text: i18n.ts.upload, action: () => { - uploadOne(item); + this.uploadOne(item); }, }, { icon: 'ti ti-x', text: i18n.ts.remove, danger: true, action: () => { - removeItem(item); + this.removeItem(item); }, }); } else if (item.uploading) { @@ -409,13 +421,13 @@ export function useUploader(options: { return menu; } - async function uploadOne(item: UploaderItem): Promise { + private async uploadOne(item: UploaderItem): Promise { item.uploadFailed = false; item.uploading = true; const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, { name: item.uploadName ?? item.name, - folderId: options.folderId === undefined ? prefer.s.uploadFolder : options.folderId, + folderId: this.options.folderId === undefined ? prefer.s.uploadFolder : this.options.folderId, isSensitive: item.isSensitive ?? false, caption: item.caption ?? null, onProgress: (progress) => { @@ -438,7 +450,7 @@ export function useUploader(options: { await filePromise.then((file) => { item.uploaded = file; item.abort = null; - events.emit('itemUploaded', { item }); + this.events.emit('itemUploaded', { item }); }).catch(err => { item.uploadFailed = true; item.progress = null; @@ -450,26 +462,26 @@ export function useUploader(options: { }); } - async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる - items.value = items.value.map(item => ({ + public async upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる + this.items.value = this.items.value.map(item => ({ ...item, aborted: false, uploadFailed: false, uploading: false, })); - for (const item of items.value.filter(item => item.uploaded == null)) { + for (const item of this.items.value.filter(item => item.uploaded == null)) { // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック if (item.aborted) { continue; } - await uploadOne(item); + await this.uploadOne(item); } } - function abortAll() { - for (const item of items.value) { + public abortAll() { + for (const item of this.items.value) { if (item.uploaded != null) { continue; } @@ -482,12 +494,12 @@ export function useUploader(options: { } } - async function preprocess(item: UploaderItem): Promise { + private async preprocess(item: UploaderItem): Promise { item.preprocessing = true; try { if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) { - await preprocessForImage(item); + await this.preprocessForImage(item); } } catch (err) { console.error('Failed to preprocess image', err); @@ -498,12 +510,12 @@ export function useUploader(options: { item.preprocessing = false; } - async function preprocessForImage(item: UploaderItem): Promise { + private async preprocessForImage(item: UploaderItem): Promise { const imageBitmap = await window.createImageBitmap(item.file); let preprocessedFile: Blob | File = item.file; - const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable; + const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && this.$i.policies.watermarkAvailable; const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId); if (needsWatermark && preset != null) { const canvas = window.document.createElement('canvas'); @@ -563,24 +575,4 @@ export function useUploader(options: { item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null; item.preprocessedFile = markRaw(preprocessedFile); } - - onUnmounted(() => { - for (const item of items.value) { - if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); - } - }); - - return { - items, - addFiles, - removeItem, - abortAll, - upload, - getMenu, - uploading: computed(() => items.value.some(item => item.uploading)), - readyForUpload: computed(() => items.value.length > 0 && items.value.some(item => item.uploaded == null) && !items.value.some(item => item.uploading || item.preprocessing)), - allItemsUploaded: computed(() => items.value.every(item => item.uploaded != null)), - events, - }; } - diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 51ac9d66f0..fe23112c43 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only :initialText="initialText" :initialVisibility="visibility" :initialFiles="files" + :initialLocalFiles="tempFiles" :initialLocalOnly="localOnly" :reply="reply" :renote="renote" @@ -33,6 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; +import { get } from 'idb-keyval'; import MkButton from '@/components/MkButton.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; @@ -41,7 +43,19 @@ import { definePage } from '@/page.js'; import { postMessageToParentWindow } from '@/utility/post-message.js'; import { i18n } from '@/i18n.js'; +//#region parameters const urlParams = new URLSearchParams(window.location.search); +// merge hash parameters +try { + const hashParams = new URLSearchParams(window.location.hash.slice(1)); + for (const [key, value] of hashParams.entries()) { + urlParams.set(key, value); + } +} catch (e) { + console.error('Failed to parse hash parameters:', e); +} +//#endregion + const localOnlyQuery = urlParams.get('localOnly'); const visibilityQuery = urlParams.get('visibility') as typeof Misskey.noteVisibilities[number]; @@ -56,6 +70,7 @@ const visibility = ref(Misskey.noteVisibilities.includes(visibilityQuery) ? visi const localOnly = ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : undefined); const files = ref([] as Misskey.entities.DriveFile[]); const visibleUsers = ref([] as Misskey.entities.UserDetailed[]); +const tempFiles = ref([] as File[]); async function init() { let noteText = ''; @@ -182,6 +197,29 @@ async function init() { }); } + //#region Local files + // If the browser supports IndexedDB, try to get the temporary files from temp. + if (typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true) { + const filesFromIdb = await get('share-files-temp'); + if (Array.isArray(filesFromIdb) && filesFromIdb.length > 0 && filesFromIdb.every(file => file instanceof Blob)) { + tempFiles.value = filesFromIdb; + } + } + + if (urlParams.has('file')) { + try { + const file = await window.fetch(urlParams.get('file')).then(res => res.blob()); + if (file instanceof Blob) { + tempFiles.value.push(file as File); + } else { + console.error('Fetched file is not a Blob:', file); + } + } catch (e) { + console.error('Failed to fetch file:', e); + } + } + //#endregion + state.value = 'writing'; } @@ -205,7 +243,26 @@ function onPosted(): void { postMessageToParentWindow('misskey:shareForm:shareCompleted'); } -const headerActions = computed(() => []); +const headerActions = computed(() => [ + { + icon: 'ti ti-dots', + text: i18n.ts.menu, + handler: (ev: MouseEvent) => { + os.popupMenu([ + { + icon: 'ti ti-home', + text: i18n.ts.goToMisskey, + action: () => goToMisskey(), + }, + { + icon: 'ti ti-x', + text: i18n.ts.close, + action: () => close(), + }, + ], ev.currentTarget ?? ev.target); + }, + }, +]); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/types/post-form.ts b/packages/frontend/src/types/post-form.ts index 10e68d2d4a..9cd2804fbc 100644 --- a/packages/frontend/src/types/post-form.ts +++ b/packages/frontend/src/types/post-form.ts @@ -15,6 +15,7 @@ export interface PostFormProps { initialCw?: string; initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; + initialLocalFiles?: File[]; initialLocalOnly?: boolean; initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index 298af4b4b6..15bdc80389 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { get } from 'idb-keyval'; +import { get, set } from 'idb-keyval'; import * as Misskey from 'misskey-js'; import type { PushNotificationDataMap } from '@/types.js'; import type { I18n } from '@@/js/i18n.js'; @@ -39,7 +39,32 @@ async function offlineContentHTML() { return `${messages.title}
${messages.header}
v${_VERSION_}
`; } -globalThis.addEventListener('fetch', ev => { +globalThis.addEventListener('fetch', async ev => { + //#region /sw/share + const url = new URL(ev.request.url); + if (url.pathname === '/sw/share') { + ev.respondWith((async () => { + const responseUrl = new URL(ev.request.url); + responseUrl.pathname = '/share'; + const formData = await ev.request.formData(); + + if (formData.has('files')) { + const files = formData.getAll('files'); + if (files.length > 0 && files.every(file => file instanceof Blob)) { + set('share-files-temp', files); + } + } + + formData.delete('files'); + for (const [key, value] of formData.entries()) { + responseUrl.searchParams.set(key, value.toString()); + } + + return Response.redirect(responseUrl, 303); + })()); + } + + //#region others let isHTMLRequest = false; if (ev.request.headers.get('sec-fetch-dest') === 'document') { isHTMLRequest = true; @@ -62,6 +87,7 @@ globalThis.addEventListener('fetch', ev => { }); }), ); + //#endregion }); globalThis.addEventListener('push', ev => {