feat: add support for the Web Share Target API (#1100)

Co-authored-by: userquin <userquin@gmail.com>
This commit is contained in:
Horváth Bálint 2023-01-14 21:58:52 +01:00 committed by GitHub
parent a6a825e553
commit bede92404b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 221 additions and 7 deletions

View file

@ -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?.()

View 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))
}
})
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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')
})

View file

@ -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
View 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>

View file

@ -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,

View 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(),
])
}

View file

@ -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)