feat: pwa with push notifications (#337)
This commit is contained in:
parent
a18e5e2332
commit
f0c91a3974
48 changed files with 2903 additions and 14 deletions
39
components/PWAPrompt.client.vue
Normal file
39
components/PWAPrompt.client.vue
Normal 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>
|
35
components/common/CommonCheckbox.vue
Normal file
35
components/common/CommonCheckbox.vue
Normal 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>
|
37
components/common/CommonRadio.vue
Normal file
37
components/common/CommonRadio.vue
Normal 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>
|
|
@ -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>
|
185
components/notification/NotificationPreferences.client.vue
Normal file
185
components/notification/NotificationPreferences.client.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue