diff options
| author | Fuwn <[email protected]> | 2024-07-24 21:24:09 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2024-07-24 21:24:09 -0700 |
| commit | 8a94bbfba322f8011017980e4362d46c4d51bb55 (patch) | |
| tree | 4d861cd7f1d9304e6297e1b52fe759674fd5df1a | |
| parent | feat(layout): browser notifications (diff) | |
| download | due.moe-8a94bbfba322f8011017980e4362d46c4d51bb55.tar.xz due.moe-8a94bbfba322f8011017980e4362d46c4d51bb55.zip | |
feat: background notifications
| -rw-r--r-- | package.json | 8 | ||||
| -rw-r--r-- | src/jobs/index.ts | 1 | ||||
| -rw-r--r-- | src/jobs/notifications.ts | 27 | ||||
| -rw-r--r-- | src/lib/Database/user.ts | 23 | ||||
| -rw-r--r-- | src/lib/Database/userNotifications.ts | 29 | ||||
| -rw-r--r-- | src/lib/Utility/notifications.ts | 22 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 41 | ||||
| -rw-r--r-- | src/routes/api/notifications/subscribe/+server.ts | 26 | ||||
| -rw-r--r-- | src/routes/api/trigger/+server.ts | 8 | ||||
| -rw-r--r-- | src/service-worker.ts | 40 | ||||
| -rw-r--r-- | src/trigger.ts | 8 |
11 files changed, 193 insertions, 40 deletions
diff --git a/package.json b/package.json index 39c17c61..98361f79 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write ." }, + "trigger.dev": { + "endpointId": "proj_xyvcdvpvtwrkhoocrvml" + }, "devDependencies": { "@iconify/svelte": "^3.1.6", "@sveltejs/adapter-vercel": "next", @@ -38,6 +41,8 @@ "type": "module", "dependencies": { "@supabase/supabase-js": "^2.39.3", + "@trigger.dev/sdk": "^2.3.19", + "@trigger.dev/sveltekit": "^2.3.19", "@vercel/speed-insights": "^1.0.9", "dexie": "^4.0.1-alpha.25", "jsdom": "^23.0.1", @@ -50,6 +55,7 @@ "svelte-i18n": "^4.0.0", "svelte-markdown": "^0.4.1", "svelte-notifications": "^0.9.98", - "wanakana": "^5.3.1" + "wanakana": "^5.3.1", + "web-push": "^3.6.7" } } diff --git a/src/jobs/index.ts b/src/jobs/index.ts new file mode 100644 index 00000000..9ea5ce77 --- /dev/null +++ b/src/jobs/index.ts @@ -0,0 +1 @@ +export * from './notifications'; diff --git a/src/jobs/notifications.ts b/src/jobs/notifications.ts new file mode 100644 index 00000000..3c29ecfc --- /dev/null +++ b/src/jobs/notifications.ts @@ -0,0 +1,27 @@ +import { intervalTrigger } from '@trigger.dev/sdk'; +import { client } from '../trigger'; +import * as webpush from 'web-push'; +import { env as privateEnv } from '$env/dynamic/private'; +import { env } from '$env/dynamic/public'; +import { getUserSubscriptions } from '$lib/Database/userNotifications'; + +webpush.setVapidDetails( + privateEnv.VAPID_SUBJECT, + env.PUBLIC_VAPID_PUBLIC_KEY, + privateEnv.VAPID_PRIVATE_KEY +); + +client.defineJob({ + id: 'notifications', + name: 'Notifications', + version: '0.0.1', + trigger: intervalTrigger({ + seconds: 20 + }), + run: async () => { + for (const subscription of await getUserSubscriptions()) + await webpush.sendNotification(subscription['subscription'], '.'); + + return {}; + } +}); diff --git a/src/lib/Database/user.ts b/src/lib/Database/user.ts new file mode 100644 index 00000000..df68bc91 --- /dev/null +++ b/src/lib/Database/user.ts @@ -0,0 +1,23 @@ +import { type AniListAuthorisation } from '$lib/Data/AniList/identity'; +import Dexie, { type Table } from 'dexie'; + +export interface User { + id: number; + user: AniListAuthorisation; + lastNotificationID: number | null; +} + +export class UserDatabase extends Dexie { + users: Table<User>; + + constructor() { + super('users'); + this.version(1).stores({ + users: 'id, user, lastNotificationID' + }); + + this.users = this.table('users'); + } +} + +export const database = new UserDatabase(); diff --git a/src/lib/Database/userNotifications.ts b/src/lib/Database/userNotifications.ts new file mode 100644 index 00000000..c61c2aa7 --- /dev/null +++ b/src/lib/Database/userNotifications.ts @@ -0,0 +1,29 @@ +import supabase from './supabase'; + +export interface UserNotifications { + created_at: string; + updated_at: string; + user_id: number; + subscription: JSON; +} + +export const getUserSubscription = async (userId: number) => + await supabase.from('user_notifications').select('*').eq('user_id', userId); + +export const getUserSubscriptions = async () => { + const { data, error } = await supabase.from('user_notifications').select('*'); + + if (error) return []; + + return data as UserNotifications[]; +}; + +export const setUserSubscription = async (userId: number, subscription: JSON) => + await supabase.from('user_notifications').upsert( + { + user_id: userId, + updated_at: new Date().toISOString(), + subscription: subscription + }, + { onConflict: 'user_id' } + ); diff --git a/src/lib/Utility/notifications.ts b/src/lib/Utility/notifications.ts new file mode 100644 index 00000000..fe174bd6 --- /dev/null +++ b/src/lib/Utility/notifications.ts @@ -0,0 +1,22 @@ +import { env } from '$env/dynamic/public'; +import root from './root'; + +export const requestNotifications = async () => { + if ('Notification' in window && navigator.serviceWorker) + if ((await Notification.requestPermission()) === 'granted') { + const pushSubscription = await ( + await navigator.serviceWorker.ready + ).pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: env.PUBLIC_VAPID_PUBLIC_KEY + }); + + await fetch(root('/api/notifications/subscribe'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(pushSubscription) + }); + } +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d6575ded..e1381ff0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -29,6 +29,8 @@ import Announcement from '$lib/Announcement.svelte'; import Message from '$lib/Loading/Message.svelte'; import { notifications } from '$lib/Data/AniList/notifications'; + import { requestNotifications } from '$lib/Utility/notifications'; + import { database as userDatabase } from '$lib/Database/user'; injectSpeedInsights(); @@ -110,36 +112,15 @@ }); }); - if ($settings.displayAniListNotifications && data.user !== undefined) { - if ('Notification' in window && navigator.serviceWorker) - if ((await Notification.requestPermission()) === 'granted') - notificationInterval = setInterval(async () => { - try { - const recentNotifications = await notifications(data.user.accessToken); - - if ( - recentNotifications && - recentNotifications.length > 0 && - recentNotifications[0].id > lastNotificationId && - new Date(recentNotifications[0].createdAt * 1000).getTime() + 30000 > - new Date().getTime() - ) { - lastNotificationId = recentNotifications[0].id; - - if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { - navigator.serviceWorker.ready.then((registration) => { - registration.showNotification('due.moe', { - body: `${recentNotifications[0].user.name}${recentNotifications[0].context}`, - icon: recentNotifications[0].user.avatar.large || '/favicon-196x196.png' - }); - }); - } - } - } catch (error) { - console.error(error); - } - }, 15000); - } + if (!(await userDatabase.users.get($userIdentity.id))) + userDatabase.users.put({ + id: $userIdentity.id, + user: data.user, + lastNotificationID: null + }); + + if ($settings.displayAniListNotifications && data.user !== undefined) + if ('Notification' in window && navigator.serviceWorker) requestNotifications(); }); onDestroy(() => { diff --git a/src/routes/api/notifications/subscribe/+server.ts b/src/routes/api/notifications/subscribe/+server.ts new file mode 100644 index 00000000..98bd5c4d --- /dev/null +++ b/src/routes/api/notifications/subscribe/+server.ts @@ -0,0 +1,26 @@ +import { userIdentity } from '$lib/Data/AniList/identity'; +import { setUserSubscription } from '$lib/Database/userNotifications'; + +const unauthorised = new Response('Unauthorised', { status: 401 }); + +export const POST = async ({ cookies, request }) => { + const userCookie = cookies.get('user'); + + if (!userCookie) return unauthorised; + + const user = JSON.parse(userCookie); + const userId = ( + await userIdentity({ + tokenType: user['token_type'], + expiresIn: user['expires_in'], + accessToken: user['access_token'], + refreshToken: user['refresh_token'] + }) + ).id; + + if (!userId) return unauthorised; + + await setUserSubscription(userId, await request.json()); + + return new Response(null, { status: 200 }); +}; diff --git a/src/routes/api/trigger/+server.ts b/src/routes/api/trigger/+server.ts new file mode 100644 index 00000000..aff75d9c --- /dev/null +++ b/src/routes/api/trigger/+server.ts @@ -0,0 +1,8 @@ +import { createSvelteRoute } from '@trigger.dev/sveltekit'; +import { client } from '../../../trigger'; + +import '../../../jobs'; + +const svelteRoute = createSvelteRoute(client); + +export const POST = svelteRoute.POST; diff --git a/src/service-worker.ts b/src/service-worker.ts index 7d2310f6..082a25de 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -4,6 +4,8 @@ /// <reference lib="webworker" /> import { build, files, version } from '$service-worker'; +import { database } from './lib/Database/user'; +import { type Notification, notifications } from './lib/Data/AniList/notifications'; const sw = /** @type {ServiceWorkerGlobalScope} */ /** @type {unknown} */ self; @@ -90,19 +92,39 @@ self.addEventListener('fetch', (event) => { event.respondWith(respond()); }); -self.addEventListener('push', (e: Event) => { - if (!(self.Notification && self.Notification.permission === 'granted')) { +self.addEventListener('push', async () => { + if (self.Notification && self.Notification.permission !== 'granted') { return; } - const event = e as PushEvent; + try { + const user = (await database.users.toArray()).at(0); + + if (!user) return; + + const recentNotifications = await notifications(user.user.accessToken); + + if ( + recentNotifications && + recentNotifications.length > 0 && + (recentNotifications[0].id > (user.lastNotificationID as number) || + new Date(recentNotifications[0].createdAt * 1000).getTime() + 30000 > new Date().getTime()) + ) { + await database.users.update(user.id, { lastNotificationID: recentNotifications[0].id }); - if (event.data) { - event.waitUntil( (self as unknown as ServiceWorkerGlobalScope).registration.showNotification('due.moe', { - body: event.data.json().url, - icon: '/favicon-196x196.png' - }) - ); + body: `${recentNotifications[0].user.name}${recentNotifications[0].context}`, + icon: recentNotifications[0].user.avatar.large || '/favicon-196x196.png' + }); + } + } catch (error) { + console.error(error); } }); + +self.addEventListener('notificationclick', (e: Event) => { + const event = e as NotificationEvent; + + event.notification.close(); + event.waitUntil(clients.openWindow('https://anilist.co/notifications')); +}); diff --git a/src/trigger.ts b/src/trigger.ts new file mode 100644 index 00000000..92bda66b --- /dev/null +++ b/src/trigger.ts @@ -0,0 +1,8 @@ +import { TriggerClient } from '@trigger.dev/sdk'; +import { TRIGGER_API_KEY, TRIGGER_API_URL, TRIGGER_ID } from '$env/static/private'; + +export const client = new TriggerClient({ + id: TRIGGER_ID, + apiKey: TRIGGER_API_KEY, + apiUrl: TRIGGER_API_URL +}); |