diff --git a/README.md b/README.md index 928bc36a..3b3b6071 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch ### Extra included plugins
-167 additional plugins +168 additional plugins ### All Platforms @@ -152,6 +152,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch - ThemeLibrary by Fafa - Timezones by Aria - Title by Kyuuhachi +- ToastNotifications by Skully, Ethan, Buzzy - ToggleVideoBind by mochie - TosuRPC by AutumnVN - Translate+ by Prince527 & Ven diff --git a/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx b/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx new file mode 100644 index 00000000..17e5c4ad --- /dev/null +++ b/src/equicordplugins/toastNotifications/components/NotificationComponent.tsx @@ -0,0 +1,144 @@ +/* + * 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 . +*/ + +import "./styles.css"; + +import ErrorBoundary from "@components/ErrorBoundary"; +import { classes } from "@utils/misc"; +import { React, useEffect, useMemo, useState } from "@webpack/common"; + +import { settings as PluginSettings } from "../index"; +import { NotificationData } from "./Notifications"; + +export default ErrorBoundary.wrap(function NotificationComponent({ + title, + body, + richBody, + icon, + image, + permanent, + dismissOnClick, + index, + onClick, + 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"}`, + 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); + + const intervalId = setInterval(() => { + const elapsed = Date.now() - start; + if (elapsed >= AppearanceSettings.timeout) + onClose!(); + else + setElapsed(elapsed); + }, 10); + + return () => clearInterval(intervalId); + }, [isHover]); + + const timeoutProgress = elapsed / AppearanceSettings.timeout; + + // Render the notification. + return ( + + +
+ {renderBody ? richBody ??

{body}

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

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

} +
+ + + {AppearanceSettings.timeout !== 0 && !permanent && ( +
+ )} + + ); +}, { + onError: ({ props }) => props.onClose!() +}); diff --git a/src/equicordplugins/toastNotifications/components/Notifications.tsx b/src/equicordplugins/toastNotifications/components/Notifications.tsx new file mode 100644 index 00000000..fa10e2ae --- /dev/null +++ b/src/equicordplugins/toastNotifications/components/Notifications.tsx @@ -0,0 +1,107 @@ +/* + * 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 . +*/ + +import { React, ReactDOM } from "@webpack/common"; +import type { JSX, ReactNode } from "react"; +import type { Root } from "react-dom/client"; + +import { settings as PluginSettings } from "../index"; +import NotificationComponent from "./NotificationComponent"; + +let NotificationQueue: JSX.Element[] = []; +let notificationID = 0; +let RootContainer: Root; + +/** + * getNotificationContainer() + * Gets the root container for the notifications, creating it if it doesn't exist. + * @returns {Root} The root DOM container. + */ +function getNotificationContainer() { + if (!RootContainer) { + const container = document.createElement("div"); + container.id = "toastnotifications-container"; + document.body.append(container); + RootContainer = ReactDOM.createRoot(container); + } + + 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; + onClick?(): void; + onClose?(): void; +} + +export async function showNotification(notification: NotificationData) { + const root = getNotificationContainer(); + const thisNotificationID = notificationID++; + + return new Promise(resolve => { + 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 }); + })} + + ); + + resolve(); + }} + /> + ); + + // Add this notification to the queue. + NotificationQueue.push(ToastNotification); + console.log(`[DEBUG] [ToastNotifications] Added #${thisNotificationID} to queue.`); + + // Limit the number of notifications to the configured maximum. + 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 }); + })} + + ); + }); +} diff --git a/src/equicordplugins/toastNotifications/components/styles.css b/src/equicordplugins/toastNotifications/components/styles.css new file mode 100644 index 00000000..89fd0ba0 --- /dev/null +++ b/src/equicordplugins/toastNotifications/components/styles.css @@ -0,0 +1,146 @@ +:root { + /* Body */ + --toastnotifications-background-color: var(--background-secondary-alt); + --toastnotifications-text-color: var(--text-normal); + --toastnotifications-border-radius: 6px; + --toastnotifications-width: 25vw; + --toastnotifications-padding: 1.25rem; + + /* Title */ + --toastnotifications-title-color: var(--header-primary); + --toastnotifications-title-font-size: 1rem; + --toastnotifications-title-font-weight: 600; + --toastnotifications-title-line-height: 1.25rem; + + /* Close Button */ + --toastnotifications-close-button-color: var(--interactive-normal); + --toastnotifications-close-button-hover-color: var(--interactive-hover); + --toastnotifications-close-button-opacity: 0.5; + --toastnotifications-close-button-hover-opacity: 1; + + /* Message Author Image */ + --toastnotifications-image-height: 4rem; + --toastnotifications-image-width: var(--toastnotifications-image-height); + --toastnotifications-image-border-radius: 6px; + + /* Progress Bar */ + --toastnotifications-progressbar-height: 0.25rem; + + /* Position Offset - Global inherited offset by all positions */ + --toastnotifications-position-offset: 1rem; +} + +.toastnotifications-notification-root { + all: unset; + display: flex; + flex-direction: column; + color: var(--toastnotifications-text-color); + background-color: var(--toastnotifications-background-color); + border-radius: var(--toastnotifications-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); +} + +.toastnotifications-notification { + display: flex; + flex-direction: row; + padding: var(--toastnotifications-padding); + gap: 1.25rem; +} + +.toastnotifications-notification-content { + width: 100%; +} + +.toastnotifications-notification-header { + display: flex; + justify-content: space-between; +} + +.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); +} + +.toastnotifications-notification-close-btn { + all: unset; + cursor: pointer; + color: var(--toastnotifications-close-button-color); + opacity: var(--toastnotifications-close-button-opacity); + transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; +} + +.toastnotifications-notification-close-btn:hover { + color: var(--toastnotifications-close-button-hover-color); + opacity: var(--toastnotifications-close-button-hover-opacity); +} + +.toastnotifications-notification-icon { + height: var(--toastnotifications-image-height); + width: var(--toastnotifications-image-width); + border-radius: var(--toastnotifications-image-border-radius); +} + +.toastnotifications-notification-progressbar { + height: var(--toastnotifications-progressbar-height); + border-radius: 5px; + margin-top: auto; +} + +.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: 75%; + border-radius: 3px; +} + +/* Notification Positioning CSS */ +.toastnotifications-position-bottom-left { + bottom: var(--toastnotifications-position-offset); + left: var(--toastnotifications-position-offset); +} + +.toastnotifications-position-top-left { + top: var(--toastnotifications-position-offset); + left: var(--toastnotifications-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; +} diff --git a/src/equicordplugins/toastNotifications/index.tsx b/src/equicordplugins/toastNotifications/index.tsx new file mode 100644 index 00000000..efc37f70 --- /dev/null +++ b/src/equicordplugins/toastNotifications/index.tsx @@ -0,0 +1,583 @@ +/* + * 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 . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { makeRange } from "@components/PluginSettings/components"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +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 { NotificationData, showNotification } from "./components/Notifications"; +import { MessageTypes, RelationshipType, StreamingTreatment } from "./types"; + +let ignoredUsers: string[] = []; +let notifyFor: string[] = []; + +// Functional variables. +const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled"); +const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel"); +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. + +export const settings = definePluginSettings({ + position: { + type: OptionType.SELECT, + description: "The position of the toast notification", + options: [ + { + label: "Bottom Left", + value: "bottom-left", + default: true + }, + { + label: "Top Left", + value: "top-left" + }, + { + label: "Top Right", + value: "top-right" + }, + { + label: "Bottom Right", + value: "bottom-right" + }, + ] + }, + timeout: { + type: OptionType.SLIDER, + 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", + default: 100, + markers: makeRange(10, 100, 10) + }, + maxNotifications: { + type: OptionType.SLIDER, + 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", + 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 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: "" + }, + exampleButton: { + type: OptionType.COMPONENT, + description: "Show an example toast notification.", + component: () => + + } +}); + +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 = `@${GuildStore.getGuild(guildId).getRole(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.", + authors: [EquicordDevs.Skully, EquicordDevs.Ethan, EquicordDevs.Buzzy], + settings, + flux: { + async MESSAGE_CREATE({ message }: { message: Message; }) { + + const channel: Channel = ChannelStore.getChannel(message.channel_id); + const currentUser = UserStore.getCurrentUser(); + + 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 ( + ( + (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: 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 MessageTypes.CALL: { + Notification.body = "Started a call with you!"; + break; + } + case MessageTypes.CHANNEL_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 MessageTypes.CHANNEL_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 MessageTypes.CHANNEL_NAME_CHANGE: { + Notification.body = `Changed the channel name to '${message.content}'.`; + break; + } + case MessageTypes.CHANNEL_ICON_CHANGE: { + Notification.body = "Changed the channel icon."; + break; + } + case MessageTypes.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); + } + }, + + start() { + ignoredUsers = stringToList(settings.store.ignoreUsers); + notifyFor = stringToList(settings.store.notifyFor); + } +}); + +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("noti 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} 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 { + 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/equicordplugins/toastNotifications/types.ts b/src/equicordplugins/toastNotifications/types.ts new file mode 100644 index 00000000..d3c444f1 --- /dev/null +++ b/src/equicordplugins/toastNotifications/types.ts @@ -0,0 +1,39 @@ +/* + * 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 MessageTypes { + CHANNEL_RECIPIENT_ADD = 1, + CHANNEL_RECIPIENT_REMOVE = 2, + CALL = 3, + CHANNEL_NAME_CHANGE = 4, + 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 +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f8aabc88..6baba55e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1050,6 +1050,14 @@ export const EquicordDevs = Object.freeze({ name: "byeoon", id: 1167275288036655133n }, + Skully: { + name: "Skully", + id: 150298098516754432n + }, + Buzzy: { + name: "Buzzy", + id: 1273353654644117585n + }, } satisfies Record); // iife so #__PURE__ works correctly