Added native os notifications

This commit is contained in:
2026-07-01 22:11:24 +10:00
parent d1b50e2f1a
commit 84379baf6a
19 changed files with 213 additions and 39 deletions
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { usePushNotifications } from '@/Composables/usePushNotifications';
const { showPrompt, initPrompt, enable, dismiss } = usePushNotifications();
onMounted(() => {
initPrompt();
});
</script>
<template>
<v-snackbar
v-model="showPrompt"
:timeout="-1"
location="bottom"
color="surface"
class="push-prompt"
>
<div class="d-flex align-center ga-3">
<v-icon icon="mdi-bell-outline" />
<span>Receive notifications for things that are important for you</span>
</div>
<template #actions>
<v-btn variant="text" @click="dismiss">Not now</v-btn>
<v-btn variant="text" color="primary" @click="enable">Enable</v-btn>
</template>
</v-snackbar>
</template>
@@ -0,0 +1,60 @@
// resources/js/composables/usePushNotifications.ts
import { ref } from 'vue';
import axios from 'axios';
const STORAGE_KEY = 'push-permission-prompted';
export function usePushNotifications() {
const isSupported = 'serviceWorker' in navigator && 'PushManager' in window;
const showPrompt = ref(false);
function shouldPrompt(): boolean {
if (!isSupported) return false;
if (Notification.permission !== 'default') return false;
if (localStorage.getItem(STORAGE_KEY)) return false;
return true;
}
function initPrompt() {
showPrompt.value = shouldPrompt();
}
// ← replace the old version with this one
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const buffer = new ArrayBuffer(rawData.length);
const outputArray = new Uint8Array(buffer);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function enable() {
localStorage.setItem(STORAGE_KEY, '1');
showPrompt.value = false;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(window.vapidPublicKey),
});
await axios.post('/push-subscriptions', subscription.toJSON());
}
function dismiss() {
localStorage.setItem(STORAGE_KEY, '1');
showPrompt.value = false;
}
return { isSupported, showPrompt, initPrompt, enable, dismiss };
}
+2
View File
@@ -8,6 +8,7 @@ import {router, usePage} from "@inertiajs/vue3";
import { ref } from "vue";
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
import {SharedProps} from "@/Types/types";
import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue";
const transitionKey = ref(0);
const page = usePage<SharedProps>().props
@@ -31,6 +32,7 @@ router.on('success', () => {
<NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
<StatusNotifications />
<ToastNotifications />
<PushNotificationPrompt v-if="page.auth.user?.id" />
</Radar>
</template>
+1
View File
@@ -4,5 +4,6 @@ declare global {
interface Window {
Echo: Echo<'reverb'>;
Pusher: unknown;
vapidPublicKey: string;
}
}
-1
View File
@@ -19,7 +19,6 @@ import {createPinia} from "pinia";
if ('serviceWorker' in navigator) {
import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true })
navigator.serviceWorker.register('scripts/sw.js');
})
}
+2
View File
@@ -18,3 +18,5 @@ window.Echo = new Echo({
forceTLS: data.scheme === 'https',
enabledTransports: ['ws', 'wss'],
});
window.vapidPublicKey = data.vapidPublicKey;
+3 -3
View File
@@ -14,9 +14,9 @@ export const useNotificationStore = defineStore('notifications', () => {
latestNotification.value = notification;
unreadCount.value++;
if (notification.is_achievement) {
achievementSound.play().catch(() => {});
}
achievementSound.play().catch(() => {});
}
async function dismiss(notification: Notification) {
+37
View File
@@ -0,0 +1,37 @@
// This line is required by vite-plugin-pwa's injectManifest strategy —
// it's the token the build looks for to inject the precache list.
// We don't use it for anything (no offline caching), so it's discarded.
self.__WB_MANIFEST;
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || '/img/app_icons/icon-192.png',
badge: '/img/app_icons/icon-192.png',
data: data.data,
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
})
);
});