diff --git a/src/renderer/apis/notification.ts b/src/renderer/apis/notification.ts new file mode 100644 index 000000000..1a30819aa --- /dev/null +++ b/src/renderer/apis/notification.ts @@ -0,0 +1,159 @@ +import { DISCORD_BLURPLE } from "src/constants"; +import type { ButtonItemProps } from "../modules/components/ButtonItem"; + +export interface NotificationProps { + id?: string; + timeout: number; + origin?: string; + name?: string; + color?: string; + gradient?: [string, 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")); + } +} + +/** + * @internal + * @hidden + */ +export const NotificationHandler = new RPNotificationHandler(); + +/** + * Send an in-app notification on discord. + * + * @example + * ``` + * import { NotificationAPI } from "replugged"; + * + * const notification = NotificationAPI.coremod("Notification"); + * + * export async function start() { + * notification.notify({ + * header: "Example", + timeout: 10000, + content: "This is an example notification!" + }) + * } + * + * export function stop() { + * notification.dismissAll(); + * } + * ``` + */ +export class NotificationAPI { + public origin: string; + public name: string; + public color: string; + private notifications: Array<() => void> = []; + + /** + * + * @param origin Origin of the context (e.g. API, Plugin, Coremod...) + * @param name Name of the context (e.g. Notices, SilentTyping, Badges...) + * @param color Color of the prefix as hex or a CSS color + */ + public constructor(origin: string, name: string, color?: string) { + this.origin = origin; + this.name = name; + this.color = color ?? DISCORD_BLURPLE; + } + + /** + * A function to send in app notification. + * @param notification The notification details to show + * @returns A callback to dismiss the notification + */ + 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.type} -${Date.now()}`; + const dismiss = NotificationHandler.sendNotification(notification as NotificationPropsWithId); + this.notifications.push(dismiss); + return () => { + dismiss(); + this.notifications = this.notifications.filter((f) => f !== dismiss); + }; + } + + /** + * Dismiss all notifications made by from this origin + */ + public dismissAll(): void { + for (const dismiss of this.notifications) { + dismiss(); + } + this.notifications = []; + } + + /** + * Convenience method to create a new {@link NotificationAPI} for an API. + * @internal + * @param name Name of the API + * @param color Color of the prefix as hex or a CSS color (default: blurple) + * @returns {@link NotificationAPI} with origin "API" + */ + public static api(name: string, color?: string): NotificationAPI { + return new NotificationAPI("API", name, color); + } + + /** + * Convenience method to create a new {@link NotificationAPI} for an coremod. + * @internal + * @param name Name of the Coremod + * @param color Color of the prefix as hex or a CSS color (default: blurple) + * @returns {@link NotificationAPI} with origin "Coremod" + */ + public static coremod(name: string, color?: string): NotificationAPI { + return new NotificationAPI("Coremod", name, color); + } + + /** + * Convenience method to create a new {@link NotificationAPI} for an Plugin. + * @internal + * @param name Name of the Plugin + * @param color Color of the prefix as hex or a CSS color (default: blurple) + * @returns {@link NotificationAPI} with origin "Plugin" + */ + 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..f8c75200e --- /dev/null +++ b/src/renderer/coremods/notification/index.tsx @@ -0,0 +1,9 @@ +import NotificationContainer from "./notification"; + +/** + * @internal + * @hidden + */ +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..a9ac2cacc --- /dev/null +++ b/src/renderer/coremods/notification/notification.css @@ -0,0 +1,184 @@ +/*======== Toast Styling ========*/ + +.replugged-notification-container { + display: flex; + flex-direction: column; + align-items: flex-end; + position: fixed; + bottom: 25px; + right: 25px; + z-index: 999; + max-height: 69%; + overflow: hidden scroll; + scroll-snap-type: y proximity; +} + +.replugged-notification-container::-webkit-scrollbar, +.replugged-notification-container::-webkit-scrollbar-track, +.replugged-notification-container::-webkit-scrollbar-thumb, +.replugged-notification-container::-webkit-scrollbar-corner { + opacity: 0; +} + +.replugged-notification-container > .replugged-notification:last-child { + scroll-snap-align: end; +} + +.replugged-notification { + display: flex; + flex-direction: column; + margin-bottom: 10px; + background-color: var(--background-base-lower); + border: 1px solid var(--border-normal); + 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; + background-color: var(--background-base-lowest); + border-radius: 8px; + padding: 14px; + align-items: center; +} + +.replugged-notification .header span:has(> .icon) { + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; +} + +.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 { + opacity: 0.5; + transition: opacity 0.2s; + margin-left: auto; + cursor: pointer; +} + +.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-primary); + font-size: 14px; + line-height: 1.4; + background-color: var(--background-base-low); + border-bottom: 1px solid solid var(--border-normal); + 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..473983cd2 --- /dev/null +++ b/src/renderer/coremods/notification/notification.tsx @@ -0,0 +1,168 @@ +import { React, classNames } from "@common"; +import { notification } from "@replugged"; +import { Button, Clickable, Progress, Tooltip } from "@components"; +import Icons from "./icons"; +import type { NotificationPropsWithId } from "../../apis/notification"; +import { DISCORD_BLURPLE } from "src/constants"; + +import "./notification.css"; + +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]; +} + +const Notification = React.memo((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 React.memo((): 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..aacedd3ad --- /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: /(\.app,children:\[.+?)\]/, + 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 791f5618c..8ee4b7f2e 100644 --- a/src/renderer/managers/coremods.ts +++ b/src/renderer/managers/coremods.ts @@ -9,6 +9,7 @@ import experimentsPlaintext from "../coremods/experiments/plaintextPatches"; import languagePlaintext from "../coremods/language/plaintextPatches"; import messagePopoverPlaintext from "../coremods/messagePopover/plaintextPatches"; import noDevtoolsWarningPlaintext from "../coremods/noDevtoolsWarning/plaintextPatches"; +import notificationPlaintext from "../coremods/notification/plaintextPatches"; import noticesPlaintext from "../coremods/notices/plaintextPatches"; import notrackPlaintext from "../coremods/notrack/plaintextPatches"; import popoutThemingPlaintext from "../coremods/popoutTheming/plaintextPatches"; @@ -32,6 +33,7 @@ export namespace coremods { export let messagePopover: Coremod; export let noDevtoolsWarning: Coremod; export let notices: Coremod; + export let notification: Coremod; export let notrack: Coremod; export let rdtComponentSourceFix: Coremod; export let rpc: Coremod; @@ -59,6 +61,7 @@ export async function startAll(): Promise { coremods.messagePopover = await import("../coremods/messagePopover"); coremods.noDevtoolsWarning = await import("../coremods/noDevtoolsWarning"); coremods.notices = await import("../coremods/notices"); + coremods.notification = await import("../coremods/notification"); coremods.notrack = await import("../coremods/notrack"); coremods.rdtComponentSourceFix = await import("../coremods/rdtComponentSourceFix"); coremods.rpc = await import("../coremods/rpc"); @@ -90,6 +93,7 @@ export function runPlaintextPatches(): void { { patch: languagePlaintext, name: "replugged.coremod.language" }, { patch: messagePopoverPlaintext, name: "replugged.coremod.messagePopover" }, { patch: noDevtoolsWarningPlaintext, name: "replugged.coremod.noDevtoolsWarning" }, + { patch: notificationPlaintext, name: "replugged.coremod.notification" }, { patch: noticesPlaintext, name: "replugged.coremod.notices" }, { patch: notrackPlaintext, name: "replugged.coremod.notrack" }, { patch: popoutThemingPlaintext, name: "replugged.coremod.popoutTheming" }, diff --git a/src/renderer/modules/common/components.ts b/src/renderer/modules/common/components.ts index e36f4fa01..e5ea49576 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 { NoticeType } from "@components/Notice"; import type { OriginalTextType } from "@components/Text"; @@ -53,6 +53,7 @@ export type DiscordComponents = { | SwitchType | TextAreaType | OriginalTooltipType + | ProgressType | unknown >; diff --git a/src/renderer/modules/components/ButtonItem.tsx b/src/renderer/modules/components/ButtonItem.tsx index 4fc81fe37..a1e0e866f 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..e2da1b452 --- /dev/null +++ b/src/renderer/modules/components/Progress.tsx @@ -0,0 +1,15 @@ +import type React from "react"; +import components from "../common/components"; +import { getFunctionBySource } from "@webpack"; + +interface ProgressProps { + animate?: boolean; + className?: string; + itemClassName?: string; + style?: React.CSSProperties; + percent: number; + foregroundGradientColor?: string[]; +} + +export type ProgressType = React.FC; +export default getFunctionBySource(components, ".progressBar")!; diff --git a/src/renderer/modules/components/index.ts b/src/renderer/modules/components/index.ts index 50157fba0..56acb8658 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";