From 73fbe89a4b4e545796e9cc6aae707de0a4eed3a1 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 25 Oct 2023 18:58:33 +0300 Subject: initial work on showing notifications through serviceworkers --- src/services/sw/sw.js | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/services/sw/sw.js (limited to 'src/services/sw/sw.js') diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js new file mode 100644 index 00000000..b13c9a1b --- /dev/null +++ b/src/services/sw/sw.js @@ -0,0 +1,124 @@ +import runtime from 'serviceworker-webpack5-plugin/lib/runtime' + +function urlBase64ToUint8Array (base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/') + + const rawData = window.atob(base64) + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) +} + +function isSWSupported () { + return 'serviceWorker' in navigator +} + +function isPushSupported () { + return 'PushManager' in window +} + +function getOrCreateServiceWorker () { + return runtime.register() + .catch((err) => console.error('Unable to get or create a service worker.', err)) +} + +function subscribePush (registration, isEnabled, vapidPublicKey) { + if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config')) + if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found')) + + const subscribeOptions = { + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) + } + return registration.pushManager.subscribe(subscribeOptions) +} + +function unsubscribePush (registration) { + return registration.pushManager.getSubscription() + .then((subscribtion) => { + if (subscribtion === null) { return } + return subscribtion.unsubscribe() + }) +} + +function deleteSubscriptionFromBackEnd (token) { + return fetch('/api/v1/push/subscription/', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }).then((response) => { + if (!response.ok) throw new Error('Bad status code from server.') + return response + }) +} + +function sendSubscriptionToBackEnd (subscription, token, notificationVisibility) { + return window.fetch('/api/v1/push/subscription/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + subscription, + data: { + alerts: { + follow: notificationVisibility.follows, + favourite: notificationVisibility.likes, + mention: notificationVisibility.mentions, + reblog: notificationVisibility.repeats, + move: notificationVisibility.moves + } + } + }) + }).then((response) => { + if (!response.ok) throw new Error('Bad status code from server.') + return response.json() + }).then((responseData) => { + if (!responseData.id) throw new Error('Bad response from server.') + return responseData + }) +} +export function initServiceWorker () { + if (!isSWSupported()) return + getOrCreateServiceWorker() +} + +export async function showDesktopNotification (content) { + const { active: sw } = await window.navigator.serviceWorker.getRegistration() + sw.postMessage({ type: 'desktopNotification', content }) +} + +export async function updateFocus () { + const { active: sw } = await window.navigator.serviceWorker.getRegistration() + sw.postMessage({ type: 'updateFocus' }) +} + +export function registerPushNotifications (isEnabled, vapidPublicKey, token, notificationVisibility) { + if (isPushSupported()) { + getOrCreateServiceWorker() + .then((registration) => subscribePush(registration, isEnabled, vapidPublicKey)) + .then((subscription) => sendSubscriptionToBackEnd(subscription, token, notificationVisibility)) + .catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`)) + } +} + +export function unregisterPushNotifications (token) { + if (isPushSupported()) { + Promise.all([ + deleteSubscriptionFromBackEnd(token), + getOrCreateServiceWorker() + .then((registration) => { + return unsubscribePush(registration).then((result) => [registration, result]) + }) + .then(([registration, unsubResult]) => { + if (!unsubResult) { + console.warn('Push subscription cancellation wasn\'t successful') + } + }) + ]).catch((e) => console.warn(`Failed to disable Web Push Notifications: ${e.message}`)) + } +} -- cgit v1.2.3-70-g09d2 From 0628aac664be4ccce361d319fad201c44d9257fe Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Thu, 26 Oct 2023 15:42:21 +0300 Subject: fallback to old notification method, don't spam if old way of creating notification fails, try to use favicon --- .../desktop_notification_utils.js | 14 ++++++++++++-- src/services/notification_utils/notification_utils.js | 6 +++++- src/services/sw/sw.js | 2 +- src/sw.js | 15 +++++++++------ 4 files changed, 27 insertions(+), 10 deletions(-) (limited to 'src/services/sw/sw.js') diff --git a/src/services/desktop_notification_utils/desktop_notification_utils.js b/src/services/desktop_notification_utils/desktop_notification_utils.js index c31a1030..eb58f39b 100644 --- a/src/services/desktop_notification_utils/desktop_notification_utils.js +++ b/src/services/desktop_notification_utils/desktop_notification_utils.js @@ -1,8 +1,18 @@ -import { showDesktopNotification as swDesktopNotification } from '../sw/sw.js' +import { showDesktopNotification as swDesktopNotification, isSWSupported } from '../sw/sw.js' +const state = { failCreateNotif: false } export const showDesktopNotification = (rootState, desktopNotificationOpts) => { if (!('Notification' in window && window.Notification.permission === 'granted')) return if (rootState.statuses.notifications.desktopNotificationSilence) { return } - swDesktopNotification(desktopNotificationOpts) + if (isSWSupported()) { + swDesktopNotification(desktopNotificationOpts) + } else if (!state.failCreateNotif) { + try { + const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts) + setTimeout(desktopNotification.close.bind(desktopNotification), 5000) + } catch { + state.failCreateNotif = true + } + } } diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index 0f8b9b02..ede1cc3d 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -76,8 +76,12 @@ export const unseenNotificationsFromStore = store => filter(filteredNotificationsFromStore(store), ({ seen }) => !seen) export const prepareNotificationObject = (notification, i18n) => { + const nodes = document.querySelectorAll('link[rel="icon"]') + const icon = nodes[0].href + const notifObj = { - tag: notification.id + tag: notification.id, + icon } const status = notification.status const title = notification.from_profile.name diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js index b13c9a1b..3b62bac8 100644 --- a/src/services/sw/sw.js +++ b/src/services/sw/sw.js @@ -10,7 +10,7 @@ function urlBase64ToUint8Array (base64String) { return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) } -function isSWSupported () { +export function isSWSupported () { return 'serviceWorker' in navigator } diff --git a/src/sw.js b/src/sw.js index 1889d15f..1b08fe69 100644 --- a/src/sw.js +++ b/src/sw.js @@ -59,16 +59,19 @@ self.addEventListener('message', async (event) => { console.log(event) if (type === 'desktopNotification') { - const { title, body, icon, id } = content - if (state.notificationIds.has(id)) return - state.notificationIds.add(id) - setTimeout(() => state.notificationIds.delete(id), 10000) - self.registration.showNotification('SWTEST: ' + title, { body, icon }) + const { title, ...rest } = content + const { tag } = rest + if (state.notificationIds.has(tag)) return + state.notificationIds.add(tag) + setTimeout(() => state.notificationIds.delete(tag), 10000) + self.registration.showNotification(title, rest) } if (type === 'updateFocus') { state.lastFocused = event.source.id - console.log(state) + + const notifications = await self.registration.getNotifications() + notifications.forEach(n => n.close()) } }) -- cgit v1.2.3-70-g09d2 From f449bfe2f1d77172aee0433f63ec4a82bcc7ea1e Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Thu, 9 Nov 2023 01:52:39 +0200 Subject: SW-to-window communication --- src/services/sw/sw.js | 8 ++++++-- src/sw.js | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) (limited to 'src/services/sw/sw.js') diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js index 3b62bac8..7de490fe 100644 --- a/src/services/sw/sw.js +++ b/src/services/sw/sw.js @@ -82,9 +82,13 @@ function sendSubscriptionToBackEnd (subscription, token, notificationVisibility) return responseData }) } -export function initServiceWorker () { +export async function initServiceWorker () { if (!isSWSupported()) return - getOrCreateServiceWorker() + await getOrCreateServiceWorker() + navigator.serviceWorker.addEventListener('message', (event) => { + console.log('SW MESSAGE', event) + // TODO actually act upon click (open drawer on mobile for now) + }) } export async function showDesktopNotification (content) { diff --git a/src/sw.js b/src/sw.js index 1b08fe69..b95d56a4 100644 --- a/src/sw.js +++ b/src/sw.js @@ -56,7 +56,6 @@ self.addEventListener('push', async (event) => { self.addEventListener('message', async (event) => { const { type, content } = event.data - console.log(event) if (type === 'desktopNotification') { const { title, ...rest } = content @@ -79,6 +78,11 @@ self.addEventListener('notificationclick', (event) => { event.notification.close() event.waitUntil(getWindowClients().then((list) => { + for (let i = 0; i < list.length; i++) { + const client = list[i] + client.postMessage({ type: 'notificationClicked', id: event.notification.tag }) + } + for (let i = 0; i < list.length; i++) { const client = list[i] if (state.lastFocused === null || client.id === state.lastFocused) { -- cgit v1.2.3-70-g09d2 From e0b8ad9f141f418ab3d8ebc7a9e68bcb755c820a Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Thu, 9 Nov 2023 01:58:33 +0200 Subject: add initial structure for notification settings --- src/modules/config.js | 3 +++ src/services/sw/sw.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) (limited to 'src/services/sw/sw.js') diff --git a/src/modules/config.js b/src/modules/config.js index 56f8cba5..83d34fc9 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -65,6 +65,9 @@ export const defaultState = { chatMention: true, polls: true }, + notificationSettings: { + nativeNotifications: ['follows', 'mentions', 'followRequest', 'reports', 'chatMention', 'polls'] + }, webPushNotifications: false, muteWords: [], highlight: {}, diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js index 7de490fe..2875d36e 100644 --- a/src/services/sw/sw.js +++ b/src/services/sw/sw.js @@ -87,7 +87,7 @@ export async function initServiceWorker () { await getOrCreateServiceWorker() navigator.serviceWorker.addEventListener('message', (event) => { console.log('SW MESSAGE', event) - // TODO actually act upon click (open drawer on mobile for now) + // TODO actually act upon click (open drawer on mobile, open chat/thread etc) }) } -- cgit v1.2.3-70-g09d2 From ec2937ec1f3b0ae153f79604eb35b57ffe0f9af2 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Mon, 13 Nov 2023 17:29:25 +0200 Subject: add options for marking single notification as read --- src/components/notification/notification.js | 6 ++++++ src/components/notification/notification.vue | 1 + src/components/notifications/notifications.js | 20 ++++++++++++++++++++ src/components/notifications/notifications.vue | 6 +++++- src/components/status/status.js | 3 +++ src/components/status/status.vue | 3 +++ src/modules/statuses.js | 8 ++++++++ .../desktop_notification_utils.js | 22 +++++++++++++++++++++- .../notification_utils/notification_utils.js | 5 +++-- src/services/sw/sw.js | 9 +++++++++ src/sw.js | 6 ++++++ 11 files changed, 85 insertions(+), 4 deletions(-) (limited to 'src/services/sw/sw.js') diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 420db4f0..0e938c42 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -50,6 +50,7 @@ const Notification = { } }, props: ['notification'], + emits: ['interacted'], components: { StatusContent, UserAvatar, @@ -72,6 +73,9 @@ const Notification = { getUser (notification) { return this.$store.state.users.usersObject[notification.from_profile.id] }, + interacted () { + this.$emit('interacted') + }, toggleMute () { this.unmuted = !this.unmuted }, @@ -95,6 +99,7 @@ const Notification = { } }, doApprove () { + this.$emit('interacted') this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id }) @@ -114,6 +119,7 @@ const Notification = { } }, doDeny () { + this.$emit('interacted') this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) .then(() => { this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id }) diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 6b3315f9..01ad395f 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -6,6 +6,7 @@ class="Notification" :compact="true" :statusoid="notification.status" + @interacted="interacted" />
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 571df0f1..4cbe8093 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -159,6 +159,26 @@ const Notifications = { updateScrollPosition () { this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop }, + notificationClicked (notification) { + const { type, id, seen } = notification + if (!seen) { + switch (type) { + case 'mention': + case 'pleroma:report': + case 'follow_request': + break + default: + this.markOneAsSeen(id) + } + } + }, + notificationInteracted (notification) { + const { id, seen } = notification + if (!seen) this.markOneAsSeen(id) + }, + markOneAsSeen (id) { + this.$store.dispatch('markSingleNotificationAsSeen', { id }) + }, markAsSeen () { this.$store.dispatch('markNotificationsAsSeen') this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 999f8e9c..27ae23cf 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -67,9 +67,13 @@ role="listitem" class="notification" :class="{unseen: !minimalMode && !notification.seen}" + @click="e => notificationClicked(notification)" >
- +