diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Vencord.ts | 2 | ||||
-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 | ||||
-rw-r--r-- | src/api/settings.ts | 4 | ||||
-rw-r--r-- | src/components/VencordSettings/VencordTab.tsx | 28 | ||||
-rw-r--r-- | src/plugins/crashHandler.ts | 2 | ||||
-rw-r--r-- | src/plugins/devCompanion.dev.tsx | 6 | ||||
-rw-r--r-- | src/webpack/common/react.ts | 4 |
10 files changed, 311 insertions, 15 deletions
diff --git a/src/Vencord.ts b/src/Vencord.ts index af6ca08..00f8a58 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -54,6 +54,7 @@ async function init() { title: "Vencord has been updated!", body: "Click here to restart", permanent: true, + noPersist: true, onClick() { if (needsFullRestart) window.DiscordNative.app.relaunch(); @@ -69,6 +70,7 @@ async function init() { title: "A Vencord update is available!", body: "Click here to view the update", permanent: true, + noPersist: true, onClick() { SettingsRouter.open("VencordUpdater"); } 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); +} diff --git a/src/api/settings.ts b/src/api/settings.ts index 0aaa490..321a4c4 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -47,6 +47,7 @@ export interface Settings { timeout: number; position: "top-right" | "bottom-right"; useNative: "always" | "never" | "not-focused"; + logLimit: number; }; } @@ -66,7 +67,8 @@ const DefaultSettings: Settings = { notifications: { timeout: 5000, position: "bottom-right", - useNative: "not-focused" + useNative: "not-focused", + logLimit: 50 } }; diff --git a/src/components/VencordSettings/VencordTab.tsx b/src/components/VencordSettings/VencordTab.tsx index 120f4c5..7113421 100644 --- a/src/components/VencordSettings/VencordTab.tsx +++ b/src/components/VencordSettings/VencordTab.tsx @@ -17,6 +17,7 @@ */ +import { openNotificationLogModal } from "@api/Notifications/notificationLog"; import { useSettings } from "@api/settings"; import { classNameFactory } from "@api/Styles"; import DonateButton from "@components/DonateButton"; @@ -165,7 +166,7 @@ function VencordSettings() { { label: "Only use Desktop notifications when Discord is not focused", value: "not-focused", default: true }, { label: "Always use Desktop notifications", value: "always" }, { label: "Always use Vencord notifications", value: "never" }, - ]satisfies Array<{ value: typeof settings["notifications"]["useNative"]; } & Record<string, any>>} + ] satisfies Array<{ value: typeof settings["notifications"]["useNative"]; } & Record<string, any>>} closeOnSelect={true} select={v => notifSettings.useNative = v} isSelected={v => v === notifSettings.useNative} @@ -179,7 +180,7 @@ function VencordSettings() { options={[ { label: "Bottom Right", value: "bottom-right", default: true }, { label: "Top Right", value: "top-right" }, - ]satisfies Array<{ value: typeof settings["notifications"]["position"]; } & Record<string, any>>} + ] satisfies Array<{ value: typeof settings["notifications"]["position"]; } & Record<string, any>>} select={v => notifSettings.position = v} isSelected={v => v === notifSettings.position} serialize={identity} @@ -198,6 +199,29 @@ function VencordSettings() { onMarkerRender={v => (v / 1000) + "s"} stickToMarkers={false} /> + + <Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Log Limit</Forms.FormTitle> + <Forms.FormText className={Margins.bottom16}> + The amount of notifications to save in the log until old ones are removed. + Set to <code>0</code> to disable Notification log and <code>∞</code> to never automatically remove old Notifications + </Forms.FormText> + <Slider + markers={[0, 25, 50, 75, 100, 200]} + minValue={0} + maxValue={200} + stickToMarkers={true} + initialValue={notifSettings.logLimit} + onValueChange={v => notifSettings.logLimit = v} + onValueRender={v => v === 200 ? "∞" : v} + onMarkerRender={v => v === 200 ? "∞" : v} + /> + + <Button + onClick={openNotificationLogModal} + disabled={notifSettings.logLimit === 0} + > + Open Notification Log + </Button> </React.Fragment> ); } diff --git a/src/plugins/crashHandler.ts b/src/plugins/crashHandler.ts index 6457e09..61b06b3 100644 --- a/src/plugins/crashHandler.ts +++ b/src/plugins/crashHandler.ts @@ -78,6 +78,7 @@ export default definePlugin({ color: "#eed202", title: "Discord has crashed!", body: "Awn :( Discord has crashed more than five times, not attempting to recover.", + noPersist: true, }); } catch { } @@ -111,6 +112,7 @@ export default definePlugin({ color: "#eed202", title: "Discord has crashed!", body: "Attempting to recover...", + noPersist: true, }); } catch { } } diff --git a/src/plugins/devCompanion.dev.tsx b/src/plugins/devCompanion.dev.tsx index b9a7e4d..c675fc2 100644 --- a/src/plugins/devCompanion.dev.tsx +++ b/src/plugins/devCompanion.dev.tsx @@ -116,7 +116,8 @@ function initWs(isManual = false) { showNotification({ title: "Dev Companion Error", body: (e as ErrorEvent).message || "No Error Message", - color: "var(--status-danger, red)" + color: "var(--status-danger, red)", + noPersist: true, }); }); @@ -128,7 +129,8 @@ function initWs(isManual = false) { showNotification({ title: "Dev Companion Disconnected", body: e.reason || "No Reason provided", - color: "var(--status-danger, red)" + color: "var(--status-danger, red)", + noPersist: true, }); }); diff --git a/src/webpack/common/react.ts b/src/webpack/common/react.ts index d73a3df..1719613 100644 --- a/src/webpack/common/react.ts +++ b/src/webpack/common/react.ts @@ -24,10 +24,12 @@ export let useState: typeof React.useState; export let useEffect: typeof React.useEffect; export let useMemo: typeof React.useMemo; export let useRef: typeof React.useRef; +export let useReducer: typeof React.useReducer; +export let useCallback: typeof React.useCallback; export const ReactDOM: typeof import("react-dom") & typeof import("react-dom/client") = findByPropsLazy("createPortal", "render"); waitFor("useState", m => { React = m; - ({ useEffect, useState, useMemo, useRef } = React); + ({ useEffect, useState, useMemo, useRef, useReducer, useCallback } = React); }); |