<template> <router-view /> <!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind --> <NotificationGroup group="alert"> <div class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end" > <Notification v-slot="{ notifications, close }" enter="transform ease-out duration-300 transition" enter-from="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-4" enter-to="translate-y-0 opacity-100 sm:translate-x-0" leave="transition ease-in duration-500" leave-from="opacity-100" leave-to="opacity-0" move="transition duration-500" move-delay="delay-300" > <div v-for="notification in notifications" :key="notification.id" class="w-full" role="alert" > <div v-if="notification.type === 'toast'" class="w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-900/90 text-white rounded-lg shadow-md" > <div class="w-full px-4 py-3"> <span class="font-semibold">{{ notification.title }}</span> <p class="text-sm">{{ notification.text }}</p> </div> </div> <div v-if="notification.type === 'info'" class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-100 rounded-lg shadow-md" > <div class="flex items-center justify-center w-12 bg-slate-600 text-slate-100" > <fa icon="circle-info" class="fa-fw fa-xl"></fa> </div> <div class="relative w-full pl-4 pr-8 py-2 text-slate-900"> <span class="font-semibold">{{ notification.title }}</span> <p class="text-sm">{{ notification.text }}</p> <button @click="close(notification.id)" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600" > <fa icon="xmark" class="fa-fw"></fa> </button> </div> </div> <div v-if="notification.type === 'success'" class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-emerald-100 rounded-lg shadow-md" > <div class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100" > <fa icon="circle-info" class="fa-fw fa-xl"></fa> </div> <div class="relative w-full pl-4 pr-8 py-2 text-emerald-900"> <span class="font-semibold">{{ notification.title }}</span> <p class="text-sm">{{ notification.text }}</p> <button @click="close(notification.id)" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600" > <fa icon="xmark" class="fa-fw"></fa> </button> </div> </div> <div v-if="notification.type === 'warning'" class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-amber-100 rounded-lg shadow-md" > <div class="flex items-center justify-center w-12 bg-amber-600 text-amber-100" > <fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa> </div> <div class="relative w-full pl-4 pr-8 py-2 text-amber-900"> <span class="font-semibold">{{ notification.title }}</span> <p class="text-sm">{{ notification.text }}</p> <button @click="close(notification.id)" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600" > <fa icon="xmark" class="fa-fw"></fa> </button> </div> </div> <div v-if="notification.type === 'danger'" class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-rose-100 rounded-lg shadow-md" > <div class="flex items-center justify-center w-12 bg-rose-600 text-rose-100" > <fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa> </div> <div class="relative w-full pl-4 pr-8 py-2 text-rose-900"> <span class="font-semibold">{{ notification.title }}</span> <p class="text-sm">{{ notification.text }}</p> <button @click="close(notification.id)" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600" > <fa icon="xmark" class="fa-fw"></fa> </button> </div> </div> </div> </Notification> </div> </NotificationGroup> <!-- These are general-purpose messages - except there are some for turning app notifications on and off. --> <NotificationGroup group="modal"> <div class="fixed z-[100] top-0 inset-x-0 w-full"> <Notification v-slot="{ notifications, close }" enter="transform ease-out duration-300 transition" enter-from="translate-y-2 opacity-0 sm:translate-y-4" enter-to="translate-y-0 opacity-100 sm:translate-y-0" leave="transition ease-in duration-500" leave-from="opacity-100" leave-to="opacity-0" move="transition duration-500" move-delay="delay-300" > <div v-for="notification in notifications" :key="notification.id" class="w-full" role="alert" > <!-- type "confirm" will post a message and, with onYes function, show a "Yes" button to call that function --> <div v-if="notification.type === 'confirm'" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <div class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" > <div class="w-full px-6 py-6 text-slate-900 text-center"> <p class="text-lg mb-4"> {{ notification.title }} </p> <button v-if="notification.onYes" @click=" notification.onYes(); close(notification.id); " class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" > Yes </button> <button @click="close(notification.id)" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" > {{ notification.onYes ? "Cancel" : "Close" }} </button> </div> </div> </div> <div v-if="notification.type === 'notification-permission'" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <div class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" > <div class="w-full px-6 py-6 text-slate-900 text-center"> <p v-if="serviceWorkerReady" class="text-lg mb-4"> Would you like to be notified of new activity once a day? </p> <p v-else class="text-lg mb-4"> Waiting for system initialization, which may take up to 10 seconds... <fa icon="spinner" spin /> </p> <div v-if="serviceWorkerReady"> <span class="flex flex-row justify-center"> <span class="mt-2">Yes, tell me at: </span> <input type="number" class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20" v-model="hourInput" /> <span class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20" @click="hourAm = !hourAm" > <span v-if="hourAm"> AM <fa icon="chevron-down" /> </span> <span v-else> PM <fa icon="chevron-up" /> </span> </span> </span> <button class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md" @click=" () => { if (checkHour()) { close(notification.id); turnOnNotifications(); } } " > Turn on Daily Message </button> </div> <button @click="close(notification.id)" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md" > No, Not Now </button> </div> </div> </div> <div v-if="notification.type === 'notification-mute'" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <div class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" > <div class="w-full px-6 py-6 text-slate-900 text-center"> <p class="text-lg mb-4">Mute app notifications:</p> <button class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" > For 1 Hour </button> <button class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" > For 8 Hours </button> <button class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" > For 24 Hours </button> <button class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" > Until I turn it back on </button> <button @click="close(notification.id)" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" > Cancel </button> </div> </div> </div> <div v-if="notification.type === 'notification-off'" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <div class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" > <div class="w-full px-6 py-6 text-slate-900 text-center"> <p class="text-lg mb-4"> Would you like to <b>turn off</b> notifications for this app? </p> <button @click=" close(notification.id); turnOffNotifications(); " class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2" > Turn Off Notifications </button> <button @click="close(notification.id)" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" > Leave it On </button> </div> </div> </div> </div> </Notification> </div> </NotificationGroup> </template> <style></style> <script lang="ts"> import axios from "axios"; import { Vue, Component } from "vue-facing-decorator"; import * as libsUtil from "@/libs/util"; interface ServiceWorkerMessage { type: string; data: string; } interface ServiceWorkerResponse { // Define the properties and their types success: boolean; message?: string; } // Example interface for error interface ErrorResponse { message: string; // Other properties as needed } interface VapidResponse { data: { vapidKey: string; }; } interface PushSubscriptionWithTime extends PushSubscriptionJSON { notifyTime: { utcHour: number }; } import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; import { db } from "@/db/index"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { sendTestThroughPushServer } from "@/libs/util"; @Component export default class App extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; b64 = ""; hourAm = true; hourInput = "8"; serviceWorkerReady = true; async mounted() { try { await db.open(); const settings = await db.settings.get(MASTER_SETTINGS_KEY); let pushUrl = DEFAULT_PUSH_SERVER; if (settings?.webPushServer) { pushUrl = settings.webPushServer; } if (pushUrl.startsWith("http://localhost")) { console.log("Not checking for VAPID in this local environment."); } else { await axios .get(pushUrl + "/web-push/vapid") .then((response: VapidResponse) => { this.b64 = response.data?.vapidKey || ""; console.log("Got vapid key:", this.b64); console.log("response...", response); navigator.serviceWorker.addEventListener("controllerchange", () => { console.log("New service worker is now controlling the page"); }); }); if (!this.b64) { this.$notify( { group: "alert", type: "danger", title: "Error Setting Notifications", text: "Could not set notifications.", }, -1, ); } } } catch (error) { if (window.location.host.startsWith("localhost")) { console.log("Ignoring the error getting VAPID for local development."); } else { console.error("Got an error initializing notifications:", error); this.$notify( { group: "alert", type: "danger", title: "Error Setting Notifications", text: "Got an error setting notifications.", }, -1, ); } } // there may be a long pause here on first initialization navigator.serviceWorker?.ready.then(() => { this.serviceWorkerReady = true; }); } private sendMessageToServiceWorker( message: ServiceWorkerMessage, ): Promise<unknown> { return new Promise((resolve, reject) => { if (navigator.serviceWorker.controller) { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = (event: MessageEvent) => { if (event.data.error) { reject(event.data.error as ErrorResponse); } else { resolve(event.data as ServiceWorkerResponse); } }; navigator.serviceWorker.controller.postMessage(message, [ messageChannel.port2, ]); } else { reject("Service worker controller not available"); } }); } private askPermission(): Promise<NotificationPermission> { console.log("Requesting permission for notifications:", navigator); if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) { return Promise.reject("Service worker not available."); } const secret = localStorage.getItem("secret"); if (!secret) { return Promise.reject("No secret found."); } return this.sendSecretToServiceWorker(secret) .then(() => this.checkNotificationSupport()) .then(() => this.requestNotificationPermission()) .catch((error) => Promise.reject(error)); } private sendSecretToServiceWorker(secret: string): Promise<void> { const message: ServiceWorkerMessage = { type: "SEND_LOCAL_DATA", data: secret, }; return this.sendMessageToServiceWorker(message).then((response) => { console.log("Response from service worker:", response); }); } private checkNotificationSupport(): Promise<void> { if (!("Notification" in window)) { alert("This browser does not support notifications."); return Promise.reject("This browser does not support notifications."); } if (Notification.permission === "granted") { return Promise.resolve(); } return Promise.resolve(); } private requestNotificationPermission(): Promise<NotificationPermission> { return Notification.requestPermission().then((permission) => { if (permission !== "granted") { alert( "Allow this app permission to make notifications for personal reminders." + " You can adjust them at any time in your settings.", ); throw new Error("We weren't granted permission."); } return permission; }); } // this allows us to show an error without closing the dialog checkHour() { if (!libsUtil.isNumeric(this.hourInput)) { this.$notify( { group: "alert", type: "danger", title: "Not a Number", text: "The time must be an hour number.", }, 5000, ); return false; } const hourNum = libsUtil.numberOrZero(this.hourInput); if (!Number.isInteger(hourNum)) { this.$notify( { group: "alert", type: "danger", title: "Not a Whole Number", text: "The time must be a whole hour number.", }, 5000, ); return false; } if (hourNum < 1 || 12 < hourNum) { this.$notify( { group: "alert", type: "danger", title: "Not a Whole Number", text: "The time must be an hour between 1 and 12.", }, 5000, ); return false; } return true; } public async turnOnNotifications() { return this.askPermission() .then((permission) => { console.log("Permission granted:", permission); // Call the function and handle promises this.subscribeToPush() .then(() => { console.log("Subscribed successfully."); return navigator.serviceWorker?.ready; }) .then((registration) => { return registration.pushManager.getSubscription(); }) .then(async (subscription) => { if (subscription) { await this.$notify( { group: "alert", type: "info", title: "Notification Setup Underway", text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.", }, -1, ); // we already checked that this is a valid hour number const rawHourNum = libsUtil.numberOrZero(this.hourInput); const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12); const hourNum = adjHourNum % 24; const utcHour = hourNum + Math.round(new Date().getTimezoneOffset() / 60); const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24; const subscriptionWithTime: PushSubscriptionWithTime = { notifyTime: { utcHour: finalUtcHour }, ...subscription.toJSON(), }; await this.sendSubscriptionToServer(subscriptionWithTime); return subscriptionWithTime; } else { throw new Error("Subscription object is not available."); } }) .then(async (subscription: PushSubscriptionWithTime) => { console.log( "Subscription data sent to server and all finished successfully.", ); await sendTestThroughPushServer(subscription, true); this.$notify( { group: "alert", type: "success", title: "Notifications Turned On", text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.", }, -1, ); }) .catch((error) => { console.error( "Subscription or server communication failed:", error, ); alert( "Subscription or server communication failed. Try again in a while.", ); }); }) .catch((error) => { console.error( "An error occurred setting notification permissions:", error, ); alert("Some error occurred setting notification permissions."); }); } private urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding) .replace(/-/g, "+") .replace(/_/g, "/"); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } private subscribeToPush(): Promise<void> { return new Promise<void>((resolve, reject) => { if (!("serviceWorker" in navigator && "PushManager" in window)) { const errorMsg = "Push messaging is not supported"; console.warn(errorMsg); return reject(new Error(errorMsg)); } if (Notification.permission !== "granted") { const errorMsg = "Notification permission not granted"; console.warn(errorMsg); return reject(new Error(errorMsg)); } const applicationServerKey = this.urlBase64ToUint8Array(this.b64); const options: PushSubscriptionOptions = { userVisibleOnly: true, applicationServerKey: applicationServerKey, }; navigator.serviceWorker.ready .then((registration) => { return registration.pushManager.subscribe(options); }) .then((subscription) => { console.log("Push subscription successful:", subscription); resolve(); }) .catch((error) => { console.error("Push subscription failed:", error, options); // Inform the user about the issue alert( "We encountered an issue setting up push notifications. " + "If you wish to revoke notification permissions, please do so in your browser settings.", ); reject(error); }); }); } private sendSubscriptionToServer( subscription: PushSubscriptionWithTime, ): Promise<void> { console.log("About to send subscription...", subscription); return fetch("/web-push/subscribe", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(subscription), }).then((response) => { if (!response.ok) { throw new Error("Failed to send subscription to server"); } console.log("Subscription sent to server successfully."); }); } async turnOffNotifications() { let subscription; const pushProviderSuccess = await navigator.serviceWorker?.ready .then((registration) => { return registration.pushManager.getSubscription(); }) .then((subscript) => { subscription = subscript; if (subscription) { return subscription.unsubscribe(); } else { console.log("Subscription object is not available."); return false; } }) .catch((error) => { console.error("Push provider server communication failed:", error); return false; }); const pushServerSuccess = await fetch("/web-push/unsubscribe", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(subscription), }) .then((response) => { return response.ok; }) .catch((error) => { console.error("Push server communication failed:", error); return false; }); alert( "Notifications are off. Push provider unsubscribe " + (pushProviderSuccess ? "succeeded" : "failed") + (pushProviderSuccess === pushServerSuccess ? " and" : " but") + " push server unsubscribe " + (pushServerSuccess ? "succeeded" : "failed") + ".", ); } } </script>