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