Add Notification log (#745)

This commit is contained in:
V 2023-04-01 02:47:49 +02:00 committed by GitHub
parent 4dff1c5bd5
commit 6960a439c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 333 additions and 19 deletions

@ -34,11 +34,13 @@
"dependencies": { "dependencies": {
"@vap/core": "0.0.12", "@vap/core": "0.0.12",
"@vap/shiki": "0.10.3", "@vap/shiki": "0.10.3",
"fflate": "^0.7.4" "fflate": "^0.7.4",
"nanoid": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/diff": "^5.0.2", "@types/diff": "^5.0.2",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/nanoid": "^3.0.0",
"@types/node": "^18.11.18", "@types/node": "^18.11.18",
"@types/react": "^18.0.27", "@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",

16
pnpm-lock.yaml generated

@ -11,6 +11,7 @@ patchedDependencies:
specifiers: specifiers:
'@types/diff': ^5.0.2 '@types/diff': ^5.0.2
'@types/lodash': ^4.14.191 '@types/lodash': ^4.14.191
'@types/nanoid': ^3.0.0
'@types/node': ^18.11.18 '@types/node': ^18.11.18
'@types/react': ^18.0.27 '@types/react': ^18.0.27
'@types/react-dom': ^18.0.10 '@types/react-dom': ^18.0.10
@ -31,6 +32,7 @@ specifiers:
fflate: ^0.7.4 fflate: ^0.7.4
highlight.js: 10.6.0 highlight.js: 10.6.0
moment: ^2.29.4 moment: ^2.29.4
nanoid: ^4.0.2
puppeteer-core: ^19.6.0 puppeteer-core: ^19.6.0
standalone-electron-types: ^1.0.0 standalone-electron-types: ^1.0.0
stylelint: ^14.16.1 stylelint: ^14.16.1
@ -43,10 +45,12 @@ dependencies:
'@vap/core': 0.0.12 '@vap/core': 0.0.12
'@vap/shiki': 0.10.3 '@vap/shiki': 0.10.3
fflate: 0.7.4 fflate: 0.7.4
nanoid: 4.0.2
devDependencies: devDependencies:
'@types/diff': 5.0.2 '@types/diff': 5.0.2
'@types/lodash': 4.14.191 '@types/lodash': 4.14.191
'@types/nanoid': 3.0.0
'@types/node': 18.11.18 '@types/node': 18.11.18
'@types/react': 18.0.27 '@types/react': 18.0.27
'@types/react-dom': 18.0.10 '@types/react-dom': 18.0.10
@ -417,6 +421,13 @@ packages:
resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==}
dev: true dev: true
/@types/nanoid/3.0.0:
resolution: {integrity: sha512-UXitWSmXCwhDmAKe7D3hNQtQaHeHt5L8LO1CB8GF8jlYVzOv5cBWDNqiJ+oPEWrWei3i3dkZtHY/bUtd0R/uOQ==}
deprecated: This is a stub types definition. nanoid provides its own type definitions, so you do not need this installed.
dependencies:
nanoid: 4.0.2
dev: true
/@types/node/18.11.18: /@types/node/18.11.18:
resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==}
dev: true dev: true
@ -2245,6 +2256,11 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/nanoid/4.0.2:
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
engines: {node: ^14 || ^16 || >=18}
hasBin: true
/nanomatch/1.2.13: /nanomatch/1.2.13:
resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}

@ -36,7 +36,7 @@ const commonOptions = {
entryPoints: ["browser/Vencord.ts"], entryPoints: ["browser/Vencord.ts"],
globalName: "Vencord", globalName: "Vencord",
format: "iife", format: "iife",
external: ["plugins", "git-hash"], external: ["plugins", "git-hash", "/assets/*"],
plugins: [ plugins: [
globPlugins, globPlugins,
...commonOpts.plugins, ...commonOpts.plugins,

@ -193,7 +193,7 @@ export const commonOpts = {
legalComments: "linked", legalComments: "linked",
banner, banner,
plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin],
external: ["~plugins", "~git-hash", "~git-remote"], external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"], inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement", jsxFactory: "VencordCreateElement",
jsxFragment: "VencordFragment", jsxFragment: "VencordFragment",

@ -54,6 +54,7 @@ async function init() {
title: "Vencord has been updated!", title: "Vencord has been updated!",
body: "Click here to restart", body: "Click here to restart",
permanent: true, permanent: true,
noPersist: true,
onClick() { onClick() {
if (needsFullRestart) if (needsFullRestart)
window.DiscordNative.app.relaunch(); window.DiscordNative.app.relaunch();
@ -69,6 +70,7 @@ async function init() {
title: "A Vencord update is available!", title: "A Vencord update is available!",
body: "Click here to view the update", body: "Click here to view the update",
permanent: true, permanent: true,
noPersist: true,
onClick() { onClick() {
SettingsRouter.open("VencordUpdater"); SettingsRouter.open("VencordUpdater");
} }

@ -20,6 +20,7 @@ import "./styles.css";
import { useSettings } from "@api/settings"; import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { classes } from "@utils/misc";
import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common"; import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common";
import { NotificationData } from "./Notifications"; import { NotificationData } from "./Notifications";
@ -33,8 +34,10 @@ export default ErrorBoundary.wrap(function NotificationComponent({
onClick, onClick,
onClose, onClose,
image, image,
permanent permanent,
}: NotificationData) { className,
dismissOnClick
}: NotificationData & { className?: string; }) {
const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications; const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications;
const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused()); const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused());
@ -61,11 +64,12 @@ export default ErrorBoundary.wrap(function NotificationComponent({
return ( return (
<button <button
className="vc-notification-root" className={classes("vc-notification-root", className)}
style={position === "bottom-right" ? { bottom: "1rem" } : { top: "3rem" }} style={position === "bottom-right" ? { bottom: "1rem" } : { top: "3rem" }}
onClick={() => { onClick={() => {
onClose!();
onClick?.(); onClick?.();
if (dismissOnClick !== false)
onClose!();
}} }}
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();

@ -23,6 +23,7 @@ import type { ReactNode } from "react";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import NotificationComponent from "./NotificationComponent"; import NotificationComponent from "./NotificationComponent";
import { persistNotification } from "./notificationLog";
const NotificationQueue = new Queue(); const NotificationQueue = new Queue();
@ -56,6 +57,10 @@ export interface NotificationData {
color?: string; color?: string;
/** Whether this notification should not have a timeout */ /** Whether this notification should not have a timeout */
permanent?: boolean; 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) { function _showNotification(notification: NotificationData, id: number) {
@ -86,6 +91,8 @@ export async function requestPermission() {
} }
export async function showNotification(data: NotificationData) { export async function showNotification(data: NotificationData) {
persistNotification(data);
if (shouldBeNative() && await requestPermission()) { if (shouldBeNative() && await requestPermission()) {
const { title, body, icon, image, onClick = null, onClose = null } = data; const { title, body, icon, image, onClick = null, onClose = null } = data;
const n = new Notification(title, { const n = new Notification(title, {

@ -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)}
/>
));
}

@ -3,16 +3,20 @@
all: unset; all: unset;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 25vw;
min-height: 10vh;
color: var(--text-normal); color: var(--text-normal);
background-color: var(--background-secondary-alt); background-color: var(--background-secondary-alt);
position: absolute;
z-index: 2147483647;
right: 1rem;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
cursor: pointer; 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 { .vc-notification {
@ -72,3 +76,47 @@
.vc-notification-img { .vc-notification-img {
width: 100%; 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);
}

@ -47,6 +47,7 @@ export interface Settings {
timeout: number; timeout: number;
position: "top-right" | "bottom-right"; position: "top-right" | "bottom-right";
useNative: "always" | "never" | "not-focused"; useNative: "always" | "never" | "not-focused";
logLimit: number;
}; };
} }
@ -66,7 +67,8 @@ const DefaultSettings: Settings = {
notifications: { notifications: {
timeout: 5000, timeout: 5000,
position: "bottom-right", position: "bottom-right",
useNative: "not-focused" useNative: "not-focused",
logLimit: 50
} }
}; };

@ -17,6 +17,7 @@
*/ */
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { useSettings } from "@api/settings"; import { useSettings } from "@api/settings";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import DonateButton from "@components/DonateButton"; import DonateButton from "@components/DonateButton";
@ -198,6 +199,29 @@ function VencordSettings() {
onMarkerRender={v => (v / 1000) + "s"} onMarkerRender={v => (v / 1000) + "s"}
stickToMarkers={false} 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> </React.Fragment>
); );
} }

@ -78,6 +78,7 @@ export default definePlugin({
color: "#eed202", color: "#eed202",
title: "Discord has crashed!", title: "Discord has crashed!",
body: "Awn :( Discord has crashed more than five times, not attempting to recover.", body: "Awn :( Discord has crashed more than five times, not attempting to recover.",
noPersist: true,
}); });
} catch { } } catch { }
@ -111,6 +112,7 @@ export default definePlugin({
color: "#eed202", color: "#eed202",
title: "Discord has crashed!", title: "Discord has crashed!",
body: "Attempting to recover...", body: "Attempting to recover...",
noPersist: true,
}); });
} catch { } } catch { }
} }

@ -116,7 +116,8 @@ function initWs(isManual = false) {
showNotification({ showNotification({
title: "Dev Companion Error", title: "Dev Companion Error",
body: (e as ErrorEvent).message || "No Error Message", 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({ showNotification({
title: "Dev Companion Disconnected", title: "Dev Companion Disconnected",
body: e.reason || "No Reason provided", body: e.reason || "No Reason provided",
color: "var(--status-danger, red)" color: "var(--status-danger, red)",
noPersist: true,
}); });
}); });

@ -24,10 +24,12 @@ export let useState: typeof React.useState;
export let useEffect: typeof React.useEffect; export let useEffect: typeof React.useEffect;
export let useMemo: typeof React.useMemo; export let useMemo: typeof React.useMemo;
export let useRef: typeof React.useRef; 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"); export const ReactDOM: typeof import("react-dom") & typeof import("react-dom/client") = findByPropsLazy("createPortal", "render");
waitFor("useState", m => { waitFor("useState", m => {
React = m; React = m;
({ useEffect, useState, useMemo, useRef } = React); ({ useEffect, useState, useMemo, useRef, useReducer, useCallback } = React);
}); });