Added native os notifications
This commit is contained in:
@@ -23,3 +23,5 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
pgdata/
|
||||
/public/sw.js
|
||||
/public/manifest.webmanifest
|
||||
|
||||
+3
-2
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Http\Controllers\UserFlightController;
|
||||
use App\Settings\SettingsRegistry;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
@@ -12,9 +11,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use App\Traits\HasAchievements;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use NotificationChannels\WebPush\HasPushSubscriptions;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])]
|
||||
@@ -23,7 +24,7 @@ class User extends Authenticatable
|
||||
{
|
||||
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasAchievements, HasApiTokens, HasRoles;
|
||||
use HasFactory, HasAchievements, HasApiTokens, HasRoles, HasPushSubscriptions, Notifiable;
|
||||
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,21 @@ namespace App\Observers;
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\Notification;
|
||||
use App\Notifications\PushNotification;
|
||||
|
||||
class NotificationObserver
|
||||
{
|
||||
public function created(Notification $notification): void
|
||||
{
|
||||
\Log::info('Notification created: ' . $notification->id);
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ return [
|
||||
'https://flightsgoneby.com',
|
||||
'https://www.flightsgoneby.com',
|
||||
'http://flightsgoneby.test:8000',
|
||||
'http://localhost:8000',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
Generated
+2
-1
@@ -39,7 +39,8 @@
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue": "^3.4.0"
|
||||
"vue": "^3.4.0",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
||||
+4
-1
@@ -4,8 +4,10 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"postbuild": "cp public/build/sw.js public/sw.js && cp public/build/manifest.webmanifest public/manifest.webmanifest",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,7 +33,8 @@
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue": "^3.4.0"
|
||||
"vue": "^3.4.0",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -30,6 +30,7 @@ use Inertia\Inertia;
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Route::get('/dashboard', function () {
|
||||
return Inertia::render('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::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::get('/flights/add', [FlightController::class, 'add'])->name('flights.add');
|
||||
Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit');
|
||||
|
||||
+9
-5
@@ -21,9 +21,17 @@ export default defineConfig({
|
||||
}),
|
||||
vuetify({ autoImport: true }),
|
||||
VitePWA({
|
||||
base: '/',
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'resources/js',
|
||||
filename: 'sw.js',
|
||||
injectManifest: {
|
||||
globPatterns: [],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon-180.png'],
|
||||
manifest: {
|
||||
@@ -31,6 +39,7 @@ export default defineConfig({
|
||||
short_name: 'FlightsGoneBy',
|
||||
description: 'Track and log your flight history',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_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' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// Avoid caching Inertia/API responses by default — just static assets
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/admin/],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user