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
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>
|
|
|