feat: pwa with push notifications (#337)

zio/stable
Joaquín Sánchez 2022-12-18 00:29:16 +01:00 committed by GitHub
parent a18e5e2332
commit f0c91a3974
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 2903 additions and 14 deletions

View File

@ -1 +1 @@
MOCK_USER='{"user":{"server":"universeodon.com","token":"BLMfvYGgiEPgLpiunVS0JYxxqzga3S58C60DDwu1jvw","account":{"id":"109424142224653388","username":"elkdev","acct":"elkdev@universeodon.com","displayName":"Elk Dev Team","locked":false,"bot":false,"discoverable":null,"group":false,"createdAt":"2022-11-28T00:00:00.000Z","note":"","url":"https://universeodon.com/@elkdev","avatar":"https://universeodon.com/avatars/original/missing.png","avatarStatic":"https://universeodon.com/avatars/original/missing.png","header":"https://universeodon.com/headers/original/missing.png","headerStatic":"https://universeodon.com/headers/original/missing.png","followersCount":0,"followingCount":0,"statusesCount":0,"lastStatusAt":null,"noindex":false,"source":{"privacy":"public","sensitive":false,"language":null,"note":"","fields":[],"followRequestsCount":0},"emojis":[],"fields":[],"role":{"id":"-99","name":"","permissions":"65536","color":"","highlighted":false}}},"server":{"109424142224653388":{"uri":"universeodon.com","title":"Universeodon","shortDescription":"Be one with the fediverse.","description":"","email":"novae@universeodon.com","version":"4.0.2","urls":{"streamingApi":"wss://universeodon.com"},"stats":{"userCount":57026,"statusCount":283364,"domainCount":11515},"thumbnail":"https://media.universeodon.com/site_uploads/files/000/000/003/@1x/9de6fc1bbd150b05.png","languages":["en"],"registrations":true,"approvalRequired":false,"invitesEnabled":true,"configuration":{"accounts":{"maxFeaturedTags":10},"statuses":{"maxCharacters":500,"maxMediaAttachments":4,"charactersReservedPerUrl":23},"mediaAttachments":{"supportedMimeTypes":["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"],"imageSizeLimit":10485760,"imageMatrixLimit":16777216,"videoSizeLimit":41943040,"videoFrameRateLimit":60,"videoMatrixLimit":2304000},"polls":{"maxOptions":4,"maxCharactersPerOption":50,"minExpiration":300,"maxExpiration":2629746}},"contactAccount":{"id":"109287809647205395","username":"supernovae","acct":"supernovae","displayName":"Supernovae","locked":false,"bot":false,"discoverable":true,"group":false,"createdAt":"2022-11-04T00:00:00.000Z","note":"","url":"https://universeodon.com/@supernovae","avatar":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","avatarStatic":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","header":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","headerStatic":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","followersCount":6387,"followingCount":305,"statusesCount":1753,"lastStatusAt":"2022-11-28","noindex":false,"emojis":[],"fields":[]},"rules":[]}}}' MOCK_USER='{"user":{"server":"universeodon.com","token":"yZcpj0FmnsEkUvBiXSCb_KQnccl2IU0kx9TfDbcxPJY","vapidKey":"BJwtUVlyCabpMnLI6HOyu-qMfJswxEq_c8pgRymxjTN_vCzMWfGrRHrwNczj9LIokAHtxh6Ziw1Kq7_ERDoriz0=","account":{"id":"109424142224653388","username":"elkdev","acct":"elkdev@universeodon.com","displayName":"Elk Dev Team","locked":false,"bot":false,"discoverable":null,"group":false,"createdAt":"2022-11-28T00:00:00.000Z","note":"","url":"https://universeodon.com/@elkdev","avatar":"https://universeodon.com/avatars/original/missing.png","avatarStatic":"https://universeodon.com/avatars/original/missing.png","header":"https://universeodon.com/headers/original/missing.png","headerStatic":"https://universeodon.com/headers/original/missing.png","followersCount":3,"followingCount":4,"statusesCount":20,"lastStatusAt":"2022-12-13","noindex":false,"source":{"privacy":"public","sensitive":false,"language":null,"note":"","fields":[],"followRequestsCount":0},"emojis":[],"fields":[],"role":{"id":"-99","name":"","permissions":"65536","color":"","highlighted":false}}},"server":{"109424142224653388":{"uri":"universeodon.com","title":"Universeodon","shortDescription":"Be one with the fediverse.","description":"","email":"novae@universeodon.com","version":"4.0.2","urls":{"streamingApi":"wss://universeodon.com"},"stats":{"userCount":57026,"statusCount":283364,"domainCount":11515},"thumbnail":"https://media.universeodon.com/site_uploads/files/000/000/003/@1x/9de6fc1bbd150b05.png","languages":["en"],"registrations":true,"approvalRequired":false,"invitesEnabled":true,"configuration":{"accounts":{"maxFeaturedTags":10},"statuses":{"maxCharacters":500,"maxMediaAttachments":4,"charactersReservedPerUrl":23},"mediaAttachments":{"supportedMimeTypes":["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"],"imageSizeLimit":10485760,"imageMatrixLimit":16777216,"videoSizeLimit":41943040,"videoFrameRateLimit":60,"videoMatrixLimit":2304000},"polls":{"maxOptions":4,"maxCharactersPerOption":50,"minExpiration":300,"maxExpiration":2629746}},"contactAccount":{"id":"109287809647205395","username":"supernovae","acct":"supernovae","displayName":"Supernovae","locked":false,"bot":false,"discoverable":true,"group":false,"createdAt":"2022-11-04T00:00:00.000Z","note":"","url":"https://universeodon.com/@supernovae","avatar":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","avatarStatic":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","header":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","headerStatic":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","followersCount":6387,"followingCount":305,"statusesCount":1753,"lastStatusAt":"2022-11-28","noindex":false,"emojis":[],"fields":[]},"rules":[]}}}'

View File

@ -2,3 +2,5 @@
*.png *.png
*.ico *.ico
*.toml *.toml
https-dev-config/localhost.crt
https-dev-config/localhost.key

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ dist
.DS_Store .DS_Store
.idea/ .idea/
.vite-inspect .vite-inspect
.netlify/
public/shiki public/shiki

View File

@ -13,4 +13,5 @@ const key = computed(() => `${currentServer.value}:${currentUser.value?.account.
<NuxtLayout :key="key"> <NuxtLayout :key="key">
<NuxtPage v-if="isMastoInitialised" /> <NuxtPage v-if="isMastoInitialised" />
</NuxtLayout> </NuxtLayout>
<PWAPrompt />
</template> </template>

View File

@ -0,0 +1,39 @@
<script setup>
import { usePWA } from '~/composables/pwa'
const { close, needRefresh, updateServiceWorker } = usePWA()
</script>
<!-- TODO: remove shadow on mobile and position it above the bottom nav -->
<template>
<div
v-if="needRefresh"
role="alertdialog"
aria-labelledby="pwa-toast-title"
aria-describedby="pwa-toast-description"
animate animate-back-in-up md:animate-back-in-right
z11
fixed
bottom-14 md:bottom-0 right-0
m-2 p-4
bg-base border="~ base"
rounded
text-left
shadow
>
<h2 id="pwa-toast-title" sr-only>
{{ $t('pwa.title') }}
</h2>
<div id="pwa-toast-message">
{{ $t('pwa.message') }}
</div>
<div m-t4 flex="~ colum" gap-x="4">
<button type="button" btn-solid text-sm px-2 py-1 text-center @click="updateServiceWorker()">
{{ $t('pwa.reload') }}
</button>
<button type="button" btn-outline px-2 py-1 text-sm text-center @click="close">
{{ $t('pwa.close') }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
label: string
hover?: boolean
}>()
const { modelValue } = defineModel<{
modelValue: boolean
}>()
</script>
<template>
<label
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
:class="hover ? 'hover:bg-active ml--2 pl-4' : null"
@click.prevent="modelValue = !modelValue"
>
<span
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
aria-hidden="true"
/>
<input
v-model="modelValue"
type="checkbox"
sr-only
>
<span ml-2 pointer-events-none>{{ label }}</span>
</label>
</template>
<style>
.common-checkbox:focus-within {
outline: none;
border-bottom: 1px solid var(--c-text-base);
}
</style>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
label: string
value: any
hover?: boolean
}>()
const { modelValue } = defineModel<{
modelValue: any
}>()
</script>
<template>
<label
class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
:class="hover ? 'hover:bg-active ml--2 pl-4' : null"
@click.prevent="modelValue = value"
>
<span
:class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'"
aria-hidden="true"
/>
<input
v-model="modelValue"
type="radio"
:value="value"
sr-only
>
<span ml-2 pointer-events-none>{{ label }}</span>
</label>
</template>
<style>
.common-radio:focus-within {
outline: none;
border-bottom: 1px solid var(--c-text-base);
}
</style>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
defineProps<{
withHeader?: boolean
busy?: boolean
animate?: boolean
}>()
defineEmits(['hide', 'subscribe'])
</script>
<template>
<div flex="~ col" role="alert" aria-labelledby="notifications-warning" :class="withHeader ? 'border-b border-base' : null">
<header v-if="withHeader" flex items-center pb-2>
<h2 id="notifications-warning" text-md font-bold w-full>
{{ $t('notification.settings.warning.enable_title') }}
</h2>
<button
flex rounded-4
type="button"
:title="$t('notification.settings.warning.enable_close')"
hover:bg-active cursor-pointer transition-100
:disabled="busy"
@click="$emit('hide')"
>
<span aria-hidden="true" i-ri:close-circle-line />
</button>
</header>
<p>
{{ $t(withHeader ? 'notification.settings.warning.enable_description' : 'notification.settings.warning.enable_description_short') }}
</p>
<button
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
type="button"
:class="busy ? 'border-transparent' : null"
:disabled="busy"
@click="$emit('subscribe')"
>
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" />
{{ $t('notification.settings.warning.enable_desktop') }}
</button>
</div>
</template>

View File

@ -0,0 +1,185 @@
<script setup lang="ts">
import { usePushManager } from '~/composables/push-notifications/usePushManager'
defineProps<{ show: boolean }>()
let busy = $ref<boolean>(false)
let animateSave = $ref<boolean>(false)
let animateSubscription = $ref<boolean>(false)
let animateRemoveSubscription = $ref<boolean>(false)
const {
pushNotificationData,
saveEnabled,
undoChanges,
hiddenNotification,
isSubscribed,
isSupported,
notificationPermission,
updateSubscription,
subscribe,
unsubscribe,
} = usePushManager()
const pwaEnabled = useRuntimeConfig().public.pwaEnabled
const hideNotification = () => {
const key = currentUser.value?.account?.acct
if (key)
hiddenNotification.value[key] = true
}
const showWarning = $computed(() => {
if (!pwaEnabled)
return false
return isSupported
&& (!isSubscribed.value || !notificationPermission.value || notificationPermission.value === 'prompt')
&& !(hiddenNotification.value[currentUser.value?.account?.acct ?? ''] === true)
})
const saveSettings = async () => {
if (busy)
return
busy = true
await nextTick()
animateSave = true
try {
const subscription = await updateSubscription()
// todo: handle error
}
finally {
busy = false
animateSave = false
}
}
const doSubscribe = async () => {
if (busy)
return
busy = true
await nextTick()
animateSubscription = true
try {
const subscription = await subscribe()
// todo: apply some logic based on the result: subscription === 'subscribed'
// todo: maybe throwing an error instead just a literal to show a dialog with the error
// todo: handle error
}
finally {
busy = false
animateSubscription = false
}
}
const removeSubscription = async () => {
if (busy)
return
busy = true
await nextTick()
animateRemoveSubscription = true
try {
await unsubscribe()
}
finally {
busy = false
animateRemoveSubscription = false
}
}
onActivated(() => (busy = false))
</script>
<template>
<div v-if="pwaEnabled && (showWarning || show)">
<Transition name="slide-down">
<div v-if="show" flex="~ col" border="b base" px5 py4>
<header flex items-center pb-2>
<h2 id="notifications-title" text-md font-bold w-full>
{{ $t('notification.settings.title') }}
</h2>
</header>
<template v-if="isSupported">
<div v-if="isSubscribed" flex="~ col">
<form flex="~ col" gap-y-2 @submit.prevent="saveSettings">
<fieldset flex="~ col" gap-y-1 py-1>
<legend>{{ $t('notification.settings.alerts.title') }}</legend>
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('notification.settings.alerts.follow')" />
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('notification.settings.alerts.favourite')" />
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('notification.settings.alerts.reblog')" />
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('notification.settings.alerts.mention')" />
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('notification.settings.alerts.poll')" />
</fieldset>
<fieldset flex="~ col" gap-y-1 py-1>
<legend>{{ $t('notification.settings.policy.title') }}</legend>
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('notification.settings.policy.all')" />
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('notification.settings.policy.followed')" />
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('notification.settings.policy.follower')" />
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('notification.settings.policy.none')" />
</fieldset>
<div flex="~ col" gap-y-4 py-1 sm="~ justify-between flex-row">
<button
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
:class="busy || !saveEnabled ? 'border-transparent' : null"
:disabled="busy || !saveEnabled"
>
<span :class="busy && animateSave ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:save-2-fill'" />
{{ $t('notification.settings.save_settings') }}
</button>
<button
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
type="button"
:class="busy || !saveEnabled ? 'border-transparent' : null"
:disabled="busy || !saveEnabled"
@click="undoChanges"
>
<span aria-hidden="true" class="i-material-symbols:undo-rounded" />
{{ $t('notification.settings.undo_settings') }}
</button>
</div>
</form>
<form flex="~ col" mt-4 @submit.prevent="removeSubscription">
<span border="b base 2px" class="bg-$c-text-secondary" />
<button
btn-outline rounded-full font-bold py-4 flex="~ gap2 center" m5
:class="busy ? 'border-transparent' : null"
:disabled="busy"
>
<span aria-hidden="true" :class="busy && animateRemoveSubscription ? 'i-ri:loader-2-fill animate-spin' : 'i-material-symbols:cancel-rounded'" />
{{ $t('notification.settings.unsubscribe') }}
</button>
</form>
</div>
<template v-else>
<p v-if="showWarning" role="alert" aria-labelledby="notifications-title">
{{ $t('notification.settings.unsubscribed_with_warning') }}
</p>
<NotificationEnablePushNotification
v-else
:animate="animateSubscription"
:busy="busy"
@hide="hideNotification"
@subscribe="doSubscribe"
/>
</template>
</template>
<p v-else role="alert" aria-labelledby="notifications-unsupported">
{{ $t('notification.settings.unsupported') }}
</p>
</div>
</Transition>
<NotificationEnablePushNotification
v-if="showWarning"
with-header
px5
py4
:animate="animateSubscription"
:busy="busy"
@hide="hideNotification"
@subscribe="doSubscribe"
/>
</div>
</template>

View File

@ -0,0 +1,119 @@
import type {
CreatePushSubscriptionParams,
PushSubscription as MastoPushSubscription,
} from 'masto'
import type {
CreatePushNotification,
PushManagerSubscriptionInfo,
RequiredUserLogin,
} from '~/composables/push-notifications/types'
import { useMasto } from '~/composables/masto'
import { currentUser, removePushNotifications } from '~/composables/users'
export const createPushSubscription = async (
user: RequiredUserLogin,
notificationData: CreatePushNotification,
): Promise<MastoPushSubscription | undefined> => {
const { server: serverEndpoint, vapidKey } = user
return await getRegistration()
.then(getPushSubscription)
.then(({ registration, subscription }): Promise<MastoPushSubscription | undefined> => {
if (subscription) {
const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey!)).toString()
const subscriptionServerKey = urlBase64ToUint8Array(vapidKey).toString()
// If the VAPID public key did not change and the endpoint corresponds
// to the endpoint saved in the backend, the subscription is valid
// If push subscription is not there, we need to create it: it is fetched on login
if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint && user.pushSubscription) {
return Promise.resolve(user.pushSubscription)
}
else if (user.pushSubscription) {
// if we have a subscription, but it is not valid, we need to remove it
return unsubscribeFromBackend(false)
.then(() => subscribe(registration, vapidKey))
.then(subscription => sendSubscriptionToBackend(subscription, notificationData))
}
}
return subscribe(registration, vapidKey).then(
subscription => sendSubscriptionToBackend(subscription, notificationData),
)
})
.catch((error) => {
if (error.code === 20 && error.name === 'AbortError')
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.')
else if (error.code === 5 && error.name === 'InvalidCharacterError')
console.error('The VAPID public key seems to be invalid:', vapidKey)
return getRegistration()
.then(getPushSubscription)
.then(() => unsubscribeFromBackend(true))
.then(() => Promise.resolve(undefined))
.catch((e) => {
console.error(e)
return Promise.resolve(undefined)
})
})
}
// Taken from https://www.npmjs.com/package/web-push
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i)
outputArray[i] = rawData.charCodeAt(i)
return outputArray
}
function getRegistration() {
return navigator.serviceWorker.ready
}
async function getPushSubscription(registration: ServiceWorkerRegistration): Promise<PushManagerSubscriptionInfo> {
const subscription = await registration.pushManager.getSubscription()
return { registration, subscription }
}
async function subscribe(
registration: ServiceWorkerRegistration,
applicationServerKey: string,
): Promise<PushSubscription> {
return await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(applicationServerKey),
})
}
async function unsubscribeFromBackend(fromSWPushManager: boolean) {
const cu = currentUser.value
if (cu)
await removePushNotifications(cu, fromSWPushManager)
}
async function sendSubscriptionToBackend(
subscription: PushSubscription,
data: CreatePushNotification,
): Promise<MastoPushSubscription> {
const { endpoint, keys } = subscription.toJSON()
const params: CreatePushSubscriptionParams = {
subscription: {
endpoint: endpoint!,
keys: {
p256dh: keys!.p256dh!,
auth: keys!.auth!,
},
},
data,
}
return await useMasto().pushSubscriptions.create(params)
}

View File

@ -0,0 +1,22 @@
import type { PushSubscription as MastoPushSubscription, PushSubscriptionAlerts, SubscriptionPolicy } from 'masto'
import type { UserLogin } from '~/types'
export type SubscriptionResult = 'subscribed' | 'notification-denied' | 'invalid-state'
export interface PushManagerSubscriptionInfo {
registration: ServiceWorkerRegistration
subscription: PushSubscription | null
}
export interface RequiredUserLogin extends Required<Omit<UserLogin, 'account' | 'pushSubscription'>> {
pushSubscription?: MastoPushSubscription
}
export interface CreatePushNotification {
alerts?: Partial<PushSubscriptionAlerts> | null
policy?: SubscriptionPolicy
}
export type PushNotificationRequest = Record<string, boolean>
export type PushNotificationPolicy = Record<string, SubscriptionPolicy>

View File

@ -0,0 +1,171 @@
import type {
CreatePushNotification,
PushNotificationPolicy,
PushNotificationRequest,
SubscriptionResult,
} from '~/composables/push-notifications/types'
import { createPushSubscription } from '~/composables/push-notifications/createPushSubscription'
import { STORAGE_KEY_NOTIFICATION, STORAGE_KEY_NOTIFICATION_POLICY } from '~/constants'
import { currentUser, removePushNotifications } from '~/composables/users'
const supportsPushNotifications = typeof window !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& 'getKey' in PushSubscription.prototype
export const usePushManager = () => {
const isSubscribed = ref(false)
const notificationPermission = ref<PermissionState | undefined>(
Notification.permission === 'denied'
? 'denied'
: Notification.permission === 'granted'
? 'granted'
: Notification.permission === 'default'
? 'prompt'
: undefined,
)
const isSupported = $computed(() => supportsPushNotifications)
const hiddenNotification = useLocalStorage<PushNotificationRequest>(STORAGE_KEY_NOTIFICATION, {})
const configuredPolicy = useLocalStorage<PushNotificationPolicy>(STORAGE_KEY_NOTIFICATION_POLICY, {})
const pushNotificationData = ref({
follow: currentUser.value?.pushSubscription?.alerts.follow ?? true,
favourite: currentUser.value?.pushSubscription?.alerts.favourite ?? true,
reblog: currentUser.value?.pushSubscription?.alerts.reblog ?? true,
mention: currentUser.value?.pushSubscription?.alerts.mention ?? true,
poll: currentUser.value?.pushSubscription?.alerts.poll ?? true,
policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all',
})
const { history, commit, clear } = useManualRefHistory(pushNotificationData, { clone: true })
const saveEnabled = computed(() => {
const current = pushNotificationData.value
const previous = history.value?.[0]?.snapshot
return current.favourite !== previous.favourite
|| current.reblog !== previous.reblog
|| current.mention !== previous.mention
|| current.follow !== previous.follow
|| current.poll !== previous.poll
|| current.policy !== previous.policy
})
watch(() => currentUser.value?.pushSubscription, (subscription) => {
isSubscribed.value = !!subscription
pushNotificationData.value = {
follow: subscription?.alerts.follow ?? false,
favourite: subscription?.alerts.favourite ?? false,
reblog: subscription?.alerts.reblog ?? false,
mention: subscription?.alerts.mention ?? false,
poll: subscription?.alerts.poll ?? false,
policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all',
}
}, { immediate: true, flush: 'post' })
const subscribe = async (notificationData?: CreatePushNotification): Promise<SubscriptionResult> => {
if (!isSupported || !currentUser.value)
return 'invalid-state'
const { pushSubscription, server, token, vapidKey, account: { acct } } = currentUser.value
if (!token || !server || !vapidKey)
return 'invalid-state'
let permission: PermissionState | undefined
if (!notificationPermission.value || (notificationPermission.value === 'prompt' && !hiddenNotification.value[acct])) {
// safari 16 does not support navigator.permissions.query for notifications
try {
permission = (await navigator.permissions?.query({ name: 'notifications' }))?.state
}
catch {
permission = await Promise.resolve(Notification.requestPermission()).then((p: NotificationPermission) => {
return p === 'default' ? 'prompt' : p
})
}
}
else {
permission = notificationPermission.value
}
if (!permission || permission === 'denied') {
notificationPermission.value = permission
return 'notification-denied'
}
currentUser.value.pushSubscription = await createPushSubscription({
pushSubscription, server, token, vapidKey,
}, notificationData ?? {
alerts: {
follow: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
},
policy: 'all',
})
await nextTick()
notificationPermission.value = permission
hiddenNotification.value[acct] = true
return 'subscribed'
}
const unsubscribe = async () => {
if (!isSupported || !isSubscribed || !currentUser.value)
return false
await removePushNotifications(currentUser.value)
}
const saveSettings = async () => {
commit()
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = pushNotificationData.value.policy
await nextTick()
clear()
await nextTick()
}
const undoChanges = () => {
const current = pushNotificationData.value
const previous = history.value[0].snapshot
current.favourite = previous.favourite
current.reblog = previous.reblog
current.mention = previous.mention
current.follow = previous.follow
current.poll = previous.poll
current.policy = previous.policy
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = previous.policy
commit()
clear()
}
const updateSubscription = async () => {
if (currentUser.value) {
currentUser.value.pushSubscription = await useMasto().pushSubscriptions.update({
data: {
alerts: {
follow: pushNotificationData.value.follow,
favourite: pushNotificationData.value.favourite,
reblog: pushNotificationData.value.reblog,
mention: pushNotificationData.value.mention,
poll: pushNotificationData.value.poll,
},
policy: pushNotificationData.value.policy,
},
})
await saveSettings()
}
}
return {
pushNotificationData,
saveEnabled,
undoChanges,
hiddenNotification,
isSupported,
isSubscribed,
notificationPermission,
updateSubscription,
subscribe,
unsubscribe,
}
}

View File

@ -0,0 +1,42 @@
import { useRegisterSW } from 'virtual:pwa-register/vue'
export const usePWA = () => {
const online = useOnline()
const {
needRefresh,
updateServiceWorker,
} = useRegisterSW({
immediate: true,
onRegisteredSW(swUrl, r) {
if (!r || r.installing)
return
setInterval(async () => {
if (!online.value)
return
const resp = await fetch(swUrl, {
cache: 'no-store',
headers: {
'cache': 'no-store',
'cache-control': 'no-cache',
},
})
if (resp?.status === 200)
await r.update()
}, 60 * 60 * 1000 /* 1 hour */)
},
})
const close = async () => {
needRefresh.value = false
}
return {
needRefresh,
updateServiceWorker,
close,
}
}

View File

@ -1,3 +1,5 @@
import { pwaInfo } from 'virtual:pwa-info'
import type { Link } from '@unhead/schema'
import { APP_NAME, STORAGE_KEY_LANG } from '~/constants' import { APP_NAME, STORAGE_KEY_LANG } from '~/constants'
export function setupPageHeader() { export function setupPageHeader() {
@ -6,11 +8,34 @@ export function setupPageHeader() {
const i18n = useI18n() const i18n = useI18n()
const link: Link[] = []
if (pwaInfo && pwaInfo.webManifest) {
const { webManifest } = pwaInfo
if (webManifest) {
const { href, useCredentials } = webManifest
if (useCredentials) {
link.push({
rel: 'manifest',
href,
crossorigin: 'use-credentials',
})
}
else {
link.push({
rel: 'manifest',
href,
})
}
}
}
useHeadFixed({ useHeadFixed({
htmlAttrs: { htmlAttrs: {
lang: () => i18n.locale.value, lang: () => i18n.locale.value,
}, },
titleTemplate: title => `${title ? `${title} | ` : ''}${APP_NAME}${isDev ? ' (dev)' : isPreview ? ' (preview)' : ''}`, titleTemplate: title => `${title ? `${title} | ` : ''}${APP_NAME}${isDev ? ' (dev)' : isPreview ? ' (preview)' : ''}`,
link,
}) })
// eslint-disable-next-line no-unused-expressions // eslint-disable-next-line no-unused-expressions

View File

@ -2,7 +2,16 @@ import { login as loginMasto } from 'masto'
import type { Account, AccountCredentials, Instance, WsEvents } from 'masto' import type { Account, AccountCredentials, Instance, WsEvents } from 'masto'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { UserLogin } from '~/types' import type { UserLogin } from '~/types'
import { DEFAULT_POST_CHARS_LIMIT, DEFAULT_SERVER, STORAGE_KEY_CURRENT_USER, STORAGE_KEY_SERVERS, STORAGE_KEY_USERS } from '~/constants' import {
DEFAULT_POST_CHARS_LIMIT,
DEFAULT_SERVER,
STORAGE_KEY_CURRENT_USER,
STORAGE_KEY_NOTIFICATION,
STORAGE_KEY_NOTIFICATION_POLICY,
STORAGE_KEY_SERVERS,
STORAGE_KEY_USERS,
} from '~/constants'
import type { PushNotificationPolicy, PushNotificationRequest } from '~/composables/push-notifications/types'
const mock = process.mock const mock = process.mock
const users = useLocalStorage<UserLogin[]>(STORAGE_KEY_USERS, mock ? [mock.user] : [], { deep: true }) const users = useLocalStorage<UserLogin[]>(STORAGE_KEY_USERS, mock ? [mock.user] : [], { deep: true })
@ -53,12 +62,15 @@ export async function loginTo(user?: Omit<UserLogin, 'account'> & { account?: Ac
else { else {
try { try {
const [me, server] = await Promise.all([ const [me, server, pushSubscription] = await Promise.all([
masto.accounts.verifyCredentials(), masto.accounts.verifyCredentials(),
masto.instances.fetch(), masto.instances.fetch(),
// we get 404 response instead empty data
masto.pushSubscriptions.fetch().catch(() => Promise.resolve(undefined)),
]) ])
user.account = me user.account = me
user.pushSubscription = pushSubscription
currentUserId.value = me.id currentUserId.value = me.id
servers.value[me.id] = server servers.value[me.id] = server
@ -83,6 +95,37 @@ export async function loginTo(user?: Omit<UserLogin, 'account'> & { account?: Ac
return masto return masto
} }
export async function removePushNotifications(user: UserLogin, fromSWPushManager = true) {
// unsubscribe push notifications
try {
await useMasto().pushSubscriptions.remove()
}
catch {
// ignore
}
// clear push subscription
user.pushSubscription = undefined
const { acct } = user.account
// clear request notification permission
delete useLocalStorage<PushNotificationRequest>(STORAGE_KEY_NOTIFICATION, {}).value[acct]
// clear push notification policy
delete useLocalStorage<PushNotificationPolicy>(STORAGE_KEY_NOTIFICATION_POLICY, {}).value[acct]
// we remove the sw push manager if required and there are no more accounts with subscriptions
if (fromSWPushManager && (users.value.length === 0 || users.value.every(u => !u.pushSubscription))) {
// clear sw push subscription
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription)
await subscription.unsubscribe()
}
catch {
// juts ignore
}
}
}
export async function signout() { export async function signout() {
// TODO: confirm // TODO: confirm
if (!currentUser.value) if (!currentUser.value)
@ -97,6 +140,8 @@ export async function signout() {
clearUserLocalStorage() clearUserLocalStorage()
delete servers.value[_currentUserId] delete servers.value[_currentUserId]
await removePushNotifications(currentUser.value)
currentUserId.value = '' currentUserId.value = ''
// Remove the current user from the users // Remove the current user from the users
users.value.splice(index, 1) users.value.splice(index, 1)

51
config/pwa.ts 100644
View File

@ -0,0 +1,51 @@
import { isCI } from 'std-env'
import type { VitePWANuxtOptions } from '../modules/pwa/types'
const isPreview = process.env.PULL_REQUEST === 'true'
const pwa: VitePWANuxtOptions = {
mode: isCI ? 'production' : 'development',
// disabled PWA only on production
disable: !isPreview && process.env.VITE_DEV_PWA !== 'true',
scope: '/',
srcDir: './service-worker',
filename: 'sw.ts',
strategies: 'injectManifest',
injectRegister: false,
includeManifestIcons: false,
manifest: {
scope: '/',
id: '/',
name: `Elk${isCI ? isPreview ? ' (preview)' : '' : ' (dev)'}`,
short_name: `Elk${isCI ? isPreview ? ' (preview)' : '' : ' (dev)'}`,
description: `A nimble Mastodon Web Client${isCI ? isPreview ? ' (preview)' : '' : ' (development)'}`,
theme_color: '#ffffff',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'logo.svg',
sizes: '250x250',
type: 'image/png',
purpose: 'any maskable',
},
],
},
injectManifest: {
globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'],
},
devOptions: {
enabled: process.env.VITE_DEV_PWA === 'true',
type: 'module',
},
}
export { pwa }

View File

@ -15,5 +15,7 @@ export const STORAGE_KEY_FEATURE_FLAGS = 'elk-feature-flags'
export const STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS = 'elk-hide-explore-posts-tips' export const STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS = 'elk-hide-explore-posts-tips'
export const STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS = 'elk-hide-explore-news-tips' export const STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS = 'elk-hide-explore-news-tips'
export const STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS = 'elk-hide-explore-tags-tips' export const STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS = 'elk-hide-explore-tags-tips'
export const STORAGE_KEY_NOTIFICATION = 'elk-notification'
export const STORAGE_KEY_NOTIFICATION_POLICY = 'elk-notification-policy'
export const HANDLED_MASTO_URLS = /^(https?:\/\/)?([\w\d-]+\.)+\w+\/(@[@\w\d-\.]+)(\/objects)?(\/\d+)?$/ export const HANDLED_MASTO_URLS = /^(https?:\/\/)?([\w\d-]+\.)+\w+\/(@[@\w\d-\.]+)(\/objects)?(\/\d+)?$/

View File

@ -54,4 +54,5 @@ const reload = async () => {
</slot> </slot>
</MainContent> </MainContent>
</NuxtLayout> </NuxtLayout>
<PWAPrompt />
</template> </template>

View File

@ -0,0 +1,12 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
process.env.NITRO_SSL_CERT = readFileSync(fileURLToPath(new URL('./localhost.crt', import.meta.url)), 'utf8')
process.env.NITRO_SSL_KEY = readFileSync(fileURLToPath(new URL('./localhost.key', import.meta.url)), 'utf8')
async function run() {
await import('../.output/server/index.mjs')
}
run()

View File

@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIELjCCApagAwIBAgIRAKdt7EA97mviRgEBUUnM2LAwDQYJKoZIhvcNAQELBQAw
dTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSUwIwYDVQQLDBxCTEFD
S1JPQ0tcSm9hcXXDrW5AQmxhY2tSb2NrMSwwKgYDVQQDDCNta2NlcnQgQkxBQ0tS
T0NLXEpvYXF1w61uQEJsYWNrUm9jazAeFw0yMjA4MzAyMTQxNTRaFw0yNDExMzAy
MjQxNTRaMFAxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0
ZTElMCMGA1UECwwcQkxBQ0tST0NLXEpvYXF1w61uQEJsYWNrUm9jazCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAPepkg2Nec3FUxqNfrq/8pHXL88G2Bsn
Oyy2bJ1D3k9/7Mn5RkZ67dCs9XVa4u5gtkGnMy+S5FqGyhahEaaW6k45Vbs8uIgE
1i8tx90r6rtIqXedkJyrhdr5xZWNzzj2ItmFkU1KGnCbFj8ZgXLW2miqXbWgpLLe
eRTOIadcQJlQJC5LTAIzOSsZyWvrQw2UaOjAqrSdFbXm0/G/V6MFBlsat6MgsFDg
8JuvITDYX6dX0jhtO5mQvJRESkP/5TaOdxzxjjTnXrTEIYn+QJUJ+rwa2d9fv7pM
CdQ0kHBezYKzp2NMKp7rpIQxFbFzR9NADk8wLaAQMUBz5QS435Q9998CAwEAAaNe
MFwwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQY
MBaAFAnP38cmUbpd5YTbKDLGa6I2FkzyMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAN
BgkqhkiG9w0BAQsFAAOCAYEAUIQp0DtACRVAAat4Sl1kzbOI35aIQjkSsX4KgIgC
8HX3qFa4NpbOBmshvgAZFrNQzJS/dLz3oOg7Ww/UH6BZjQT5QCHK7ASA12Ick1Iz
V3aTicaXn7ZyHMOpJwJXgwh6Ekv/sNjr7Sc0NahisRkAR1KglymQtYm3bGbFEKzW
0pyGnDvDBmLuQCfxq2ZwnX7eqM9R3BXBVRFe3uRoqIdwHorlupZ8N+rfyumJjIUP
gOWDUf3VuqnqhjK0BMDdTEkF8po9YQ5Qtj5Iw0JSSBfWE4WJsqyC+4EueCCHuNYs
rTtItjGT6WNGnEGI8VNijtmwHL1cPDmE6l+YrnCK0JG0u21Y+osWMeRhJhGAPY4d
cIu15gcm/PRG0hdYZrGuz18hKNy9NRtIK+na8k2R6o+uNDekIB7Pk6DHoA8Z6UDU
Q5Au+2FAt6mMyFNcifj965nPAnnSv1hg45fFwIww8edClXqfB6MCNxFO6Yzfn2UD
ABNN450+8Nd1pgXJEifiDAIe
-----END CERTIFICATE-----

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD3qZINjXnNxVMa
jX66v/KR1y/PBtgbJzsstmydQ95Pf+zJ+UZGeu3QrPV1WuLuYLZBpzMvkuRahsoW
oRGmlupOOVW7PLiIBNYvLcfdK+q7SKl3nZCcq4Xa+cWVjc849iLZhZFNShpwmxY/
GYFy1tpoql21oKSy3nkUziGnXECZUCQuS0wCMzkrGclr60MNlGjowKq0nRW15tPx
v1ejBQZbGrejILBQ4PCbryEw2F+nV9I4bTuZkLyUREpD/+U2jncc8Y405160xCGJ
/kCVCfq8GtnfX7+6TAnUNJBwXs2Cs6djTCqe66SEMRWxc0fTQA5PMC2gEDFAc+UE
uN+UPfffAgMBAAECggEBAO84XLo4iInI6x+/wsSSObTDXQuk+cMontDumHVDpA24
bDkfTdEwVlv1ZNbpZj+JLSK3ZQqz4VzLy5IWHJ2EMmhCm1vTKA9CVLyXhPFOxVoH
sqG2kYOzbgT4s/BkXOARZ9IiYRp91JImS1PByDbr72WgAgo5VDzuBZiiDwHAaylp
yDwwvPPtps6cP7k30RW7l3W2CmL2p8cta9g8NfNBmq6NHDjwsqvxzPNZ2O2S+V70
55DBH2yNtHa7lwDC9jcwULhtGk2k9kqwfyd9c+QxeIxX/sA7xDJSmK72yutTT+Hw
F5Cttw6aifcRQURoLQ6Qwm1iP93rKN/FRxqGlCFpDMECgYEA/4gL0/m59gaCjShK
SyfaTkGzqJWVNEiHq8Qc9/2DQ4cl0u5aKI6H7wBRiTXfpeYmWmDjGGAEl0UlxtFu
c7OREA47wbgNc4cBvgrrslUzyduV+0CIRWypxgiT+KTl7T2lVy5VreYlr4gaM0rt
68N3jbktyM1R57aIs5XJODAtZWECgYEA+B3UkndgAyk8tUrZaOn7jE4ZnwrLUnRw
nGbAiZG3lmZEULO8jBFJFM2oeuj6467+ckZRviIVWQ8T2KqMye8eB7+fHOBOKCXs
PsCUV/asqN9ibh9UumOKkAsnO1G4p1+EJHzYNNucO3dEF2QTmEQvyHnw4tvxgos5
bf1YJjZLJT8CgYEAwDkDTM6LCXwUMUOhv6+XFU9vat47gz0cciXw9MyMNfwwg+Ax
iljOAQhoTaNtPktHhq1jqC5yxaiKpmldgUQPV9idMzjVRZbFxMRKUbiuYKcCyCLf
X/pCLGq/hUfmfvTksBR2934t00G7E+LF35kHEmG/A1MQzhIN+6ot2ErFm4ECgYAH
o9OB1w8ryb9GzdE3+8x1G4qKbSipl1BIYJmZItWGWgvMeFxb68RWUabYcggXrrHD
DwtBUYdawK4Zw9al+SjxkCL0HqwJbHGD1SY8NypF4OsE/Q3810fS+6TvnKqU7MoC
3Z1Cs2hyJFACcGByFddq0uZp9d/P5z2Td3OZaZ6SvQKBgEQ754VsRiO9zYR70kXf
5ZG75rZICgu14fHxwStCWghvD/4AT53y6kQ1gpdTEKspCmYv3f5xbOPVIW4agHEw
DHZK7EsLEW1EmXdA9QIBKgcBqaGEzuKVtWFzCgNupssC8N9ys3X8r2nNM4qeXHsz
t1+EzpMzGsmMWuJ5l0TcI+W+
-----END PRIVATE KEY-----

View File

@ -144,6 +144,38 @@
"missing_type": "MISSING notification.type:", "missing_type": "MISSING notification.type:",
"reblogged_post": "reblogged your post", "reblogged_post": "reblogged your post",
"request_to_follow": "requested to follow you", "request_to_follow": "requested to follow you",
"settings": {
"alerts": {
"favourite": "Favourites",
"follow": "New followers",
"mention": "Mentions",
"poll": "Polls",
"reblog": "Reblog your post",
"title": "What notifications to receive?"
},
"close_btn": "Close desktop notification settings",
"policy": {
"all": "From anyone",
"followed": "Of people I follow",
"follower": "Of people who follow me",
"none": "From no one",
"title": "Who can I receive notifications from?"
},
"save_settings": "Save settings changes",
"show_btn": "Show desktop notification settings",
"title": "Desktop notification settings",
"undo_settings": "Undo settings changes",
"unsubscribe": "Disable desktop notifications",
"unsubscribed_with_warning": "Enable notifications to receive notifications from this account by clicking \"@:notification.settings.warning.enable_desktop{'\"'} button.",
"unsupported": "Your browser does not support desktop notifications.",
"warning": {
"enable_close": "Close",
"enable_description": "To receive notifications when Elk is not open, enable desktop notifications. You can control precisely what types of interactions generate desktop notifications via the \"Show Settings\" button above once enabled.",
"enable_description_short": "To change desktop notification settings when Elk is not open, you must first enable desktop notifications.",
"enable_desktop": "Enable desktop notifications",
"enable_title": "Never miss anything"
}
},
"update_status": "updated their status" "update_status": "updated their status"
}, },
"placeholder": { "placeholder": {
@ -153,6 +185,12 @@
"replying": "Replying", "replying": "Replying",
"the_thread": "the thread" "the_thread": "the thread"
}, },
"pwa": {
"close": "Close",
"message": "@:pwa.title{','} click on @:pwa.reload button to update.",
"reload": "Reload",
"title": "New Elk version available"
},
"search": { "search": {
"search_desc": "Search for people & hashtags" "search_desc": "Search for people & hashtags"
}, },

View File

@ -141,6 +141,38 @@
"missing_type": "MISSING notification.type:", "missing_type": "MISSING notification.type:",
"reblogged_post": "retooteó tu publicación", "reblogged_post": "retooteó tu publicación",
"request_to_follow": "ha solicitado seguirte", "request_to_follow": "ha solicitado seguirte",
"settings": {
"alerts": {
"favourite": "Favoritos",
"follow": "Nuevos seguidores",
"mention": "Menciones",
"poll": "Encuestas",
"reblog": "Retooteo de tus publicaciones",
"title": "¿Qué notificaciones recibir?"
},
"close_btn": "Cerrar ajuste de las notificaciones de escritorio",
"policy": {
"all": "De cualquier persona",
"followed": "De personas que sigo",
"follower": "De personas que me siguen",
"none": "De nadie",
"title": "¿De quién puedo recibir notificaciones?"
},
"save_settings": "Guardar cambios en los ajustes",
"show_btn": "Mostrar ajustes de las notificaciones de escritorio",
"title": "Ajustes de notificaciones de escritorio",
"undo_settings": "Deshacer cambios en los ajustes",
"unsubscribe": "Cancelar notificaciones de escritorio",
"unsubscribed_with_warning": "Habilite las notificaciones para recibir notificaciones de esta cuenta haciendo clic en el botón \"@:notification.settings.warning.enable_desktop{'\"'}.",
"unsupported": "Tu navegador no soporta notificaciones de escritorio.",
"warning": {
"enable_close": "Cerrar",
"enable_description": "Para recibir notificaciones cuando Elk no esté abierto, habilite las notificaciones de escritorio. Puedes controlar con precisión qué tipos de interacciones generan notificaciones de escritorio a través del botón \"Mostrar ajustes\" de arriba una vez que estén habilitadas.",
"enable_description_short": "Para cambiar los ajustes de las notificaciones de escritorio cuando Elk no esté abierto, debe habilitar antes las notificaciones de escritorio.",
"enable_desktop": "Habilitar notificaciones de escritorio",
"enable_title": "Nunca te pierdas nada"
}
},
"update_status": "ha actualizado su estado" "update_status": "ha actualizado su estado"
}, },
"placeholder": { "placeholder": {
@ -150,6 +182,12 @@
"replying": "Respondiendo", "replying": "Respondiendo",
"the_thread": "el hilo" "the_thread": "el hilo"
}, },
"pwa": {
"close": "Cerrar",
"message": "@:pwa.title{','} haz click en el botón @:pwa.reload para actualizar.",
"reload": "Recargar",
"title": "Nueva versión de Elk disponible"
},
"state": { "state": {
"edited": "(Editado)", "edited": "(Editado)",
"editing": "Editando", "editing": "Editando",

View File

@ -0,0 +1,69 @@
import type { Nuxt } from '@nuxt/schema'
import type { VitePWAOptions } from 'vite-plugin-pwa'
import { resolve } from 'pathe'
export function configurePWAOptions(options: Partial<VitePWAOptions>, nuxt: Nuxt) {
if (!options.outDir) {
const publicDir = nuxt.options.nitro?.output?.publicDir
options.outDir = publicDir ? resolve(publicDir) : resolve(nuxt.options.buildDir, '../.output/public')
}
let config: Partial<
import('workbox-build').BasePartial
& import('workbox-build').GlobPartial
& import('workbox-build').RequiredGlobDirectoryPartial
>
if (options.strategies === 'injectManifest') {
options.injectManifest = options.injectManifest ?? {}
config = options.injectManifest
}
else {
options.workbox = options.workbox ?? {}
if (options.registerType === 'autoUpdate' && (options.injectRegister === 'script' || options.injectRegister === 'inline')) {
options.workbox.clientsClaim = true
options.workbox.skipWaiting = true
}
if (nuxt.options.dev) {
// on dev force always to use the root
options.workbox.navigateFallback = nuxt.options.app.baseURL ?? '/'
if (options.devOptions?.enabled && !options.devOptions.navigateFallbackAllowlist)
options.devOptions.navigateFallbackAllowlist = [new RegExp(nuxt.options.app.baseURL) ?? /\//]
}
config = options.workbox
// todo: change navigateFallback based on the command: use 404 only when using generate
/* else if (nuxt.options.build) {
if (!options.workbox.navigateFallback)
options.workbox.navigateFallback = '/200.html'
} */
}
if (!nuxt.options.dev)
config.manifestTransforms = [createManifestTransform(nuxt.options.app.baseURL ?? '/')]
}
function createManifestTransform(base: string): import('workbox-build').ManifestTransform {
return async (entries) => {
// prefix non html assets with base
/*
entries.filter(e => e && !e.url.endsWith('.html')).forEach((e) => {
if (!e.url.startsWith(base))
e.url = `${base}${e.url}`
})
*/
entries.filter(e => e && e.url.endsWith('.html')).forEach((e) => {
const url = e.url.startsWith('/') ? e.url.slice(1) : e.url
if (url === 'index.html') {
e.url = base
}
else {
const parts = url.split('/')
parts[parts.length - 1] = parts[parts.length - 1].replace(/\.html$/, '')
// e.url = `${base}${parts.length > 1 ? parts.slice(0, parts.length - 1).join('/') : parts[0]}`
e.url = parts.length > 1 ? parts.slice(0, parts.length - 1).join('/') : parts[0]
}
})
return { manifest: entries, warnings: [] }
}
}

View File

@ -0,0 +1,83 @@
import { defineNuxtModule } from '@nuxt/kit'
import type { VitePluginPWAAPI } from 'vite-plugin-pwa'
import { VitePWA } from 'vite-plugin-pwa'
import type { Plugin } from 'vite'
import type { VitePWANuxtOptions } from './types'
import { configurePWAOptions } from './config'
export * from './types'
export default defineNuxtModule<VitePWANuxtOptions>({
meta: {
name: 'pwa',
configKey: 'pwa',
},
defaults: nuxt => ({
base: nuxt.options.app.baseURL,
scope: nuxt.options.app.baseURL,
}),
async setup(options, nuxt) {
let vitePwaClientPlugin: Plugin | undefined
const resolveVitePluginPWAAPI = (): VitePluginPWAAPI | undefined => {
return vitePwaClientPlugin?.api
}
// TODO: combine with configurePWAOptions?
nuxt.hook('nitro:init', (nitro) => {
options.outDir = nitro.options.output.publicDir
options.injectManifest = options.injectManifest || {}
options.injectManifest.globDirectory = nitro.options.output.publicDir
})
nuxt.hook('vite:extend', ({ config }) => {
const plugin = config.plugins?.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa')
if (plugin)
throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!')
})
nuxt.hook('vite:extendConfig', (viteInlineConfig, { isClient }) => {
viteInlineConfig.plugins = viteInlineConfig.plugins || []
const plugin = viteInlineConfig.plugins.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa')
if (plugin)
throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!')
configurePWAOptions(options, nuxt)
const plugins = VitePWA(options)
viteInlineConfig.plugins.push(plugins)
if (isClient)
vitePwaClientPlugin = plugins.find(p => p.name === 'vite-plugin-pwa') as Plugin
})
if (nuxt.options.dev) {
const webManifest = `${nuxt.options.app.baseURL}${options.devOptions?.webManifestUrl ?? options.manifestFilename ?? 'manifest.webmanifest'}`
const devSw = `${nuxt.options.app.baseURL}dev-sw.js?dev-sw`
const workbox = `${nuxt.options.app.baseURL}workbox-`
// @ts-expect-error just ignore
const emptyHandle = (_req, _res, next) => {
next()
}
nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => {
if (isServer)
return
viteServer.middlewares.stack.push({ route: webManifest, handle: emptyHandle })
viteServer.middlewares.stack.push({ route: devSw, handle: emptyHandle })
})
if (!options.strategies || options.strategies === 'generateSW') {
nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => {
if (isServer)
return
viteServer.middlewares.stack.push({ route: workbox, handle: emptyHandle })
})
nuxt.hook('close', async () => {
// todo: cleanup dev-dist folder
})
}
}
else {
nuxt.hook('nitro:init', (nitro) => {
nitro.hooks.hook('rollup:before', async () => {
await resolveVitePluginPWAAPI()?.generateSW()
})
})
}
},
})

View File

@ -0,0 +1,12 @@
import type { VitePWAOptions } from 'vite-plugin-pwa'
export interface VitePWANuxtOptions extends Partial<VitePWAOptions> {}
declare module '@nuxt/schema' {
interface NuxtConfig {
pwa?: { [K in keyof VitePWANuxtOptions]?: Partial<VitePWANuxtOptions[K]> }
}
interface NuxtOptions {
pwa: VitePWANuxtOptions
}
}

View File

@ -8,8 +8,3 @@
to = "https://discord.gg/vAZSDU9J" to = "https://discord.gg/vAZSDU9J"
status = 301 status = 301
force = true force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View File

@ -2,10 +2,16 @@ import { fileURLToPath } from 'node:url'
import Inspect from 'vite-plugin-inspect' import Inspect from 'vite-plugin-inspect'
import { isCI } from 'std-env' import { isCI } from 'std-env'
import { i18n } from './config/i18n' import { i18n } from './config/i18n'
import { pwa } from './config/pwa'
const isPreview = process.env.PULL_REQUEST === 'true' const isPreview = process.env.PULL_REQUEST === 'true'
export default defineNuxtConfig({ export default defineNuxtConfig({
typescript: {
tsConfig: {
exclude: ['../service-worker'],
},
},
modules: [ modules: [
'@vueuse/nuxt', '@vueuse/nuxt',
'@unocss/nuxt', '@unocss/nuxt',
@ -14,6 +20,7 @@ export default defineNuxtConfig({
'@nuxtjs/i18n', '@nuxtjs/i18n',
'~/modules/purge-comments', '~/modules/purge-comments',
'~/modules/setup-components', '~/modules/setup-components',
'~/modules/pwa/index', // change to '@vite-pwa/nuxt' once released and remove pwa module
], ],
experimental: { experimental: {
reactivityTransform: true, reactivityTransform: true,
@ -66,6 +73,7 @@ export default defineNuxtConfig({
}, },
public: { public: {
env: isCI ? isPreview ? 'staging' : 'production' : 'local', env: isCI ? isPreview ? 'staging' : 'production' : 'local',
pwaEnabled: isCI || process.env.VITE_DEV_PWA === 'true',
translateApi: '', translateApi: '',
// Masto uses Mastodon version checks to see what features are enabled. // Masto uses Mastodon version checks to see what features are enabled.
// Mastodon alternatives like GoToSocial will always fail these checks, so // Mastodon alternatives like GoToSocial will always fail these checks, so
@ -77,6 +85,13 @@ export default defineNuxtConfig({
fsBase: 'node_modules/.cache/servers', fsBase: 'node_modules/.cache/servers',
}, },
}, },
routeRules: {
'/manifest.webmanifest': {
headers: {
'Content-Type': 'application/manifest+json',
},
},
},
nitro: { nitro: {
publicAssets: [ publicAssets: [
...(!isCI || isPreview ? [{ dir: fileURLToPath(new URL('./public-dev', import.meta.url)) }] : []), ...(!isCI || isPreview ? [{ dir: fileURLToPath(new URL('./public-dev', import.meta.url)) }] : []),
@ -99,10 +114,14 @@ export default defineNuxtConfig({
{ rel: 'alternate icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'alternate icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'icon', type: 'image/png', href: '/favicon-16x16.png', sizes: '16x16' }, { rel: 'icon', type: 'image/png', href: '/favicon-16x16.png', sizes: '16x16' },
{ rel: 'icon', type: 'image/png', href: '/favicon-32x32.png', sizes: '32x32' }, { rel: 'icon', type: 'image/png', href: '/favicon-32x32.png', sizes: '32x32' },
{ rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#ffffff' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' },
], ],
meta: [{ name: 'theme-color', content: '#ffffff' }],
}, },
}, },
i18n, i18n,
pwa,
}) })
declare global { declare global {

View File

@ -6,15 +6,21 @@
"homepage": "https://elk.zone/", "homepage": "https://elk.zone/",
"scripts": { "scripts": {
"build": "nuxi build", "build": "nuxi build",
"build:pwa": "VITE_DEV_PWA=true nuxi build",
"build:netlify:pwa": "VITE_DEV_PWA=true NITRO_PRESET=netlify nuxi build",
"dev": "nuxi dev --port 5314", "dev": "nuxi dev --port 5314",
"dev:pwa": "VITE_DEV_PWA=true nuxi dev --port 5314",
"dev:mocked": "nuxi dev --port 5314 --dotenv .env.mock", "dev:mocked": "nuxi dev --port 5314 --dotenv .env.mock",
"dev:mocked:pwa": "VITE_DEV_PWA=true nuxi dev --port 5314 --dotenv .env.mock",
"dev:mocked:pwa:ssl": "VITE_DEV_PWA=true nuxi dev --port 5314 --https --ssl-cert ./https-dev-config/localhost.crt --ssl-key ./https-dev-config/localhost.key --dotenv .env.mock",
"start": "PORT=5314 node .output/server/index.mjs", "start": "PORT=5314 node .output/server/index.mjs",
"start:https": "PORT=5314 node ./https-dev-config/local-https-server.mjs",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "nuxi typecheck", "typecheck": "nuxi typecheck",
"prepare": "esno scripts/prepare.ts", "prepare": "esno scripts/prepare.ts",
"generate": "nuxi generate", "generate": "nuxi generate",
"test:unit": "vitest", "test:unit": "vitest",
"test:typecheck": "vue-tsc --noEmit", "test:typecheck": "vue-tsc --noEmit && vue-tsc --noEmit --project service-worker/tsconfig.json",
"test": "nr test:unit", "test": "nr test:unit",
"postinstall": "nuxi prepare && simple-git-hooks" "postinstall": "nuxi prepare && simple-git-hooks"
}, },
@ -82,9 +88,11 @@
"ultrahtml": "^1.0.4", "ultrahtml": "^1.0.4",
"unplugin-auto-import": "^0.12.0", "unplugin-auto-import": "^0.12.0",
"vite-plugin-inspect": "^0.7.9", "vite-plugin-inspect": "^0.7.9",
"vite-plugin-pwa": "^0.13.3",
"vitest": "^0.25.3", "vitest": "^0.25.3",
"vue-tsc": "^1.0.11", "vue-tsc": "^1.0.11",
"vue-virtual-scroller": "2.0.0-beta.4" "vue-virtual-scroller": "2.0.0-beta.4",
"workbox-window": "^6.5.4"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "pnpm lint-staged" "pre-commit": "pnpm lint-staged"

View File

@ -4,6 +4,8 @@ definePageMeta({
}) })
const { t } = useI18n() const { t } = useI18n()
const showSettings = ref(false)
const pwaEnabled = useRuntimeConfig().public.pwaEnabled
const tabs = $computed(() => [ const tabs = $computed(() => [
{ {
@ -17,6 +19,10 @@ const tabs = $computed(() => [
display: t('tab.notifications_mention'), display: t('tab.notifications_mention'),
}, },
] as const) ] as const)
onActivated(() => {
showSettings.value = false
})
</script> </script>
<template> <template>
@ -28,9 +34,26 @@ const tabs = $computed(() => [
</NuxtLink> </NuxtLink>
</template> </template>
<template v-if="pwaEnabled" #actions>
<button
flex rounded-4 p1
hover:bg-active cursor-pointer transition-100
:title="$t(showSettings ? 'notification.settings.close_btn' : 'notification.settings.show_btn')"
@click="showSettings = !showSettings"
>
<span aria-hidden="true" w-1.75em h-1.75em :class="showSettings ? 'i-ri:close-circle-line' : 'i-ri:settings-3-fill'" />
</button>
</template>
<template #header> <template #header>
<CommonRouteTabs replace :options="tabs" /> <CommonRouteTabs replace :options="tabs" />
</template> </template>
<NuxtPage />
<slot>
<template v-if="pwaEnabled">
<NotificationPreferences :show="showSettings" />
</template>
<NuxtPage />
</slot>
</MainContent> </MainContent>
</template> </template>

View File

@ -43,7 +43,11 @@ export default defineNuxtPlugin(async (nuxtApp) => {
if (process.client) { if (process.client) {
const { query } = useRoute() const { query } = useRoute()
const user = typeof query.server === 'string' && typeof query.token === 'string' const user = typeof query.server === 'string' && typeof query.token === 'string'
? { server: query.server, token: query.token } ? {
server: query.server,
token: query.token,
vapidKey: typeof query.vapid_key === 'string' ? query.vapid_key : undefined,
}
: currentUser.value : currentUser.value
nuxtApp.hook('app:suspense:resolve', () => { nuxtApp.hook('app:suspense:resolve', () => {

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

11
public/favicon.svg 100644
View File

@ -0,0 +1,11 @@
<svg width="250" height="250" viewBox="0 0 250 250" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_6_40" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="1" width="240" height="234">
<path d="M244 123C244 187.617 205.617 235 141 235C76.3827 235 38 204.117 38 139.5C38 111.194 -8.72891 36.2356 8.00002 16C29.4601 -9.95861 88.6887 5.99994 125 5.99994C189.617 5.99994 244 58.3827 244 123Z" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_6_40)">
<path d="M116.94 88.0994C103.596 89.6517 96.5039 86.0813 92.2336 98.8104C92.2336 98.8104 106.57 120.465 144.774 119.922C142.639 128.77 143.63 135.29 143.63 143.129C143.63 169.208 123.041 191.95 77.6687 191.95C54.6395 191.95 26.6536 196.141 5.30196 207.861C-9.87294 216.166 -21.746 228.197 -27 244.884L-21.0444 253.345L-9.64418 253.5V301.389L-23.5532 323.355L-19.5574 387H-6.36518L-5.22134 335.773C1.33666 331.892 16.3591 321.802 29.17 306.279C46.5564 285.4 59.9011 255.052 44.1924 217.486L55.9358 212.441C68.823 243.255 64.324 269.955 53.0381 291.454C74.6185 290.756 93.1486 289.359 108.857 286.72L105.273 243.022L117.932 241.935L129.98 387H143.096L145.308 292.541C155.755 288.039 179.547 271.507 190.68 214.071C192.052 207.085 192.815 201.186 193.196 196.141C194.95 183.335 195.941 168.898 196.247 152.443L177.564 146.467H234.984L240.551 133.66C235.137 133.893 228.655 131.021 228.655 131.021L229.952 124.812H242L176.801 90.4279C169.557 93.222 161.931 96.87 156.593 101.294C152.323 98.1895 137.53 88.4874 116.94 88.0994Z" fill="#EA9E44"/>
<path d="M6.21704 24.4927L18.4942 21C24.4422 42.5773 31.839 54.375 41.1422 60.3515C49.5303 65.4509 60.8925 65.5906 72.9409 64.9309C69.4331 63.7666 66.1541 62.1367 63.1039 59.8858C56.3171 54.8407 50.5217 46.4582 46.1751 31.2454L58.376 27.5974C61.655 39.0846 65.4678 45.682 70.577 49.4852C75.6861 53.2108 81.8628 54.1422 89.1834 54.9184C102.909 56.4707 120.067 57.0916 141.495 67.1817C144.393 68.2684 147.367 69.6655 150.264 71.2178C149.883 70.4416 149.502 69.6655 148.968 68.8117C145.308 62.9904 138.14 56.8588 124.871 51.8913L129.141 39.7832C150.722 47.7 159.262 58.9544 162.694 67.8803C166.659 78.048 164.219 86.0037 164.219 86.0037C161.169 87.0127 158.119 88.4098 154.611 89.4964C147.977 84.9171 141.724 81.4631 135.776 78.7466C113.814 70.4416 92.3099 76.1076 73.2459 77.8928C58.9098 79.2123 45.7938 78.5913 34.4317 71.2954C23.2221 64.1547 13.3851 50.4942 6.21704 24.4927Z" fill="#C16929"/>
<path d="M90.0984 45.2939C87.582 39.5503 86.0569 32.4872 86.7432 23.7942L99.4016 24.7256C98.6391 35.2814 102.299 42.4221 106.417 47.0791C101.079 46.1477 95.9701 46.039 90.0984 45.2939Z" fill="#C16929"/>
<path d="M170.167 43.9744L178.479 34.2724C200.059 53.366 186.638 80.687 186.638 80.687L174.819 79.3675C174.437 73.1271 173.675 61.5313 168.184 54.996C171.768 56.8355 174.819 58.8613 178.174 61.9038C178.174 56.2378 176.42 49.5628 170.167 43.9744Z" fill="#C16929"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /

View File

@ -0,0 +1,79 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1747 6857 l-108 -30 6 -29 c19 -84 86 -269 136 -374 148 -310 345
-492 614 -565 69 -19 107 -23 275 -23 183 -1 228 2 390 24 36 5 90 11 120 15
30 3 71 8 90 10 133 17 344 22 450 11 204 -22 368 -84 581 -218 l86 -55 79 29
c43 16 85 35 92 42 30 29 18 216 -21 323 -62 178 -242 350 -480 463 -155 73
-140 75 -177 -27 -51 -141 -52 -134 27 -165 149 -60 314 -185 370 -281 12 -20
20 -37 17 -37 -4 0 -36 14 -73 31 -131 61 -213 94 -321 131 -84 28 -211 61
-280 73 -19 3 -42 8 -50 10 -8 2 -42 7 -75 10 -33 4 -67 8 -75 10 -8 2 -44 7
-80 10 -103 10 -292 34 -323 40 -175 35 -275 126 -360 326 -21 47 -37 91 -37
97 0 6 -4 22 -9 35 l-10 25 -105 -32 c-58 -18 -108 -35 -112 -38 -19 -19 101
-295 166 -383 71 -96 182 -186 266 -217 19 -7 34 -15 34 -19 0 -14 -305 -2
-385 15 -227 49 -370 193 -510 518 -8 21 -32 88 -52 150 -19 62 -38 116 -42
119 -3 3 -55 -8 -114 -24z"/>
<path d="M3128 6827 c-6 -17 -2 -133 6 -192 7 -52 43 -174 55 -187 4 -4 31
-10 61 -13 97 -9 154 -15 199 -21 l44 -5 -30 38 c-61 79 -89 159 -99 288 -4
44 -8 81 -9 83 -2 2 -29 5 -62 8 -32 2 -81 6 -110 8 -34 3 -53 1 -55 -7z"/>
<path d="M4800 6612 c-13 -15 -45 -52 -72 -81 -26 -30 -48 -58 -48 -62 0 -4
16 -27 37 -51 48 -57 91 -154 99 -224 l7 -57 -54 39 c-65 47 -106 74 -115 74
-3 0 6 -20 19 -45 23 -41 57 -137 61 -175 1 -8 5 -35 9 -60 3 -25 8 -69 11
-99 3 -29 6 -54 8 -56 6 -4 158 -25 188 -25 25 0 32 6 45 36 47 112 70 306 51
424 -17 109 -74 232 -152 327 -51 62 -66 68 -94 35z"/>
<path d="M3618 5645 c-2 -2 -44 -5 -93 -6 -162 -4 -231 -40 -275 -141 l-21
-50 23 -26 c163 -184 444 -320 713 -347 118 -12 131 -13 184 -14 51 -1 53 -2
47 -25 -11 -44 -17 -151 -20 -356 -3 -220 -12 -269 -70 -395 -96 -209 -301
-382 -557 -469 -58 -20 -206 -55 -261 -62 -24 -3 -54 -7 -68 -10 -34 -6 -172
-13 -375 -19 -93 -3 -188 -8 -210 -10 -22 -2 -65 -7 -95 -10 -276 -30 -601
-118 -819 -221 -342 -162 -570 -387 -675 -667 l-26 -67 56 -79 56 -79 101 -1
c55 -1 103 -4 105 -6 3 -3 5 -202 5 -444 -1 -422 -2 -440 -21 -468 -11 -15
-68 -104 -127 -198 l-108 -170 2 -65 c1 -36 4 -85 6 -110 2 -25 6 -97 10 -160
3 -63 8 -137 11 -164 2 -27 6 -92 9 -145 3 -53 7 -123 10 -156 2 -33 7 -116
11 -185 6 -111 10 -171 13 -192 1 -3 56 -5 123 -5 l122 2 1 110 c3 172 16 756
19 799 1 22 7 41 14 44 6 2 60 37 119 79 446 312 760 725 867 1140 31 118 40
200 40 343 1 192 -20 319 -79 490 -14 41 -29 87 -34 101 -9 26 -7 28 97 72 76
32 109 42 114 34 18 -27 88 -260 108 -357 24 -116 37 -418 21 -500 -5 -25 -11
-65 -14 -90 -13 -100 -70 -281 -133 -416 -19 -41 -34 -77 -34 -80 0 -5 251 3
340 11 25 2 95 7 155 10 61 3 117 8 125 10 8 2 49 7 90 10 41 3 104 11 140 16
36 6 88 13 115 16 51 5 68 17 60 43 -2 8 -7 56 -10 105 -3 50 -7 104 -9 120
-3 17 -7 72 -10 122 -3 51 -8 108 -11 125 -5 39 -17 207 -19 262 -1 44 -10 41
120 51 49 4 93 7 98 8 10 2 12 -5 21 -118 4 -49 8 -103 10 -120 2 -16 6 -70
10 -120 4 -49 8 -103 10 -120 2 -16 6 -73 10 -125 3 -52 8 -106 10 -120 2 -14
6 -61 10 -105 7 -105 15 -200 20 -250 2 -22 7 -80 10 -130 4 -49 8 -101 10
-115 3 -14 7 -63 10 -110 3 -47 8 -107 11 -134 2 -27 7 -78 9 -115 3 -36 7
-92 10 -124 3 -31 7 -84 10 -117 3 -33 7 -86 10 -118 3 -31 7 -84 10 -117 2
-33 7 -91 10 -130 3 -38 7 -88 10 -110 6 -57 18 -214 19 -251 l1 -31 123 -1
122 0 1 37 c0 20 2 86 4 146 3 109 10 375 21 845 3 135 7 299 9 365 2 66 4
172 4 235 l1 115 53 29 c314 172 557 552 718 1121 22 77 42 151 44 165 5 30
22 115 30 150 11 52 31 191 40 275 3 28 8 70 11 95 3 25 7 59 9 75 11 89 23
249 30 415 4 74 8 163 10 197 2 39 -1 66 -8 71 -7 5 -73 28 -147 51 -74 23
-144 46 -155 51 -12 5 190 9 505 10 l525 0 48 113 c26 61 47 115 47 120 0 4
-14 7 -31 7 -39 0 -150 28 -167 43 -9 7 -11 22 -7 46 4 20 8 43 10 51 1 12 24
16 116 18 99 2 110 4 89 16 -95 49 -297 155 -355 186 -38 20 -124 66 -190 100
-66 35 -147 77 -180 95 -33 17 -123 65 -200 105 -77 40 -165 86 -195 102 -37
21 -63 28 -81 24 -45 -9 -215 -92 -288 -141 -38 -25 -71 -45 -74 -45 -3 0 -32
17 -64 38 -113 75 -287 144 -448 177 -70 15 -278 29 -287 20z"/>
<path d="M4962 2267 c-85 -143 -176 -272 -267 -379 -36 -42 -65 -79 -65 -81 0
-3 23 -14 50 -26 28 -12 89 -39 136 -61 47 -22 87 -40 89 -40 3 0 42 -17 87
-39 46 -21 131 -59 190 -86 105 -46 107 -47 97 -73 -10 -25 -58 -138 -85 -197
-53 -119 -194 -448 -194 -454 0 -13 201 -104 210 -94 4 4 112 214 239 465 218
430 230 457 213 470 -11 7 -46 36 -78 64 -32 28 -160 138 -284 244 -124 106
-230 198 -237 204 -7 6 -15 53 -19 106 -4 52 -7 96 -8 97 -1 1 -34 -53 -74
-120z"/>
<path d="M2746 1662 c-2 -1 -91 -5 -197 -8 l-194 -6 -20 -26 c-65 -86 -194
-231 -287 -323 l-107 -107 9 -63 c5 -35 11 -91 14 -124 4 -33 8 -76 11 -95 6
-43 17 -136 20 -170 1 -14 5 -45 8 -70 3 -25 9 -76 13 -115 3 -38 8 -78 9 -87
2 -9 7 -48 10 -85 4 -37 9 -75 10 -83 2 -8 6 -50 10 -92 4 -43 10 -79 14 -79
16 -4 217 -6 227 -2 8 3 11 139 11 465 l0 460 49 46 c213 194 394 397 488 543
l17 27 -56 -2 c-31 0 -58 -2 -59 -4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -32,6 +32,6 @@ export default defineEventHandler(async (event) => {
}, },
}) })
const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token })}` const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token, vapid_key: app.vapid_key })}`
await sendRedirect(event, url, 302) await sendRedirect(event, url, 302)
}) })

View File

@ -0,0 +1,65 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { onNotificationClick, onPush } from './web-push-notifications'
declare const self: ServiceWorkerGlobalScope
/*
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
*/
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING')
self.skipWaiting()
})
const entries = self.__WB_MANIFEST
if (import.meta.env.DEV)
entries.push({ url: '/', revision: Math.random().toString() })
precacheAndRoute(entries)
// clean old assets
cleanupOutdatedCaches()
// allow only fallback in dev: we don't want to cache anything
let allowlist: undefined | RegExp[]
if (import.meta.env.DEV)
allowlist = [/^\/$/]
// deny api and server page calls
let denylist: undefined | RegExp[]
if (import.meta.env.PROD)
denylist = [/^\/api\//, /^\/login\//, /^\/oauth\//, /^\/signin\//]
// only cache pages and external assets on local build + start or in production
if (import.meta.env.PROD) {
// external assets: rn avatars from mas.to
// requires <img crossorigin="anonymous".../> and http header: Allow-Control-Allow-Origin: *
/*
registerRoute(
({ sameOrigin, request }) => !sameOrigin && request.destination === 'image',
new NetworkFirst({
cacheName: 'elk-external-media',
plugins: [
// add opaque responses?
new CacheableResponsePlugin({ statuses: [/!* 0, *!/200] }),
// 15 days max
new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 15 }),
],
}),
)
*/
}
// to allow work offline
registerRoute(new NavigationRoute(
createHandlerBoundToURL('/'),
{ allowlist, denylist },
))
self.addEventListener('push', onPush)
self.addEventListener('notificationclick', onNotificationClick)

View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"lib": ["ESNext", "WebWorker"],
"types": ["vite/client", "service-worker"]
},
"include": ["./"],
"extends": "../tsconfig.json"
}

View File

@ -0,0 +1,9 @@
export interface PushPayload {
access_token: string
notification_id: string
notification_type: 'follow' | 'favourite' | 'reblog' | 'mention' | 'poll'
preferred_locale: string
title: string
body: string
icon: string
}

View File

@ -0,0 +1,85 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import type { PushPayload } from '~/service-worker/types'
declare const self: ServiceWorkerGlobalScope
export const onPush = (event: PushEvent) => {
const promise = isClientFocused().then((isFocused) => {
if (isFocused)
return Promise.resolve()
const options: PushPayload = event.data!.json()
const {
access_token,
body,
icon,
notification_id,
notification_type,
preferred_locale,
} = options
let url = 'home'
if (notification_type) {
switch (notification_type) {
case 'follow':
url = 'notifications'
break
case 'mention':
url = 'notifications/mention'
break
}
}
const notificationOptions: NotificationOptions = {
badge: '/pwa-192x192.png',
body,
data: {
access_token,
preferred_locale,
url: `/${url}`,
},
dir: 'auto',
icon,
lang: preferred_locale,
tag: notification_id,
timestamp: new Date().getUTCDate(),
}
return self.registration.showNotification(options.title, notificationOptions)
})
event.waitUntil(promise)
}
export const onNotificationClick = (event: NotificationEvent) => {
const reactToNotificationClick = new Promise((resolve) => {
event.notification.close()
resolve(openUrl(event.notification.data.url))
})
event.waitUntil(reactToNotificationClick)
}
function findBestClient(clients: WindowClient[]) {
const focusedClient = clients.find(client => client.focused)
const visibleClient = clients.find(client => client.visibilityState === 'visible')
return focusedClient || visibleClient || clients[0]
}
async function openUrl(url: string) {
const clients = await self.clients.matchAll({ type: 'window' })
// Chrome 42-48 does not support navigate
if (clients.length !== 0 && 'navigate' in clients[0]) {
const client = findBestClient(clients as WindowClient[])
await client.navigate(url).then(client => client?.focus())
}
await self.clients.openWindow(url)
}
function isClientFocused() {
return self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(windowClients => Promise.resolve(windowClients.some(windowClient => windowClient.focused)))
}

2
shims.d.ts vendored
View File

@ -1 +1,3 @@
/// <reference types="@types/wicg-file-system-access" /> /// <reference types="@types/wicg-file-system-access" />
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/client" />

View File

@ -1,4 +1,4 @@
import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, MastoClient, Notification, Status } from 'masto' import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, MastoClient, Notification, PushSubscription, Status } from 'masto'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { Mutable } from './utils' import type { Mutable } from './utils'
@ -16,6 +16,8 @@ export interface UserLogin {
server: string server: string
token?: string token?: string
account: AccountCredentials account: AccountCredentials
vapidKey?: string
pushSubscription?: PushSubscription
} }
export interface ElkMasto extends MastoClient { export interface ElkMasto extends MastoClient {