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
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { EditorContent } from '@tiptap/vue-3'
|
||||
import type { mastodon } from 'masto'
|
||||
import type { Ref } from 'vue'
|
||||
import type { Draft } from '~/types'
|
||||
|
||||
const {
|
||||
|
@ -90,6 +89,19 @@ async function publish() {
|
|||
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({
|
||||
focusEditor: () => {
|
||||
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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"attachments_exceed_server_limit": "The number of attachments exceeded the limit per post.",
|
||||
"attachments_limit_error": "Limit per post exceeded",
|
||||
|
|
|
@ -359,6 +359,11 @@
|
|||
"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": {
|
||||
"attachments_exceed_server_limit": "The number of attachments exceeded the limit per post.",
|
||||
"attachments_limit_error": "Limit per post exceeded",
|
||||
|
|
|
@ -356,6 +356,11 @@
|
|||
"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": {
|
||||
"attachments_exceed_server_limit": "Número máximo de archivos adjuntos por publicación excedido.",
|
||||
"attachments_limit_error": "Límite por publicación excedido",
|
||||
|
|
|
@ -5,8 +5,12 @@ export default defineNuxtRouteMiddleware((to) => {
|
|||
return
|
||||
|
||||
onMastoInit(() => {
|
||||
if (!currentUser.value)
|
||||
if (!currentUser.value) {
|
||||
if (to.path === '/home' && to.query['share-target'] !== undefined)
|
||||
return navigateTo('/share-target')
|
||||
else
|
||||
return navigateTo(`/${currentServer.value}/public/local`)
|
||||
}
|
||||
if (to.path === '/')
|
||||
return navigateTo('/home')
|
||||
})
|
||||
|
|
|
@ -5,12 +5,30 @@ import { getEnv } from '../../config/env'
|
|||
import { i18n } from '../../config/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[]
|
||||
|
||||
type WebManifestEntry = Pick<ManifestOptions, 'name' | 'short_name' | 'description'>
|
||||
type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ManifestOptions, 'dir' | 'lang'>>
|
||||
type WebManifestEntry = Pick<ExtendedManifestOptions, 'name' | 'short_name' | 'description'>
|
||||
type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ExtendedManifestOptions, 'dir' | 'lang'>>
|
||||
|
||||
export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
||||
const { env } = await getEnv()
|
||||
|
@ -73,6 +91,21 @@ export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
|||
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`] = {
|
||||
scope: '/',
|
||||
|
@ -98,6 +131,21 @@ export const createI18n = async (): Promise<LocalizedWebManifest> => {
|
|||
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
|
||||
|
|
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 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) => {
|
||||
setInterval(async () => {
|
||||
if (!online.value)
|
||||
|
@ -54,6 +60,7 @@ export default defineNuxtPlugin(() => {
|
|||
return {
|
||||
provide: {
|
||||
pwa: reactive({
|
||||
isInstalled,
|
||||
swActivated,
|
||||
registrationError,
|
||||
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 { onNotificationClick, onPush } from './web-push-notifications'
|
||||
import { onShareTarget } from './share-target'
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
|
||||
|
@ -32,7 +33,7 @@ if (import.meta.env.DEV)
|
|||
// deny api and server page calls
|
||||
let denylist: undefined | RegExp[]
|
||||
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
|
||||
if (import.meta.env.PROD) {
|
||||
|
@ -90,3 +91,4 @@ registerRoute(new NavigationRoute(
|
|||
|
||||
self.addEventListener('push', onPush)
|
||||
self.addEventListener('notificationclick', onNotificationClick)
|
||||
self.addEventListener('fetch', onShareTarget)
|
||||
|
|
Loading…
Reference in a new issue