diff --git a/src/equicordplugins/toastNotifications/README.md b/src/equicordplugins/toastNotifications/README.md
new file mode 100644
index 000000000..d9d7f68d3
--- /dev/null
+++ b/src/equicordplugins/toastNotifications/README.md
@@ -0,0 +1,108 @@
+# ToastNotifications
+
+Displays a pop-up _'toast'_ notification in a configurable corner of the screen whenever you receive a message in a DM, group DM, or server channel.
+
+- [Configuration](#-configuration)
+ - [Notification Position](#notification-position)
+ - [Notification Timeout](#notification-timeout)
+ - [Opacity](#opacity)
+ - [Max Notifications](#max-notifications)
+ - [Disable In Streamer Mode](#disable-in-streamer-mode)
+ - [Respect Do Not Disturb](#respect-do-not-disturb)
+ - [Direct Messages](#direct-messages)
+ - [Group Messages](#group-messages)
+ - [Friend Server Notifications](#friend-server-notifications)
+ - [Ignored Users](#ignored-users)
+ - [Notify For](#notify-for)
+- [Theming](#-theming)
+
+## 🔧 Configuration
+
+### Notification Position
+
+Determines which corner of your screen the notification will appear in, valid options are:
+
+- Bottom Left _(Default)_
+- Bottom Right
+- Top Left
+- Top Right
+
+## Notification Timeout
+
+The duration _(in seconds)_ for which notifications will be shown on the screen before disappearing, a progress bar is shown below notifications to indicate how long is left before they disappear.
+
+> [!NOTE]
+> You can hover over a notification while it is visible to pause the timeout and keep it on screen indefinitely until you move your mouse away from it again.
+
+### Opacity
+
+The visible opacity of the notification message to display, acceptable value is between 10% and 100%.
+
+### Max Notifications
+
+The maximum number of concurrent notifications to display on the screen at once, if this limit is reached, the oldest notification will be removed from the screen to make room for the new one.
+
+### Disable In Streamer Mode
+
+When enabled, notifications are suppressed while Discord's streamer mode is active.
+
+### Respect Do Not Disturb
+
+If enabled, notifications will not be shown when your Discord status is set to Do Not Disturb mode.
+
+### Direct Messages
+
+Toggle notifications for messages sent in direct messages.
+
+### Group Messages
+
+Toggle notifications for messages sent in group DMs.
+
+### Friend Server Notifications
+
+When enabled, messages from friends in shared servers always trigger a notification regardless of the channel's notification level.
+
+### Ignored Users
+
+A comma-separated list of Discord User IDs whose messages will not trigger a notification. This is useful for ignoring messages from users who are not important to you or who send too many messages. Example value:
+
+- `123456789012345678,234567890123456789,345678901234567890`
+
+The above example will ignore messages from the users with IDs `123456789012345678`, `234567890123456789`, and `345678901234567890`. You can find a user's ID by right-clicking on their name in Discord and selecting Copy ID _(Developer Mode must be enabled in Discord settings)_.
+
+### Notify For
+
+A comma-separated list of channel IDs to **always** receive notifications from, regardless of the channel's notification level or mute state.
+
+---
+
+## 🎨 Theming
+
+This plugin supports theming and exposes a number of CSS variables to allow you to customize the appearance of notifications.
+
+| CSS Variable | Description |
+| ------------------------------------------------- | -------------------------------------------------------------------------- |
+| `--vc-toast-notifications-background-color` | Background color of the notification card. |
+| `--vc-toast-notifications-text-color` | Default text color inside notifications. |
+| `--vc-toast-notifications-border-radius` | Border radius of the notification card. |
+| `--vc-toast-notifications-width` | Width of the card. Defaults to `fit-content`. |
+| `--vc-toast-notifications-min-width` | Minimum width to use regardless of content. |
+| `--vc-toast-notifications-max-width` | Maximum width a single notification can grow to. |
+| `--vc-toast-notifications-min-height` | Minimum height to use regardless of content. |
+| `--vc-toast-notifications-max-height` | Maximum height a single notification can grow to. |
+| `--vc-toast-notifications-padding` | Inner padding of the notification card. |
+| `--vc-toast-notifications-position-offset` | Distance from the screen corner the stack is anchored at. |
+| `--vc-toast-notifications-title-color` | Color of titles (system notifications) and context headers (group/server). |
+| `--vc-toast-notifications-title-font-size` | Font size of titles and context headers. |
+| `--vc-toast-notifications-title-font-weight` | Font weight of titles and context headers. |
+| `--vc-toast-notifications-title-line-height` | Line height of titles and context headers. |
+| `--vc-toast-notifications-image-height` | Height of the avatar/icon shown in system notifications. |
+| `--vc-toast-notifications-image-width` | Width of the avatar/icon shown in system notifications. |
+| `--vc-toast-notifications-image-border-radius` | Border radius of the avatar/icon in system notifications. |
+| `--vc-toast-notifications-close-button-color` | Color of the dismiss (X) button. |
+| `--vc-toast-notifications-close-button-hover-color` | Color of the dismiss (X) button on hover. |
+| `--vc-toast-notifications-close-button-opacity` | Opacity of the dismiss (X) button at rest. |
+| `--vc-toast-notifications-close-button-hover-opacity` | Opacity of the dismiss (X) button on hover. |
+| `--vc-toast-notifications-progressbar-height` | Height of the progress bar shown at the bottom of notifications. |
+| `--vc-toast-notifications-progressbar-color` | Color of the progress bar. |
+
diff --git a/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx b/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx
index 5318b1e26..af3e391fe 100644
--- a/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx
+++ b/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx
@@ -1,144 +1,179 @@
/*
- * 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 .
-*/
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
import "./styles.css";
import ErrorBoundary from "@components/ErrorBoundary";
import { settings as PluginSettings } from "@equicordplugins/toastNotifications/index";
-import { classes } from "@utils/misc";
-import { React, useEffect, useMemo, useState } from "@webpack/common";
+import { classNameFactory } from "@utils/css";
+import { findComponentByCodeLazy } from "@webpack";
+import { FluxDispatcher, GuildStore, IconUtils, React, useEffect, useMemo, useRef, useState } from "@webpack/common";
-import { NotificationData } from "./Notifications";
+import { MessageNotification, NotificationData } from "./Notifications";
-export default ErrorBoundary.wrap(function NotificationComponent({
- title,
- body,
- richBody,
- icon,
- image,
- permanent,
- dismissOnClick,
- index,
- onClick,
- onClose,
- attachments
-}: NotificationData & { index?: number; }) {
+export const cl = classNameFactory("vc-toast-notifications-");
+const MessageComponent = findComponentByCodeLazy("childrenExecutedCommand:", ".hideAccessories");
+
+function isMessageNotification(props: NotificationData): props is MessageNotification {
+ return "mockedMessage" in props;
+}
+
+function renderContextHeader(channel: MessageNotification["channel"]): React.ReactNode {
+ if (channel.isGroupDM()) {
+ const icon = channel.icon
+ ? IconUtils.getChannelIconURL?.({ id: channel.id, icon: channel.icon, size: 32 })
+ : undefined;
+ const rawName = channel.name?.trim() || channel.rawRecipients.slice(0, 3).map(e => e.username).join(", ");
+ const name = rawName.length > 20 ? rawName.substring(0, 20) + "..." : rawName;
+ return (
+
+ {icon &&
}
+
{name}
+
+ );
+ }
+
+ if (channel.guild_id) {
+ const guild = GuildStore.getGuild(channel.guild_id);
+ const icon = guild?.icon
+ ? IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 32 })
+ : undefined;
+ return (
+
+ {icon &&
}
+
+ {guild?.name ?? "Unknown Server"}
+ {channel.name && (
+ <>
+ {" \u203A "}
+ #{channel.name}
+ >
+ )}
+
+
+ );
+ }
+
+ return null;
+}
+
+export default ErrorBoundary.wrap(function NotificationComponent(props: NotificationData) {
const [isHover, setIsHover] = useState(false);
const [elapsed, setElapsed] = useState(0);
- let renderBody: boolean = true;
- let footer: boolean = false;
+ const timeout = (PluginSettings.store.timeout ?? 5) * 1000;
+ const opacity = PluginSettings.store.opacity / 100;
+ const startRef = useRef(Date.now());
- if (attachments > 0) footer = true;
-
- if (body === "") renderBody = false;
-
- // Precompute appearance settings.
- const AppearanceSettings = {
- position: `toastnotifications-position-${PluginSettings.store.position || "bottom-left"}`,
- timeout: (PluginSettings.store.timeout * 1000) || 5000,
- opacity: PluginSettings.store.opacity / 100,
- };
-
- const start = useMemo(() => Date.now(), [isHover]); // Reset the timer when the user hovers over the notification.
-
- // Precompute the position style.
- const positionStyle = useMemo(() => {
- if (index === undefined) return {};
- const isTopPosition = AppearanceSettings.position.includes("top");
- const actualHeight = 115; // Update this with the actual height including margin
- const effectiveIndex = index % PluginSettings.store.maxNotifications;
- const offset = 10 + (effectiveIndex * actualHeight); // 10 is the base offset
-
- return isTopPosition ? { top: `${offset}px` } : { bottom: `${offset}px` };
- }, [index, AppearanceSettings.position]);
-
- // Handle notification timeout.
useEffect(() => {
- if (isHover || permanent) return void setElapsed(0);
+ if (isHover || props.permanent) {
+ setElapsed(0);
+ return;
+ }
+ startRef.current = Date.now();
const intervalId = setInterval(() => {
- const elapsed = Date.now() - start;
- if (elapsed >= AppearanceSettings.timeout)
- onClose!();
- else
- setElapsed(elapsed);
+ const next = Date.now() - startRef.current;
+ if (next >= timeout) props.onClose!();
+ else setElapsed(next);
}, 10);
return () => clearInterval(intervalId);
- }, [isHover]);
+ }, [isHover, props.permanent, timeout]);
- const timeoutProgress = elapsed / AppearanceSettings.timeout;
+ const timeoutProgress = elapsed / timeout;
- // Render the notification.
- return (
+ const handleClick = () => {
+ props.onClick?.();
+ if (props.dismissOnClick !== false) props.onClose!();
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Also marks the message as read.
+ if (isMessageNotification(props)) {
+ FluxDispatcher.dispatch({
+ type: "BULK_ACK",
+ context: "APP",
+ channels: [{
+ channelId: props.channel.id,
+ messageId: props.message.id,
+ readStateType: 0
+ }]
+ });
+ }
+
+ props.onClose!();
+ };
+
+ const closeButton = useMemo(() => (
{
- onClick?.();
- if (dismissOnClick !== false)
- onClose!();
- }}
- onContextMenu={e => {
+ className={cl("notification-close-btn")}
+ onClick={e => {
e.preventDefault();
e.stopPropagation();
- onClose!();
+ props.onClose!();
}}
+ >
+
+ Dismiss Notification
+
+
+
+ ), [props.onClose]);
+
+ let content: React.ReactNode;
+ if (isMessageNotification(props)) {
+ content = (
+
+ {renderContextHeader(props.channel)}
+
+
+ );
+ } else {
+ const { title, body, icon } = props;
+ content = (
+
+ {icon &&
}
+
+
+
{title}
+
+ {body && (
+
+ {body.length > 500 ? body.slice(0, 500) + "..." : body}
+
+ )}
+
+
+ );
+ }
+
+ return (
+ setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
-
- {icon &&
}
-
-
-
{title}
-
{
- e.preventDefault();
- e.stopPropagation();
- onClose!();
- }}
- >
-
- Dismiss Notification
-
-
-
-
-
- {renderBody ? (
- richBody ?? (
-
- {body.length > 500 ? body.slice(0, 500) + "..." : body}
-
- )
- ) : null}
- {PluginSettings.store.renderImages && image &&
}
- {footer &&
{`${attachments} attachment${attachments > 1 ? "s" : ""} ${attachments > 1 ? "were" : "was"} sent.`}
}
-
-
-
- {AppearanceSettings.timeout !== 0 && !permanent && (
+ {closeButton}
+ {content}
+ {timeout !== 0 && !props.permanent && (
)}
diff --git a/src/equicordplugins/toastNotifications/components/Notifications.tsx b/src/equicordplugins/toastNotifications/components/Notifications.tsx
index 463d1be36..13b3b1370 100644
--- a/src/equicordplugins/toastNotifications/components/Notifications.tsx
+++ b/src/equicordplugins/toastNotifications/components/Notifications.tsx
@@ -1,24 +1,13 @@
/*
- * 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 .
-*/
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
import { settings as PluginSettings } from "@equicordplugins/toastNotifications/index";
-import { createRoot, React } from "@webpack/common";
-import type { JSX, ReactNode } from "react";
+import { Channel, Message } from "@vencord/discord-types";
+import { createRoot } from "@webpack/common";
+import type { JSX } from "react";
import type { Root } from "react-dom/client";
import NotificationComponent from "./NotificationComponent";
@@ -26,31 +15,50 @@ import NotificationComponent from "./NotificationComponent";
let NotificationQueue: JSX.Element[] = [];
let notificationID = 0;
let RootContainer: Root | undefined;
+let ToastContainer: HTMLDivElement | undefined;
function getNotificationContainer() {
+ // If the root container doesn't exist, create it.
if (!RootContainer) {
- const container = document.createElement("div");
- container.id = "toastnotifications-container";
- document.body.append(container);
- RootContainer = createRoot(container);
+ ToastContainer = document.createElement("div");
+ ToastContainer.id = "vc-toast-notifications-container";
+ document.body.append(ToastContainer);
+ RootContainer = createRoot(ToastContainer);
+ }
+
+ // Keep the container's position class in sync with the user's setting.
+ if (ToastContainer) {
+ ToastContainer.className = `vc-toast-notifications-position-${PluginSettings.store.position ?? "bottom-left"}`;
}
return RootContainer;
}
-export interface NotificationData {
- title: string; // Title to display in the notification.
- body: string; // Notification body text.
- richBody?: ReactNode; // Same as body, though a rich ReactNode to be rendered within the notification.
- icon?: string; // Avatar image of the message author or source.
- image?: string; // Large image to display in the notification for attachments.
- permanent?: boolean; // Whether or not the notification should be permanent or timeout.
- dismissOnClick?: boolean; // Whether or not the notification should be dismissed when clicked.
- attachments: number;
+export function setContainerPosition(position: string) {
+ if (ToastContainer) ToastContainer.className = `vc-toast-notifications-position-${position ?? "bottom-left"}`;
+}
+
+interface BaseNotification {
+ permanent?: boolean;
+ dismissOnClick?: boolean;
onClick?(): void;
onClose?(): void;
}
+export interface MessageNotification extends BaseNotification {
+ message: Message;
+ mockedMessage: Message;
+ channel: Channel;
+}
+
+export interface SystemNotification extends BaseNotification {
+ title: string;
+ body: string;
+ icon?: string;
+}
+
+export type NotificationData = MessageNotification | SystemNotification;
+
export async function showNotification(notification: NotificationData) {
const root = getNotificationContainer();
const thisNotificationID = notificationID++;
@@ -59,44 +67,34 @@ export async function showNotification(notification: NotificationData) {
const ToastNotification = (
{
- // Remove this notification from the queue.
NotificationQueue = NotificationQueue.filter(n => n.key !== thisNotificationID.toString());
- notification.onClose?.(); // Trigger the onClose callback if it exists.
- console.log(`[DEBUG] [ToastNotifications] Removed #${thisNotificationID} from queue.`);
-
- // Re-render remaining notifications with new reversed indices.
- root.render(
- <>
- {NotificationQueue.map((notification, index) => {
- const reversedIndex = (NotificationQueue.length - 1) - index;
- return React.cloneElement(notification, { index: reversedIndex });
- })}
- >
- );
-
+ notification.onClose?.();
+ root.render(<>{NotificationQueue}>);
resolve();
}}
/>
);
- // Add this notification to the queue.
+ // Push this notification into the stack.
NotificationQueue.push(ToastNotification);
- console.log(`[DEBUG] [ToastNotifications] Added #${thisNotificationID} to queue.`);
- // Limit the number of notifications to the configured maximum.
+ // If the queue exceeds the maximum number of notifications, remove the oldest one.
if (NotificationQueue.length > PluginSettings.store.maxNotifications) NotificationQueue.shift();
- // Render the notifications.
- root.render(
- <>
- {NotificationQueue.map((notification, index) => {
- const reversedIndex = (NotificationQueue.length - 1) - index;
- return React.cloneElement(notification, { index: reversedIndex });
- })}
- >
- );
+ root.render(<>{NotificationQueue}>);
});
}
+
+/**
+ * Tears down the notification root and removes the container from the DOM.
+ * Called when the plugin is disabled.
+ */
+export function teardownNotifications() {
+ NotificationQueue = [];
+ RootContainer?.unmount();
+ RootContainer = undefined;
+ ToastContainer?.remove();
+ ToastContainer = undefined;
+}
diff --git a/src/equicordplugins/toastNotifications/components/styles.css b/src/equicordplugins/toastNotifications/components/styles.css
index 81828da13..ffc89022b 100644
--- a/src/equicordplugins/toastNotifications/components/styles.css
+++ b/src/equicordplugins/toastNotifications/components/styles.css
@@ -1,151 +1,226 @@
:root {
/* Body */
- --toastnotifications-background-color: var(--background-base-lower-alt);
- --toastnotifications-text-color: var(--text-default);
- --toastnotifications-border-radius: 6px;
- --toastnotifications-width: 25vw;
- --toastnotifications-padding: 1.25rem;
+ --vc-toast-notifications-background-color: var(--background-base-lower-alt);
+ --vc-toast-notifications-text-color: var(--text-default);
+ --vc-toast-notifications-border-radius: 6px;
+ --vc-toast-notifications-width: fit-content;
+ --vc-toast-notifications-min-width: 25rem;
+ --vc-toast-notifications-max-width: 50%;
+ --vc-toast-notifications-min-height: 10vh;
+ --vc-toast-notifications-max-height: 50%;
+ --vc-toast-notifications-padding: 1rem;
/* Title */
- --toastnotifications-title-color: var(--text-strong);
- --toastnotifications-title-font-size: 1rem;
- --toastnotifications-title-font-weight: 600;
- --toastnotifications-title-line-height: 1.25rem;
+ --vc-toast-notifications-title-color: var(--text-strong);
+ --vc-toast-notifications-title-font-size: 1rem;
+ --vc-toast-notifications-title-font-weight: 600;
+ --vc-toast-notifications-title-line-height: 1.25rem;
/* Close Button */
- --toastnotifications-close-button-color: var(--interactive-icon-default);
- --toastnotifications-close-button-hover-color: var(--interactive-icon-hover);
- --toastnotifications-close-button-opacity: 0.5;
- --toastnotifications-close-button-hover-opacity: 1;
+ --vc-toast-notifications-close-button-color: var(--interactive-icon-default);
+ --vc-toast-notifications-close-button-hover-color: var(--interactive-icon-hover);
+ --vc-toast-notifications-close-button-opacity: 0.5;
+ --vc-toast-notifications-close-button-hover-opacity: 1;
/* Message Author Image */
- --toastnotifications-image-height: 4rem;
- --toastnotifications-image-width: var(--toastnotifications-image-height);
- --toastnotifications-image-border-radius: 6px;
+ --vc-toast-notifications-image-height: 4rem;
+ --vc-toast-notifications-image-width: var(--vc-toast-notifications-image-height);
+ --vc-toast-notifications-image-border-radius: 6px;
/* Progress Bar */
- --toastnotifications-progressbar-height: 0.25rem;
- --toastnotifications-progressbar-color: var(--brand-500);
+ --vc-toast-notifications-progressbar-height: 0.25rem;
+ --vc-toast-notifications-progressbar-color: var(--brand-500);
/* Position Offset - Global inherited offset by all positions */
- --toastnotifications-position-offset: 1rem;
+ --vc-toast-notifications-position-offset: 1rem;
}
-.toastnotifications-notification-root {
+#vc-toast-notifications-container {
+ position: fixed;
+ z-index: 2147483647;
+ display: flex;
+ gap: 0.75rem;
+ pointer-events: none;
+ max-height: calc(100vh - var(--vc-toast-notifications-position-offset) * 2);
+}
+
+#vc-toast-notifications-container.vc-toast-notifications-position-bottom-left,
+#vc-toast-notifications-container.vc-toast-notifications-position-bottom-right {
+ flex-direction: column;
+ justify-content: flex-end;
+}
+
+#vc-toast-notifications-container.vc-toast-notifications-position-top-left,
+#vc-toast-notifications-container.vc-toast-notifications-position-top-right {
+ flex-direction: column-reverse;
+ justify-content: flex-start;
+}
+
+.vc-toast-notifications-notification-root {
all: unset;
+ pointer-events: auto;
+ position: relative;
display: flex;
flex-direction: column;
- color: var(--toastnotifications-text-color);
- background-color: var(--toastnotifications-background-color);
- border-radius: var(--toastnotifications-border-radius);
+ color: var(--vc-toast-notifications-text-color);
+ background-color: var(--vc-toast-notifications-background-color);
+ border-radius: var(--vc-toast-notifications-border-radius);
overflow: hidden;
cursor: pointer;
- position: absolute;
- z-index: 2147483647;
- right: 1rem;
- width: var(--toastnotifications-width);
- min-height: 10vh;
- bottom: calc(1rem + var(--notification-index) * 12vh);
+ min-width: var(--vc-toast-notifications-min-width);
+ width: var(--vc-toast-notifications-width);
+ max-width: var(--vc-toast-notifications-max-width);
+ min-height: var(--vc-toast-notifications-min-height);
+ max-height: var(--vc-toast-notifications-max-height);
+ flex-shrink: 0;
}
-.toastnotifications-notification {
+.vc-toast-notifications-notification-content {
display: flex;
- flex-direction: row;
- padding: var(--toastnotifications-padding);
- gap: 1.25rem;
-}
-
-.toastnotifications-notification-content {
+ flex-direction: column;
+ padding: var(--vc-toast-notifications-padding) 0.2rem;
width: 100%;
}
-.toastnotifications-notification-header {
+.vc-toast-notifications-context-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--vc-toast-notifications-title-color);
+ font-size: var(--vc-toast-notifications-title-font-size);
+ font-weight: var(--vc-toast-notifications-title-font-weight);
+ line-height: var(--vc-toast-notifications-title-line-height);
+ padding: 0 0.8rem 0.5rem;
+}
+
+.vc-toast-notifications-context-header-icon {
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.vc-toast-notifications-context-header-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.vc-toast-notifications-context-header-separator {
+ color: var(--text-muted);
+ font-weight: 400;
+}
+
+.vc-toast-notifications-context-header-channel {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+/* System-mode notifications (friend activity, example) */
+.vc-toast-notifications-notification-system {
+ display: flex;
+ flex-direction: row;
+ padding: var(--vc-toast-notifications-padding);
+ gap: 1.25rem;
+ width: 100%;
+}
+
+.vc-toast-notifications-notification-system .vc-toast-notifications-notification-content {
+ display: block;
+ padding: 0;
+ flex: 1;
+ min-width: 0;
+}
+
+.vc-toast-notifications-notification-header {
display: flex;
justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.5rem;
}
-.toastnotifications-notification-title {
- color: var(--toastnotifications-title-color);
- font-size: var(--toastnotifications-title-font-size);
- font-weight: var(--toastnotifications-title-font-weight);
- line-height: var(--toastnotifications-title-line-height);
+.vc-toast-notifications-notification-title {
+ color: var(--vc-toast-notifications-title-color);
+ font-size: var(--vc-toast-notifications-title-font-size);
+ font-weight: var(--vc-toast-notifications-title-font-weight);
+ line-height: var(--vc-toast-notifications-title-line-height);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.toastnotifications-notification-close-btn {
+.vc-toast-notifications-notification-close-btn {
all: unset;
cursor: pointer;
- color: var(--toastnotifications-close-button-color);
- opacity: var(--toastnotifications-close-button-opacity);
+ color: var(--vc-toast-notifications-close-button-color);
+ opacity: var(--vc-toast-notifications-close-button-opacity);
transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out;
+ position: absolute;
+ top: 0.4rem;
+ right: 0.4rem;
+ z-index: 1;
+ width: 1.5rem;
+ height: 1.5rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background-color: var(--vc-toast-notifications-background-color);
}
-.toastnotifications-notification-close-btn:hover {
- color: var(--toastnotifications-close-button-hover-color);
- opacity: var(--toastnotifications-close-button-hover-opacity);
+.vc-toast-notifications-notification-close-btn svg {
+ width: 1.25rem;
+ height: 1.25rem;
}
-.toastnotifications-notification-icon {
- height: var(--toastnotifications-image-height);
- width: var(--toastnotifications-image-width);
- border-radius: var(--toastnotifications-image-border-radius);
+.vc-toast-notifications-notification-close-btn:hover {
+ color: var(--vc-toast-notifications-close-button-hover-color);
+ opacity: var(--vc-toast-notifications-close-button-hover-opacity);
}
-.toastnotifications-notification-progressbar {
- height: var(--toastnotifications-progressbar-height);
+.vc-toast-notifications-notification-icon {
+ height: var(--vc-toast-notifications-image-height);
+ width: var(--vc-toast-notifications-image-width);
+ border-radius: var(--vc-toast-notifications-image-border-radius);
+}
+
+.vc-toast-notifications-notification-progressbar {
+ height: var(--vc-toast-notifications-progressbar-height);
+ background-color: var(--vc-toast-notifications-progressbar-color);
border-radius: 5px;
margin-top: auto;
}
-.toastnotifications-notification-p {
+.vc-toast-notifications-notification-p {
margin: 0.5rem 0 0;
margin-bottom: 3px;
line-height: 140%;
- word-break: break-all;
+ overflow-wrap: anywhere;
}
-.toastnotifications-notification-footer {
- margin: 0;
- margin-top: 4px;
- line-height: 140%;
- font-size: 10px;
+/* Container positioning. */
+#vc-toast-notifications-container.vc-toast-notifications-position-bottom-left {
+ bottom: var(--vc-toast-notifications-position-offset);
+ left: var(--vc-toast-notifications-position-offset);
}
-.toastnotifications-notification-img {
- width: 75%;
- border-radius: 3px;
+#vc-toast-notifications-container.vc-toast-notifications-position-top-left {
+ top: var(--vc-toast-notifications-position-offset);
+ left: var(--vc-toast-notifications-position-offset);
}
-/* Notification Positioning CSS */
-.toastnotifications-position-bottom-left {
- bottom: var(--toastnotifications-position-offset);
- left: var(--toastnotifications-position-offset);
+#vc-toast-notifications-container.vc-toast-notifications-position-top-right {
+ top: var(--vc-toast-notifications-position-offset);
+ right: var(--vc-toast-notifications-position-offset);
}
-.toastnotifications-position-top-left {
- top: var(--toastnotifications-position-offset);
- left: var(--toastnotifications-position-offset);
+#vc-toast-notifications-container.vc-toast-notifications-position-bottom-right {
+ bottom: var(--vc-toast-notifications-position-offset);
+ right: var(--vc-toast-notifications-position-offset);
}
-.toastnotifications-position-top-right {
- top: var(--toastnotifications-position-offset);
- right: var(--toastnotifications-position-offset);
-}
-
-.toastnotifications-position-bottom-right {
- bottom: var(--toastnotifications-position-offset);
- right: var(--toastnotifications-position-offset);
-}
-
-/* Rich Body classes */
-.toastnotifications-mention-class {
- color: var(--mention-foreground);
- background: var(--mention-background);
- /* stylelint-disable-next-line value-no-vendor-prefix */
- unicode-bidi: -moz-plaintext;
- unicode-bidi: plaintext;
- font-weight: 500;
-}
-
-.visual-refresh .toastnotifications-notification-root {
+.visual-refresh .vc-toast-notifications-notification-root {
background-color: var(--background-base-low);
}
\ No newline at end of file
diff --git a/src/equicordplugins/toastNotifications/index.tsx b/src/equicordplugins/toastNotifications/index.tsx
index dfa961176..9100341f9 100644
--- a/src/equicordplugins/toastNotifications/index.tsx
+++ b/src/equicordplugins/toastNotifications/index.tsx
@@ -1,49 +1,38 @@
/*
- * 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 .
-*/
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
import { definePluginSettings } from "@api/Settings";
+import { Button } from "@components/Button";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { makeRange, OptionType } from "@utils/types";
-import { Channel, Message, User } from "@vencord/discord-types";
-import { MessageType, RelationshipType } from "@vencord/discord-types/enums";
+import { Channel, Message } from "@vencord/discord-types";
import { findByPropsLazy, findStore } from "@webpack";
-import { Button, ChannelStore, GuildRoleStore, IconUtils, NavigationRouter, RelationshipStore, SelectedChannelStore, StreamerModeStore, UserStore } from "@webpack/common";
-import { ReactNode } from "react";
+import { ChannelStore, IconUtils, MessageStore, NavigationRouter, PresenceStore, RelationshipStore, SelectedChannelStore, StreamerModeStore, UserStore } from "@webpack/common";
-import { NotificationData, showNotification } from "./components/Notifications";
-import { StreamingTreatment } from "./types";
+import { setContainerPosition, showNotification, teardownNotifications } from "./components/Notifications";
+
+const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled");
+const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel");
+const ChannelRTCActions = findByPropsLazy("updateChatOpen", "toggleParticipants");
+
+const ID_REGEX = /^\d{17,20}$/;
let ignoredUsers: string[] = [];
let notifyFor: string[] = [];
-// Functional variables.
-const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled");
-const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel");
-const ChannelRTCActions = findByPropsLazy("updateChatOpen", "toggleParticipants");
-const UserUtils = findByPropsLazy("getGlobalName");
-
-// Adjustable variables.
-const USER_MENTION_REGEX = /<@!?(\d{17,20})>|<#(\d{17,20})>|<@&(\d{17,20})>/g; // This regex captures user, channel, and role mentions.
+enum NotificationLevel {
+ ALL_MESSAGES = 0,
+ ONLY_MENTIONS = 1,
+ NO_MESSAGES = 2
+}
export const settings = definePluginSettings({
position: {
type: OptionType.SELECT,
- description: "The position of the toast notification",
+ description: "The position of the toast notification.",
options: [
{
label: "Bottom Left",
@@ -62,530 +51,186 @@ export const settings = definePluginSettings({
label: "Bottom Right",
value: "bottom-right"
},
- ]
+ ],
+ onChange: value => setContainerPosition(value),
},
timeout: {
type: OptionType.SLIDER,
- description: "Time in seconds notifications will be shown for",
+ description: "Time in seconds notifications will be shown for.",
default: 5,
markers: makeRange(1, 15, 1)
},
opacity: {
type: OptionType.SLIDER,
- description: "Opacity of the notification",
+ description: "The visible opacity of the notification.",
default: 100,
markers: makeRange(10, 100, 10)
},
maxNotifications: {
type: OptionType.SLIDER,
- description: "Maximum number of notifications displayed at once",
+ description: "Maximum number of notifications displayed at once.",
default: 3,
markers: makeRange(1, 5, 1)
},
- determineServerNotifications: {
- type: OptionType.BOOLEAN,
- description: "Automatically determine what server notifications to show based on your channel/guild settings",
- default: true
- },
disableInStreamerMode: {
type: OptionType.BOOLEAN,
- description: "Disable notifications while in streamer mode",
+ description: "Do not show notifications when streamer mode is enabled.",
default: true
},
- renderImages: {
+ respectDoNotDisturb: {
type: OptionType.BOOLEAN,
- description: "Render images in notifications",
- default: true
+ description: "Do not show notifications when your status is Do Not Disturb.",
+ default: false
},
directMessages: {
type: OptionType.BOOLEAN,
- description: "Show notifications for direct messages",
+ description: "Show notifications for direct messages.",
default: true
},
groupMessages: {
type: OptionType.BOOLEAN,
- description: "Show notifications for group messages",
+ description: "Show notifications for group messages.",
default: true
},
friendServerNotifications: {
type: OptionType.BOOLEAN,
- description: "Show notifications when friends send messages in servers they share with you",
+ description: "Show notifications when friends send messages in servers they share with you.",
default: true
},
- friendActivity: {
- type: OptionType.BOOLEAN,
- description: "Show notifications for adding someone or receiving a friend request",
- default: true
- },
- streamingTreatment: {
- type: OptionType.SELECT,
- description: "How to treat notifications while sharing your screen",
- options: [
- {
- label: "Normal - Show the notification as normal",
- value: StreamingTreatment.NORMAL,
- default: true
- },
- {
- label: "No Content - Hide the notification body",
- value: StreamingTreatment.NO_CONTENT
- },
- {
- label: "Ignore - Don't show the notification at all",
- value: StreamingTreatment.IGNORE
- }
- ]
- },
- notifyFor: {
- type: OptionType.STRING,
- description: "Create a list of channel ids to receive notifications from (separate with commas)",
- onChange: () => { notifyFor = stringToList(settings.store.notifyFor); },
- default: ""
- },
ignoreUsers: {
type: OptionType.STRING,
- description: "Create a list of user ids to ignore all their notifications from (separate with commas)",
- onChange: () => { ignoredUsers = stringToList(settings.store.ignoreUsers); },
- default: ""
+ description: "A list of user IDs (separate by commas) to ignore displaying notifications for.",
+ onChange: () => { ignoredUsers = parseIdList(settings.store.ignoreUsers); },
+ default: "",
+ placeholder: "000000000000000000,111111111111111111,222222222222222222"
+ },
+ notifyFor: {
+ type: OptionType.STRING,
+ description: "A list of channel IDs (separate by commas) to always receive notifications from.",
+ onChange: () => { notifyFor = parseIdList(settings.store.notifyFor); },
+ default: "",
+ placeholder: "000000000000000000,111111111111111111,222222222222222222"
},
exampleButton: {
type: OptionType.COMPONENT,
description: "Show an example toast notification.",
- component: () =>
-
- Show Example Notification
-
+ component: () => Show Example Notification
}
});
-function stringToList(str: string): string[] {
- if (str !== "") {
- const array: string[] = [];
- const string = str.replace(/\s/g, "");
- const splitArray = string.split(",");
- splitArray.forEach(id => {
- array.push(id);
- });
-
- return array;
- }
- return [];
-}
-
-function limitMessageLength(body: string, hasAttachments: boolean): string {
- if (hasAttachments) {
- if (body?.length > 30) {
- return body.substring(0, 27) + "...";
- }
- }
-
- if (body?.length > 165) {
- return body.substring(0, 162) + "...";
- }
-
- return body;
-}
-
-function getName(user: User): string {
- return RelationshipStore.getNickname(user.id) ?? UserUtils.getName(user);
-}
-
-const addMention = (id: string, type: string, guildId?: string): ReactNode => {
- let name;
- if (type === "user")
- name = `@${UserStore.getUser(id)?.username || "unknown-user"}`;
- else if (type === "channel")
- name = `#${ChannelStore.getChannel(id)?.name || "unknown-channel"}`;
- else if (type === "role" && guildId)
- name = `@${GuildRoleStore.getRole(guildId, id)?.name || "unknown-role"}`;
-
- // Return the mention as a styled span.
- return (
-
- {name}
-
- );
-};
-
export default definePlugin({
name: "ToastNotifications",
- description: "Show a toast notification whenever you receive a direct message.",
+ description: "Show a pop-up toast notification, configurable for DMs, group, friends, or guild channels.",
tags: ["Appearance", "Customisation", "Notifications"],
authors: [EquicordDevs.Skully, EquicordDevs.Ethan, EquicordDevs.Buzzy],
settings,
flux: {
- async MESSAGE_CREATE({ message }: { message: Message; }) {
-
+ MESSAGE_CREATE({ message }: { message: Message; }) {
const channel: Channel = ChannelStore.getChannel(message.channel_id);
+ if (!channel) return;
+
const currentUser = UserStore.getCurrentUser();
- const isStreaming = findStore("ApplicationStreamingStore").getAnyStreamForUser(UserStore.getCurrentUser()?.id);
-
- const streamerMode = settings.store.disableInStreamerMode;
- const currentUserStreamerMode = StreamerModeStore.enabled;
-
- if (streamerMode && currentUserStreamerMode) return;
- if (isStreaming && settings.store.streamingTreatment === StreamingTreatment.IGNORE) return;
-
+ // Check global conditions for all message types.
if (
- (
- (message.author.id === currentUser.id) // If message is from the user.
- || (channel.id === SelectedChannelStore.getChannelId()) // If the user is currently in the channel.
- || (ignoredUsers.includes(message.author.id)) // If the user is ignored.
- )
+ (message.author.id === currentUser.id) // If message is from the user.
+ || (channel.id === SelectedChannelStore.getChannelId()) // If the user is currently in the channel.
+ || (ignoredUsers.includes(message.author.id)) // If the user is ignored.
+ || (settings.store.respectDoNotDisturb && PresenceStore.getStatus(currentUser.id) === "dnd") // If notifications are disabled while in DND.
+ || (settings.store.disableInStreamerMode && StreamerModeStore.enabled) // If notifications are disabled in streamer mode.
) return;
- if (channel.guild_id) { // If this is a guild message and not a private message.
- handleGuildMessage(message);
- return;
+ // Channel type checks.
+ if (channel.guild_id) {
+ if (!shouldNotifyForGuildMessage(message, channel)) return;
+ } else {
+ if (
+ (!settings.store.directMessages && channel.isDM()) // If DM notifications are disabled.
+ || (!settings.store.groupMessages && channel.isGroupDM()) // If group DM notifications are disabled.
+ || MuteStore.isChannelMuted(null, channel.id) // If the user has muted the DM/group channel.
+ ) return;
}
- if (!settings.store.directMessages && channel.isDM() || !settings.store.groupMessages && channel.isGroupDM() || MuteStore.isChannelMuted(null, channel.id)) return;
+ // Retrieve the message component for the message.
+ const mockedMessage: Message | undefined = MessageStore.getMessages(message.channel_id)?.receiveMessage(message)?.get(message.id);
+ if (!mockedMessage) return console.error(`[ToastNotifications] Failed to retrieve mocked message from MessageStore for message ID ${message.id}!`);
- // Prepare the notification.
- const Notification: NotificationData = {
- title: getName(message.author),
- icon: IconUtils.getUserAvatarURL(message.author),
- body: message.content,
- attachments: message.attachments?.length,
- richBody: null,
- permanent: false,
- onClick() { SelectedChannelActionCreators.selectPrivateChannel(message.channel_id); }
- };
-
- const notificationText = message.content?.length > 0 ? message.content : false;
- const richBodyElements: ReactNode[] = [];
-
- // If this channel is a group DM, include the channel name.
- if (channel.isGroupDM()) {
- let channelName = channel.name?.trim() ?? false;
- if (!channelName) { // If the channel doesn't have a set name, use the first 3 recipients.
- channelName = channel.rawRecipients.slice(0, 3).map(e => e.username).join(", ");
- }
-
- // Finally, truncate the channel name if it's too long.
- const truncatedChannelName = channelName?.length > 20 ? channelName.substring(0, 20) + "..." : channelName;
- Notification.title = `${message.author.username} (${truncatedChannelName})`;
- }
- else if (channel.guild_id) // If this is a guild message and not a private message.
- {
- Notification.title = `${getName(message.author)} (#${channel.name})`;
- }
-
- // Handle specific message types.
- switch (message.type) {
- case MessageType.CALL: {
- Notification.body = "Started a call with you!";
- break;
- }
- case MessageType.RECIPIENT_ADD: {
- const actor = UserStore.getUser(message.author.id);
- const user = message.mentions[0];
- const targetUser = UserStore.getUser((user as any).id);
-
- Notification.body = `${getName(targetUser)} was added to the group by ${getName(actor)}.`;
- break;
- }
- case MessageType.RECIPIENT_REMOVE: {
- const actor = UserStore.getUser(message.author.id);
- const user = message.mentions[0];
- const targetUser = UserStore.getUser((user as any).id);
-
- if (actor.id !== targetUser.id) {
- Notification.body = `${getName(targetUser)} was removed from the group by ${getName(actor)}.`;
- } else {
- Notification.body = "Left the group.";
- }
- break;
- }
- case MessageType.CHANNEL_NAME_CHANGE: {
- Notification.body = `Changed the channel name to '${message.content}'.`;
- break;
- }
- case MessageType.CHANNEL_ICON_CHANGE: {
- Notification.body = "Changed the channel icon.";
- break;
- }
- case MessageType.CHANNEL_PINNED_MESSAGE: {
- Notification.body = "Pinned a message.";
- break;
- }
- }
-
- // Message contains an embed.
- if (message.embeds?.length !== 0) {
- Notification.body = notificationText || "Sent an embed.";
- }
-
- // Message contains a sticker.
- if (message?.stickerItems) {
- Notification.body = notificationText || "Sent a sticker.";
- }
-
- // Message contains an attachment.
- if (message.attachments?.length !== 0) {
- const images = message.attachments.filter(e => typeof e?.content_type === "string" && e?.content_type.startsWith("image"));
- // Label the notification with the attachment type.
- if (images?.length !== 0) {
- Notification.body = notificationText || ""; // Dont show any body
- Notification.image = images[0].url;
- } else {
- Notification.body += ` [Attachment: ${message.attachments[0].filename}]`;
- }
- }
-
- // TODO: Format emotes properly.
- const matches = Notification.body.match(new RegExp("()", "g"));
- if (matches) {
- for (const match of matches) {
- Notification.body = Notification.body.replace(new RegExp(`${match}`, "g"), `:${match.split(":")[1]}:`);
- }
- }
-
- // Replace any mention of users, roles and channels.
- if (message.mentions?.length !== 0 || message.mentionRoles?.length > 0) {
- let lastIndex = 0;
- Notification.body.replace(USER_MENTION_REGEX, (match, userId, channelId, roleId, offset) => {
- richBodyElements.push(Notification.body.slice(lastIndex, offset));
-
- // Add the mention itself as a styled span.
- if (userId) {
- richBodyElements.push(addMention(userId, "user"));
- } else if (channelId) {
- richBodyElements.push(addMention(channelId, "channel"));
- } else if (roleId) {
- richBodyElements.push(addMention(roleId, "role", channel.guild_id));
- }
-
- lastIndex = offset + match?.length;
- return match; // This value is not used but is necessary for the replace function
- });
- }
-
- if (richBodyElements?.length > 0) {
- const MyRichBodyComponent = () => <>{richBodyElements}>;
- Notification.richBody = ;
- }
-
- Notification.body = limitMessageLength(Notification.body, Notification.attachments > 0);
-
- if (isStreaming && settings.store.streamingTreatment === StreamingTreatment.NO_CONTENT) {
- Notification.body = "Message content has been redacted.";
- }
-
- if (!settings.store.renderImages) {
- Notification.icon = undefined;
- }
-
- showNotification(Notification);
- },
-
- async RELATIONSHIP_ADD({ relationship }) {
- if (ignoredUsers.includes(relationship.user.id)) return;
- relationshipAdd(relationship.user, relationship.type);
+ showNotification({
+ message,
+ mockedMessage,
+ channel,
+ onClick: () => channel.guild_id
+ ? navigateToChannel(channel)
+ : SelectedChannelActionCreators.selectPrivateChannel(channel.id)
+ });
}
},
start() {
- ignoredUsers = stringToList(settings.store.ignoreUsers);
- notifyFor = stringToList(settings.store.notifyFor);
+ ignoredUsers = parseIdList(settings.store.ignoreUsers);
+ notifyFor = parseIdList(settings.store.notifyFor);
+ },
+
+ stop() {
+ teardownNotifications();
+ ignoredUsers = [];
+ notifyFor = [];
}
});
-function switchChannels(guildId: string | null, channelId: string) {
- if (!ChannelStore.hasChannel(channelId)) return;
- NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/`);
+/**
+ * Splits a comma-separated string into a list of valid Discord snowflake IDs.
+ */
+function parseIdList(str: string): string[] {
+ if (!str) return [];
+ return str.replace(/\s/g, "").split(",").filter(id => ID_REGEX.test(id));
}
+/**
+ * Navigates the client to the given channel, opening the chat panel for voice channels.
+ */
function navigateToChannel(channel: Channel) {
- if (channel.isGuildVocal?.()) {
- switchChannels(channel.guild_id, channel.id);
- ChannelRTCActions.updateChatOpen(channel.id, true);
- return;
- }
-
- switchChannels(channel.guild_id, channel.id);
+ if (!ChannelStore.hasChannel(channel.id)) return;
+ NavigationRouter.transitionTo(`/channels/${channel.guild_id ?? "@me"}/${channel.id}/`);
+ if (channel.isGuildVocal?.()) ChannelRTCActions.updateChatOpen(channel.id, true);
}
-enum NotificationLevel {
- ALL_MESSAGES = 0,
- ONLY_MENTIONS = 1,
- NO_MESSAGES = 2
-}
+/**
+ * Decides whether a guild message should trigger a notification based on the
+ * channel allowlist, friend-server-notifications setting, channel/guild mutes,
+ * and the user's configured notification level for the channel/guild.
+ */
+function shouldNotifyForGuildMessage(message: Message, channel: Channel): boolean {
+ // Allowlist always wins.
+ if (notifyFor.includes(channel.id)) return true;
-function findNotificationLevel(channel: Channel): NotificationLevel {
- const store = findStore("UserGuildSettingsStore");
- const userGuildSettings = store.getAllSettings().userGuildSettings[channel.guild_id];
+ // Friend-in-server toggle.
+ if (settings.store.friendServerNotifications && RelationshipStore.isFriend(message.author.id)) return true;
- if (!settings.store.determineServerNotifications || MuteStore.isGuildOrCategoryOrChannelMuted(channel.guild_id, channel.id)) {
- return NotificationLevel.NO_MESSAGES;
- }
+ // Respect the user's mute state for the guild/category/channel.
+ if (MuteStore.isGuildOrCategoryOrChannelMuted(channel.guild_id, channel.id)) return false;
- if (userGuildSettings) {
- const channelOverrides = userGuildSettings.channel_overrides?.[channel.id];
- const guildDefault = userGuildSettings.message_notifications;
+ // Resolve the user's configured notification level for the channel/guild.
+ const userGuildSettings = findStore("UserGuildSettingsStore").getAllSettings().userGuildSettings[channel.guild_id];
+ if (!userGuildSettings) return false;
- // Check if channel overrides exist and are in the expected format
- if (channelOverrides && typeof channelOverrides === "object" && "message_notifications" in channelOverrides) {
- return channelOverrides.message_notifications;
- }
+ const channelOverride = userGuildSettings.channel_overrides?.[channel.id];
+ const level: NotificationLevel = (channelOverride && typeof channelOverride === "object" && "message_notifications" in channelOverride)
+ ? channelOverride.message_notifications
+ : (typeof userGuildSettings.message_notifications === "number" ? userGuildSettings.message_notifications : NotificationLevel.NO_MESSAGES);
- // Check if guild default is in the expected format
- if (typeof guildDefault === "number") {
- return guildDefault;
- }
- }
+ if (level === NotificationLevel.NO_MESSAGES) return false;
+ if (level === NotificationLevel.ALL_MESSAGES) return true;
- // Return a default value if no valid overrides or guild default is found
- return NotificationLevel.NO_MESSAGES;
-}
-
-async function handleGuildMessage(message: Message) {
- const c = ChannelStore.getChannel(message.channel_id);
- const notificationLevel: number = findNotificationLevel(c);
- let t = false;
- // 0: All messages 1: Only mentions 2: No messages
- // todo: check if the user who sent it is a friend
- const all = notifyFor.includes(message.channel_id);
- const friend = settings.store.friendServerNotifications && RelationshipStore.isFriend(message.author.id);
-
- if (!all && !friend) {
- t = true;
- const isMention: boolean = message.content.includes(`<@${UserStore.getCurrentUser().id}>`);
- const meetsMentionCriteria = notificationLevel !== NotificationLevel.ALL_MESSAGES && !isMention;
-
- if (notificationLevel === NotificationLevel.NO_MESSAGES || meetsMentionCriteria) return;
- }
-
- const channel: Channel = ChannelStore.getChannel(message.channel_id);
-
- const notificationText = message.content.length > 0 ? message.content : false;
- const richBodyElements: React.ReactNode[] = [];
-
- // Prepare the notification.
- const Notification: NotificationData = {
- title: `${getName(message.author)} (#${channel.name})`,
- icon: IconUtils.getUserAvatarURL(message.author),
- body: message.content,
- attachments: message.attachments?.length,
- richBody: null,
- permanent: false,
- onClick() { navigateToChannel(channel); }
- };
-
- if (message.embeds?.length !== 0) {
- Notification.body = notificationText || "Sent an embed.";
- }
-
- // Message contains a sticker.
- if (message?.stickerItems) {
- Notification.body = notificationText || "Sent a sticker.";
- }
-
- // Message contains an attachment.
- if (message.attachments?.length !== 0) {
- const images = message.attachments.filter(e => typeof e?.content_type === "string" && e?.content_type.startsWith("image"));
- // Label the notification with the attachment type.
- if (images?.length !== 0) {
- Notification.body = notificationText || ""; // Dont show any body
- Notification.image = images[0].url;
- } else {
- Notification.body += ` [Attachment: ${message.attachments[0].filename}]`;
- }
- }
-
- // TODO: Format emotes properly.
- const matches = Notification.body.match(new RegExp("( )", "g"));
- if (matches) {
- for (const match of matches) {
- Notification.body = Notification.body.replace(new RegExp(`${match}`, "g"), `:${match.split(":")[1]}:`);
- }
- }
-
- // Replace any mention of users, roles and channels.
- if (message.mentions?.length !== 0 || message.mentionRoles?.length > 0) {
- let lastIndex = 0;
- Notification.body.replace(USER_MENTION_REGEX, (match, userId, channelId, roleId, offset) => {
- richBodyElements.push(Notification.body.slice(lastIndex, offset));
-
- // Add the mention itself as a styled span.
- if (userId) {
- richBodyElements.push(addMention(userId, "user"));
- } else if (channelId) {
- richBodyElements.push(addMention(channelId, "channel"));
- } else if (roleId) {
- richBodyElements.push(addMention(roleId, "role", channel.guild_id));
- }
-
- lastIndex = offset + match?.length;
- return match; // This value is not used but is necessary for the replace function
- });
- }
-
- if (richBodyElements?.length > 0) {
- const MyRichBodyComponent = () => <>{richBodyElements}>;
- Notification.richBody = ;
- }
-
- Notification.body = limitMessageLength(Notification.body, Notification.attachments > 0);
-
- const isStreaming = findStore("ApplicationStreamingStore").getAnyStreamForUser(UserStore.getCurrentUser()?.id);
-
- if (isStreaming && settings.store.streamingTreatment === StreamingTreatment.NO_CONTENT) {
- Notification.body = "Message content has been redacted.";
- }
-
- if (!settings.store.renderImages) {
- Notification.icon = undefined;
- }
-
- await showNotification(Notification);
-
-}
-
-async function relationshipAdd(user: User, type: Number) {
- user = UserStore.getUser(user.id);
- if (!settings.store.friendActivity) return;
-
- const Notification: NotificationData = {
- title: "",
- icon: IconUtils.getUserAvatarURL(user),
- body: "",
- attachments: 0,
- };
-
- if (!settings.store.renderImages) {
- Notification.icon = undefined;
- }
-
- if (type === RelationshipType.FRIEND) {
- Notification.title = `${user.username} is now your friend`;
- Notification.body = "You can now message them directly.";
- Notification.onClick = () => switchChannels(null, user.id);
-
- await showNotification(Notification);
-
- } else if (type === RelationshipType.INCOMING_REQUEST) {
-
- Notification.title = `${user.username} sent you a friend request`;
- Notification.body = "You can accept or decline it in the Friends tab.";
- Notification.onClick = () => switchChannels(null, "");
-
- await showNotification(Notification);
- }
+ // Otherwise we only notify if the user was mentioned.
+ return message.content.includes(`<@${UserStore.getCurrentUser().id}>`);
}
function showExampleNotification(): Promise {
- const Notification: NotificationData = {
+ return showNotification({
title: "Example Notification",
- icon: IconUtils.getUserAvatarURL(UserStore.getCurrentUser()),
body: "This is an example toast notification!",
- attachments: 0,
+ icon: IconUtils.getUserAvatarURL(UserStore.getCurrentUser()),
permanent: false
- };
-
- if (!settings.store.renderImages) {
- Notification.icon = undefined;
- }
- return showNotification(Notification);
+ });
}
diff --git a/src/equicordplugins/toastNotifications/types.ts b/src/equicordplugins/toastNotifications/types.ts
deleted file mode 100644
index 6081eac0f..000000000
--- a/src/equicordplugins/toastNotifications/types.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Vencord, a modification for Discord's desktop app
- * Copyright (c) 2022 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 .
-*/
-
-export const enum StreamingTreatment {
- NORMAL = 0,
- NO_CONTENT = 1,
- IGNORE = 2
-}