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
+3 -1
View File
@@ -22,4 +22,6 @@
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
pgdata/ pgdata/
/public/sw.js
/public/manifest.webmanifest
+3 -2
View File
@@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Http\Controllers\UserFlightController;
use App\Settings\SettingsRegistry; use App\Settings\SettingsRegistry;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -12,9 +11,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use App\Traits\HasAchievements; use App\Traits\HasAchievements;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use NotificationChannels\WebPush\HasPushSubscriptions;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])] #[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])]
@@ -23,7 +24,7 @@ class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, HasAchievements, HasApiTokens, HasRoles; use HasFactory, HasAchievements, HasApiTokens, HasRoles, HasPushSubscriptions, Notifiable;
protected $casts = [ protected $casts = [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use App\Models\Notification as NotificationModel;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class PushNotification extends Notification
{
public function __construct(
protected NotificationModel $notification
) {}
public function via($notifiable): array
{
return [WebPushChannel::class];
}
public function toWebPush($notifiable, $notification): WebPushMessage
{
return (new WebPushMessage)
->title($this->notification->title)
->body($this->notification->body)
->icon( '/img/app_icons/icon-192.png')
->data([
'url' => $this->notification->url,
'notification_id' => $this->notification->id,
]);
}
}
+9
View File
@@ -3,12 +3,21 @@ namespace App\Observers;
use App\Events\NotificationCreated; use App\Events\NotificationCreated;
use App\Models\Notification; use App\Models\Notification;
use App\Notifications\PushNotification;
class NotificationObserver class NotificationObserver
{ {
public function created(Notification $notification): void public function created(Notification $notification): void
{ {
\Log::info('Notification created: ' . $notification->id); \Log::info('Notification created: ' . $notification->id);
broadcast(new NotificationCreated($notification)); broadcast(new NotificationCreated($notification));
try {
$notification->user->notify(new \App\Notifications\PushNotification($notification));
\Log::info('WebPush notify() called for user ' . $notification->user_id);
} catch (\Throwable $e) {
\Log::error('WebPush send failed: ' . $e->getMessage(), ['exception' => $e]);
}
} }
} }
+1
View File
@@ -23,6 +23,7 @@ return [
'https://flightsgoneby.com', 'https://flightsgoneby.com',
'https://www.flightsgoneby.com', 'https://www.flightsgoneby.com',
'http://flightsgoneby.test:8000', 'http://flightsgoneby.test:8000',
'http://localhost:8000',
], ],
'allowed_origins_patterns' => [], 'allowed_origins_patterns' => [],
+2 -1
View File
@@ -39,7 +39,8 @@
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vite-plugin-vuetify": "^2.1.3", "vite-plugin-vuetify": "^2.1.3",
"vue": "^3.4.0" "vue": "^3.4.0",
"workbox-precaching": "^7.4.1"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
+4 -1
View File
@@ -4,8 +4,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"postbuild": "cp public/build/sw.js public/sw.js && cp public/build/manifest.webmanifest public/manifest.webmanifest",
"dev": "vite", "dev": "vite",
"dev:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" \"npm run dev\" > /dev/null 2>&1", "dev:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" \"npm run dev\" > /dev/null 2>&1",
"serve:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" > /dev/null 2>&1",
"updateVersion": "cross-var docker build -t dredgy/flights-api:$npm_config_tag -t dredgy/flights-api:latest . && cross-var docker push dredgy/flights-api:$npm_config_tag && docker push dredgy/flights-api:latest" "updateVersion": "cross-var docker build -t dredgy/flights-api:$npm_config_tag -t dredgy/flights-api:latest . && cross-var docker push dredgy/flights-api:$npm_config_tag && docker push dredgy/flights-api:latest"
}, },
"devDependencies": { "devDependencies": {
@@ -31,7 +33,8 @@
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vite-plugin-vuetify": "^2.1.3", "vite-plugin-vuetify": "^2.1.3",
"vue": "^3.4.0" "vue": "^3.4.0",
"workbox-precaching": "^7.4.1"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
-24
View File
@@ -1,24 +0,0 @@
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || '/icons/trophy-192.png',
badge: '/icons/badge-72.png',
data: data.data,
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(url) && 'focus' in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow(url);
})
);
});
@@ -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 { ref } from "vue";
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue"; import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
import {SharedProps} from "@/Types/types"; import {SharedProps} from "@/Types/types";
import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue";
const transitionKey = ref(0); const transitionKey = ref(0);
const page = usePage<SharedProps>().props 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" /> <NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
<StatusNotifications /> <StatusNotifications />
<ToastNotifications /> <ToastNotifications />
<PushNotificationPrompt v-if="page.auth.user?.id" />
</Radar> </Radar>
</template> </template>
+1
View File
@@ -4,5 +4,6 @@ declare global {
interface Window { interface Window {
Echo: Echo<'reverb'>; Echo: Echo<'reverb'>;
Pusher: unknown; Pusher: unknown;
vapidPublicKey: string;
} }
} }
-1
View File
@@ -19,7 +19,6 @@ import {createPinia} from "pinia";
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
import('virtual:pwa-register').then(({ registerSW }) => { import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true }) registerSW({ immediate: true })
navigator.serviceWorker.register('scripts/sw.js');
}) })
} }
+2
View File
@@ -18,3 +18,5 @@ window.Echo = new Echo({
forceTLS: data.scheme === 'https', forceTLS: data.scheme === 'https',
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
window.vapidPublicKey = data.vapidPublicKey;
+3 -3
View File
@@ -14,9 +14,9 @@ export const useNotificationStore = defineStore('notifications', () => {
latestNotification.value = notification; latestNotification.value = notification;
unreadCount.value++; unreadCount.value++;
if (notification.is_achievement) {
achievementSound.play().catch(() => {}); achievementSound.play().catch(() => {});
}
} }
async function dismiss(notification: Notification) { 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);
}
})
);
});
+1 -1
View File
@@ -13,7 +13,7 @@
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Apple Touch Icons --> <!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/img/app_icons/apple-touch-icon-180.png-"> <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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="FlightsGoneBy"> <meta name="apple-mobile-web-app-title" content="FlightsGoneBy">
<meta name="theme-color" content="#020d29"> <meta name="theme-color" content="#020d29">
+14
View File
@@ -30,6 +30,7 @@ use Inertia\Inertia;
]); ]);
}); });
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return Inertia::render('Dashboard'); return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
@@ -40,7 +41,20 @@ use Inertia\Inertia;
Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery'); Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery');
}); });
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::post('/push-subscriptions', function (Illuminate\Http\Request $request) {
$request->user()->updatePushSubscription(
$request->endpoint,
$request->keys['p256dh'] ?? null,
$request->keys['auth'] ?? null
);
return response()->noContent();
});
Route::post('/flights', [FlightController::class, 'store'])->name('flights.store'); Route::post('/flights', [FlightController::class, 'store'])->name('flights.store');
Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add'); Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add');
Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit'); Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit');
+9 -5
View File
@@ -21,9 +21,17 @@ export default defineConfig({
}), }),
vuetify({ autoImport: true }), vuetify({ autoImport: true }),
VitePWA({ VitePWA({
base: '/',
registerType: 'autoUpdate', registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'resources/js',
filename: 'sw.js',
injectManifest: {
globPatterns: [],
},
devOptions: { devOptions: {
enabled: true, enabled: true,
type: 'module',
}, },
includeAssets: ['favicon.ico', 'apple-touch-icon-180.png'], includeAssets: ['favicon.ico', 'apple-touch-icon-180.png'],
manifest: { manifest: {
@@ -31,6 +39,7 @@ export default defineConfig({
short_name: 'FlightsGoneBy', short_name: 'FlightsGoneBy',
description: 'Track and log your flight history', description: 'Track and log your flight history',
start_url: '/', start_url: '/',
scope: '/',
display: 'standalone', display: 'standalone',
background_color: '#020d29', background_color: '#020d29',
theme_color: '#020d29', theme_color: '#020d29',
@@ -41,11 +50,6 @@ export default defineConfig({
{ src: '/img/app_icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, { src: '/img/app_icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
], ],
}, },
workbox: {
// Avoid caching Inertia/API responses by default — just static assets
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
navigateFallbackDenylist: [/^\/api/, /^\/admin/],
},
}), }),
], ],
resolve: { resolve: {