diff options
author | V <vendicated@riseup.net> | 2023-04-01 02:47:49 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-01 02:47:49 +0200 |
commit | 6960a439c9c2af261517b00b0b16a7fc5756c48b (patch) | |
tree | 4e1832c5cc295b26046e193ac5cf60bcd2590568 /src/api/Notifications | |
parent | 4dff1c5bd5b16e926bc628acf11118344832a374 (diff) | |
download | Vencord-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.tsx | 12 | ||||
-rw-r--r-- | src/api/Notifications/Notifications.tsx | 7 | ||||
-rw-r--r-- | src/api/Notifications/notificationLog.tsx | 203 | ||||
-rw-r--r-- | src/api/Notifications/styles.css | 58 |
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); +} |