Skip to content

Commit

Permalink
feat: pwa with push notifications (elk-zone#337)
Browse files Browse the repository at this point in the history
  • Loading branch information
userquin authored Dec 17, 2022
1 parent a18e5e2 commit f0c91a3
Show file tree
Hide file tree
Showing 48 changed files with 3,002 additions and 113 deletions.
2 changes: 1 addition & 1 deletion .env.mock
Original file line number Diff line number Diff line change
@@ -1 +1 @@
MOCK_USER='{"user":{"server":"universeodon.com","token":"BLMfvYGgiEPgLpiunVS0JYxxqzga3S58C60DDwu1jvw","account":{"id":"109424142224653388","username":"elkdev","acct":"[email protected]","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":"[email protected]","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":"[email protected]","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":"[email protected]","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 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
*.png
*.ico
*.toml
https-dev-config/localhost.crt
https-dev-config/localhost.key
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dist
.DS_Store
.idea/
.vite-inspect
.netlify/

public/shiki

Expand Down
1 change: 1 addition & 0 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ const key = computed(() => `${currentServer.value}:${currentUser.value?.account.
<NuxtLayout :key="key">
<NuxtPage v-if="isMastoInitialised" />
</NuxtLayout>
<PWAPrompt />
</template>
39 changes: 39 additions & 0 deletions components/PWAPrompt.client.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions components/common/CommonCheckbox.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions components/common/CommonRadio.vue
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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 changes: 185 additions & 0 deletions components/notification/NotificationPreferences.client.vue
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit f0c91a3

Please sign in to comment.