feat(toastNotifications): Native message rendering & merge with latest version (#1052)

* feat: merge toastNotifications with original

* feat: add README for toastNotifications

* refactor: css class namespace

* refactor: setContainerPosition on setting change

* fix: timeout default to 5 on nullish
This commit is contained in:
Skully 2026-05-06 12:51:34 +01:00 • committed by GitHub
parent 686a55270d
commit 56e2e46a8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 599 additions and 761 deletions

View file

@ -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 <kbd>Copy ID</kbd> _(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. |

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
* 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 (
<div className={cl("context-header")}>
{icon && <img className={cl("context-header-icon")} src={icon} alt="" />}
<span className={cl("context-header-name")}>{name}</span>
</div>
);
}
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 (
<div className={cl("context-header")}>
{icon && <img className={cl("context-header-icon")} src={icon} alt="" />}
<span className={cl("context-header-name")}>
{guild?.name ?? "Unknown Server"}
{channel.name && (
<>
<span className={cl("context-header-separator")}>{" \u203A "}</span>
<span className={cl("context-header-channel")}>#{channel.name}</span>
</>
)}
</span>
</div>
);
}
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(() => (
<button
style={positionStyle}
className={classes("toastnotifications-notification-root", AppearanceSettings.position)}
onClick={() => {
onClick?.();
if (dismissOnClick !== false)
onClose!();
}}
onContextMenu={e => {
className={cl("notification-close-btn")}
onClick={e => {
e.preventDefault();
e.stopPropagation();
onClose!();
props.onClose!();
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" role="img" aria-labelledby="vc-toast-notifications-dismiss-title">
<title id="vc-toast-notifications-dismiss-title">Dismiss Notification</title>
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
), [props.onClose]);
let content: React.ReactNode;
if (isMessageNotification(props)) {
content = (
<div className={cl("notification-content")}>
{renderContextHeader(props.channel)}
<MessageComponent
id={`toastnotification-mock-${props.message.id}`}
message={props.mockedMessage}
channel={props.channel}
subscribeToComponentDispatch={false}
/>
</div>
);
} else {
const { title, body, icon } = props;
content = (
<div className={cl("notification-system")}>
{icon && <img className={cl("notification-icon")} src={icon} alt="" />}
<div className={cl("notification-content")}>
<div className={cl("notification-header")}>
<h2 className={cl("notification-title")}>{title}</h2>
</div>
{body && (
<p className={cl("notification-p")}>
{body.length > 500 ? body.slice(0, 500) + "..." : body}
</p>
)}
</div>
</div>
);
}
return (
<button
style={{ opacity }}
className={cl("notification-root")}
onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<div className="toastnotifications-notification">
{icon && <img className="toastnotifications-notification-icon" src={icon} alt="User Avatar" />}
<div className="toastnotifications-notification-content">
<div className="toastnotifications-notification-header">
<h2 className="toastnotifications-notification-title">{title}</h2>
<button
className="toastnotifications-notification-close-btn"
onClick={e => {
e.preventDefault();
e.stopPropagation();
onClose!();
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" role="img" aria-labelledby="toastnotifications-notification-dismiss-title">
<title id="toastnotifications-notification-dismiss-title">Dismiss Notification</title>
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
<div>
{renderBody ? (
richBody ?? (
<p className="toastnotifications-notification-p">
{body.length > 500 ? body.slice(0, 500) + "..." : body}
</p>
)
) : null}
{PluginSettings.store.renderImages && image && <img className="toastnotifications-notification-img" src={image} alt="ToastNotification Image" />}
{footer && <p className="toastnotifications-notification-footer">{`${attachments} attachment${attachments > 1 ? "s" : ""} ${attachments > 1 ? "were" : "was"} sent.`}</p>}
</div>
</div>
</div>
{AppearanceSettings.timeout !== 0 && !permanent && (
{closeButton}
{content}
{timeout !== 0 && !props.permanent && (
<div
className="toastnotifications-notification-progressbar"
style={{ width: `${(1 - timeoutProgress) * 100}%`, backgroundColor: "var(--toastnotifications-progressbar-color)" }}
className={cl("notification-progressbar")}
style={{ width: `${(1 - timeoutProgress) * 100}%` }}
/>
)}
</button>

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
* 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 = (
<NotificationComponent
key={thisNotificationID.toString()}
index={NotificationQueue.length}
{...notification}
onClose={() => {
// 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;
}

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
* 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: () =>
<Button onClick={showExampleNotification}>
Show Example Notification
</Button>
component: () => <Button onClick={showExampleNotification}>Show Example Notification</Button>
}
});
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 (
<span key={`${type}-${id}`} className={"toastnotifications-mention-class"}>
{name}
</span>
);
};
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("(<a?:\\w+:\\d+>)", "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 = <MyRichBodyComponent />;
}
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("(<a?:\\w+:\\d+>)", "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 = <MyRichBodyComponent />;
}
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<void> {
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);
});
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
export const enum StreamingTreatment {
NORMAL = 0,
NO_CONTENT = 1,
IGNORE = 2
}