aboutsummaryrefslogtreecommitdiff
path: root/src/api/Notifications
diff options
context:
space:
mode:
authorV <vendicated@riseup.net>2023-04-01 02:47:49 +0200
committerGitHub <noreply@github.com>2023-04-01 02:47:49 +0200
commit6960a439c9c2af261517b00b0b16a7fc5756c48b (patch)
tree4e1832c5cc295b26046e193ac5cf60bcd2590568 /src/api/Notifications
parent4dff1c5bd5b16e926bc628acf11118344832a374 (diff)
downloadVencord-6960a439c9c2af261517b00b0b16a7fc5756c48b.tar.gz
Vencord-6960a439c9c2af261517b00b0b16a7fc5756c48b.tar.bz2
Vencord-6960a439c9c2af261517b00b0b16a7fc5756c48b.zip
Add Notification log (#745)
Diffstat (limited to 'src/api/Notifications')
-rw-r--r--src/api/Notifications/NotificationComponent.tsx12
-rw-r--r--src/api/Notifications/Notifications.tsx7
-rw-r--r--src/api/Notifications/notificationLog.tsx203
-rw-r--r--src/api/Notifications/styles.css58
4 files changed, 271 insertions, 9 deletions
diff --git a/src/api/Notifications/NotificationComponent.tsx b/src/api/Notifications/NotificationComponent.tsx
index 53c1b81..542c29b 100644
--- a/src/api/Notifications/NotificationComponent.tsx
+++ b/src/api/Notifications/NotificationComponent.tsx
@@ -20,6 +20,7 @@ import "./styles.css";
import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
+import { classes } from "@utils/misc";
import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common";
import { NotificationData } from "./Notifications";
@@ -33,8 +34,10 @@ export default ErrorBoundary.wrap(function NotificationComponent({
onClick,
onClose,
image,
- permanent
-}: NotificationData) {
+ permanent,
+ className,
+ dismissOnClick
+}: NotificationData & { className?: string; }) {
const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications;
const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused());
@@ -61,11 +64,12 @@ export default ErrorBoundary.wrap(function NotificationComponent({
return (
<button
- className="vc-notification-root"
+ className={classes("vc-notification-root", className)}
style={position === "bottom-right" ? { bottom: "1rem" } : { top: "3rem" }}
onClick={() => {
- onClose!();
onClick?.();
+ if (dismissOnClick !== false)
+ onClose!();
}}
onContextMenu={e => {
e.preventDefault();
diff --git a/src/api/Notifications/Notifications.tsx b/src/api/Notifications/Notifications.tsx
index a6b2ccd..c842ec8 100644
--- a/src/api/Notifications/Notifications.tsx
+++ b/src/api/Notifications/Notifications.tsx
@@ -23,6 +23,7 @@ import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import NotificationComponent from "./NotificationComponent";
+import { persistNotification } from "./notificationLog";
const NotificationQueue = new Queue();
@@ -56,6 +57,10 @@ export interface NotificationData {
color?: string;
/** Whether this notification should not have a timeout */
permanent?: boolean;
+ /** Whether this notification should not be persisted in the Notification Log */
+ noPersist?: boolean;
+ /** Whether this notification should be dismissed when clicked (defaults to true) */
+ dismissOnClick?: boolean;
}
function _showNotification(notification: NotificationData, id: number) {
@@ -86,6 +91,8 @@ export async function requestPermission() {
}
export async function showNotification(data: NotificationData) {
+ persistNotification(data);
+
if (shouldBeNative() && await requestPermission()) {
const { title, body, icon, image, onClick = null, onClose = null } = data;
const n = new Notification(title, {
diff --git a/src/api/Notifications/notificationLog.tsx b/src/api/Notifications/notificationLog.tsx
new file mode 100644
index 0000000..72f09ac
--- /dev/null
+++ b/src/api/Notifications/notificationLog.tsx
@@ -0,0 +1,203 @@
+/*
+ * Vencord, a modification for Discord's desktop app
+ * Copyright (c) 2023 Vendicated and contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import * as DataStore from "@api/DataStore";
+import { Settings } from "@api/settings";
+import { classNameFactory } from "@api/Styles";
+import { useAwaiter } from "@utils/misc";
+import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
+import { Alerts, Button, Forms, moment, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
+import { nanoid } from "nanoid";
+import type { DispatchWithoutAction } from "react";
+
+import NotificationComponent from "./NotificationComponent";
+import type { NotificationData } from "./Notifications";
+
+interface PersistentNotificationData extends Pick<NotificationData, "title" | "body" | "image" | "icon" | "color"> {
+ timestamp: number;
+ id: string;
+}
+
+const KEY = "notification-log";
+
+const getLog = async () => {
+ const log = await DataStore.get(KEY) as PersistentNotificationData[] | undefined;
+ return log ?? [];
+};
+
+const cl = classNameFactory("vc-notification-log-");
+const signals = new Set<DispatchWithoutAction>();
+
+export async function persistNotification(notification: NotificationData) {
+ if (notification.noPersist) return;
+
+ const limit = Settings.notifications.logLimit;
+ if (limit === 0) return;
+
+ await DataStore.update(KEY, (old: PersistentNotificationData[] | undefined) => {
+ const log = old ?? [];
+
+ // Omit stuff we don't need
+ const {
+ onClick, onClose, richBody, permanent, noPersist, dismissOnClick,
+ ...pureNotification
+ } = notification;
+
+ log.unshift({
+ ...pureNotification,
+ timestamp: Date.now(),
+ id: nanoid()
+ });
+
+ if (log.length > limit && limit !== 200)
+ log.length = limit;
+
+ return log;
+ });
+
+ signals.forEach(x => x());
+}
+
+export async function deleteNotification(timestamp: number) {
+ const log = await getLog();
+ const index = log.findIndex(x => x.timestamp === timestamp);
+ if (index === -1) return;
+
+ log.splice(index, 1);
+ await DataStore.set(KEY, log);
+ signals.forEach(x => x());
+}
+
+export function useLogs() {
+ const [signal, setSignal] = useReducer(x => x + 1, 0);
+
+ useEffect(() => {
+ signals.add(setSignal);
+ return () => void signals.delete(setSignal);
+ }, []);
+
+ const [log, _, pending] = useAwaiter(getLog, {
+ fallbackValue: [],
+ deps: [signal]
+ });
+
+ return [log, pending] as const;
+}
+
+function NotificationEntry({ data }: { data: PersistentNotificationData; }) {
+ const [removing, setRemoving] = useState(false);
+ const ref = React.useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const div = ref.current!;
+
+ const setHeight = () => {
+ if (div.clientHeight === 0) return requestAnimationFrame(setHeight);
+ div.style.height = `${div.clientHeight}px`;
+ };
+
+ setHeight();
+ }, []);
+
+ return (
+ <div className={cl("wrapper", { removing })} ref={ref}>
+ <NotificationComponent
+ {...data}
+ permanent={true}
+ dismissOnClick={false}
+ onClose={() => {
+ if (removing) return;
+ setRemoving(true);
+
+ setTimeout(() => deleteNotification(data.timestamp), 200);
+ }}
+ richBody={
+ <div className={cl("body")}>
+ {data.body}
+ <Timestamp timestamp={moment(data.timestamp)} className={cl("timestamp")} />
+ </div>
+ }
+ />
+ </div>
+ );
+}
+
+export function NotificationLog({ log, pending }: { log: PersistentNotificationData[], pending: boolean; }) {
+ if (!log.length && !pending)
+ return (
+ <div className={cl("container")}>
+ <div className={cl("empty")} />
+ <Forms.FormText style={{ textAlign: "center" }}>
+ No notifications yet
+ </Forms.FormText>
+ </div>
+ );
+
+ return (
+ <div className={cl("container")}>
+ {log.map(n => <NotificationEntry data={n} key={n.id} />)}
+ </div>
+ );
+}
+
+function LogModal({ modalProps, close }: { modalProps: ModalProps; close(): void; }) {
+ const [log, pending] = useLogs();
+
+ return (
+ <ModalRoot {...modalProps} size={ModalSize.LARGE}>
+ <ModalHeader>
+ <Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Log</Text>
+ <ModalCloseButton onClick={close} />
+ </ModalHeader>
+
+ <ModalContent>
+ <NotificationLog log={log} pending={pending} />
+ </ModalContent>
+
+ <ModalFooter>
+ <Button
+ disabled={log.length === 0}
+ onClick={() => {
+ Alerts.show({
+ title: "Are you sure?",
+ body: `This will permanently remove ${log.length} notification${log.length === 1 ? "" : "s"}. This action cannot be undone.`,
+ async onConfirm() {
+ await DataStore.set(KEY, []);
+ signals.forEach(x => x());
+ },
+ confirmText: "Do it!",
+ confirmColor: "vc-notification-log-danger-btn",
+ cancelText: "Nevermind"
+ });
+ }}
+ >
+ Clear Notification Log
+ </Button>
+ </ModalFooter>
+ </ModalRoot>
+ );
+}
+
+export function openNotificationLogModal() {
+ const key = openModal(modalProps => (
+ <LogModal
+ modalProps={modalProps}
+ close={() => closeModal(key)}
+ />
+ ));
+}
diff --git a/src/api/Notifications/styles.css b/src/api/Notifications/styles.css
index cd37142..98dff6d 100644
--- a/src/api/Notifications/styles.css
+++ b/src/api/Notifications/styles.css
@@ -3,16 +3,20 @@
all: unset;
display: flex;
flex-direction: column;
- width: 25vw;
- min-height: 10vh;
color: var(--text-normal);
background-color: var(--background-secondary-alt);
- position: absolute;
- z-index: 2147483647;
- right: 1rem;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
+ width: 100%;
+}
+
+.vc-notification-root:not(.vc-notification-log-wrapper > .vc-notification-root) {
+ position: absolute;
+ z-index: 2147483647;
+ right: 1rem;
+ width: 25vw;
+ min-height: 10vh;
}
.vc-notification {
@@ -72,3 +76,47 @@
.vc-notification-img {
width: 100%;
}
+
+.vc-notification-log-empty {
+ height: 218px;
+ background: url("/assets/b36de980b174d7b798c89f35c116e5c6.svg") center no-repeat;
+ margin-bottom: 40px;
+}
+
+.vc-notification-log-container {
+ display: flex;
+ flex-direction: column;
+ padding: 1em;
+ overflow: hidden;
+}
+
+.vc-notification-log-wrapper {
+ transition: 200ms ease;
+ transition-property: height, opacity;
+}
+
+.vc-notification-log-wrapper:not(:last-child) {
+ margin-bottom: 1em;
+}
+
+.vc-notification-log-removing {
+ height: 0 !important;
+ opacity: 0;
+ margin-bottom: 1em;
+}
+
+.vc-notification-log-body {
+ display: flex;
+ flex-direction: column;
+}
+
+.vc-notification-log-timestamp {
+ margin-left: auto;
+ font-size: 0.8em;
+ font-weight: lighter;
+}
+
+.vc-notification-log-danger-btn {
+ color: var(--white-500);
+ background-color: var(--button-danger-background);
+}