|
|
|
<template>
|
|
|
|
<router-view />
|
|
|
|
|
|
|
|
<!-- 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>
|
|
|
|
|
|
|
|
<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"
|
|
|
|
>
|
|
|
|
<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 class="text-lg mb-4">
|
|
|
|
Would you like to <b>turn on</b> notifications for this app?
|
|
|
|
</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"
|
|
|
|
@click="
|
|
|
|
close(notification.id);
|
|
|
|
turnOnNotifications();
|
|
|
|
"
|
|
|
|
>
|
|
|
|
Turn on 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"
|
|
|
|
>
|
|
|
|
Maybe Later
|
|
|
|
</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
|
|
|
|
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 { Vue, Component } from "vue-facing-decorator";
|
|
|
|
import axios from "axios";
|
|
|
|
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;
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
import { AppString } from "@/constants/app";
|
|
|
|
import { db } from "@/db/index";
|
|
|
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
|
|
|
|
|
|
interface Notification {
|
|
|
|
group: string;
|
|
|
|
type: string;
|
|
|
|
title: string;
|
|
|
|
text: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Component
|
|
|
|
export default class App extends Vue {
|
|
|
|
$notify!: (notification: Notification, timeout?: number) => void;
|
|
|
|
|
|
|
|
b64 = "";
|
|
|
|
|
|
|
|
async mounted() {
|
|
|
|
try {
|
|
|
|
await db.open();
|
|
|
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
|
|
let pushUrl: string = AppString.DEFAULT_PUSH_SERVER;
|
|
|
|
if (settings?.webPushServer) {
|
|
|
|
pushUrl = settings.webPushServer;
|
|
|
|
}
|
|
|
|
|
|
|
|
await axios
|
|
|
|
.get(pushUrl + "/web-push/vapid")
|
|
|
|
.then((response: VapidResponse) => {
|
|
|
|
this.b64 = response.data?.vapidKey || "";
|
|
|
|
console.log("Got vapid key:", this.b64);
|
|
|
|
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) {
|
|
|
|
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,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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> {
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
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((subscription) => {
|
|
|
|
if (subscription) {
|
|
|
|
return this.sendSubscriptionToServer(subscription);
|
|
|
|
} else {
|
|
|
|
throw new Error("Subscription object is not available.");
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.then(() => {
|
|
|
|
console.log("Subscription data sent to server.");
|
|
|
|
})
|
|
|
|
.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. See logs.",
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
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(
|
|
|
|
"Subscription or server communication 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: PushSubscription,
|
|
|
|
): 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.");
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</script>
|