forked from Mirrors/elk
feat: add support for the Web Share Target API (#1100)
Co-authored-by: userquin <userquin@gmail.com>
This commit is contained in:
parent
a6a825e553
commit
bede92404b
11 changed files with 221 additions and 7 deletions
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import type { Draft } from '~/types'
|
import type { Draft } from '~/types'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -90,6 +89,19 @@ async function publish() {
|
||||||
emit('published', status)
|
emit('published', status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useWebShareTarget(async ({ data: { data, action } }: any) => {
|
||||||
|
if (action !== 'compose-with-shared-data')
|
||||||
|
return
|
||||||
|
|
||||||
|
editor.value?.commands.focus('end')
|
||||||
|
|
||||||
|
if (data.text !== undefined)
|
||||||
|
editor.value?.commands.insertContent(data.text)
|
||||||
|
|
||||||
|
if (data.files !== undefined)
|
||||||
|
await uploadAttachments(data.files)
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusEditor: () => {
|
focusEditor: () => {
|
||||||
editor.value?.commands?.focus?.()
|
editor.value?.commands?.focus?.()
|
||||||
|
|
24
composables/web-share-target.ts
Normal file
24
composables/web-share-target.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
export function useWebShareTarget(listener?: (message: MessageEvent) => void) {
|
||||||
|
if (process.server)
|
||||||
|
return
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
// PWA must be installed to use share target
|
||||||
|
if (useNuxtApp().$pwa.isInstalled && 'serviceWorker' in navigator) {
|
||||||
|
if (listener)
|
||||||
|
navigator.serviceWorker.addEventListener('message', listener)
|
||||||
|
|
||||||
|
navigator.serviceWorker.getRegistration()
|
||||||
|
.then((registration) => {
|
||||||
|
if (registration && registration.active) {
|
||||||
|
// we need to signal the service worker that we are ready to receive data
|
||||||
|
registration.active.postMessage({ action: 'ready-to-receive' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Could not get registration', err))
|
||||||
|
|
||||||
|
if (listener)
|
||||||
|
onBeforeUnmount(() => navigator.serviceWorker.removeEventListener('message', listener))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -282,6 +282,11 @@
|
||||||
"label": "Logged in users"
|
"label": "Logged in users"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"share-target": {
|
||||||
|
"description": "Elk can be configured so that you can share content from other applications, simply install Elk on your device or computer and sign in.",
|
||||||
|
"hint": "In order to share content with Elk, Elk must be installed and you must be signed in.",
|
||||||
|
"title": "Share with Elk"
|
||||||
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"attachments_exceed_server_limit": "The number of attachments exceeded the limit per post.",
|
"attachments_exceed_server_limit": "The number of attachments exceeded the limit per post.",
|
||||||
"attachments_limit_error": "Limit per post exceeded",
|
"attachments_limit_error": "Limit per post exceeded",
|
||||||
|
|
|
@ -359,6 +359,11 @@
|
||||||
"label": "Wellness"
|
"label": "Wellness"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"share-target": {
|
||||||
|
"description": "Elk can be configured so that you can share content from other applications, simply install Elk on your device or computer and sign in.",
|
||||||
|
"hint": "In order to share content with Elk, Elk must be installed and you must be signed in.",
|
||||||
|
"title": "Share with Elk"
|
||||||
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"attachments_exceed_server_limit": "The number of attachments exceeded the limit per post.",
|
"attachments_exceed_server_limit": "The number of attachments exceeded the limit per post.",
|
||||||
"attachments_limit_error": "Limit per post exceeded",
|
"attachments_limit_error": "Limit per post exceeded",
|
||||||
|
|
|
@ -356,6 +356,11 @@
|
||||||
"label": "Bienestar"
|
"label": "Bienestar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"share-target": {
|
||||||
|
"description": "Elk puede ser configurado para que pueda compartir contenido desde otras aplicaciones, simplemente tiene que instalar Elk en su dispositivo u ordenador e iniciar sesión.",
|
||||||
|
"hint": "Para poder compartir contenido con Elk, debes instalar Elk e iniciar sesión.",
|
||||||
|
"title": "Compartir con Elk"
|
||||||
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"attachments_exceed_server_limit": "Número máximo de archivos adjuntos por publicación excedido.",
|
"attachments_exceed_server_limit": "Número máximo de archivos adjuntos por publicación excedido.",
|
||||||
"attachments_limit_error": "Límite por publicación excedido",
|
"attachments_limit_error": "Límite por publicación excedido",
|
||||||
|
|
|
@ -5,8 +5,12 @@ export default defineNuxtRouteMiddleware((to) => {
|
||||||
return
|
return
|
||||||
|
|
||||||
onMastoInit(() => {
|
onMastoInit(() => {
|
||||||
if (!currentUser.value)
|
if (!currentUser.value) {
|
||||||
return navigateTo(`/${currentServer.value}/public/local`)
|
if (to.path === '/home' && to.query['share-target'] !== undefined)
|
||||||
|
return navigateTo('/share-target')
|
||||||
|
else
|
||||||
|
return navigateTo(`/${currentServer.value}/public/local`)
|
||||||
|
}
|
||||||
if (to.path === '/')
|
if (to.path === '/')
|
||||||
return navigateTo('/home')
|
return navigateTo('/home')
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,12 +5,30 @@ import { getEnv } from '../../config/env'
|
||||||
import { i18n } from '../../config/i18n'
|
import { i18n } from '../../config/i18n'
|
||||||
import type { LocaleObject } from '#i18n'
|
import type { LocaleObject } from '#i18n'
|
||||||
|
|
||||||
export type LocalizedWebManifest = Record<string, Partial<ManifestOptions>>
|
// We have to extend the ManifestOptions interface from 'vite-plugin-pwa'
|
||||||
|
// as that interface doesn't define the share_target field of Web App Manifests.
|
||||||
|
interface ExtendedManifestOptions extends ManifestOptions {
|
||||||
|
share_target: {
|
||||||
|
action: string
|
||||||
|
method: string
|
||||||
|
enctype: string
|
||||||
|
params: {
|
||||||
|
text: string
|
||||||
|
url: string
|
||||||
|
files: [{
|
||||||
|
name: string
|
||||||
|
accept: string[]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalizedWebManifest = Record<string, Partial<ExtendedManifestOptions>>
|
||||||
|
|
||||||
export const pwaLocales = i18n.locales as LocaleObject[]
|
export const pwaLocales = i18n.locales as LocaleObject[]
|
||||||
|
|
||||||
type WebManifestEntry = Pick<ManifestOptions, 'name' | 'short_name' | 'description'>
|
type WebManifestEntry = Pick<ExtendedManifestOptions, 'name' | 'short_name' | 'description'>
|
||||||
type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ManifestOptions, 'dir' | 'lang'>>
|
type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ExtendedManifestOptions, 'dir' | 'lang'>>
|
||||||
|
|
||||||
export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
||||||
const { env } = await getEnv()
|
const { env } = await getEnv()
|
||||||
|
@ -73,6 +91,21 @@ export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
share_target: {
|
||||||
|
action: '/web-share-target',
|
||||||
|
method: 'POST',
|
||||||
|
enctype: 'multipart/form-data',
|
||||||
|
params: {
|
||||||
|
text: 'text',
|
||||||
|
url: 'text',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: 'files',
|
||||||
|
accept: ['image/*', 'video/*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
acc[`${lang}-dark`] = {
|
acc[`${lang}-dark`] = {
|
||||||
scope: '/',
|
scope: '/',
|
||||||
|
@ -98,6 +131,21 @@ export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
share_target: {
|
||||||
|
action: '/web-share-target',
|
||||||
|
method: 'POST',
|
||||||
|
enctype: 'multipart/form-data',
|
||||||
|
params: {
|
||||||
|
text: 'text',
|
||||||
|
url: 'text',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: 'files',
|
||||||
|
accept: ['image/*', 'video/*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
|
|
38
pages/share-target.vue
Normal file
38
pages/share-target.vue
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: () => {
|
||||||
|
if (!useRuntimeConfig().public.pwaEnabled)
|
||||||
|
return navigateTo('/')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useWebShareTarget()
|
||||||
|
|
||||||
|
const pwaIsInstalled = process.server ? false : useNuxtApp().$pwa.isInstalled
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MainContent>
|
||||||
|
<template #title>
|
||||||
|
<NuxtLink to="/share-target" flex items-center gap-2>
|
||||||
|
<div i-ri:share-line />
|
||||||
|
<span>{{ $t('share-target.title') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<slot>
|
||||||
|
<div flex="~ col" px5 py2 gap-y-4>
|
||||||
|
<div
|
||||||
|
v-if="!pwaIsInstalled || !currentUser"
|
||||||
|
role="alert"
|
||||||
|
gap-1
|
||||||
|
p-2
|
||||||
|
text-red-600 dark:text-red-400
|
||||||
|
border="~ base rounded red-600 dark:red-400"
|
||||||
|
>
|
||||||
|
{{ $t('share-target.hint') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ $t('share-target.description') }}</div>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</MainContent>
|
||||||
|
</template>
|
|
@ -5,6 +5,12 @@ export default defineNuxtPlugin(() => {
|
||||||
const registrationError = ref(false)
|
const registrationError = ref(false)
|
||||||
const swActivated = ref(false)
|
const swActivated = ref(false)
|
||||||
|
|
||||||
|
// https://thomashunter.name/posts/2021-12-11-detecting-if-pwa-twa-is-installed
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
const ios = ua.match(/iPhone|iPad|iPod/)
|
||||||
|
const standalone = window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
const isInstalled = !!(standalone || (ios && !ua.match(/Safari/)))
|
||||||
|
|
||||||
const registerPeriodicSync = (swUrl: string, r: ServiceWorkerRegistration) => {
|
const registerPeriodicSync = (swUrl: string, r: ServiceWorkerRegistration) => {
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
if (!online.value)
|
if (!online.value)
|
||||||
|
@ -54,6 +60,7 @@ export default defineNuxtPlugin(() => {
|
||||||
return {
|
return {
|
||||||
provide: {
|
provide: {
|
||||||
pwa: reactive({
|
pwa: reactive({
|
||||||
|
isInstalled,
|
||||||
swActivated,
|
swActivated,
|
||||||
registrationError,
|
registrationError,
|
||||||
needRefresh,
|
needRefresh,
|
||||||
|
|
64
service-worker/share-target.ts
Normal file
64
service-worker/share-target.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
/// <reference lib="WebWorker" />
|
||||||
|
declare const self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
const clientResolves: { [key: string]: Function } = {}
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data.action !== 'ready-to-receive')
|
||||||
|
return
|
||||||
|
|
||||||
|
const id: string | undefined = (event.source as any)?.id ?? undefined
|
||||||
|
|
||||||
|
if (id && clientResolves[id] !== undefined)
|
||||||
|
clientResolves[id]()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onShareTarget = (event: FetchEvent) => {
|
||||||
|
if (!event.request.url.endsWith('/web-share-target') || event.request.method !== 'POST')
|
||||||
|
return
|
||||||
|
|
||||||
|
event.waitUntil(handleSharedTarget(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSharedTarget(event: FetchEvent) {
|
||||||
|
event.respondWith(Response.redirect('/home?share-target=true'))
|
||||||
|
await waitForClientToGetReady(event.resultingClientId)
|
||||||
|
|
||||||
|
const [client, formData] = await getClientAndFormData(event)
|
||||||
|
if (client === undefined)
|
||||||
|
return
|
||||||
|
|
||||||
|
await sendShareTargetMessage(client, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendShareTargetMessage(client: Client, data: FormData) {
|
||||||
|
const sharedData: { text?: string; files?: File[] } = {}
|
||||||
|
|
||||||
|
const text = data.get('text')
|
||||||
|
if (text !== null)
|
||||||
|
sharedData.text = text.toString()
|
||||||
|
|
||||||
|
const files: File[] = []
|
||||||
|
for (const [name, file] of data.entries()) {
|
||||||
|
if (name === 'files' && file instanceof File)
|
||||||
|
files.push(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length !== 0)
|
||||||
|
sharedData.files = files
|
||||||
|
|
||||||
|
client.postMessage({ data: sharedData, action: 'compose-with-shared-data' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForClientToGetReady(clientId: string) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
clientResolves[clientId] = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientAndFormData(event: FetchEvent): Promise<[client: Client | undefined, formData: FormData]> {
|
||||||
|
return Promise.all([
|
||||||
|
self.clients.get(event.resultingClientId),
|
||||||
|
event.request.formData(),
|
||||||
|
])
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import { StaleWhileRevalidate } from 'workbox-strategies'
|
||||||
import { ExpirationPlugin } from 'workbox-expiration'
|
import { ExpirationPlugin } from 'workbox-expiration'
|
||||||
|
|
||||||
import { onNotificationClick, onPush } from './web-push-notifications'
|
import { onNotificationClick, onPush } from './web-push-notifications'
|
||||||
|
import { onShareTarget } from './share-target'
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope
|
declare const self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
@ -32,7 +33,7 @@ if (import.meta.env.DEV)
|
||||||
// deny api and server page calls
|
// deny api and server page calls
|
||||||
let denylist: undefined | RegExp[]
|
let denylist: undefined | RegExp[]
|
||||||
if (import.meta.env.PROD)
|
if (import.meta.env.PROD)
|
||||||
denylist = [/^\/api\//, /^\/login\//, /^\/oauth\//, /^\/signin\//]
|
denylist = [/^\/api\//, /^\/login\//, /^\/oauth\//, /^\/signin\//, /^\/web-share-target\//]
|
||||||
|
|
||||||
// only cache pages and external assets on local build + start or in production
|
// only cache pages and external assets on local build + start or in production
|
||||||
if (import.meta.env.PROD) {
|
if (import.meta.env.PROD) {
|
||||||
|
@ -90,3 +91,4 @@ registerRoute(new NavigationRoute(
|
||||||
|
|
||||||
self.addEventListener('push', onPush)
|
self.addEventListener('push', onPush)
|
||||||
self.addEventListener('notificationclick', onNotificationClick)
|
self.addEventListener('notificationclick', onNotificationClick)
|
||||||
|
self.addEventListener('fetch', onShareTarget)
|
||||||
|
|
Loading…
Reference in a new issue