diff --git a/src/api/NicknameIcons.tsx b/src/api/NicknameIcons.tsx new file mode 100644 index 000000000..8b0fbc202 --- /dev/null +++ b/src/api/NicknameIcons.tsx @@ -0,0 +1,40 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Logger } from "@utils/Logger"; +import { ReactNode } from "react"; + +export interface NicknameIconProps { + userId: string; +} + +export type NicknameIconFactory = (props: NicknameIconProps) => ReactNode | Promise; + +export interface NicknameIcon { + priority: number; + factory: NicknameIconFactory; +} + +const nicknameIcons = new Map(); +const logger = new Logger("NicknameIcons"); + +export function addNicknameIcon(id: string, factory: NicknameIconFactory, priority = 0) { + return nicknameIcons.set(id, { + priority, + factory: ErrorBoundary.wrap(factory, { noop: true, onError: error => logger.error(`Failed to render ${id}`, error) }) + }); +} + +export function removeNicknameIcon(id: string) { + return nicknameIcons.delete(id); +} + +export function _renderIcons(props: NicknameIconProps) { + return Array.from(nicknameIcons) + .sort((a, b) => b[1].priority - a[1].priority) + .map(([id, { factory: NicknameIcon }]) => ); +} diff --git a/src/api/index.ts b/src/api/index.ts index d4d7b4614..274c002f6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -27,6 +27,7 @@ import * as $MessageDecorations from "./MessageDecorations"; import * as $MessageEventsAPI from "./MessageEvents"; import * as $MessagePopover from "./MessagePopover"; import * as $MessageUpdater from "./MessageUpdater"; +import * as $NicknameIcons from "./NicknameIcons"; import * as $Notices from "./Notices"; import * as $Notifications from "./Notifications"; import * as $ServerList from "./ServerList"; @@ -122,3 +123,8 @@ export const MessageUpdater = $MessageUpdater; * An API allowing you to get an user setting */ export const UserSettings = $UserSettings; + +/** + * An API allowing you to add icons to the nickname, in profiles + */ +export const NicknameIcons = $NicknameIcons; diff --git a/src/plugins/_api/nicknameIcons.ts b/src/plugins/_api/nicknameIcons.ts new file mode 100644 index 000000000..be3763cc7 --- /dev/null +++ b/src/plugins/_api/nicknameIcons.ts @@ -0,0 +1,23 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "NicknameIconsAPI", + description: "API to add icons to the nickname, in profiles", + authors: [Devs.Nuckyz], + patches: [ + { + find: "#{intl::USER_PROFILE_LOAD_ERROR}", + replacement: { + match: /(\.fetchError.+?\?)null/, + replace: (_, rest) => `${rest}Vencord.Api.NicknameIcons._renderIcons(arguments[0])` + } + } + ] +}); diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 50523e98f..45ac0334c 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -25,6 +25,7 @@ import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccesso import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations"; import { addMessageClickListener, addMessagePreEditListener, addMessagePreSendListener, removeMessageClickListener, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents"; import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/MessagePopover"; +import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons"; import { Settings, SettingsStore } from "@api/Settings"; import { Logger } from "@utils/Logger"; import { canonicalizeFind } from "@utils/patches"; @@ -92,7 +93,7 @@ function isReporterTestable(p: Plugin, part: ReporterTestable) { const pluginKeysToBind: Array = [ "onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick", - "renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton" + "renderChatBarButton", "renderMemberListDecorator", "renderNicknameIcon", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton" ]; const neededApiPlugins = new Set(); @@ -124,6 +125,7 @@ for (const p of pluginsValues) if (isPluginEnabled(p.name)) { if (p.onBeforeMessageEdit || p.onBeforeMessageSend || p.onMessageClick) neededApiPlugins.add("MessageEventsAPI"); if (p.renderChatBarButton) neededApiPlugins.add("ChatInputButtonAPI"); if (p.renderMemberListDecorator) neededApiPlugins.add("MemberListDecoratorsAPI"); + if (p.renderNicknameIcon) neededApiPlugins.add("NicknameIconsAPI"); if (p.renderMessageAccessory) neededApiPlugins.add("MessageAccessoriesAPI"); if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI"); if (p.renderMessagePopoverButton) neededApiPlugins.add("MessagePopoverAPI"); @@ -256,7 +258,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: const { name, commands, contextMenus, userProfileBadge, onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, - renderChatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton + renderChatBarButton, renderMemberListDecorator, renderNicknameIcon, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton } = p; if (p.start) { @@ -306,6 +308,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: if (renderChatBarButton) addChatBarButton(name, renderChatBarButton); if (renderMemberListDecorator) addMemberListDecorator(name, renderMemberListDecorator); + if (renderNicknameIcon) addNicknameIcon(name, renderNicknameIcon); if (renderMessageDecoration) addMessageDecoration(name, renderMessageDecoration); if (renderMessageAccessory) addMessageAccessory(name, renderMessageAccessory); if (renderMessagePopoverButton) addMessagePopoverButton(name, renderMessagePopoverButton); @@ -317,7 +320,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu const { name, commands, contextMenus, userProfileBadge, onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, - renderChatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton + renderChatBarButton, renderMemberListDecorator, renderNicknameIcon, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton } = p; if (p.stop) { @@ -365,6 +368,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu if (renderChatBarButton) removeChatBarButton(name); if (renderMemberListDecorator) removeMemberListDecorator(name); + if (renderNicknameIcon) removeNicknameIcon(name); if (renderMessageDecoration) removeMessageDecoration(name); if (renderMessageAccessory) removeMessageAccessory(name); if (renderMessagePopoverButton) removeMessagePopoverButton(name); diff --git a/src/plugins/platformIndicators/index.tsx b/src/plugins/platformIndicators/index.tsx index 4612082f5..80c8ebb18 100644 --- a/src/plugins/platformIndicators/index.tsx +++ b/src/plugins/platformIndicators/index.tsx @@ -18,15 +18,15 @@ import "./style.css"; -import { addProfileBadge, BadgePosition, BadgeUserArgs, ProfileBadge, removeProfileBadge } from "@api/Badges"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations"; -import { Settings } from "@api/Settings"; -import ErrorBoundary from "@components/ErrorBoundary"; +import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons"; +import { definePluginSettings, migratePluginSetting } from "@api/Settings"; import { Devs } from "@utils/constants"; +import { classes } from "@utils/misc"; import definePlugin, { OptionType } from "@utils/types"; import { filters, findStoreLazy, mapMangledModuleLazy } from "@webpack"; -import { PresenceStore, Tooltip, UserStore } from "@webpack/common"; +import { PresenceStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common"; import { User } from "discord-types/general"; export interface Session { @@ -44,10 +44,26 @@ const SessionsStore = findStoreLazy("SessionsStore") as { getSessions(): Record; }; -function Icon(path: string, opts?: { viewBox?: string; width?: number; height?: number; }) { - return ({ color, tooltip, small }: { color: string; tooltip: string; small: boolean; }) => ( +const { useStatusFillColor } = mapMangledModuleLazy(".concat(.5625*", { + useStatusFillColor: filters.byCode(".hex") +}); + +interface IconFactoryOpts { + viewBox?: string; + width?: number; + height?: number; +} + +interface IconProps { + color: string; + tooltip: string; + small?: boolean; +} + +function Icon(path: string, opts?: IconFactoryOpts) { + return ({ color, tooltip, small }: IconProps) => ( - {(tooltipProps: any) => ( + {tooltipProps => ( { +const PlatformIcon = ({ platform, status, small }: PlatformIconProps) => { const tooltip = platform === "embedded" ? "Console" : platform[0].toUpperCase() + platform.slice(1); @@ -84,155 +104,146 @@ const PlatformIcon = ({ platform, status, small }: { platform: Platform, status: return ; }; -function ensureOwnStatus(user: User) { - if (user.id === UserStore.getCurrentUser().id) { - const sessions = SessionsStore.getSessions(); - if (typeof sessions !== "object") return null; - const sortedSessions = Object.values(sessions).sort(({ status: a }, { status: b }) => { - if (a === b) return 0; - if (a === "online") return 1; - if (b === "online") return -1; - if (a === "idle") return 1; - if (b === "idle") return -1; - return 0; - }); - - const ownStatus = Object.values(sortedSessions).reduce((acc, curr) => { - if (curr.clientInfo.client !== "unknown") - acc[curr.clientInfo.client] = curr.status; - return acc; - }, {}); - - const { clientStatuses } = PresenceStore.getState(); - clientStatuses[UserStore.getCurrentUser().id] = ownStatus; +function useEnsureOwnStatus(user: User) { + if (user.id !== UserStore.getCurrentUser()?.id) { + return; } + + const sessions = useStateFromStores([SessionsStore], () => SessionsStore.getSessions()); + if (typeof sessions !== "object") return null; + const sortedSessions = Object.values(sessions).sort(({ status: a }, { status: b }) => { + if (a === b) return 0; + if (a === "online") return 1; + if (b === "online") return -1; + if (a === "idle") return 1; + if (b === "idle") return -1; + return 0; + }); + + const ownStatus = Object.values(sortedSessions).reduce((acc, curr) => { + if (curr.clientInfo.client !== "unknown") + acc[curr.clientInfo.client] = curr.status; + return acc; + }, {}); + + const { clientStatuses } = PresenceStore.getState(); + clientStatuses[UserStore.getCurrentUser().id] = ownStatus; } -function getBadges({ userId }: BadgeUserArgs): ProfileBadge[] { - const user = UserStore.getUser(userId); - - if (!user || user.bot) return []; - - ensureOwnStatus(user); - - const status = PresenceStore.getState()?.clientStatuses?.[user.id] as Record; - if (!status) return []; - - return Object.entries(status).map(([platform, status]) => ({ - component: () => ( - - - - ), - key: `vc-platform-indicator-${platform}` - })); +interface PlatformIndicatorProps { + user: User; + isProfile?: boolean; + isMessage?: boolean; + isMemberList?: boolean; } -const PlatformIndicator = ({ user, wantMargin = true, wantTopMargin = false, small = false }: { user: User; wantMargin?: boolean; wantTopMargin?: boolean; small?: boolean; }) => { - if (!user || user.bot) return null; +const PlatformIndicator = ({ user, isProfile, isMessage, isMemberList }: PlatformIndicatorProps) => { + if (user == null || user.bot) return null; + useEnsureOwnStatus(user); - ensureOwnStatus(user); + const status: Record | undefined = useStateFromStores([PresenceStore], () => PresenceStore.getState()?.clientStatuses?.[user.id]); + if (status == null) { + return null; + } - const status = PresenceStore.getState()?.clientStatuses?.[user.id] as Record; - if (!status) return null; - - const icons = Object.entries(status).map(([platform, status]) => ( + const icons = Array.from(Object.entries(status), ([platform, status]) => ( )); - if (!icons.length) return null; + if (!icons.length) { + return null; + } return ( - {icons} - + ); }; -const badge: ProfileBadge = { - getBadges, - position: BadgePosition.START, -}; +function toggleMemberListDecorators(enabled: boolean) { + if (enabled) { + addMemberListDecorator("PlatformIndicators", props => ); + } else { + removeMemberListDecorator("PlatformIndicators"); + } +} -const indicatorLocations = { +function toggleNicknameIcons(enabled: boolean) { + if (enabled) { + addNicknameIcon("PlatformIndicators", props => , 1); + } else { + removeNicknameIcon("PlatformIndicators"); + } +} + +function toggleMessageDecorators(enabled: boolean) { + if (enabled) { + addMessageDecoration("PlatformIndicators", props => ); + } else { + removeMessageDecoration("PlatformIndicators"); + } +} + +migratePluginSetting("PlatformIndicators", "badges", "profiles"); +const settings = definePluginSettings({ list: { - description: "In the member list", - onEnable: () => addMemberListDecorator("platform-indicator", props => - - - - ), - onDisable: () => removeMemberListDecorator("platform-indicator") + type: OptionType.BOOLEAN, + description: "Show indicators in the member list", + default: true, + onChange: toggleMemberListDecorators }, - badges: { - description: "In user profiles, as badges", - onEnable: () => addProfileBadge(badge), - onDisable: () => removeProfileBadge(badge) + profiles: { + type: OptionType.BOOLEAN, + description: "Show indicators in user profiles", + default: true, + onChange: toggleNicknameIcons }, messages: { - description: "Inside messages", - onEnable: () => addMessageDecoration("platform-indicator", props => - - - - ), - onDisable: () => removeMessageDecoration("platform-indicator") + type: OptionType.BOOLEAN, + description: "Show indicators inside messages", + default: true, + onChange: toggleMessageDecorators + }, + colorMobileIndicator: { + type: OptionType.BOOLEAN, + description: "Whether to make the mobile indicator match the color of the user status.", + default: true, + restartNeeded: true } -}; +}); export default definePlugin({ name: "PlatformIndicators", description: "Adds platform indicators (Desktop, Mobile, Web...) to users", authors: [Devs.kemo, Devs.TheSun, Devs.Nuckyz, Devs.Ven], - dependencies: ["MessageDecorationsAPI", "MemberListDecoratorsAPI"], + dependencies: ["MemberListDecoratorsAPI", "NicknameIconsAPI", "MessageDecorationsAPI"], + settings, start() { - const settings = Settings.plugins.PlatformIndicators; - const { displayMode } = settings; - - // transfer settings from the old ones, which had a select menu instead of booleans - if (displayMode) { - if (displayMode !== "both") settings[displayMode] = true; - else { - settings.list = true; - settings.badges = true; - } - settings.messages = true; - delete settings.displayMode; - } - - Object.entries(indicatorLocations).forEach(([key, value]) => { - if (settings[key]) value.onEnable(); - }); + if (settings.store.list) toggleMemberListDecorators(true); + if (settings.store.profiles) toggleNicknameIcons(true); + if (settings.store.messages) toggleMessageDecorators(true); }, stop() { - Object.entries(indicatorLocations).forEach(([_, value]) => { - value.onDisable(); - }); + if (settings.store.list) toggleMemberListDecorators(false); + if (settings.store.profiles) toggleNicknameIcons; + if (settings.store.messages) toggleMessageDecorators(false); }, patches: [ { find: ".Masks.STATUS_ONLINE_MOBILE", - predicate: () => Settings.plugins.PlatformIndicators.colorMobileIndicator, + predicate: () => settings.store.colorMobileIndicator, replacement: [ { // Return the STATUS_ONLINE_MOBILE mask if the user is on mobile, no matter the status @@ -248,7 +259,7 @@ export default definePlugin({ }, { find: ".AVATAR_STATUS_MOBILE_16;", - predicate: () => Settings.plugins.PlatformIndicators.colorMobileIndicator, + predicate: () => settings.store.colorMobileIndicator, replacement: [ { // Return the AVATAR_STATUS_MOBILE size mask if the user is on mobile, no matter the status @@ -269,32 +280,12 @@ export default definePlugin({ }, { find: "}isMobileOnline(", - predicate: () => Settings.plugins.PlatformIndicators.colorMobileIndicator, + predicate: () => settings.store.colorMobileIndicator, replacement: { // Make isMobileOnline return true no matter what is the user status match: /(?<=\i\[\i\.\i\.MOBILE\])===\i\.\i\.ONLINE/, replace: "!= null" } } - ], - - options: { - ...Object.fromEntries( - Object.entries(indicatorLocations).map(([key, value]) => { - return [key, { - type: OptionType.BOOLEAN, - description: `Show indicators ${value.description.toLowerCase()}`, - // onChange doesn't give any way to know which setting was changed, so restart required - restartNeeded: true, - default: true - }]; - }) - ), - colorMobileIndicator: { - type: OptionType.BOOLEAN, - description: "Whether to make the mobile indicator match the color of the user status.", - default: true, - restartNeeded: true - } - } + ] }); diff --git a/src/plugins/platformIndicators/style.css b/src/plugins/platformIndicators/style.css index 38ea5ef4b..a5566fdda 100644 --- a/src/plugins/platformIndicators/style.css +++ b/src/plugins/platformIndicators/style.css @@ -2,6 +2,20 @@ display: inline-flex; justify-content: center; align-items: center; - vertical-align: top; - position: relative; + gap: 2px; +} + +.vc-platform-indicator-profile { + background: rgb(var(--bg-overlay-color) / var(--bg-overlay-opacity-6)); + border: 1px solid var(--border-faint); + border-radius: var(--radius-xs); + border-color: var(--profile-body-border-color); + margin: 0 1px; + padding: 0 1px; +} + +.vc-platform-indicator-message { + position: relative; + vertical-align: top; + top: 2px; } diff --git a/src/plugins/userVoiceShow/index.tsx b/src/plugins/userVoiceShow/index.tsx index f3063f590..1fa6358a1 100644 --- a/src/plugins/userVoiceShow/index.tsx +++ b/src/plugins/userVoiceShow/index.tsx @@ -20,6 +20,7 @@ import "./style.css"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations"; +import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; @@ -51,19 +52,10 @@ export default definePlugin({ name: "UserVoiceShow", description: "Shows an indicator when a user is in a Voice Channel", authors: [Devs.Nuckyz, Devs.LordElias], - dependencies: ["MemberListDecoratorsAPI", "MessageDecorationsAPI"], + dependencies: ["NicknameIconsAPI", "MemberListDecoratorsAPI", "MessageDecorationsAPI"], settings, patches: [ - // User Popout, Full Size Profile, Direct Messages Side Profile - { - find: "#{intl::USER_PROFILE_LOAD_ERROR}", - replacement: { - match: /(\.fetchError.+?\?)null/, - replace: (_, rest) => `${rest}$self.VoiceChannelIndicator({userId:arguments[0]?.userId,isProfile:true})` - }, - predicate: () => settings.store.showInUserProfileModal - }, // To use without the MemberList decorator API /* // Guild Members List { @@ -95,6 +87,9 @@ export default definePlugin({ ], start() { + if (settings.store.showInUserProfileModal) { + addNicknameIcon("UserVoiceShow", ({ userId }) => ); + } if (settings.store.showInMemberList) { addMemberListDecorator("UserVoiceShow", ({ user }) => user == null ? null : ); } @@ -104,6 +99,7 @@ export default definePlugin({ }, stop() { + removeNicknameIcon("UserVoiceShow"); removeMemberListDecorator("UserVoiceShow"); removeMessageDecoration("UserVoiceShow"); }, diff --git a/src/utils/types.ts b/src/utils/types.ts index 8f0ec3860..d9f0e820a 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -25,9 +25,11 @@ import { MessageAccessoryFactory } from "@api/MessageAccessories"; import { MessageDecorationFactory } from "@api/MessageDecorations"; import { MessageClickListener, MessageEditListener, MessageSendListener } from "@api/MessageEvents"; import { MessagePopoverButtonFactory } from "@api/MessagePopover"; +import { NicknameIconFactory } from "@api/NicknameIcons"; import { FluxEvents } from "@webpack/types"; import { JSX } from "react"; import { Promisable } from "type-fest"; +import { LiteralStringUnion } from "type-fest/source/literal-union"; // exists to export default definePlugin({...}) export default function definePlugin

(p: P & Record) { @@ -88,7 +90,7 @@ export interface PluginDef { * These will automatically be enabled and loaded before your plugin * Generally these will be API plugins */ - dependencies?: string[], + dependencies?: Array>, /** * Whether this plugin is required and forcefully enabled */ @@ -161,8 +163,10 @@ export interface PluginDef { renderMessageDecoration?: MessageDecorationFactory; renderMemberListDecorator?: MemberListDecoratorFactory; + renderNicknameIcon?: NicknameIconFactory; renderChatBarButton?: ChatBarButtonFactory; + } export const enum StartAt {