Added native os notifications
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Vendored
+1
@@ -4,5 +4,6 @@ declare global {
|
||||
interface Window {
|
||||
Echo: Echo<'reverb'>;
|
||||
Pusher: unknown;
|
||||
vapidPublicKey: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
@@ -18,3 +18,5 @@ window.Echo = new Echo({
|
||||
forceTLS: data.scheme === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
|
||||
window.vapidPublicKey = data.vapidPublicKey;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -13,7 +13,7 @@
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/img/app_icons/apple-touch-icon-180.png-">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="FlightsGoneBy">
|
||||
<meta name="theme-color" content="#020d29">
|
||||
|
||||
Reference in New Issue
Block a user