feat: pwa with push notifications (#337)
|
@ -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":[]}}}'
|
||||
|
|
|
@ -2,3 +2,5 @@
|
|||
*.png
|
||||
*.ico
|
||||
*.toml
|
||||
https-dev-config/localhost.crt
|
||||
https-dev-config/localhost.key
|
||||
|
|
|
@ -7,6 +7,7 @@ dist
|
|||
.DS_Store
|
||||
.idea/
|
||||
.vite-inspect
|
||||
.netlify/
|
||||
|
||||
public/shiki
|
||||
|
||||
|
|
1
app.vue
|
@ -13,4 +13,5 @@ const key = computed(() => `${currentServer.value}:${currentUser.value?.account.
|
|||
<NuxtLayout :key="key">
|
||||
<NuxtPage v-if="isMastoInitialised" />
|
||||
</NuxtLayout>
|
||||
<PWAPrompt />
|
||||
</template>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { pwaInfo } from 'virtual:pwa-info'
|
||||
import type { Link } from '@unhead/schema'
|
||||
import { APP_NAME, STORAGE_KEY_LANG } from '~/constants'
|
||||
|
||||
export function setupPageHeader() {
|
||||
|
@ -6,11 +8,34 @@ export function setupPageHeader() {
|
|||
|
||||
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({
|
||||
htmlAttrs: {
|
||||
lang: () => i18n.locale.value,
|
||||
},
|
||||
titleTemplate: title => `${title ? `${title} | ` : ''}${APP_NAME}${isDev ? ' (dev)' : isPreview ? ' (preview)' : ''}`,
|
||||
link,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
|
|
@ -2,7 +2,16 @@ import { login as loginMasto } from 'masto'
|
|||
import type { Account, AccountCredentials, Instance, WsEvents } from 'masto'
|
||||
import type { Ref } from 'vue'
|
||||
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 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 {
|
||||
try {
|
||||
const [me, server] = await Promise.all([
|
||||
const [me, server, pushSubscription] = await Promise.all([
|
||||
masto.accounts.verifyCredentials(),
|
||||
masto.instances.fetch(),
|
||||
// we get 404 response instead empty data
|
||||
masto.pushSubscriptions.fetch().catch(() => Promise.resolve(undefined)),
|
||||
])
|
||||
|
||||
user.account = me
|
||||
user.pushSubscription = pushSubscription
|
||||
currentUserId.value = me.id
|
||||
servers.value[me.id] = server
|
||||
|
||||
|
@ -83,6 +95,37 @@ export async function loginTo(user?: Omit<UserLogin, 'account'> & { account?: Ac
|
|||
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() {
|
||||
// TODO: confirm
|
||||
if (!currentUser.value)
|
||||
|
@ -97,6 +140,8 @@ export async function signout() {
|
|||
clearUserLocalStorage()
|
||||
delete servers.value[_currentUserId]
|
||||
|
||||
await removePushNotifications(currentUser.value)
|
||||
|
||||
currentUserId.value = ''
|
||||
// Remove the current user from the users
|
||||
users.value.splice(index, 1)
|
||||
|
|
|
@ -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 }
|
|
@ -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_NEWS_TIPS = 'elk-hide-explore-news-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+)?$/
|
||||
|
|
|
@ -54,4 +54,5 @@ const reload = async () => {
|
|||
</slot>
|
||||
</MainContent>
|
||||
</NuxtLayout>
|
||||
<PWAPrompt />
|
||||
</template>
|
||||
|
|
|
@ -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()
|
||||
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -144,6 +144,38 @@
|
|||
"missing_type": "MISSING notification.type:",
|
||||
"reblogged_post": "reblogged your post",
|
||||
"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"
|
||||
},
|
||||
"placeholder": {
|
||||
|
@ -153,6 +185,12 @@
|
|||
"replying": "Replying",
|
||||
"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_desc": "Search for people & hashtags"
|
||||
},
|
||||
|
|
|
@ -141,6 +141,38 @@
|
|||
"missing_type": "MISSING notification.type:",
|
||||
"reblogged_post": "retooteó tu publicación",
|
||||
"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"
|
||||
},
|
||||
"placeholder": {
|
||||
|
@ -150,6 +182,12 @@
|
|||
"replying": "Respondiendo",
|
||||
"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": {
|
||||
"edited": "(Editado)",
|
||||
"editing": "Editando",
|
||||
|
|
|
@ -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: [] }
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -8,8 +8,3 @@
|
|||
to = "https://discord.gg/vAZSDU9J"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
|
|
@ -2,10 +2,16 @@ import { fileURLToPath } from 'node:url'
|
|||
import Inspect from 'vite-plugin-inspect'
|
||||
import { isCI } from 'std-env'
|
||||
import { i18n } from './config/i18n'
|
||||
import { pwa } from './config/pwa'
|
||||
|
||||
const isPreview = process.env.PULL_REQUEST === 'true'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
typescript: {
|
||||
tsConfig: {
|
||||
exclude: ['../service-worker'],
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
'@vueuse/nuxt',
|
||||
'@unocss/nuxt',
|
||||
|
@ -14,6 +20,7 @@ export default defineNuxtConfig({
|
|||
'@nuxtjs/i18n',
|
||||
'~/modules/purge-comments',
|
||||
'~/modules/setup-components',
|
||||
'~/modules/pwa/index', // change to '@vite-pwa/nuxt' once released and remove pwa module
|
||||
],
|
||||
experimental: {
|
||||
reactivityTransform: true,
|
||||
|
@ -66,6 +73,7 @@ export default defineNuxtConfig({
|
|||
},
|
||||
public: {
|
||||
env: isCI ? isPreview ? 'staging' : 'production' : 'local',
|
||||
pwaEnabled: isCI || process.env.VITE_DEV_PWA === 'true',
|
||||
translateApi: '',
|
||||
// Masto uses Mastodon version checks to see what features are enabled.
|
||||
// Mastodon alternatives like GoToSocial will always fail these checks, so
|
||||
|
@ -77,6 +85,13 @@ export default defineNuxtConfig({
|
|||
fsBase: 'node_modules/.cache/servers',
|
||||
},
|
||||
},
|
||||
routeRules: {
|
||||
'/manifest.webmanifest': {
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json',
|
||||
},
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
publicAssets: [
|
||||
...(!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: 'icon', type: 'image/png', href: '/favicon-16x16.png', sizes: '16x16' },
|
||||
{ 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,
|
||||
pwa,
|
||||
})
|
||||
|
||||
declare global {
|
||||
|
|
12
package.json
|
@ -6,15 +6,21 @@
|
|||
"homepage": "https://elk.zone/",
|
||||
"scripts": {
|
||||
"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:pwa": "VITE_DEV_PWA=true nuxi dev --port 5314",
|
||||
"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:https": "PORT=5314 node ./https-dev-config/local-https-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "nuxi typecheck",
|
||||
"prepare": "esno scripts/prepare.ts",
|
||||
"generate": "nuxi generate",
|
||||
"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",
|
||||
"postinstall": "nuxi prepare && simple-git-hooks"
|
||||
},
|
||||
|
@ -82,9 +88,11 @@
|
|||
"ultrahtml": "^1.0.4",
|
||||
"unplugin-auto-import": "^0.12.0",
|
||||
"vite-plugin-inspect": "^0.7.9",
|
||||
"vite-plugin-pwa": "^0.13.3",
|
||||
"vitest": "^0.25.3",
|
||||
"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": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
|
|
|
@ -4,6 +4,8 @@ definePageMeta({
|
|||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const showSettings = ref(false)
|
||||
const pwaEnabled = useRuntimeConfig().public.pwaEnabled
|
||||
|
||||
const tabs = $computed(() => [
|
||||
{
|
||||
|
@ -17,6 +19,10 @@ const tabs = $computed(() => [
|
|||
display: t('tab.notifications_mention'),
|
||||
},
|
||||
] as const)
|
||||
|
||||
onActivated(() => {
|
||||
showSettings.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -28,9 +34,26 @@ const tabs = $computed(() => [
|
|||
</NuxtLink>
|
||||
</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>
|
||||
<CommonRouteTabs replace :options="tabs" />
|
||||
</template>
|
||||
|
||||
<slot>
|
||||
<template v-if="pwaEnabled">
|
||||
<NotificationPreferences :show="showSettings" />
|
||||
</template>
|
||||
<NuxtPage />
|
||||
</slot>
|
||||
</MainContent>
|
||||
</template>
|
||||
|
|
|
@ -43,7 +43,11 @@ export default defineNuxtPlugin(async (nuxtApp) => {
|
|||
if (process.client) {
|
||||
const { query } = useRoute()
|
||||
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
|
||||
|
||||
nuxtApp.hook('app:suspense:resolve', () => {
|
||||
|
|
1452
pnpm-lock.yaml
After Width: | Height: | Size: 6.4 KiB |
After Width: | Height: | Size: 6.8 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 8.3 KiB |
|
@ -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 |
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 20 KiB |
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Allow: /
|
|
@ -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 |
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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)
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "WebWorker"],
|
||||
"types": ["vite/client", "service-worker"]
|
||||
},
|
||||
"include": ["./"],
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)))
|
||||
}
|
|
@ -1 +1,3 @@
|
|||
/// <reference types="@types/wicg-file-system-access" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
|
|
@ -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 { Mutable } from './utils'
|
||||
|
||||
|
@ -16,6 +16,8 @@ export interface UserLogin {
|
|||
server: string
|
||||
token?: string
|
||||
account: AccountCredentials
|
||||
vapidKey?: string
|
||||
pushSubscription?: PushSubscription
|
||||
}
|
||||
|
||||
export interface ElkMasto extends MastoClient {
|
||||
|
|