5 changed files with 491 additions and 506 deletions
			
			
		| @ -0,0 +1,481 @@ | |||
| <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"> | |||
|             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 && vapidKey"> | |||
|             <span class="flex flex-row justify-center"> | |||
|               <span class="mt-2">Yes, tell me 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> | |||
|             <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 } from "@/db/index"; | |||
| 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 { | |||
|   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 { | |||
|   $notify!: (notification: NotificationIface, timeout?: number) => void; | |||
| 
 | |||
|   hourAm = true; | |||
|   hourInput = "8"; | |||
|   isVisible = false; | |||
|   minuteInput = "00"; | |||
|   serviceWorkerReady = false; | |||
|   vapidKey = ""; | |||
| 
 | |||
|   async open() { | |||
|     this.isVisible = true; | |||
|     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; | |||
|     }); | |||
|   } | |||
| 
 | |||
|   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 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."); | |||
|     } | |||
| 
 | |||
|     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) => { | |||
|       logConsoleAndDb( | |||
|         "Response from service worker: " + JSON.stringify(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 (window.Notification.permission === "granted") { | |||
|       return Promise.resolve(); | |||
|     } | |||
|     return Promise.resolve(); | |||
|   } | |||
| 
 | |||
|   private requestNotificationPermission(): Promise<NotificationPermission> { | |||
|     return window.Notification.requestPermission().then( | |||
|       (permission: string) => { | |||
|         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; | |||
|       }, | |||
|     ); | |||
|   } | |||
| 
 | |||
|   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(); | |||
|     } | |||
|   } | |||
| 
 | |||
|   public async turnOnNotifications() { | |||
|     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) { | |||
|           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: "DAILY_CHECK", | |||
|             ...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); | |||
|         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) => { | |||
|         logConsoleAndDb( | |||
|           "Got an error setting notification permissions: " + | |||
|             " string " + | |||
|             error.toString() + | |||
|             " JSON " + | |||
|             JSON.stringify(error), | |||
|           true, | |||
|         ); | |||
|         alert("Some error occurred setting notification permissions."); | |||
|         // unsubscribe just in case we failed after getting a subscription | |||
|         navigator.serviceWorker?.ready | |||
|           .then((registration) => registration.pushManager.getSubscription()) | |||
|           .then((subscription) => { | |||
|             if (subscription) { | |||
|               subscription.unsubscribe(); | |||
|             } | |||
|           }); | |||
|       }); | |||
|   } | |||
| 
 | |||
|   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 | |||
|           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> { | |||
|     logConsoleAndDb("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) { | |||
|         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> | |||
					Loading…
					
					
				
		Reference in new issue