diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 3cd83efa1a..25bbf14b4a 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -158,13 +158,19 @@ export class ClientServerService { 'purpose': 'any', }], 'share_target': { - 'action': '/share/', - 'method': 'GET', - 'enctype': 'application/x-www-form-urlencoded', + 'action': '/sw/share', + 'method': 'POST', + 'enctype': 'multipart/form-data', 'params': { 'title': 'title', 'text': 'text', 'url': 'url', + 'files': [ + { + 'name': 'files', + 'accept': '*/*', + }, + ], }, }, 'shortcuts': [{ diff --git a/packages/backend/src/server/web/manifest.json b/packages/backend/src/server/web/manifest.json index 90d4530857..5c06fe61a8 100644 --- a/packages/backend/src/server/web/manifest.json +++ b/packages/backend/src/server/web/manifest.json @@ -26,13 +26,19 @@ } ], "share_target": { - "action": "/share/", - "method": "GET", - "enctype": "application/x-www-form-urlencoded", + "action": "/sw/share", + "method": "POST", + "enctype": "multipart/form-data", "params": { "title": "title", "text": "text", - "url": "url" + "url": "url", + "files": [ + { + "name": "files", + "accept": "*/*" + } + ] } }, "shortcuts": [ diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 17f93a4ec8..429fc605ad 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -227,6 +227,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/pages/share.vue b/packages/frontend/src/pages/share.vue index 368537ec91..9c24e0a3ec 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, del } 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 = ''; @@ -181,6 +196,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') && urlParams.get('file').startsWith('data:')) { + 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'; } @@ -201,10 +239,31 @@ function goToMisskey(): void { function onPosted(): void { state.value = 'posted'; + // SWが保存したファイルは投稿が完了するまでIndexedDBに保持 + del('share-files-temp'); 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 90c8605f31..00d274656b 100644 --- a/packages/frontend/src/types/post-form.ts +++ b/packages/frontend/src/types/post-form.ts @@ -22,6 +22,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..933437f8cf 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'; @@ -40,6 +40,34 @@ async function offlineContentHTML() { } globalThis.addEventListener('fetch', 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(); + + // とりあえず初期化 (IndexedDBの削除は時間がかかる可能性があるため空の配列をセット) + await set('share-url-temp', []); + 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); + })()); + return; + } + + //#region others let isHTMLRequest = false; if (ev.request.headers.get('sec-fetch-dest') === 'document') { isHTMLRequest = true; @@ -62,6 +90,7 @@ globalThis.addEventListener('fetch', ev => { }); }), ); + //#endregion }); globalThis.addEventListener('push', ev => {