aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2024-07-24 21:24:09 -0700
committerFuwn <[email protected]>2024-07-24 21:24:09 -0700
commit8a94bbfba322f8011017980e4362d46c4d51bb55 (patch)
tree4d861cd7f1d9304e6297e1b52fe759674fd5df1a
parentfeat(layout): browser notifications (diff)
downloaddue.moe-8a94bbfba322f8011017980e4362d46c4d51bb55.tar.xz
due.moe-8a94bbfba322f8011017980e4362d46c4d51bb55.zip
feat: background notifications
-rw-r--r--package.json8
-rw-r--r--src/jobs/index.ts1
-rw-r--r--src/jobs/notifications.ts27
-rw-r--r--src/lib/Database/user.ts23
-rw-r--r--src/lib/Database/userNotifications.ts29
-rw-r--r--src/lib/Utility/notifications.ts22
-rw-r--r--src/routes/+layout.svelte41
-rw-r--r--src/routes/api/notifications/subscribe/+server.ts26
-rw-r--r--src/routes/api/trigger/+server.ts8
-rw-r--r--src/service-worker.ts40
-rw-r--r--src/trigger.ts8
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
+});