From 126ce3fdc9953bb33c6cf74f28ca7afb1d42bf59 Mon Sep 17 00:00:00 2001 From: RobinRMC Date: Mon, 28 Apr 2025 18:07:43 +0200 Subject: [PATCH] Update ToastNotifications --- .../components/NotificationComponent.tsx | 37 +- .../components/Notifications.tsx | 23 +- .../ToastNotifications/components/styles.css | 15 +- src/plusplugins/ToastNotifications/index.tsx | 412 +++++++++++++++--- src/plusplugins/ToastNotifications/types.ts | 33 +- 5 files changed, 401 insertions(+), 119 deletions(-) diff --git a/src/plusplugins/ToastNotifications/components/NotificationComponent.tsx b/src/plusplugins/ToastNotifications/components/NotificationComponent.tsx index 2f7d7024..ee4b080e 100644 --- a/src/plusplugins/ToastNotifications/components/NotificationComponent.tsx +++ b/src/plusplugins/ToastNotifications/components/NotificationComponent.tsx @@ -1,20 +1,8 @@ /* - * 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) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ import "./styles.css"; @@ -35,11 +23,21 @@ export default ErrorBoundary.wrap(function NotificationComponent({ dismissOnClick, index, onClick, - onClose + onClose, + attachments }: NotificationData & { index?: number; }) { const [isHover, setIsHover] = useState(false); const [elapsed, setElapsed] = useState(0); + let renderBody: boolean = true; + let footer: boolean = false; + + if (attachments > 0) + footer = true; + + if (body === "") + renderBody = false; + // Precompute appearance settings. const AppearanceSettings = { position: `toastnotifications-position-${PluginSettings.store.position || "bottom-left"}`, @@ -115,11 +113,12 @@ export default ErrorBoundary.wrap(function NotificationComponent({
- {richBody ??

{body}

} + {renderBody ? richBody ??

{body}

: null} + {PluginSettings.store.renderImages && image && ToastNotification Image} + {footer &&

{`${attachments} attachment${attachments > 1 ? "s" : ""} ${attachments > 1 ? "were" : "was"} sent.`}

}
- {image && ToastNotification Image} {AppearanceSettings.timeout !== 0 && !permanent && (
. -*/ + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ import { React, ReactDOM } from "@webpack/common"; -import type { ReactNode } from "react"; +import type { JSX, ReactNode } from "react"; import type { Root } from "react-dom/client"; import { settings as PluginSettings } from "../index"; @@ -51,6 +39,7 @@ export interface NotificationData { 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; onClick?(): void; onClose?(): void; } diff --git a/src/plusplugins/ToastNotifications/components/styles.css b/src/plusplugins/ToastNotifications/components/styles.css index a7be76ca..89fd0ba0 100644 --- a/src/plusplugins/ToastNotifications/components/styles.css +++ b/src/plusplugins/ToastNotifications/components/styles.css @@ -97,11 +97,21 @@ .toastnotifications-notification-p { margin: 0.5rem 0 0; + margin-bottom: 3px; line-height: 140%; + word-break: break-all; +} + +.toastnotifications-notification-footer { + margin: 0; + margin-top: 4px; + line-height: 140%; + font-size: 10px; } .toastnotifications-notification-img { - width: 100%; + width: 75%; + border-radius: 3px; } /* Notification Positioning CSS */ @@ -129,7 +139,8 @@ .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; -} \ No newline at end of file +} diff --git a/src/plusplugins/ToastNotifications/index.tsx b/src/plusplugins/ToastNotifications/index.tsx index e173b611..c13b87f7 100644 --- a/src/plusplugins/ToastNotifications/index.tsx +++ b/src/plusplugins/ToastNotifications/index.tsx @@ -1,36 +1,26 @@ /* - * 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) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later */ import { definePluginSettings } from "@api/Settings"; import { makeRange } from "@components/PluginSettings/components"; -import { Devs } from "@utils/constants"; +import { EquicordDevs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; -import { findByPropsLazy } from "@webpack"; -import { Button, ChannelStore, GuildStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common"; -import type { Channel, Message, User } from "discord-types/general"; +import { findByPropsLazy, findStore } from "@webpack"; +import { Button, ChannelStore, GuildStore, NavigationRouter, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common"; +import { Channel, Message, User } from "discord-types/general"; import { ReactNode } from "react"; -import { Webpack } from "Vencord"; import { NotificationData, showNotification } from "./components/Notifications"; -import { MessageTypes } from "./types"; +import { MessageTypes, RelationshipType, StreamingTreatment } from "./types"; + +let ignoredUsers: string[] = []; +let notifyFor: string[] = []; // Functional variables. -const MuteStore = Webpack.findByPropsLazy("isSuppressEveryoneEnabled"); +const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled"); const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel"); const UserUtils = findByPropsLazy("getGlobalName"); @@ -79,6 +69,72 @@ export const settings = definePluginSettings({ default: 3, markers: makeRange(1, 5, 1) }, + determineServerNotifications: { + type: OptionType.BOOLEAN, + description: "Automatically determine which server notifications to show based on your channel/guild settings", + default: true + }, + disableInStreamerMode: { + type: OptionType.BOOLEAN, + description: "Disable notifications while in streamer mode", + default: true + }, + renderImages: { + type: OptionType.BOOLEAN, + description: "Render images in notifications", + default: true + }, + directMessages: { + type: OptionType.BOOLEAN, + description: "Show notifications for direct messages", + default: true + }, + groupMessages: { + type: OptionType.BOOLEAN, + 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", + 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 notifications as normal", + value: StreamingTreatment.NORMAL, + default: true + }, + { + label: "No Content - Hide notifications body", + value: StreamingTreatment.NO_CONTENT + }, + { + label: "Ignore - Don't show notifications 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 not receive notifications from (separate with commas)", + onChange: () => { ignoredUsers = stringToList(settings.store.ignoreUsers); }, + default: "" + }, exampleButton: { type: OptionType.COMPONENT, description: "Show an example toast notification", @@ -89,26 +145,38 @@ export const settings = definePluginSettings({ } }); -/** - * getName() - * Helper function to get a user's nickname if they have one, otherwise their username. - * - * @param {User} user The user to get the name of. - * @returns {String} The name of the user. - */ +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); } -/** - * addMention() - * Helper function to add a mention to a notification. - * - * @param {string} id The id of the user, channel or role. - * @param {string} type The type of mention. - * @param {string} guildId The id of the guild. - * @returns {ReactNode} The mention as a ReactNode. - */ const addMention = (id: string, type: string, guildId?: string): ReactNode => { let name; if (type === "user") @@ -128,36 +196,51 @@ const addMention = (id: string, type: string, guildId?: string): ReactNode => { export default definePlugin({ name: "ToastNotifications", - description: "Show a toast notification whenever you receive a direct message.", - authors: [Devs.Skully], + description: "Receive in-app notifications", + authors: [{ name: "Skully", id: 150298098516754432n }, EquicordDevs.Ethan, EquicordDevs.Buzzy], settings, flux: { async MESSAGE_CREATE({ message }: { message: Message; }) { + const channel: Channel = ChannelStore.getChannel(message.channel_id); const currentUser = UserStore.getCurrentUser(); - // Determine whether or not to show notifications. + const isStreaming = findStore("ApplicationStreamingStore").getAnyStreamForUser(UserStore.getCurrentUser()?.id); + + const streamerMode = settings.store.disableInStreamerMode; + const currentUserStreamerMode = findStore("StreamerModeStore").enabled; + + if (streamerMode && currentUserStreamerMode) return; + if (isStreaming && settings.store.streamingTreatment === StreamingTreatment.IGNORE) return; + if ( ( - (channel.guild_id) // If this is a guild message and not a private message. - || (message.author.id === currentUser.id) // If message is from the user. - || (!MuteStore.allowAllMessages(channel)) // If user has muted the channel. + (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. ) ) return; + if (channel.guild_id) { // If this is a guild message and not a private message. + handleGuildMessage(message); + return; + } + + if (!settings.store.directMessages && channel.isDM() || !settings.store.groupMessages && channel.isGroupDM() || MuteStore.isChannelMuted(null, channel.id)) return; + // Prepare the notification. const Notification: NotificationData = { title: getName(message.author), icon: `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`, 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: React.ReactNode[] = []; + 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()) { @@ -167,7 +250,7 @@ export default definePlugin({ } // Finally, truncate the channel name if it's too long. - const truncatedChannelName = channelName.length > 20 ? channelName.substring(0, 20) + "..." : channelName; + 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. @@ -183,14 +266,16 @@ export default definePlugin({ } case MessageTypes.CHANNEL_RECIPIENT_ADD: { const actor = UserStore.getUser(message.author.id); - const targetUser = UserStore.getUser(message.mentions[0]?.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 MessageTypes.CHANNEL_RECIPIENT_REMOVE: { const actor = UserStore.getUser(message.author.id); - const targetUser = UserStore.getUser(message.mentions[0]?.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)}.`; @@ -214,7 +299,7 @@ export default definePlugin({ } // Message contains an embed. - if (message.embeds.length !== 0) { + if (message.embeds?.length !== 0) { Notification.body = notificationText || "Sent an embed."; } @@ -224,11 +309,11 @@ export default definePlugin({ } // Message contains an attachment. - if (message.attachments.length !== 0) { + 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 || "Sent an image."; + if (images?.length !== 0) { + Notification.body = notificationText || ""; // Dont show any body Notification.image = images[0].url; } else { Notification.body += ` [Attachment: ${message.attachments[0].filename}]`; @@ -244,7 +329,7 @@ export default definePlugin({ } // Replace any mention of users, roles and channels. - if (message.mentions.length !== 0 || message.mentionRoles?.length > 0) { + 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)); @@ -258,32 +343,229 @@ export default definePlugin({ richBodyElements.push(addMention(roleId, "role", channel.guild_id)); } - lastIndex = offset + match.length; + lastIndex = offset + match?.length; return match; // This value is not used but is necessary for the replace function }); } - if (richBodyElements.length > 0) { + 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); } + }, + + start() { + ignoredUsers = stringToList(settings.store.ignoreUsers); + notifyFor = stringToList(settings.store.notifyFor); } }); -/** - * showExampleNotification() - * Helper function to show an example notification. - * - * @returns {Promise} A promise that resolves when the notification is shown. - */ +function switchChannels(guildId: string | null, channelId: string) { + if (!ChannelStore.hasChannel(channelId)) return; + NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/`); +} + +enum NotificationLevel { + ALL_MESSAGES = 0, + ONLY_MENTIONS = 1, + NO_MESSAGES = 2 +} + +function findNotificationLevel(channel: Channel): NotificationLevel { + const store = findStore("UserGuildSettingsStore"); + const userGuildSettings = store.getAllSettings().userGuildSettings[channel.guild_id]; + + if (!settings.store.determineServerNotifications || MuteStore.isGuildOrCategoryOrChannelMuted(channel.guild_id, channel.id)) { + return NotificationLevel.NO_MESSAGES; + } + + if (userGuildSettings) { + const channelOverrides = userGuildSettings.channel_overrides?.[channel.id]; + const guildDefault = userGuildSettings.message_notifications; + + // Check if channel overrides exist and are in the expected format + if (channelOverrides && typeof channelOverrides === "object" && "message_notifications" in channelOverrides) { + return channelOverrides.message_notifications; + } + + // Check if guild default is in the expected format + if (typeof guildDefault === "number") { + return guildDefault; + } + } + + // 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: `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`, + body: message.content, + attachments: message.attachments?.length, + richBody: null, + permanent: false, + onClick() { switchChannels(channel.guild_id, channel.id); } + }; + + 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; + } + + console.log("Notification that went through: " + t); + 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: user.getAvatarURL(), + 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} has sent you a friend request`; + Notification.body = "You can accept or decline it in the Friends tab."; + Notification.onClick = () => switchChannels(null, ""); + + await showNotification(Notification); + } +} + function showExampleNotification(): Promise { - return showNotification({ - title: "Example Notification", + const Notification: NotificationData = { + title: "Example notification", icon: `https://cdn.discordapp.com/avatars/${UserStore.getCurrentUser().id}/${UserStore.getCurrentUser().avatar}.png?size=128`, body: "This is an example toast notification!", + attachments: 0, permanent: false - }); + }; + + if (!settings.store.renderImages) { + Notification.icon = undefined; + } + return showNotification(Notification); } diff --git a/src/plusplugins/ToastNotifications/types.ts b/src/plusplugins/ToastNotifications/types.ts index cf9c2ab6..38cf17b0 100644 --- a/src/plusplugins/ToastNotifications/types.ts +++ b/src/plusplugins/ToastNotifications/types.ts @@ -1,20 +1,8 @@ /* - * 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 . -*/ + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ export const enum MessageTypes { CHANNEL_RECIPIENT_ADD = 1, @@ -24,3 +12,16 @@ export const enum MessageTypes { CHANNEL_ICON_CHANGE = 5, CHANNEL_PINNED_MESSAGE = 6, } + +export const enum RelationshipType { + FRIEND = 1, + BLOCKED = 2, + INCOMING_REQUEST = 3, + OUTGOING_REQUEST = 4, +} + +export const enum StreamingTreatment { + NORMAL = 0, + NO_CONTENT = 1, + IGNORE = 2 +}