<template> <transition enter-active-class="transform ease-out duration-300 transition" enter-from-class="translate-y-2 opacity-0 sm:translate-y-4" enter-to-class="translate-y-0 opacity-100 sm:translate-y-0" leave-active-class="transition ease-in duration-500" leave-from-class="opacity-100" leave-to-class="opacity-0" > <div v-if="isVisible" class="fixed z-[100] top-0 inset-x-0 w-full 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 && vapidKey" class="text-lg mb-4"> <span v-if="pushType === DAILY_CHECK_TITLE"> Would you like to be notified of new activity, up to once a day? </span> <span v-else> Would you like to get a reminder message once a day? </span> </p> <p v-else class="text-lg mb-4"> Waiting for system initialization, which may take up to 5 seconds... <fa icon="spinner" spin /> </p> <div v-if="serviceWorkerReady && vapidKey"> <div v-if="pushType === DAILY_CHECK_TITLE"> <span>Yes, send me a message when there is new data for me</span> </div> <div v-else> <span>Yes, send me this message:</span> <!-- eslint-disable --> <textarea type="text" id="push-message" v-model="messageInput" class="rounded border border-slate-400 mt-2 px-2 py-2 w-full" maxlength="100" ></textarea > <!-- eslint-enable --> <span class="w-full flex justify-between text-xs text-slate-500"> <span></span> <span>(100 characters max)</span> </span> </div> <div> <span class="flex flex-row justify-center"> <span class="mt-2">... at: </span> <input type="number" @change="checkHourInput" 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" /> <input type="number" @change="checkMinuteInput" class="border border-slate-400 mt-2 px-2 py-2 text-center w-20" v-model="minuteInput" /> <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> </div> <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=" close(); turnOnNotifications(); " > Turn on Daily Message </button> </div> <button @click="close()" 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> </transition> </template> <script lang="ts"> import { Component, Vue } from "vue-facing-decorator"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; import { logConsoleAndDb, retrieveSettingsForActiveAccount, secretDB, } from "@/db/index"; import { MASTER_SECRET_KEY } from "@/db/tables/secret"; import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util"; import * as libsUtil from "@/libs/util"; // Example interface for error interface ErrorResponse { message: string; } // PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson interface PushSubscriptionWithTime extends PushSubscriptionJSON { message?: string; notifyTime: { utcHour: number; minute: number }; notifyType: string; } interface ServiceWorkerMessage { type: string; data: string; } interface ServiceWorkerResponse { // Define the properties and their types success: boolean; message?: string; } interface VapidResponse { data: { vapidKey: string; }; } @Component export default class PushNotificationPermission extends Vue { // eslint-disable-next-line $notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>; DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE; DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE; callback: (success: boolean, time: string, message?: string) => void = () => {}; hourAm = true; hourInput = "8"; isVisible = false; messageInput = ""; minuteInput = "00"; pushType = ""; serviceWorkerReady = false; vapidKey = ""; async open( pushType: string, callback?: (success: boolean, time: string, message?: string) => void, ) { this.callback = callback || this.callback; this.isVisible = true; this.pushType = pushType; try { const settings = await retrieveSettingsForActiveAccount(); let pushUrl = DEFAULT_PUSH_SERVER; if (settings?.webPushServer) { pushUrl = settings.webPushServer; } if (pushUrl.startsWith("http://localhost")) { logConsoleAndDb("Not checking for VAPID in this local environment."); } else { let responseData = ""; await this.axios .get(pushUrl + "/web-push/vapid") .then((response: VapidResponse) => { this.vapidKey = response.data?.vapidKey || ""; logConsoleAndDb("Got vapid key: " + this.vapidKey); responseData = JSON.stringify(response.data); navigator.serviceWorker?.addEventListener( "controllerchange", () => { logConsoleAndDb( "New service worker is now controlling the page", ); }, ); }); if (!this.vapidKey) { this.$notify( { group: "alert", type: "danger", title: "Error Setting Notifications", text: "Could not set notifications.", }, 5000, ); logConsoleAndDb( "Error Setting Notifications: web push server response didn't have vapidKey: " + responseData, true, ); } } } catch (error) { if (window.location.host.startsWith("localhost")) { logConsoleAndDb( "Ignoring the error getting VAPID for local development.", ); } else { logConsoleAndDb( "Got an error initializing notifications: " + JSON.stringify(error), true, ); this.$notify( { group: "alert", type: "danger", title: "Error Setting Notifications", text: "Got an error setting notifications.", }, 5000, ); } } // there may be a long pause here on first initialization navigator.serviceWorker?.ready.then(() => { this.serviceWorkerReady = true; }); if (this.pushType === this.DIRECT_PUSH_TITLE) { this.messageInput = "Click to share some gratitude with the world -- even if they're unnamed."; // focus on the message input setTimeout(function () { document.getElementById("push-message")?.focus(); }, 100); } else { // not critical but doesn't make sense in a daily check this.messageInput = ""; } } private close() { this.isVisible = false; } 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 async askPermission(): Promise<NotificationPermission> { logConsoleAndDb( "Requesting permission for notifications: " + JSON.stringify(navigator), ); if ( !("serviceWorker" in navigator && navigator.serviceWorker?.controller) ) { return Promise.reject("Service worker not available."); } await secretDB.open(); const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.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) => { logConsoleAndDb( "Response from service worker: " + JSON.stringify(response), ); }); } private checkNotificationSupport(): Promise<void> { if (!("Notification" in window)) { this.$notify( { group: "alert", type: "danger", title: "Browser Notifications Are Not Supported", text: "This browser does not support notifications.", }, 3000, ); return Promise.reject("This browser does not support notifications."); } if (window.Notification.permission === "granted") { return Promise.resolve(); } return Promise.resolve(); } private requestNotificationPermission(): Promise<NotificationPermission> { return window.Notification.requestPermission().then( (permission: string) => { if (permission !== "granted") { this.$notify( { group: "alert", type: "danger", title: "Error Requesting Notification Permission", text: "Allow this app permission to make notifications for personal reminders." + " You can adjust them at any time in your settings.", }, -1, ); throw new Error("We weren't granted permission."); } return permission; }, ); } private checkHourInput() { const hourNum = parseInt(this.hourInput); if (isNaN(hourNum)) { this.hourInput = "12"; } else if (hourNum < 1) { this.hourInput = "12"; this.hourAm = !this.hourAm; } else if (hourNum > 12) { this.hourInput = "1"; this.hourAm = !this.hourAm; } else { this.hourInput = hourNum.toString(); } } private checkMinuteInput() { const minuteNum = parseInt(this.minuteInput); if (isNaN(minuteNum)) { this.minuteInput = "00"; } else if (minuteNum < 0) { this.minuteInput = "59"; } else if (minuteNum < 10) { this.minuteInput = "0" + minuteNum; } else if (minuteNum > 59) { this.minuteInput = "00"; } else { this.minuteInput = minuteNum.toString(); } } private async turnOnNotifications() { let notifyCloser = () => {}; return this.askPermission() .then((permission) => { logConsoleAndDb("Permission granted: " + JSON.stringify(permission)); // Call the function and handle promises return this.subscribeToPush(); }) .then(() => { logConsoleAndDb("Subscribed successfully."); return navigator.serviceWorker?.ready; }) .then((registration) => { return registration.pushManager.getSubscription(); }) .then(async (subscription) => { if (subscription) { notifyCloser = 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 = this.hourAm ? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum rawHourNum === 12 ? 0 : rawHourNum : // Otherwise it's PM, so keep a 12 but otherwise add 12 rawHourNum === 12 ? 12 : rawHourNum + 12; const hourNum = adjHourNum % 24; // probably unnecessary now const utcHour = hourNum + Math.round(new Date().getTimezoneOffset() / 60); const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24; const minuteNum = libsUtil.numberOrZero(this.minuteInput); const utcMinute = minuteNum + Math.round(new Date().getTimezoneOffset() % 60); const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60; const subscriptionWithTime: PushSubscriptionWithTime = { notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute }, notifyType: this.pushType, message: this.messageInput, ...subscription.toJSON(), }; await this.sendSubscriptionToServer(subscriptionWithTime); // To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1 logConsoleAndDb( "Subscription data sent to server with endpoint: " + subscription.endpoint, ); return subscriptionWithTime; } else { throw new Error("Subscription object is not available."); } }) .then(async (subscription: PushSubscriptionWithTime) => { logConsoleAndDb( "Subscription data sent to server and all finished successfully.", ); await libsUtil.sendTestThroughPushServer(subscription, true); notifyCloser(); setTimeout(() => { this.$notify( { group: "alert", type: "success", title: "Notification Is On", text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.", }, 7000, ); }, 500); const timeText = // eslint-disable-next-line this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM"); this.callback(true, timeText, this.messageInput); }) .catch((error) => { logConsoleAndDb( "Got an error setting notification permissions: " + " string " + error.toString() + " JSON " + JSON.stringify(error), true, ); this.$notify( { group: "alert", type: "danger", title: "Error Setting Notification Permissions", text: "Could not set notification permissions.", }, 3000, ); // if we want to also unsubscribe, be sure to do that only if no other notification is active }); } 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 (window.Notification.permission !== "granted") { const errorMsg = "Notification permission not granted"; console.warn(errorMsg); return reject(new Error(errorMsg)); } const applicationServerKey = urlBase64ToUint8Array(this.vapidKey); const options: PushSubscriptionOptions = { userVisibleOnly: true, applicationServerKey: applicationServerKey, }; navigator.serviceWorker?.ready .then((registration) => { return registration.pushManager.subscribe(options); }) .then((subscription) => { logConsoleAndDb( "Push subscription successful: " + JSON.stringify(subscription), ); resolve(); }) .catch((error) => { logConsoleAndDb( "Push subscription failed: " + JSON.stringify(error) + " - " + JSON.stringify(options), true, ); // Inform the user about the issue this.$notify( { group: "alert", type: "danger", title: "Error Setting Push Notifications", text: "We encountered an issue setting up push notifications. " + "If you wish to revoke notification permissions, please do so in your browser settings.", }, -1, ); reject(error); }); }); } private sendSubscriptionToServer( subscription: PushSubscriptionWithTime, ): Promise<void> { logConsoleAndDb( "About to send subscription... " + JSON.stringify(subscription), ); return fetch("/web-push/subscribe", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(subscription), }).then((response) => { if (!response.ok) { console.error("Bad response subscribing to web push: ", response); throw new Error("Failed to send push subscription to server"); } logConsoleAndDb("Push subscription sent to server successfully."); }); } } </script> <style scoped> /* Add any specific styles for this component here */ </style>