diff --git a/src/renderer/apis/notification.ts b/src/renderer/apis/notification.ts new file mode 100644 index 000000000..8985a0508 --- /dev/null +++ b/src/renderer/apis/notification.ts @@ -0,0 +1,95 @@ +import { ButtonItemProps } from "@components/ButtonItem"; +export interface NotificationProps { + id?: string; + timeout: number; + origin?: string; + name?: string; + color?: string; + iconColor?: string; + imageClassName?: string | undefined; + header: React.ReactNode; + content: React.ReactNode; + image: string; + icon?: React.FunctionComponent> | false; + buttons?: ButtonItemProps[]; + className?: string; + style?: React.CSSProperties; + hideProgressBar?: boolean; + type?: string; +} +export interface NotificationPropsWithId extends NotificationProps { + id: string; + origin: string; + name: string; + color: string; +} +class RPNotificationHandler extends EventTarget { + private notifications = new Map(); + + public sendNotification(notification: NotificationPropsWithId): () => void { + this.notifications.set(notification.id, notification); + this.dispatchEvent(new CustomEvent("rpNotificationUpdate")); + return () => { + this.notifications.delete(notification.id); + this.dispatchEvent(new CustomEvent("rpNotificationUpdate")); + }; + } + + public getNotifications(): NotificationPropsWithId[] { + return Array.from(this.notifications.values()); + } + + public closeNotification(id: string): void { + this.notifications.delete(id); + this.dispatchEvent(new CustomEvent("rpNotificationUpdate")); + } +} +export const NotificationHandler = new RPNotificationHandler(); + +export class NotificationAPI { + public origin: string; + public name: string; + public color: string; + private notifications: Array<() => void> = []; + + public constructor(origin: string, name: string, color?: string) { + this.origin = origin; + this.name = name; + this.color = color ?? "#5864f2"; + } + + public notify(notification: NotificationProps): () => void { + notification.name = this.name; + notification.origin = this.origin; + notification.type ??= "info"; + notification.color ??= this.color; + notification.id = `${this.origin}-${this.name}-${notification.header}-${ + notification.type + } -${Date.now()}`; + const dismiss = NotificationHandler.sendNotification(notification as NotificationPropsWithId); + this.notifications.push(dismiss); + return () => { + dismiss(); + this.notifications = this.notifications.filter((f) => f !== dismiss); + }; + } + + public dismissAll(): void { + for (const dismiss of this.notifications) { + dismiss(); + } + this.notifications = []; + } + + public static api(name: string, color?: string): NotificationAPI { + return new NotificationAPI("API", name, color); + } + + public static coremod(name: string, color?: string): NotificationAPI { + return new NotificationAPI("Coremod", name, color); + } + + public static plugin(name: string, color?: string): NotificationAPI { + return new NotificationAPI("Plugin", name, color); + } +} diff --git a/src/renderer/coremods/notification/icons/Close.tsx b/src/renderer/coremods/notification/icons/Close.tsx new file mode 100644 index 000000000..53c4f8b1d --- /dev/null +++ b/src/renderer/coremods/notification/icons/Close.tsx @@ -0,0 +1,9 @@ +import { React } from "@common"; +export default React.memo((props: React.SVGProps) => ( + + + +)); diff --git a/src/renderer/coremods/notification/icons/Danger.tsx b/src/renderer/coremods/notification/icons/Danger.tsx new file mode 100644 index 000000000..b27dacf97 --- /dev/null +++ b/src/renderer/coremods/notification/icons/Danger.tsx @@ -0,0 +1,9 @@ +import { React } from "@common"; +export default React.memo((props: React.SVGProps) => ( + + + +)); diff --git a/src/renderer/coremods/notification/icons/Info.tsx b/src/renderer/coremods/notification/icons/Info.tsx new file mode 100644 index 000000000..8669c58ef --- /dev/null +++ b/src/renderer/coremods/notification/icons/Info.tsx @@ -0,0 +1,9 @@ +import { React } from "@common"; +export default React.memo((props: React.SVGProps) => ( + + + +)); diff --git a/src/renderer/coremods/notification/icons/Success.tsx b/src/renderer/coremods/notification/icons/Success.tsx new file mode 100644 index 000000000..7a2bd09a1 --- /dev/null +++ b/src/renderer/coremods/notification/icons/Success.tsx @@ -0,0 +1,9 @@ +import { React } from "@common"; +export default React.memo((props: React.SVGProps) => ( + + + +)); diff --git a/src/renderer/coremods/notification/icons/Warning.tsx b/src/renderer/coremods/notification/icons/Warning.tsx new file mode 100644 index 000000000..59754d496 --- /dev/null +++ b/src/renderer/coremods/notification/icons/Warning.tsx @@ -0,0 +1,9 @@ +import { React } from "@common"; +export default React.memo((props: React.SVGProps) => ( + + + +)); diff --git a/src/renderer/coremods/notification/icons/index.ts b/src/renderer/coremods/notification/icons/index.ts new file mode 100644 index 000000000..cb8d92b68 --- /dev/null +++ b/src/renderer/coremods/notification/icons/index.ts @@ -0,0 +1,13 @@ +import Close from "./Close"; +import Danger from "./Danger"; +import Info from "./Info"; +import Success from "./Success"; +import Warning from "./Warning"; + +export default { + Close, + Danger, + Info, + Success, + Warning, +}; diff --git a/src/renderer/coremods/notification/index.tsx b/src/renderer/coremods/notification/index.tsx new file mode 100644 index 000000000..4c5542f6a --- /dev/null +++ b/src/renderer/coremods/notification/index.tsx @@ -0,0 +1,5 @@ +import NotificationContainer from "./notification"; + +export function _renderNotification(): React.ReactElement { + return ; +} diff --git a/src/renderer/coremods/notification/notification.css b/src/renderer/coremods/notification/notification.css new file mode 100644 index 000000000..b4f373e60 --- /dev/null +++ b/src/renderer/coremods/notification/notification.css @@ -0,0 +1,187 @@ +/*======== CSS Variables ========*/ +.theme-dark { + --replugged-notification-box-shadow: rgba(0, 0, 0, 0.2); + --replugged-notification-border: rgba(28, 36, 43, 0.6); +} +.theme-light { + --replugged-notification-box-shadow: rgba(255, 255, 255, 0.1); + --replugged-notification-border: rgba(199, 199, 199, 0.06); +} +/*======== Toast Styling ========*/ + +.replugged-notification-container { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: flex-end; + position: fixed; + bottom: 25px; + right: 25px; + z-index: 999; +} +.replugged-notification { + display: flex; + flex-direction: column; + margin-bottom: 10px; + background-color: var(--background-secondary); + border: 1px solid var(--replugged-notification-border); + box-shadow: 0 8px 16px 0 var(--replugged-notification-box-shadow); + border-radius: 8px; + max-width: 600px; + min-width: 223px; + width: 320px; + position: relative; + animation: + slide-in 0.5s ease, + shake 1.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; +} + +.replugged-notification .header { + display: flex; + color: var(--header-primary); + font-weight: 600; + font-size: 24px; + line-height: 1.2; + background-color: var(--background-primary); + border-radius: 8px 8px 0 0; + padding: 12px 20px; + justify-content: center; + align-items: center; /* Center items vertically */ +} + +.replugged-notification .header span:has(.icon) { + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + margin-top: 3px; +} + +.replugged-notification .header .icon { + display: flex; + align-items: center; + justify-content: center; +} + +.replugged-notification .header .icon img { + width: 18px; + height: 18px; +} + +.replugged-notification .header .dismiss { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.2s; + margin-left: auto; + cursor: pointer; + font-size: 12px; + margin-bottom: 5px; +} + +.replugged-notification .contents { + display: flex; + border-radius: 0 0 8px 8px; + text-align: center; + justify-content: flex-end; + flex-direction: column; + padding: 10px; +} + +.replugged-notification .contents .inner { + color: var(--text-normal); + font-size: 14px; + line-height: 1.4; + background-color: var(--background-tertiary); + border-bottom: 1px solid solid var(--replugged-notification-border); + border-radius: 8px; + padding: 10px; + margin-bottom: 6px; +} + +.replugged-notification .buttons { + display: flex; + flex-wrap: wrap; + width: 95%; + padding: 10px; +} + +.replugged-notification .buttons button { + box-sizing: border-box; + min-width: calc(50% - 10px); + padding: 8px; + margin: 8px 4px; + flex: 1; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.replugged-notification .buttons button[class*="lookGhost"] { + opacity: 0.8; + transition: + background-color 0.17s ease, + color 0.17s ease, + opacity 0.17s ease, + transform 0.17s ease; +} + +.replugged-notification .buttons button[class*="lookGhost"]:hover { + opacity: 1; +} + +.replugged-notification.leaving { + animation: slide-out 0.7s ease-out; +} + +/*========= Header Types =========*/ +.replugged-notification.info .icon { + color: var(--blurple); +} + +.replugged-notification.warning .icon { + color: var(--info-warning-foreground); +} + +.replugged-notification.danger .icon { + color: var(--info-danger-foreground); +} + +.replugged-notification.success .icon { + color: var(--info-positive-foreground); +} + +/*========== Animations ==========*/ +@keyframes shake { + 10%, + 90% { + transform: translate3d(1px, 0, 0); + } + 20%, + 80% { + transform: translate3d(2px, 0, 0); + } + 30%, + 50%, + 70% { + transform: translate3d(-4px, 0, 0); + } + 40%, + 60% { + transform: translate3d(4px, 0, 0); + } +} + +@keyframes slide-in { + from { + margin-right: -500px; + opacity: 0; + } +} + +@keyframes slide-out { + to { + margin-right: -500px; + opacity: 0; + } +} diff --git a/src/renderer/coremods/notification/notification.tsx b/src/renderer/coremods/notification/notification.tsx new file mode 100644 index 000000000..1738abd69 --- /dev/null +++ b/src/renderer/coremods/notification/notification.tsx @@ -0,0 +1,160 @@ +import { React } from "@common"; +import { notification } from "@replugged"; +import { Button, Clickable, Progress, Tooltip } from "@components"; +import Icons from "./icons"; +import "./notification.css"; +import type { NotificationPropsWithId } from "../../apis/notification"; +const predefinedIcons: Record< + string, + React.MemoExoticComponent<(props: React.SVGProps) => React.ReactElement> +> = { + danger: Icons.Danger, + info: Icons.Info, + success: Icons.Success, + warning: Icons.Warning, +}; + +function NotificationGradient(hex: string): string[] { + const hexWithoutHash = hex.replace(/^#/, ""); + const num = parseInt(hexWithoutHash, 16); + const r = (num >> 16) & 0xff; + const g = (num >> 8) & 0xff; + const b = num & 0xff; + const luminance = + 0.2126 * (r / 255) ** 2.2 + 0.7152 * (g / 255) ** 2.2 + 0.0722 * (b / 255) ** 2.2; + const lightenDarken = luminance > 0.5 ? -175 : 175; + const newR = Math.min(Math.max(0, r + lightenDarken), 255); + const newG = Math.min(Math.max(0, g + lightenDarken), 255); + const newB = Math.min(Math.max(0, b + lightenDarken), 255); + const newHex = `#${((newR << 16) | (newG << 8) | newB).toString(16).padStart(6, "0")}`; + return luminance > 0.5 ? [newHex, hex] : [hex, newHex]; +} + +function Notification(props: NotificationPropsWithId): React.ReactElement | null { + const [leaving, setLeaving] = React.useState(false); + const [timeoutState, setTimeoutState] = React.useState(); + const [progress, setProgress] = React.useState(100); + const [progressState, setProgressState] = React.useState(); + const [timeLeft, setTimeLeft] = React.useState(props.timeout); + const Icon = props.icon ?? (props.type ? predefinedIcons[props.type] : predefinedIcons.info); + React.useEffect(() => { + if (!isNaN(props.timeout)) { + const timeout = setTimeout(() => { + setLeaving(true); + notification.NotificationHandler.closeNotification(props.id); + }, props.timeout); + setTimeoutState(timeout); + setProgressState( + setInterval(() => { + setTimeLeft((prev) => prev - 1000); + }, 1e3), + ); + } + return () => { + clearTimeout(timeoutState); + clearInterval(progressState); + }; + }, []); + React.useEffect(() => { + setProgress((timeLeft / props.timeout) * 100); + }, [timeLeft]); + + return ( +
+ {props.header && ( +
+ {props.icon !== false && ( + + `${text.charAt(0).toUpperCase()}${text.substring(1).toLowerCase()}`, + ) + : "Info" + }`}> +
+ {props.image ? ( + + ) : ( + Icon && + )} +
+
+ )} + {props.header} + { + setLeaving(true); + notification.NotificationHandler.closeNotification(props.id); + }}> + + +
+ )} + {props.content && ( +
+
{props.content}
+
+ )} + {props.buttons && Array.isArray(props.buttons) && ( +
+ {props.buttons.map(({ onClick, ...buttonProps }, index: number) => { + return ( +
+ )} + {timeoutState && !props.hideProgressBar && ( + + )} +
+ ); +} + +export default function notificationContainer(): React.ReactElement | null { + const [toasts, setToasts] = React.useState([]); + + const toastsUpdate = (): void => setToasts(notification.NotificationHandler.getNotifications()); + + React.useEffect(() => { + notification.NotificationHandler.addEventListener("rpNotificationUpdate", toastsUpdate); + toastsUpdate(); + + return () => { + notification.NotificationHandler.removeEventListener("rpNotificationUpdate", toastsUpdate); + }; + }, []); + + return ( +
+ {Boolean(toasts.length) && toasts.map((props) => )} +
+ ); +} diff --git a/src/renderer/coremods/notification/plaintextPatches.ts b/src/renderer/coremods/notification/plaintextPatches.ts new file mode 100644 index 000000000..80b125e40 --- /dev/null +++ b/src/renderer/coremods/notification/plaintextPatches.ts @@ -0,0 +1,14 @@ +import type { PlaintextPatch } from "src/types"; + +export default [ + { + find: "Shakeable is shaken when not mounted", + replacements: [ + { + match: /(\.DnDKeyboardHelpBar.{20,40})]/, + replace: (_, prefix) => + `${prefix},replugged.coremods?.coremods?.notification?._renderNotification?.()]`, + }, + ], + }, +] as PlaintextPatch[]; diff --git a/src/renderer/managers/coremods.ts b/src/renderer/managers/coremods.ts index 6bd1c2cf7..ac28bcd03 100644 --- a/src/renderer/managers/coremods.ts +++ b/src/renderer/managers/coremods.ts @@ -6,6 +6,7 @@ import { default as notrackPlaintext } from "../coremods/notrack/plaintextPatche import { default as noDevtoolsWarningPlaintext } from "../coremods/noDevtoolsWarning/plaintextPatches"; import { default as messagePopover } from "../coremods/messagePopover/plaintextPatches"; import { default as notices } from "../coremods/notices/plaintextPatches"; +import { default as notification } from "../coremods/notification/plaintextPatches"; import { default as contextMenu } from "../coremods/contextMenu/plaintextPatches"; import { default as languagePlaintext } from "../coremods/language/plaintextPatches"; import { default as commandsPlaintext } from "../coremods/commands/plaintextPatches"; @@ -29,6 +30,7 @@ export namespace coremods { export let installer: Coremod; export let messagePopover: Coremod; export let notices: Coremod; + export let notification: Coremod; export let contextMenu: Coremod; export let language: Coremod; export let rpc: Coremod; @@ -54,6 +56,7 @@ export async function startAll(): Promise { coremods.installer = await import("../coremods/installer"); coremods.messagePopover = await import("../coremods/messagePopover"); coremods.notices = await import("../coremods/notices"); + coremods.notification = await import("../coremods/notification"); coremods.contextMenu = await import("../coremods/contextMenu"); coremods.language = await import("../coremods/language"); coremods.rpc = await import("../coremods/rpc"); @@ -85,6 +88,7 @@ export function runPlaintextPatches(): Promise { noDevtoolsWarningPlaintext, messagePopover, notices, + notification, contextMenu, languagePlaintext, commandsPlaintext, diff --git a/src/renderer/modules/common/components.ts b/src/renderer/modules/common/components.ts index 6e0bf66e2..f4f15aa64 100644 --- a/src/renderer/modules/common/components.ts +++ b/src/renderer/modules/common/components.ts @@ -1,4 +1,4 @@ -import type { LoaderType } from "@components"; +import type { LoaderType, ProgressType } from "@components"; import type { ClickableCompType } from "@components/Clickable"; import type { OriginalTextType } from "@components/Text"; import type { ButtonType } from "../components/ButtonItem"; @@ -47,6 +47,7 @@ interface DiscordComponents { Select: SelectCompType; showToast: ShowToast; Slider: SliderCompType; + Progress: ProgressType; Spinner: LoaderType; Switch: SwitchType; Text: OriginalTextType; diff --git a/src/renderer/modules/components/ButtonItem.tsx b/src/renderer/modules/components/ButtonItem.tsx index 0f4fd6ba9..4def4a43b 100644 --- a/src/renderer/modules/components/ButtonItem.tsx +++ b/src/renderer/modules/components/ButtonItem.tsx @@ -67,7 +67,7 @@ const classes = "dividerDefault", ); -interface ButtonItemProps { +export interface ButtonItemProps { onClick?: React.MouseEventHandler; button?: string; note?: string; diff --git a/src/renderer/modules/components/Progress.tsx b/src/renderer/modules/components/Progress.tsx new file mode 100644 index 000000000..3ad1ca964 --- /dev/null +++ b/src/renderer/modules/components/Progress.tsx @@ -0,0 +1,14 @@ +import type React from "react"; +import components from "../common/components"; + +interface ProgressProps { + animate?: boolean; + className?: string; + itemClassName?: string; + style?: React.CSSProperties; + percent: number; + foregroundGradientColor?: string[]; +} + +export type ProgressType = React.FC; +export default components.Progress; diff --git a/src/renderer/modules/components/index.ts b/src/renderer/modules/components/index.ts index d5244f228..869f1bb40 100644 --- a/src/renderer/modules/components/index.ts +++ b/src/renderer/modules/components/index.ts @@ -163,6 +163,11 @@ export type { NoticeType }; export let Notice: NoticeType; importTimeout("Notice", import("./Notice"), (mod) => (Notice = mod.default)); +import type { ProgressType } from "./Progress"; +export type { ProgressType }; +export let Progress: ProgressType; +importTimeout("Progress", import("./Progress"), (mod) => (Progress = mod.default)); + /** * @internal * @hidden diff --git a/src/renderer/replugged.ts b/src/renderer/replugged.ts index 534e0d7ec..5140e0aa3 100644 --- a/src/renderer/replugged.ts +++ b/src/renderer/replugged.ts @@ -16,6 +16,9 @@ export { Injector } from "./modules/injector"; export * as logger from "./modules/logger"; export { Logger } from "./modules/logger"; +export * as notification from "./apis/notification"; +export { NotificationAPI } from "./apis/notification"; + export * as webpack from "./modules/webpack"; export * as common from "./modules/common"; export * as components from "./modules/components";