You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

659 lines
20 KiB

<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 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="isSystemReady" class="text-lg mb-4">
<span v-if="isDailyCheck">
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">
{{ waitingMessage }}
<font-awesome icon="spinner" spin />
</p>
<div v-if="canShowNotificationForm">
<div v-if="isDailyCheck">
<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
v-model="hourInput"
type="number"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
@change="checkHourInput"
/>
<input
v-model="minuteInput"
type="number"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
@change="checkMinuteInput"
/>
<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="toggleHourAm"
>
<span>{{ amPmLabel }} <font-awesome :icon="amPmIcon" /></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="handleTurnOnNotifications"
>
Turn on Daily Message
</button>
</div>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
@click="close()"
>
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 {
NOTIFY_PUSH_VAPID_ERROR,
NOTIFY_PUSH_INIT_ERROR,
NOTIFY_PUSH_BROWSER_NOT_SUPPORTED,
NOTIFY_PUSH_PERMISSION_ERROR,
NOTIFY_PUSH_SETUP_UNDERWAY,
NOTIFY_PUSH_SUCCESS,
NOTIFY_PUSH_SETUP_ERROR,
NOTIFY_PUSH_SUBSCRIPTION_ERROR,
PUSH_NOTIFICATION_TIMEOUT_SHORT,
PUSH_NOTIFICATION_TIMEOUT_MEDIUM,
PUSH_NOTIFICATION_TIMEOUT_LONG,
PUSH_NOTIFICATION_TIMEOUT_PERSISTENT,
} from "../constants/notifications";
import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
// 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({
mixins: [PlatformServiceMixin],
})
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 this.$accountSettings();
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
if (pushUrl.startsWith("http://localhost")) {
this.$logAndConsole(
"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 || "";
this.$logAndConsole("Got vapid key: " + this.vapidKey);
responseData = JSON.stringify(response.data);
navigator.serviceWorker?.addEventListener(
"controllerchange",
() => {
this.$logAndConsole(
"New service worker is now controlling the page",
);
},
);
});
if (!this.vapidKey) {
this.$notify(
{
group: "alert",
type: "danger",
title: NOTIFY_PUSH_VAPID_ERROR.title,
text: NOTIFY_PUSH_VAPID_ERROR.message,
},
PUSH_NOTIFICATION_TIMEOUT_MEDIUM,
);
this.$logAndConsole(
"Error Setting Notifications: web push server response didn't have vapidKey: " +
responseData,
true,
);
}
}
} catch (error) {
if (window.location.host.startsWith("localhost")) {
this.$logAndConsole(
"Ignoring the error getting VAPID for local development.",
);
} else {
this.$logAndConsole(
"Got an error initializing notifications: " + JSON.stringify(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: NOTIFY_PUSH_INIT_ERROR.title,
text: NOTIFY_PUSH_INIT_ERROR.message,
},
PUSH_NOTIFICATION_TIMEOUT_MEDIUM,
);
}
}
// 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 = this.notificationMessagePlaceholder;
// 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> {
// console.log(
// "Requesting permission for notifications: " + JSON.stringify(navigator),
// );
if (
!("serviceWorker" in navigator && navigator.serviceWorker?.controller)
) {
return Promise.reject("Service worker not available.");
}
// TODO: secretDB functionality needs to be migrated to PlatformServiceMixin
// For now, we'll use a temporary approach
const secret = "temporary-secret"; // Placeholder until secret management is migrated
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) => {
this.$logAndConsole(
"Response from service worker: " + JSON.stringify(response),
);
});
}
private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) {
this.$notify(
{
group: "alert",
type: "danger",
title: NOTIFY_PUSH_BROWSER_NOT_SUPPORTED.title,
text: NOTIFY_PUSH_BROWSER_NOT_SUPPORTED.message,
},
PUSH_NOTIFICATION_TIMEOUT_SHORT,
);
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: NOTIFY_PUSH_PERMISSION_ERROR.title,
text: NOTIFY_PUSH_PERMISSION_ERROR.message,
},
PUSH_NOTIFICATION_TIMEOUT_PERSISTENT,
);
throw new Error("Permission was not granted to this app.");
}
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) => {
this.$logAndConsole(
"Permission granted: " + JSON.stringify(permission),
);
// Call the function and handle promises
return this.subscribeToPush();
})
.then(() => {
this.$logAndConsole("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: NOTIFY_PUSH_SETUP_UNDERWAY.title,
text: NOTIFY_PUSH_SETUP_UNDERWAY.message,
},
PUSH_NOTIFICATION_TIMEOUT_PERSISTENT,
);
// 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
this.$logAndConsole(
"Subscription data sent to server with endpoint: " +
subscription.endpoint,
);
return subscriptionWithTime;
} else {
throw new Error("Subscription object is not available.");
}
})
.then(async (subscription: PushSubscriptionWithTime) => {
this.$logAndConsole(
"Subscription data sent to server and all finished successfully.",
);
await libsUtil.sendTestThroughPushServer(subscription, true);
notifyCloser();
setTimeout(() => {
this.$notify(
{
group: "alert",
type: "success",
title: NOTIFY_PUSH_SUCCESS.title,
text: NOTIFY_PUSH_SUCCESS.message,
},
PUSH_NOTIFICATION_TIMEOUT_LONG,
);
}, 500);
const timeText = this.notificationTimeText;
this.callback(true, timeText, this.messageInput);
})
.catch((error) => {
this.$logAndConsole(
"Got an error setting notification permissions: " +
" string " +
error.toString() +
" JSON " +
JSON.stringify(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: NOTIFY_PUSH_SETUP_ERROR.title,
text: NOTIFY_PUSH_SETUP_ERROR.message,
},
PUSH_NOTIFICATION_TIMEOUT_SHORT,
);
// 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";
logger.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (window.Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
logger.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) => {
this.$logAndConsole(
"Push subscription successful: " + JSON.stringify(subscription),
);
resolve();
})
.catch((error) => {
this.$logAndConsole(
"Push subscription failed: " +
JSON.stringify(error) +
" - " +
JSON.stringify(options),
true,
);
// Inform the user about the issue
this.$notify(
{
group: "alert",
type: "danger",
title: NOTIFY_PUSH_SUBSCRIPTION_ERROR.title,
text: NOTIFY_PUSH_SUBSCRIPTION_ERROR.message,
},
PUSH_NOTIFICATION_TIMEOUT_PERSISTENT,
);
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime,
): Promise<void> {
this.$logAndConsole(
"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) {
logger.error("Bad response subscribing to web push: ", response);
throw new Error("Failed to send push subscription to server");
}
this.$logAndConsole("Push subscription sent to server successfully.");
});
}
/**
* Computed property: isDailyCheck
* Returns true if the current pushType is DAILY_CHECK_TITLE
*/
get isDailyCheck(): boolean {
return this.pushType === this.DAILY_CHECK_TITLE;
}
/**
* Computed property: isSystemReady
* Returns true if serviceWorkerReady and vapidKey are set
*/
get isSystemReady(): boolean {
return this.serviceWorkerReady && !!this.vapidKey;
}
/**
* Computed property: canShowNotificationForm
* Returns true if serviceWorkerReady and vapidKey are set
*/
get canShowNotificationForm(): boolean {
return this.serviceWorkerReady && !!this.vapidKey;
}
/**
* Computed property: notificationMessagePlaceholder
* Returns the default message for direct push
*/
get notificationMessagePlaceholder(): string {
return "Click to share some gratitude with the world -- even if they're unnamed.";
}
/**
* Computed property: notificationTimeText
* Returns the formatted time string for display
*/
get notificationTimeText(): string {
return `${this.hourInput}:${this.minuteInput} ${this.hourAm ? "AM" : "PM"}`;
}
/**
* Toggles the AM/PM state for the hour input
*/
toggleHourAm() {
this.hourAm = !this.hourAm;
}
/**
* Computed property: amPmLabel
* Returns 'AM' or 'PM' based on hourAm
*/
get amPmLabel(): string {
return this.hourAm ? "AM" : "PM";
}
/**
* Computed property: amPmIcon
* Returns the appropriate icon for AM/PM
*/
get amPmIcon(): string {
return this.hourAm ? "chevron-down" : "chevron-up";
}
/**
* Handles the main action button click
*/
handleTurnOnNotifications() {
this.close();
this.turnOnNotifications();
}
/**
* Computed property: waitingMessage
* Returns the waiting message for initialization
*/
get waitingMessage(): string {
return "Waiting for system initialization, which may take up to 5 seconds...";
}
}
</script>
<style scoped>
/* Add any specific styles for this component here */
</style>