From bcca965a5c3d2348cb2a00bb8aefe8562b9cd038 Mon Sep 17 00:00:00 2001 From: neo Date: Sun, 1 Mar 2026 15:14:40 -0500 Subject: [PATCH 01/14] feat(platformIndicators): add vr (#851) * feat: add vr * chore: lint and add modified tag * fix: make platforms mapping a cosnt --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/plugins/platformIndicators/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/plugins/platformIndicators/index.tsx b/src/plugins/platformIndicators/index.tsx index 25c4e5957..0e1c8e04d 100644 --- a/src/plugins/platformIndicators/index.tsx +++ b/src/plugins/platformIndicators/index.tsx @@ -19,7 +19,7 @@ import "./style.css"; import { definePluginSettings, migratePluginSetting } from "@api/Settings"; -import { Devs } from "@utils/constants"; +import { Devs, EquicordDevs } from "@utils/constants"; import { classes } from "@utils/misc"; import definePlugin, { OptionType } from "@utils/types"; import { DiscordPlatform, User } from "@vencord/discord-types"; @@ -40,10 +40,16 @@ export interface Session { const SessionsStore = findStoreLazy("SessionsStore") as { getSessions(): Record; }; + const { useStatusFillColor } = mapMangledModuleLazy([".5625*", "translate"], { useStatusFillColor: filters.byCode(".hex") }); +const platformMap = { + embedded: "Console", + vr: "VR" +}; + function Icon(path: string, opts?: { viewBox?: string; width?: number; height?: number; }) { return ({ color, tooltip, small }: { color: string; tooltip: string; small: boolean; }) => ( @@ -67,14 +73,13 @@ const Icons = { web: Icon("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93Zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39Z"), mobile: Icon("M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z", { viewBox: "0 0 1000 1500", height: 17, width: 17 }), embedded: Icon("M3.06 20.4q-1.53 0-2.37-1.065T.06 16.74l1.26-9q.27-1.8 1.605-2.97T6.06 3.6h11.88q1.8 0 3.135 1.17t1.605 2.97l1.26 9q.21 1.53-.63 2.595T20.94 20.4q-.63 0-1.17-.225T18.78 19.5l-2.7-2.7H7.92l-2.7 2.7q-.45.45-.99.675t-1.17.225Zm14.94-7.2q.51 0 .855-.345T19.2 12q0-.51-.345-.855T18 10.8q-.51 0-.855.345T16.8 12q0 .51.345 .855T18 13.2Zm-2.4-3.6q.51 0 .855-.345T16.8 8.4q0-.51-.345-.855T15.6 7.2q-.51 0-.855.345T14.4 8.4q0 .51.345 .855T15.6 9.6ZM6.9 13.2h1.8v-2.1h2.1v-1.8h-2.1v-2.1h-1.8v2.1h-2.1v1.8h2.1v2.1Z", { viewBox: "0 0 24 24", height: 20, width: 20 }), + vr: Icon("M8.46 8.64a1 1 0 0 1 1 1c0 .44-.3.8-.72.92l-.11.07c-.08.06-.2.19-.2.41a.99.99 0 0 1-.98.86h-.06a1 1 0 0 1-.94-1.05l.02-.32c.05-1.06.92-1.9 1.99-1.9ZM15.55 5a5.5 5.5 0 0 1 5.15 3.67h.3a2 2 0 0 1 2 2v3.18a2 2 0 0 1-2 1.99h-.2A4.54 4.54 0 0 1 16.55 19a4.45 4.45 0 0 1-3.6-1.83 1.2 1.2 0 0 0-1.9 0 4.44 4.44 0 0 1-3.9 1.82 4.54 4.54 0 0 1-3.94-3.15H3a2 2 0 0 1-2-2v-3.18c0-1.1.9-1.99 2-1.99h.3A5.5 5.5 0 0 1 8.46 5h7.09Zm-7.1 2C6.6 7 5.06 8.5 4.97 10.41l-.02.66v3.18c0 1.43 1.05 2.66 2.34 2.74.85.06 1.63-.32 2.14-1.01a3.2 3.2 0 0 1 2.57-1.3c1 0 1.97.48 2.57 1.3.5.69 1.3 1.08 2.14 1.01 1.3-.08 2.34-1.31 2.34-2.74l-.02-3.84a3.54 3.54 0 0 0-3.49-3.43H8.45Z", { viewBox: "0 4 24 16", height: 20, width: 20 }), suncord: Icon("M7 4a6 6 0 00-6 6v4a6 6 0 006 6h10a6 6 0 006-6v-4a6 6 0 00-6-6H7zm0 11a1 1 0 01-1-1v-1H5a1 1 0 010-2h1v-1a1 1 0 012 0v1h1a1 1 0 010 2H8v1a1 1 0 01-1 1zm10-4a1 1 0 100-2 1 1 0 000 2zm1 3a1 1 0 11-2 0 1 1 0 012 0zm0-2a1 1 0 102 0 1 1 0 00-2 0zm-3 1a1 1 0 110-2 1 1 0 010 2z", { viewBox: "0 0 24 24", height: 24, width: 24 }), vencord: Icon("M14.8 2.7 9 3.1V47h3.3c1.7 0 6.2.3 10 .7l6.7.6V2l-4.2.2c-2.4.1-6.9.3-10 .5zm1.8 6.4c1 1.7-1.3 3.6-2.7 2.2C12.7 10.1 13.5 8 15 8c.5 0 1.2.5 1.6 1.1zM16 33c0 6-.4 10-1 10s-1-4-1-10 .4-10 1-10 1 4 1 10zm15-8v23.3l3.8-.7c2-.3 4.7-.6 6-.6H43V3h-2.2c-1.3 0-4-.3-6-.6L31 1.7V25z", { viewBox: "0 0 50 50" }), }; const PlatformIcon = ({ platform, status, small }) => { - const tooltip = platform === "embedded" - ? "Console" - : platform[0].toUpperCase() + platform.slice(1); + const tooltip = platformMap[platform] ?? platform.charAt(0).toUpperCase() + platform.slice(1); let Icon = Icons[platform] ?? Icons.desktop; const { ConsoleIcon } = settings.store; if (platform === "embedded") { @@ -214,7 +219,8 @@ const settings = definePluginSettings({ export default definePlugin({ name: "PlatformIndicators", description: "Adds platform indicators (Desktop, Mobile, Web...) to users", - authors: [Devs.kemo, Devs.TheSun, Devs.Nuckyz, Devs.Ven], + authors: [Devs.kemo, Devs.TheSun, Devs.Nuckyz, Devs.Ven, EquicordDevs.neoarz], + isModified: true, settings, renderNicknameIcon(props) { if (!settings.store.profiles) return null; From 79d01e31b3802eac40930bfd1c6ac525e4bcc273 Mon Sep 17 00:00:00 2001 From: oma <57739965+Eazvy@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:15:05 -0500 Subject: [PATCH 02/14] fixes not working in forum post (#845) --- src/equicordplugins/jumpTo/index.tsx | 36 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/equicordplugins/jumpTo/index.tsx b/src/equicordplugins/jumpTo/index.tsx index e74dfc913..36bfb3b7a 100644 --- a/src/equicordplugins/jumpTo/index.tsx +++ b/src/equicordplugins/jumpTo/index.tsx @@ -8,11 +8,10 @@ import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { Devs } from "@utils/constants"; import definePlugin from "@utils/types"; import { Channel, Message, User } from "@vencord/discord-types"; -import { Constants, Menu, NavigationRouter, RestAPI, SelectedChannelStore, SelectedGuildStore, Toasts } from "@webpack/common"; +import { ChannelStore, Constants, Menu, NavigationRouter, RestAPI, SelectedChannelStore, SelectedGuildStore, Toasts } from "@webpack/common"; function jumpToFirstMessage(channelId: string, guildId?: string | null) { - const url = `/channels/${guildId ?? "@me"}/${channelId}/0`; - NavigationRouter.transitionTo(url); + NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/0`); } async function jumpToLastMessage(channelId: string, guildId?: string | null) { @@ -20,12 +19,9 @@ async function jumpToLastMessage(channelId: string, guildId?: string | null) { url: Constants.Endpoints.MESSAGES(channelId), query: { limit: 1 } }); - const messageId = res.body?.[0]?.id; if (!messageId) return; - - const url = `/channels/${guildId ?? "@me"}/${channelId}/${messageId}`; - NavigationRouter.transitionTo(url); + NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/${messageId}`); } async function jumpToUserMessage(channelId: string, guildId: string, userId: string, first: boolean) { @@ -50,8 +46,7 @@ async function jumpToUserMessage(channelId: string, guildId: string, userId: str return; } - const url = `/channels/${guildId}/${channelId}/${messageId}`; - NavigationRouter.transitionTo(url); + NavigationRouter.transitionTo(`/channels/${guildId}/${channelId}/${messageId}`); } catch (e) { Toasts.show({ type: Toasts.Type.FAILURE, @@ -61,28 +56,35 @@ async function jumpToUserMessage(channelId: string, guildId: string, userId: str } } -const ChannelMenuPatch: NavContextMenuPatchCallback = (children, { channel }: { channel: Channel; }) => { - if (!channel) return; +const ChannelMenuPatch: NavContextMenuPatchCallback = ( + children, + { channel, thread }: { channel?: Channel; thread?: Channel; } +) => { + const selectedId = SelectedChannelStore.getChannelId(); + const selectedChannel = selectedId ? ChannelStore.getChannel(selectedId) : null; + const forumChild = channel?.isForumLikeChannel?.() && selectedChannel?.isThread?.() && selectedChannel.parent_id === channel.id + ? selectedChannel + : null; + const targetChannel = thread ?? forumChild ?? channel; + if (!targetChannel) return; children.push( jumpToFirstMessage(channel.id, channel.guild_id)} + action={() => jumpToFirstMessage(targetChannel.id, targetChannel.guild_id)} />, jumpToLastMessage(channel.id, channel.guild_id)} + action={() => jumpToLastMessage(targetChannel.id, targetChannel.guild_id)} /> ); }; const UserMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }: { user: User; channel?: Channel; }) => { if (!user) return; - if (!channel || channel.guild_id) return; - children.push( { if (!message) return; - const channelId = SelectedChannelStore.getChannelId(); const guildId = SelectedGuildStore.getGuildId(); if (!channelId || !guildId) return; - children.push( Date: Sun, 1 Mar 2026 15:15:33 -0500 Subject: [PATCH 03/14] betterRoleContextFix (#842) --- src/plugins/betterRoleContext/index.tsx | 34 ++++++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/plugins/betterRoleContext/index.tsx b/src/plugins/betterRoleContext/index.tsx index f9eae586d..4a99d8822 100644 --- a/src/plugins/betterRoleContext/index.tsx +++ b/src/plugins/betterRoleContext/index.tsx @@ -18,6 +18,31 @@ const GuildSettingsActions = findByPropsLazy("open", "selectRole", "updateGuild" const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!; +async function openRoleIconModal(roleId: string, roleIcon: string, roleName: string) { + const format = settings.store.roleIconFileFormat; + const contentType = format === "jpg" ? "image/jpeg" : `image/${format}`; + const original = `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/role-icons/${roleId}/${roleIcon}.${format}`; + const url = original.replace(`//${window.GLOBAL_ENV.CDN_HOST}/`, "//media.discordapp.net/"); + + openImageModal({ + url, + original, + height: 128, + width: 128, + contentType, + originalContentType: contentType, + sourceMetadata: { + identifier: { + type: "attachment", + attachmentId: roleId, + filename: `role-icon-${roleId}.${format}`, + title: roleName, + size: 1, + } + } + }); +} + function PencilIcon() { return ( { - openImageModal({ - url: `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/role-icons/${role.id}/${role.icon}.${settings.store.roleIconFileFormat}`, - height: 128, - width: 128 - }); - }} + action={() => openRoleIconModal(role.id, roleIcon, role.name)} icon={ImageIcon} /> From b7e49fb6d18eb5d4d5e56a6071c9ba18dc337309 Mon Sep 17 00:00:00 2001 From: prism Date: Sun, 1 Mar 2026 21:16:42 +0100 Subject: [PATCH 04/14] bleh (#855) --- .rules | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.rules b/.rules index 061408c20..f5d7fa075 100644 --- a/.rules +++ b/.rules @@ -62,8 +62,8 @@ ## Anti-Patterns -- `value !== null && value !== undefined` ⟹ `value` -- `array && array.length > 0` ⟹ `array.length` +- `value !== null && value !== undefined` ⟹ `value != null` +- `array && array.length > 0` ⟹ `array.length` (only when array is guaranteed to exist) - `settings?.store?.value` ⟹ `settings.store.value` - `value || defaultValue` ⟹ `value ?? defaultValue` @@ -120,7 +120,7 @@ replace: "$&,$self.renderTooltip(arguments[0].guild)" **Wrapping:** ```js -find: "#{intl::THREE_USERS_TYPING}" +find: "#{intl::SEVERAL_USERS_TYPING_STRONG}" match: /(?<="aria-atomic":!0,children:)\i/ replace: "$self.renderTypingUsers({ children: $& })" ``` @@ -171,6 +171,7 @@ Use `definePluginSettings` from `@api/Settings`, not inline settings object. From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs. - `IconUtils.getUserAvatarURL(user, canAnimate?, size?)` +- `IconUtils.getDefaultAvatarURL(id)` - `IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })` - `IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })` - `IconUtils.getGuildBannerURL(guild, canAnimate?)` From 5610ba99d04c2dafc2a03268e0e86171718757cf Mon Sep 17 00:00:00 2001 From: Jake <134332787+justjxke@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:46:36 +0000 Subject: [PATCH 05/14] Fix MessageLoggerEnhanced React child crash from persisted render state (#854) Co-authored-by: justjxke --- src/equicordplugins/messageLoggerEnhanced/db.ts | 6 ++++++ src/equicordplugins/messageLoggerEnhanced/index.tsx | 9 ++++++++- .../messageLoggerEnhanced/utils/cleanUp.ts | 10 ++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/equicordplugins/messageLoggerEnhanced/db.ts b/src/equicordplugins/messageLoggerEnhanced/db.ts index b4d8c7f73..8d80e0213 100644 --- a/src/equicordplugins/messageLoggerEnhanced/db.ts +++ b/src/equicordplugins/messageLoggerEnhanced/db.ts @@ -9,6 +9,7 @@ import { DBSchema, IDBPDatabase, openDB } from "idb"; import { LoggedMessageJSON } from "./types"; import { getMessageStatus } from "./utils"; +import { stripTransientRenderState } from "./utils/cleanUp"; import { DB_NAME, DB_VERSION } from "./utils/constants"; import { getAttachmentBlobUrl } from "./utils/saveImage"; @@ -61,6 +62,7 @@ async function cacheRecords(records: DBMessageRecord[]) { async function cacheRecord(record?: DBMessageRecord | null) { if (!record) return record; + stripTransientRenderState(record.message); cachedMessages.set(record.message_id, record.message); return record; } @@ -207,6 +209,8 @@ export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: strin } export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) { + stripTransientRenderState(message); + await db.put("messages", { channel_id: message.channel_id, message_id: message.id, @@ -218,6 +222,8 @@ export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessag } export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) { + messages.forEach(stripTransientRenderState); + const tx = db.transaction("messages", "readwrite"); const { store } = tx; diff --git a/src/equicordplugins/messageLoggerEnhanced/index.tsx b/src/equicordplugins/messageLoggerEnhanced/index.tsx index ebda9e31c..cf5a537f8 100644 --- a/src/equicordplugins/messageLoggerEnhanced/index.tsx +++ b/src/equicordplugins/messageLoggerEnhanced/index.tsx @@ -189,11 +189,18 @@ function messageCreateHandler(payload: MessageCreatePayload) { async function processMessageFetch(response: FetchMessagesResponse) { try { - if (!response.ok || response.body.length === 0) { + if (!response.ok) { Flogger.error("Failed to fetch messages", response); return; } + if (!Array.isArray(response.body)) { + Flogger.error("Failed to fetch messages: response body is not an array", response); + return; + } + + if (response.body.length === 0) return; + const firstMessage = response.body[response.body.length - 1]; const messages = await idb.getMessagesByChannelAndAfterTimestampIDB(firstMessage.channel_id, firstMessage.timestamp); diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts b/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts index 4e44c3c31..5948a28cf 100644 --- a/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts +++ b/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts @@ -22,8 +22,18 @@ import { MessageStore } from "@webpack/common"; import { LoggedMessageJSON, RefrencedMessage } from "../types"; import { getGuildIdByChannel, isGhostPinged } from "./index"; +export function stripTransientRenderState(message: any) { + // These runtime-only fields can contain React elements and must never be persisted. + delete message.customRenderedContent; + delete message.__messageloggerDiff; + delete message.__messageloggerDiffKey; + delete message.__messageloggerAggregated; + delete message.__messageloggerLastAppliedKey; +} + export function cleanupMessage(message: any, removeDetails: boolean = true): LoggedMessageJSON { const ret: LoggedMessageJSON = typeof message.toJS === "function" ? JSON.parse(JSON.stringify(message.toJS())) : { ...message }; + stripTransientRenderState(ret); if (removeDetails) { ret.author.phone = undefined; ret.author.email = undefined; From 0689736a149dcac52859ec1c74d85dcfe79b59fb Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:01:18 -0500 Subject: [PATCH 06/14] Create navIds.md --- misc/navIds.md | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 misc/navIds.md diff --git a/misc/navIds.md b/misc/navIds.md new file mode 100644 index 000000000..915b17217 --- /dev/null +++ b/misc/navIds.md @@ -0,0 +1,161 @@ +# NavIDs + +```js +const wp = webpackChunkdiscord_app.push([[Symbol()], {}, r => r]); +const navIds = [...new Set( + Object.values(wp.m).flatMap(f => + [...Function.prototype.toString.call(f) + .matchAll(/navId:\s*["'`]([^"'`]+)["'`]/g)] + .map(m => m[1]) + ) +)] +copy(navIds.sort().join("\n")); +``` + +Updated: 3/1/2026 + +```md +ChannelNotificationCustomSettingsItems +accept-invite-modal-settings-menu +activity-popout-overflow-popout +activity-shelf-item-context +add-questions +app-details-more-menu +application-directory-profile +attachment-link-context +audio-device-context +authorized-app-action-menu +automod-rule-context +channel-attach +channel-autocomplete +channel-context +channel-mention-context +channel-summaries-context-menu +clips-context +clips-filters-context +clips-more-options +collectibles-index-page-menu +collectibles-shop-tabs-overflow-menu +command-list-sort +component-button +copy-id +dev-context +devtools-overflow +devtools-popout +download-app-menu +edit-profile-popout +emoji-studio-context-menu +exit-options +expression-picker +favorite-server-context +favorites-header-popout +forum-tag +friend-row +game-context +game-profile-context +game-shop-context +game_server-popout-context-menu +gdm-context +global-discovery-search-filter-options +global-discovery-tabs-overflow-menu +group-context-menu +guild-browse-channels-context-menu +guild-context +guild-discovery-context-menu +guild-entry-context +guild-event-image-context +guild-header-popout +guild-integrations-permission-role-context +guild-moderation-roles +guild-onboarding-splash-context +guild-product-context +guild-role-connections-context +guild-settings-role-context +guild-shop-context +image-context +image-menu +invite-roles-menu +join-call-context +join-request-guild-context +launch-context +manage-multi-account +manage-streams +member-application-context-menu +member-list-settings-menu +member-safety-flags +member-safety-guild-member-${g}-menu +member-safety-roles +members-table-join-method-menu +members-table-sort-menu +members-tabs-overflow-menu +mentions-filter +menu-story +message +message-actions +message-reminder-create +moderation-raid-context +more-settings-context +non-user-bot-profile-overflow-menu +notification-actions +notifications-inbox-message-context +now-playing-menu +overlay +overlay-channel-context +overlay-click-zone-debug-context-menu +overlay-gdm-context +overlay-go-live-widget-context-menu +overlay-group-context-menu +overlay-notifications-widget-context-menu +overlay-report-to-mod-channel-context +overlay-user-context +overlay-video-widget-context-menu +overlay-voice-widget-context-menu +pip-menu +plaintext-preview-overflow-menu +playground-settings-menu +poll-media-edit-menu +progress-bar-context +quests-entry +recents-notifications +recents-thread-notifications +report-to-mod-channel-context +role-icon-context +role-subscription-context +rtc-channel +search-result-sort-menu +search-results +search-settings-cog +send-announcement-options +set-image-for-action +set-status-submenu +set-status-submenu-mobile-web +settings-footer-more-menu +sort-and-view +sound-button-context +staff-help-popout +staff-only-entry-debug +stream-context +stream-options +subscription-context +switch-accounts-submenu +test-skus +test-store-listing +text-context +textarea-context +thread-context +unknown-user-context +user-bot-profile-add-app +user-bot-profile-overflow-menu +user-context +user-profile-friend-request-buttons +user-profile-overflow-menu +user-profile-widget-context-menu +user-settings-change-avatar +user-settings-cog +video-background-context +video-device-context +webauthn-credential-actions +welcome-settings-context +widget-game-tags +wishlist-overflow-menu +``` From 59a72fcf8154b17a17cd0802766bbd3b3fe9ff6b Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:02:28 -0500 Subject: [PATCH 07/14] Add info to NavIds --- misc/navIds.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/misc/navIds.md b/misc/navIds.md index 915b17217..ab59ba514 100644 --- a/misc/navIds.md +++ b/misc/navIds.md @@ -1,5 +1,8 @@ # NavIDs +If you have `ConsoleShortcuts` enabled you can run loadLazyChunks() +to get every navId after it loads everything with the section below + ```js const wp = webpackChunkdiscord_app.push([[Symbol()], {}, r => r]); const navIds = [...new Set( From ce64a1c2e49880b4759310a4f623ba3cfff27c66 Mon Sep 17 00:00:00 2001 From: Jakub Bednarski <45859721+Leon135@users.noreply.github.com> Date: Mon, 2 Mar 2026 03:06:15 +0100 Subject: [PATCH 08/14] feat(declutter): combine functionality of annamox & betteruserarea while adding new selectors to remove unwanted ui elements (#846) * feat(HidePersonalization): Add a plugin that allows to hide personalization features such as avatar decorations, nameplates, profile effects, and clan tags with customizable options * fix(HidePersonalization): Fix no predicate bool handling on clan tags * feat(Cleanify): Introduce Cleanify plugin to enhance UI customization and migrate settings from BetterUserArea and Anammox. This plugin combines the functionality of them and adds new features * rename(Cleanify -> Declutter): Rename plugin and add Questify conflict detection * Update index.tsx * Update index.tsx * Update index.tsx * Update index.tsx * Update index.tsx --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/equicordplugins/_api/userArea.ts | 4 +- src/equicordplugins/anammox/index.ts | 139 -------- src/equicordplugins/betterUserArea/index.ts | 130 -------- src/equicordplugins/declutter/index.tsx | 334 ++++++++++++++++++++ src/utils/constants.ts | 4 + 5 files changed, 340 insertions(+), 271 deletions(-) delete mode 100644 src/equicordplugins/anammox/index.ts delete mode 100644 src/equicordplugins/betterUserArea/index.ts create mode 100644 src/equicordplugins/declutter/index.tsx diff --git a/src/equicordplugins/_api/userArea.ts b/src/equicordplugins/_api/userArea.ts index 9ed35d0c8..4f0d731c6 100644 --- a/src/equicordplugins/_api/userArea.ts +++ b/src/equicordplugins/_api/userArea.ts @@ -5,7 +5,7 @@ */ import { isPluginEnabled } from "@api/PluginManager"; -import betterUserArea from "@equicordplugins/betterUserArea"; +import declutter from "@equicordplugins/declutter"; import { Devs } from "@utils/constants"; import definePlugin from "@utils/types"; import { findCssClassesLazy } from "@webpack"; @@ -43,6 +43,6 @@ export default definePlugin({ }, shouldHideTooltips() { - return isPluginEnabled(betterUserArea.name) && betterUserArea.settings.store.removeButtonTooltips; + return isPluginEnabled(declutter.name) && declutter.settings.store.removeButtonTooltips; } }); diff --git a/src/equicordplugins/anammox/index.ts b/src/equicordplugins/anammox/index.ts deleted file mode 100644 index 1ec80516c..000000000 --- a/src/equicordplugins/anammox/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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 { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; - -export const settings = definePluginSettings({ - dms: { - type: OptionType.BOOLEAN, - default: true, - description: "Remove shops above DMs list", - restartNeeded: true, - }, - quests: { - type: OptionType.BOOLEAN, - default: false, - description: "Remove quests above DMs list", - restartNeeded: true, - }, - serverBoost: { - type: OptionType.BOOLEAN, - default: true, - description: "Remove server boost info above channel list", - restartNeeded: true, - }, - billing: { - type: OptionType.BOOLEAN, - default: true, - description: "Remove billing settings", - restartNeeded: true, - }, - gift: { - type: OptionType.BOOLEAN, - default: true, - description: "Remove gift button", - restartNeeded: true, - }, - emojiList: { - type: OptionType.BOOLEAN, - default: true, - description: "Remove unavailable categories from the emoji picker", - restartNeeded: true, - }, -}); - -export default definePlugin({ - name: "Anammox", - description: "Hides various features related to nitro and the store", - authors: [Devs.Kyuuhachi], - settings, - - patches: [ - { - // Above DMs, mouse nav - find: 'tutorialId:"direct-messages"', - replacement: [ - { - match: /"nitro-tab-group"\)/, - replace: "$&&&undefined", - predicate: () => settings.store.dms - }, - { - match: /"discord-shop"\)/, - replace: "$&&&undefined", - predicate: () => settings.store.dms - }, - { - match: /"quests"\)/, - replace: "$&&&undefined", - predicate: () => settings.store.quests - }, - ], - }, - { - // Above DMs, keyboard nav - find: ".hasLibraryApplication()&&!", - replacement: [ - { - match: /\i\.\i\.APPLICATION_STORE,/, - replace: "/*$&*/", - }, - { - match: /\i\.\i\.COLLECTIBLES_SHOP,/, - replace: "/*$&*/", - }, - ], - predicate: () => settings.store.dms, - }, - { - // Channel list server boost progress bar - find: "useGuildActionRow", - replacement: { - match: /(GUILD_NEW_MEMBER_ACTIONS_PROGRESS_BAR\)):(\i(?:\.premiumProgressBarEnabled)?)/, - replace: "$1:null" - }, - predicate: () => settings.store.serverBoost, - }, - { - // Settings, sidebar - find: ".BILLING_SECTION,", - replacement: { - match: /(?<=buildLayout:\(\)=>)\[.+?\]/, - replace: "[]", - }, - predicate: () => settings.store.billing, - }, - { - // Gift button - find: '"sticker")', - replacement: { - match: /&&\i\.push\(\([^&]*?,"gift"\)\)/, - replace: "", - }, - predicate: () => settings.store.gift, - }, - { - // Emoji list - find: "#{intl::EMOJI_PICKER_EXPAND_EMOJI_SECTION}),size:", - replacement: { - match: /(\i)=\i\|\|!\i&&\i.\i.isEmojiCategoryNitroLocked\(\{[^}]*\}\);/, - replace: "$&$1||" - }, - predicate: () => settings.store.emojiList, - }, - { - // Emoji category list - find: "#{intl::EMOJI_CATEGORY_TOP_GUILD_EMOJI},{guildName:", - replacement: { - match: /(?<=(\i)\.unshift\((\i)\):)(?=\1\.push\(\2\))/, - replace: "$2.isNitroLocked||" - }, - predicate: () => settings.store.emojiList, - } - ], -}); diff --git a/src/equicordplugins/betterUserArea/index.ts b/src/equicordplugins/betterUserArea/index.ts deleted file mode 100644 index 0d48d04f3..000000000 --- a/src/equicordplugins/betterUserArea/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * 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 { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; - -const settings = definePluginSettings({ - removeNameplate: { - type: OptionType.BOOLEAN, - description: "Remove nameplate styling.", - default: true, - restartNeeded: true - }, - removeAudioMenus: { - type: OptionType.BOOLEAN, - description: "Remove menus next to mute and deafen buttons.", - default: true, - restartNeeded: true - }, - alwaysShowUsername: { - type: OptionType.BOOLEAN, - description: "Always show username instead of status.", - default: true, - restartNeeded: true - }, - removeButtonTooltips: { - type: OptionType.BOOLEAN, - description: "Remove button tooltips.", - default: false, - restartNeeded: true - }, - removeAvatarDecoration: { - type: OptionType.BOOLEAN, - description: "Remove avatar decoration.", - default: true, - restartNeeded: true - }, - removeUsernameStyles: { - type: OptionType.BOOLEAN, - description: "Remove username colors and effects.", - default: true, - restartNeeded: true - } -}); - -export default definePlugin({ - name: "BetterUserArea", - description: "Customize and make the user area more clean.", - authors: [Devs.prism], - settings, - patches: [ - { - find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));", - replacement: [ - { - match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/, - replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration", - predicate: () => settings.store.removeNameplate - }, - { - match: /hoverText:(\i),forceHover:\i,children:/g, - replace: "hoverText:$1,forceHover:!0,children:", - predicate: () => settings.store.alwaysShowUsername - }, - { - match: /avatarDecoration:(.{0,70}),size:/, - replace: "avatarDecoration:void 0,size:", - predicate: () => settings.store.removeAvatarDecoration - }, - { - match: /displayNameStyles:(\i),/, - replace: "displayNameStyles:void 0,", - predicate: () => settings.store.removeUsernameStyles - } - ], - }, - { - find: '"MicrophoneButton"', - replacement: [ - { - match: /:\{tooltipText:\i\};/, - replace: ":{tooltipText:void 0};", - predicate: () => settings.store.removeButtonTooltips - }, - { - match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/, - replace: "", - predicate: () => settings.store.removeAudioMenus - }, - { - match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/, - replace: "", - predicate: () => settings.store.removeAudioMenus - }, - ], - }, - { - find: "#{intl::f+DDY/::raw},{outputDeviceName", - replacement: [ - { - match: /(?<=role:"switch",)tooltipText:\i\}/, - replace: "tooltipText:void 0}", - predicate: () => settings.store.removeButtonTooltips - }, - { - match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/, - replace: "", - predicate: () => settings.store.removeAudioMenus - }, - { - match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/, - replace: "", - predicate: () => settings.store.removeAudioMenus - } - ], - }, - { - find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}", - replacement: { - match: /tooltipText:\i,tooltipPositionKey/, - replace: "tooltipText:void 0,tooltipPositionKey" - }, - predicate: () => settings.store.removeButtonTooltips - }, - ], -}); diff --git a/src/equicordplugins/declutter/index.tsx b/src/equicordplugins/declutter/index.tsx new file mode 100644 index 000000000..2aef52a4e --- /dev/null +++ b/src/equicordplugins/declutter/index.tsx @@ -0,0 +1,334 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings, migratePluginSetting, migratePluginSettings } from "@api/Settings"; +import { HeadingSecondary } from "@components/Heading"; +import { Devs, EquicordDevs } from "@utils/index"; +import definePlugin, { OptionType } from "@utils/types"; + +migratePluginSettings("Declutter", "BetterUserArea", "Anammox"); + +const migrationsAnammox = [ + ["dms", "removeShopAboveDM"], + ["quests", "removeQuestsAboveDM"], + ["serverBoost", "removeServerBoostInfo"], + ["billing", "removeBillingSettings"], + ["gift", "removeGiftButton"], + ["emojiList", "removeUnavailableEmojiPicker"], +]; + +for (const [oldKey, newKey] of migrationsAnammox) { + migratePluginSetting("Anammox", newKey, oldKey); +} + +export const settings = definePluginSettings({ + userProfileHeader: { + type: OptionType.COMPONENT, + component: () => SectionSeparator("User Profile"), + }, + removeAvatarDecoration: { + type: OptionType.BOOLEAN, + description: "Remove avatar decorations.", + default: true, + restartNeeded: true, + }, + removeNameplate: { + type: OptionType.BOOLEAN, + description: "Remove nameplates.", + default: true, + restartNeeded: true, + }, + removeProfileEffect: { + type: OptionType.BOOLEAN, + description: "Remove profile animation effects on open.", + default: true, + restartNeeded: true, + }, + removeClanTag: { + type: OptionType.BOOLEAN, + description: "Remove clan tags.", + default: true, + restartNeeded: true, + }, + alwaysShowUsername: { + type: OptionType.BOOLEAN, + description: "Always show username instead of status.", + default: true, + restartNeeded: true + }, + removeUsernameStyles: { + type: OptionType.BOOLEAN, + description: "Remove username colors and effects.", + default: true, + restartNeeded: true + }, + friendsListHeader: { + type: OptionType.COMPONENT, + component: () => SectionSeparator("Above Friends/DMs List"), + }, + removeShopAboveDM: { + type: OptionType.BOOLEAN, + description: "Remove shops above DMs list.", + default: true, + restartNeeded: true, + }, + removeQuestsAboveDM: { + type: OptionType.BOOLEAN, + description: "Remove quests above DMs list.", + default: false, + restartNeeded: true, + }, + miscHeader: { + type: OptionType.COMPONENT, + component: () => SectionSeparator("Misc"), + }, + removeServerBoostInfo: { + type: OptionType.BOOLEAN, + description: "Remove server boost info above channel list.", + default: true, + restartNeeded: true, + }, + removeBillingSettings: { + type: OptionType.BOOLEAN, + description: "Remove billing settings.", + default: true, + restartNeeded: true, + }, + removeGiftButton: { + type: OptionType.BOOLEAN, + description: "Remove gift button.", + default: true, + restartNeeded: true, + }, + removeUnavailableEmojiPicker: { + type: OptionType.BOOLEAN, + description: "Remove unavailable categories from the emoji picker.", + default: true, + restartNeeded: true, + }, + removeAudioMenus: { + type: OptionType.BOOLEAN, + description: "Remove menus next to mute and deafen buttons.", + default: true, + restartNeeded: true + }, + removeButtonTooltips: { + type: OptionType.BOOLEAN, + description: "Remove button tooltips.", + default: false, + restartNeeded: true + }, +}); + +function SectionSeparator(title: string) { + return ( + <> +
+ {title} +
+ + ); +} + +export default definePlugin({ + name: "Declutter", + description: "Declutter discord ui by removing unwanted elements such as profile customizations, shops, quests and more.", + authors: [EquicordDevs.Leon135, Devs.prism, Devs.Kyuuhachi], + settings, + patches: [ + { + // Avatar decoration + find: "getAvatarDecorationURL:", + replacement: { + match: /function \i\(\i\)\{(?=.{0,10}avatarDecoration)/, + replace: "$&return null;" + }, + predicate: () => settings.store.removeAvatarDecoration, + }, + { + // Nameplate + find: "#{intl::AVATAR_MALLOW}", + replacement: { + match: /function \i\(\i\)\{(?=.{0,25}skuId:)/, + replace: "$&return null;" + }, + predicate: () => settings.store.removeNameplate, + }, + { + // Profile banner animation effect + find: "bannerAdjustment,noBorderRadius", + replacement: { + match: /\i=\i=>\{(?=.{0,50}\.useReducedMotion\))/, + replace: "$&return null;" + }, + predicate: () => settings.store.removeProfileEffect, + }, + { + // Clan tag + find: ".GuildFeatures.GUILD_TAGS)", + replacement: { + match: /(?<=\.profile\?\.badge.{0,50}\i\)\{)/, + replace: "return false;" + }, + predicate: () => settings.store.removeClanTag, + }, + { + // Username styles and allways show username + find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));", + replacement: [ + { + match: /displayNameStyles:(\i),/, + replace: "displayNameStyles:void 0,", + predicate: () => settings.store.removeUsernameStyles + }, + { + match: /hoverText:(\i),forceHover:\i,children:/g, + replace: "hoverText:$1,forceHover:!0,children:", + predicate: () => settings.store.alwaysShowUsername + }, + ], + }, + { + // Button tooltips in user area + find: '"MicrophoneButton"', + replacement: [ + { + // Button tooltips + match: /:\{tooltipText:\i\};/, + replace: ":{tooltipText:void 0};", + predicate: () => settings.store.removeButtonTooltips + }, + { + // Audio menus + match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/, + replace: "", + predicate: () => settings.store.removeAudioMenus + }, + { + // Audio menus + match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/, + replace: "", + predicate: () => settings.store.removeAudioMenus + }, + ], + }, + { + // Button tooltips in right click audio settings + find: "#{intl::f+DDY/::raw},{outputDeviceName", + replacement: [ + { + // Button tooltips + match: /(?<=role:"switch",)tooltipText:\i\}/, + replace: "tooltipText:void 0}", + predicate: () => settings.store.removeButtonTooltips + }, + { + // Audio menus + match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/, + replace: "", + predicate: () => settings.store.removeAudioMenus + }, + { + // Audio menus + match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/, + replace: "", + predicate: () => settings.store.removeAudioMenus + } + ], + }, + { + // ? Another button tooltips + find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}", + replacement: { + match: /tooltipText:\i,tooltipPositionKey/, + replace: "tooltipText:void 0,tooltipPositionKey" + }, + predicate: () => settings.store.removeButtonTooltips + }, + { + // Above DMs section, shops and quests + find: 'tutorialId:"direct-messages"', + replacement: [ + { + match: /"nitro-tab-group"\)/, + replace: "$&&&undefined", + predicate: () => settings.store.removeShopAboveDM, + }, + { + match: /"discord-shop"\)/, + replace: "$&&&undefined", + predicate: () => settings.store.removeShopAboveDM + + }, + { + match: /"quests"\)/, + replace: "$&&&undefined", + predicate: () => settings.store.removeQuestsAboveDM + }, + ], + }, + { + // Above DMs section, keyboard navigation + find: ".hasLibraryApplication()&&!", + replacement: [ + { + match: /\i\.\i\.APPLICATION_STORE,/, + replace: "/*$&*/", + }, + { + match: /\i\.\i\.COLLECTIBLES_SHOP,/, + replace: "/*$&*/", + }, + ], + predicate: () => settings.store.removeShopAboveDM, + }, + { + // Channel list server boost progress bar + find: "useGuildActionRow", + replacement: { + match: /(GUILD_NEW_MEMBER_ACTIONS_PROGRESS_BAR\)):(\i(?:\.premiumProgressBarEnabled)?)/, + replace: "$1:null" + }, + predicate: () => settings.store.removeServerBoostInfo, + }, + { + // Billing settings + find: ".BILLING_SECTION,", + replacement: { + match: /(?<=buildLayout:\(\)=>)\[.+?\]/, + replace: "[]", + }, + predicate: () => settings.store.removeBillingSettings, + }, + { + // Gift button + find: '"sticker")', + replacement: { + match: /&&\i\.push\(\([^&]*?,"gift"\)\)/, + replace: "", + }, + predicate: () => settings.store.removeGiftButton, + }, + { + // Emoji list + find: "#{intl::EMOJI_PICKER_EXPAND_EMOJI_SECTION}),size:", + replacement: { + match: /(\i)=\i\|\|!\i&&\i.\i.isEmojiCategoryNitroLocked\(\{[^}]*\}\);/, + replace: "$&$1||" + }, + predicate: () => settings.store.removeUnavailableEmojiPicker, + }, + { + // Emoji category list + find: "#{intl::EMOJI_CATEGORY_TOP_GUILD_EMOJI},{guildName:", + replacement: { + match: /(?<=(\i)\.unshift\((\i)\):)(?=\1\.push\(\2\))/, + replace: "$2.isNitroLocked||" + }, + predicate: () => settings.store.removeUnavailableEmojiPicker, + } + ], +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 495f320a9..e0fef9853 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1260,6 +1260,10 @@ export const EquicordDevs = Object.freeze({ name: "yash", id: 889150838658977874n, }, + Leon135: { + name: "Leon135", + id: 309275452231385088n, + } } satisfies Record); // iife so #__PURE__ works correctly From 33cc2a07c5b7ee82dec5a06fdaf4744c7af5e78e Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:06:26 -0500 Subject: [PATCH 09/14] Update navIds.md --- misc/navIds.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/misc/navIds.md b/misc/navIds.md index ab59ba514..d24ed96fc 100644 --- a/misc/navIds.md +++ b/misc/navIds.md @@ -11,8 +11,9 @@ const navIds = [...new Set( .matchAll(/navId:\s*["'`]([^"'`]+)["'`]/g)] .map(m => m[1]) ) -)] -copy(navIds.sort().join("\n")); +)].sort().join("\n"); +console.log(navIds); +copy(navIds); ``` Updated: 3/1/2026 From e4329abb4ef141ccf9f6f1fa2a981d67d95292cd Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:32:06 -0500 Subject: [PATCH 10/14] ChannelTabs: Themeing Fix --- .../channelTabs/components/ChannelTab.tsx | 28 ++++++++++--------- src/equicordplugins/channelTabs/style.css | 9 ++++-- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/equicordplugins/channelTabs/components/ChannelTab.tsx b/src/equicordplugins/channelTabs/components/ChannelTab.tsx index 3481edd6e..1e37ef338 100644 --- a/src/equicordplugins/channelTabs/components/ChannelTab.tsx +++ b/src/equicordplugins/channelTabs/components/ChannelTab.tsx @@ -13,7 +13,7 @@ import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/disco import { classes } from "@utils/misc"; import { Channel, Guild, User } from "@vencord/discord-types"; import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack"; -import { Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common"; +import { Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, ThemeStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common"; import { JSX } from "react"; import { TabContextMenu } from "./ContextMenus"; @@ -428,19 +428,21 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; }) // check if quests running (questify momentLet) const hasActiveQuests = activeQuestIntervals.size > 0; - + console.log(ThemeStore.theme); return
setIsHovered(true)} diff --git a/src/equicordplugins/channelTabs/style.css b/src/equicordplugins/channelTabs/style.css index 4227d3c9d..006c8b6db 100644 --- a/src/equicordplugins/channelTabs/style.css +++ b/src/equicordplugins/channelTabs/style.css @@ -294,11 +294,16 @@ } /* stylelint-disable-next-line no-descending-specificity */ -.vc-channeltabs-tab-selected .vc-channeltabs-name-text { +.vc-channeltabs-dark-theme.vc-channeltabs-tab-selected .vc-channeltabs-name-text { font-weight: 600; color: var(--white-500); } +/* stylelint-disable-next-line no-descending-specificity */ +.vc-channeltabs-light-theme.vc-channeltabs-tab-selected .vc-channeltabs-name-text { + font-weight: 600; +} + .vc-channeltabs-tab-selected .vc-channeltabs-guild-acronym-icon span { color: var(--white-500); } @@ -1229,4 +1234,4 @@ /* disable transitions when compact expand animation is disabled */ .vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge { transition: none !important; -} +} \ No newline at end of file From 706295a24ad0822c3d06426be148496adc27e463 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:35:44 -0500 Subject: [PATCH 11/14] Fix Conflict --- src/equicordplugins/declutter/index.tsx | 2 +- src/plugins/decor/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/equicordplugins/declutter/index.tsx b/src/equicordplugins/declutter/index.tsx index 2aef52a4e..ff7370317 100644 --- a/src/equicordplugins/declutter/index.tsx +++ b/src/equicordplugins/declutter/index.tsx @@ -143,7 +143,7 @@ export default definePlugin({ // Avatar decoration find: "getAvatarDecorationURL:", replacement: { - match: /function \i\(\i\)\{(?=.{0,10}avatarDecoration)/, + match: /(?<=function \i\((\i)\){)(?=.{0,100}let{avatarDecoration)/, replace: "$&return null;" }, predicate: () => settings.store.removeAvatarDecoration, diff --git a/src/plugins/decor/index.tsx b/src/plugins/decor/index.tsx index 64a16e6bb..7eb415ca6 100644 --- a/src/plugins/decor/index.tsx +++ b/src/plugins/decor/index.tsx @@ -33,7 +33,7 @@ export default definePlugin({ { find: "getAvatarDecorationURL:", replacement: { - match: /(?<=function \i\((\i)\){)(?=let{avatarDecoration)/, + match: /(?<=function \i\((\i)\){)(?=.{0,20}let{avatarDecoration)/, replace: "const vcDecorDecoration=$self.getDecorAvatarDecorationURL($1);if(vcDecorDecoration)return vcDecorDecoration;" } }, From 689bdb9410154474c55b7b6f6e77515e7f5a2156 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:38:32 -0500 Subject: [PATCH 12/14] Fix Conflict PT 2 --- src/equicordplugins/declutter/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/equicordplugins/declutter/index.tsx b/src/equicordplugins/declutter/index.tsx index ff7370317..468595bb2 100644 --- a/src/equicordplugins/declutter/index.tsx +++ b/src/equicordplugins/declutter/index.tsx @@ -143,7 +143,7 @@ export default definePlugin({ // Avatar decoration find: "getAvatarDecorationURL:", replacement: { - match: /(?<=function \i\((\i)\){)(?=.{0,100}let{avatarDecoration)/, + match: /(?<=function \i\((\i)\){)(?=.{0,150}let{avatarDecoration)/, replace: "$&return null;" }, predicate: () => settings.store.removeAvatarDecoration, From 33fcf774cc9dc292e023171219957b9eab0d1cd9 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:03:44 -0500 Subject: [PATCH 13/14] Add QrCodeIcon Icon Component --- src/components/Icons.tsx | 20 +++++++++++++++++++ src/equicordplugins/loginWithQR/index.tsx | 2 +- src/equicordplugins/loginWithQR/ui/index.ts | 2 -- .../loginWithQR/ui/modals/QrModal.tsx | 3 ++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 65b2ac1e0..332cd3ecd 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -729,3 +729,23 @@ export const ClockIcon = (props?: any) => { ); }; + +export const QrCodeIcon = (props?: any) => { + return ( + + + + + ); +}; diff --git a/src/equicordplugins/loginWithQR/index.tsx b/src/equicordplugins/loginWithQR/index.tsx index 54d15387b..d0c36ccc6 100644 --- a/src/equicordplugins/loginWithQR/index.tsx +++ b/src/equicordplugins/loginWithQR/index.tsx @@ -6,6 +6,7 @@ import { plugins } from "@api/PluginManager"; import { definePluginSettings } from "@api/Settings"; +import { QrCodeIcon } from "@components/Icons"; import { Paragraph } from "@components/Paragraph"; import SettingsPlugin from "@plugins/_core/settings"; import { EquicordDevs } from "@utils/constants"; @@ -15,7 +16,6 @@ import definePlugin, { OptionType } from "@utils/types"; import { Button } from "@webpack/common"; import { preload, unload } from "./images"; -import { QrCodeIcon } from "./ui"; import openQrModal from "./ui/modals/QrModal"; const settings = definePluginSettings({ diff --git a/src/equicordplugins/loginWithQR/ui/index.ts b/src/equicordplugins/loginWithQR/ui/index.ts index 0683fa372..905dac04e 100644 --- a/src/equicordplugins/loginWithQR/ui/index.ts +++ b/src/equicordplugins/loginWithQR/ui/index.ts @@ -32,6 +32,4 @@ type Spinner = ComponentType, "children"> & // https://github.com/Kyuuhachi/VencordPlugins/blob/main/MessageLinkTooltip/index.tsx#L11-L33 export const Spinner = findComponentByCodeLazy('"pulsingEllipsis"') as unknown as Spinner; -export const QrCodeIcon = findComponentByCodeLazy("0v3ZM20"); - export const cl = classNameFactory("qrlogin-"); diff --git a/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx b/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx index 2f7ce90fc..302640d56 100644 --- a/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx +++ b/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx @@ -5,6 +5,7 @@ */ import { BaseText } from "@components/BaseText"; +import { QrCodeIcon } from "@components/Icons"; import { wrapTab } from "@components/settings"; import loginWithQR from "@equicordplugins/loginWithQR"; import { images } from "@equicordplugins/loginWithQR/images"; @@ -21,7 +22,7 @@ import { import jsQR, { QRCode } from "jsqr"; import { MutableRefObject, ReactElement } from "react"; -import { cl, QrCodeIcon, Spinner, SpinnerTypes } from ".."; +import { cl, Spinner, SpinnerTypes } from ".."; import openVerifyModal from "./VerifyModal"; enum LoginStateType { From df890ac4556c9b88187a1a582342d5c645e3c945 Mon Sep 17 00:00:00 2001 From: nexpid Date: Mon, 2 Mar 2026 17:06:18 +0100 Subject: [PATCH 14/14] SongSpotlight: Show off songs on your profile (#853) * feat(SongSpotlight): port from vencord PR * feat(SongSpotlight): some slight code improvements * fix(components): match Link's props with vencord * fix(SongSpotlight): ditch React.memo & csp --- package.json | 1 + pnpm-lock.yaml | 20 + src/components/Link.tsx | 5 +- .../songSpotlight.desktop/index.tsx | 91 +++++ .../songSpotlight.desktop/lib/api.ts | 136 +++++++ .../songSpotlight.desktop/lib/oauth2.tsx | 46 +++ .../lib/stores/AuthorizationStore.ts | 63 +++ .../lib/stores/SongStore.ts | 61 +++ .../lib/stores/zustand.d.ts | 18 + .../songSpotlight.desktop/lib/utils.ts | 29 ++ .../songSpotlight.desktop/native.ts | 26 ++ .../songSpotlight.desktop/service.ts | 29 ++ .../songSpotlight.desktop/style.css | 367 ++++++++++++++++++ .../songSpotlight.desktop/ui/common.tsx | 224 +++++++++++ .../ui/components/AudioPlayer.tsx | 116 ++++++ .../ui/components/ProgressCircle.tsx | 62 +++ .../ui/components/ServiceIcon.tsx | 41 ++ .../ui/settings/AddSong.tsx | 119 ++++++ .../ui/settings/SongList.tsx | 229 +++++++++++ .../ui/settings/index.tsx | 232 +++++++++++ .../ui/songs/ProfileSongs.tsx | 96 +++++ .../ui/songs/WidgetSongs.tsx | 89 +++++ .../songSpotlight.desktop/ui/songs/index.tsx | 360 +++++++++++++++++ src/utils/clipboard.ts | 4 + 24 files changed, 2462 insertions(+), 2 deletions(-) create mode 100644 src/equicordplugins/songSpotlight.desktop/index.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/lib/api.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/lib/utils.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/native.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/service.ts create mode 100644 src/equicordplugins/songSpotlight.desktop/style.css create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/common.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx create mode 100644 src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx diff --git a/package.json b/package.json index a07f6f350..6e8ac3d1b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.1", "@intrnl/xxhash64": "^0.1.2", + "@song-spotlight/api": "^2.0.0", "@streamparser/json": "^0.0.22", "@types/less": "^3.0.8", "@types/stylus": "^0.48.42", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 654192c0b..a877bef20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@intrnl/xxhash64': specifier: ^0.1.2 version: 0.1.2 + '@song-spotlight/api': + specifier: ^2.0.0 + version: 2.0.0(typescript@5.9.3) '@streamparser/json': specifier: ^0.0.22 version: 0.0.22 @@ -718,6 +721,11 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@song-spotlight/api@2.0.0': + resolution: {integrity: sha512-a3gyrSojd3BzwloxZHyDXA7HhjmCNQZOYATh7AZJyCXIqilBMwFPgQ2FVkUpzf6MzZhN9emeN6OGZOJb/rkECw==} + peerDependencies: + typescript: ^5 + '@streamparser/json@0.0.22': resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==} @@ -2650,6 +2658,9 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@babel/code-frame@7.24.7': @@ -2995,6 +3006,12 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@song-spotlight/api@2.0.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + optionalDependencies: + zod: 4.3.6 + '@streamparser/json@0.0.22': {} '@stylistic/eslint-plugin@5.6.0(eslint@9.39.1)': @@ -5286,3 +5303,6 @@ snapshots: q: 1.5.1 zod@3.24.2: {} + + zod@4.3.6: + optional: true diff --git a/src/components/Link.tsx b/src/components/Link.tsx index cad60bb6e..df82de115 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -7,9 +7,9 @@ import "./Link.css"; import { classes } from "@utils/misc"; -import type { AnchorHTMLAttributes, PropsWithChildren } from "react"; +import type { AnchorHTMLAttributes, DetailedHTMLProps, PropsWithChildren } from "react"; -export interface LinkProps extends AnchorHTMLAttributes { +export interface LinkProps extends DetailedHTMLProps, HTMLAnchorElement> { disabled?: boolean; useDefaultUnderlineStyles?: boolean; } @@ -24,6 +24,7 @@ export function Link({ children, ...restProps }: PropsWithChildren) { + const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href); const needsSafeAttrs = href && !isInternal; diff --git a/src/equicordplugins/songSpotlight.desktop/index.tsx b/src/equicordplugins/songSpotlight.desktop/index.tsx new file mode 100644 index 000000000..360dd6c7e --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/index.tsx @@ -0,0 +1,91 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; + +import { useAuthorizationStore } from "./lib/stores/AuthorizationStore"; +import { useSongStore } from "./lib/stores/SongStore"; +import { clearCache } from "./service"; +import Settings from "./ui/settings"; +import ProfileSongs from "./ui/songs/ProfileSongs"; +import WidgetSongs from "./ui/songs/WidgetSongs"; + +export default definePlugin({ + name: "SongSpotlight", + description: "Show off songs on your profile", + authors: [EquicordDevs.nexpid], + settings: definePluginSettings({ + manager: { + type: OptionType.COMPONENT, + component: () => , + }, + }), + patches: [ + // Personal profile popout + { + find: ".WIDGETS_USER_PROFILE_ACCOUNT_POPOUT_NEW_BADGE]", + replacement: { + match: /user:(\i),bio:.{0,60}}\)/, + replace: "$&,$self.renderProfileSongs({userId:$1.id})", + }, + }, + // Message user popout + { + find: ".isProvisional?(", + replacement: { + match: /user:(\i),bio:.{0,60}}\)/, + replace: "$&,$self.renderProfileSongs({userId:$1.id})", + }, + }, + // DM sidebar profile + { + find: ".SIDEBAR}),nicknameIcons:", + replacement: { + match: /{userId:(\i)\.id}\)}\)]}\)/, + replace: "$&,$self.renderProfileSongs({userId:$1.id})", + }, + }, + // Full profile modal sections (lazy loaded) + { + find: ".MUTUAL_GUILDS})),", + replacement: { + match: /(\i).push\({text.{0,50}}\);/, + replace: "$&$1.push({text:\"Song Spotlight\",section:\"SONG_SPOTLIGHT\"});", + }, + }, + { + find: ".hasUnsavedChanges()&&", + replacement: { + match: /({user:(\i),.{0,80}return (\i===))/, + replace: "$1\"SONG_SPOTLIGHT\"?$self.renderWidgetSongs({user:$2}):$3", + }, + }, + ], + + flux: { + CONNECTION_OPEN: () => { + useSongStore.getState().$refresh(); + }, + }, + start() { + // the cache lives in native.ts so it persists between reloads and + // only gets cleared on full restart. we don't want that since + // audio preview URLs expire very fast, so we just clear it on + // plugin restart instead + clearCache(); + + useSongStore.getState().$refresh(); + useAuthorizationStore.persist.rehydrate(); + }, + + renderProfileSongs: ErrorBoundary.wrap(ProfileSongs, { noop: true }), + renderWidgetSongs: ErrorBoundary.wrap(WidgetSongs, { noop: true }), +}); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/api.ts b/src/equicordplugins/songSpotlight.desktop/lib/api.ts new file mode 100644 index 000000000..868f79132 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/api.ts @@ -0,0 +1,136 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { UserData } from "@song-spotlight/api/structs"; +import { showToast, Toasts, UserStore } from "@webpack/common"; + +import { useAuthorizationStore } from "./stores/AuthorizationStore"; +import { useSongStore } from "./stores/SongStore"; +import { logger } from "./utils"; + +export const apiConstants = { + api: "https://dc.songspotlight.nexpid.xyz/", + oauth2: { + clientId: "1157745434140344321", + redirectURL: "https://dc.songspotlight.nexpid.xyz/api/auth/authorize", + }, + songLimit: 6, +}; + +export async function authFetch(_url: string | URL, options?: RequestInit) { + const url = new URL(_url); + + try { + const res = await fetch(url, { + ...options, + headers: { + ...options?.headers, + Authorization: useAuthorizationStore.getState().getToken()!, + }, + }); + + if (res.ok) return res; + + // not modified + if (res.status === 304) return null; + + // unauthorized + if (res.status === 401) { + useAuthorizationStore.getState().deleteToken(); + } + + const text = await res.text(); + showToast( + !text.includes("") && res.status >= 400 && res.status <= 599 + ? `Song Spotlight: ${text}` + : `Song Spotlight fetch error at ${url.pathname}`, + Toasts.Type.FAILURE, + ); + + logger.error( + "Got an authFetch response error", + options?.method ?? "GET", + url.toString(), + res.status, + text, + ); + throw new Error(text); + } catch (error) { + showToast(`Song Spotlight: ${error}`, Toasts.Type.FAILURE); + + logger.error( + "Got an authFetch request error", + options?.method ?? "GET", + url.toString(), + error, + ); + throw error; + } +} + +export async function getData(): Promise { + return await authFetch(new URL("api/data", apiConstants.api), { + headers: { + "If-Modified-Since": useSongStore.getState().self?.at, + } as HeadersInit, + }).then(async res => { + if (!res) return useSongStore.getState().self?.data; + + const data = await res.json(); + useSongStore.getState().update({ + data, + at: res.headers.get("Last-Modified") || undefined, + }); + return data; + }); +} +export async function listData(userId: string): Promise { + if (userId === UserStore.getCurrentUser()?.id) return await getData(); + + return await authFetch(new URL(`api/data/${userId}`, apiConstants.api), { + headers: { + "If-Modified-Since": useSongStore.getState().users[userId]?.at, + } as HeadersInit, + }).then(async res => { + if (!res) return useSongStore.getState().users[userId]?.data; + + const data = await res.json(); + useSongStore.getState().update({ + userId, + data, + at: res.headers.get("Last-Modified") || undefined, + }); + return data; + }); +} +export async function saveData(data: UserData): Promise { + return await authFetch(new URL("api/data", apiConstants.api), { + method: "PUT", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + }, + }) + .then(res => res?.json()) + .then(json => { + useSongStore + .getState().update({ + data, + at: new Date().toUTCString(), + }); + return json; + }); +} +export async function deleteData(): Promise { + return await authFetch(new URL("api/data", apiConstants.api), { + method: "DELETE", + }) + .then(res => res?.json()) + .then(json => { + useSongStore.getState().delete(); + return json; + }); +} diff --git a/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx b/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx new file mode 100644 index 000000000..3ff3bcdf9 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx @@ -0,0 +1,46 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { openModal } from "@utils/modal"; +import { ApplicationIntegrationType } from "@vencord/discord-types/enums"; +import { OAuth2AuthorizeModal, showToast, Toasts } from "@webpack/common"; + +import { apiConstants, authFetch, getData } from "./api"; +import { useAuthorizationStore } from "./stores/AuthorizationStore"; +import { logger } from "./utils"; + +export function presentOAuth2Modal() { + openModal(props => ( + { + if (!location) return; + + try { + const url = new URL(location); + url.searchParams.append("whois", "equicord"); + + const token = await (await authFetch(url))?.text(); + if (!token) return; + + useAuthorizationStore.getState().setToken(token); + getData(); + + showToast("Successfully authorized!", Toasts.Type.SUCCESS); + } catch (error) { + logger.error("Got an error during OAuth2", error); + } + }} + /> + )); +} diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts new file mode 100644 index 000000000..48e1469d5 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { proxyLazy } from "@utils/lazy"; +import { UserStore, zustandCreate, zustandPersist } from "@webpack/common"; + +import { PersistedZustandStore, ZustandDefinition } from "./zustand"; + +interface AuthorizationState { + tokens: Record; + getToken(): string | undefined; + setToken(token: string): void; + deleteToken(): void; + isAuthorized(): boolean; +} + +export const useAuthorizationStore: PersistedZustandStore = proxyLazy(() => + zustandCreate( + zustandPersist( + ((set, get) => ({ + tokens: {}, + getToken() { + return get().tokens[UserStore.getCurrentUser()?.id]; + }, + setToken(token) { + const userId = UserStore.getCurrentUser()?.id; + if (userId) { + set({ + tokens: Object.assign(get().tokens, { + [userId]: token, + }), + }); + } + }, + deleteToken() { + set({ tokens: {} }); + }, + isAuthorized() { + return !!get().getToken(); + }, + })) as ZustandDefinition, + { + name: "songspotlight-auth", + storage: { + async getItem(name: string) { + return (await DataStore.get(name)) ?? null; + }, + async setItem(name: string, value: string) { + return await DataStore.set(name, value); + }, + async removeItem(name: string) { + return await DataStore.del(name); + }, + }, + partialize: ({ tokens }) => ({ tokens }), + }, + ), + ) +); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts new file mode 100644 index 000000000..b94f57b06 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts @@ -0,0 +1,61 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { UserData } from "@song-spotlight/api/structs"; +import { proxyLazy } from "@utils/lazy"; +import { UserStore, zustandCreate } from "@webpack/common"; + +import { ZustandDefinition, ZustandStore } from "./zustand"; + +interface Data { + data: UserData; + at?: string; +} + +interface SongState { + users: Record; + self?: Data; + update(props: { + userId?: string; + data: UserData; + at?: string; + }): void; + delete(userId?: string): void; + $refresh(): void; +} + +export const useSongStore: ZustandStore = proxyLazy(() => + zustandCreate( + ((set, get) => ({ + users: {}, + update({ userId, data, at }) { + userId ??= UserStore.getCurrentUser()?.id; + if (userId) { + set({ + users: { + ...get().users, + [userId]: { data, at }, + }, + }); + } + get().$refresh(); + }, + delete(userId) { + userId ??= UserStore.getCurrentUser()?.id; + if (userId) { + const { [userId]: _, ...users } = get().users; + set({ users }); + } + get().$refresh(); + }, + $refresh() { + set({ + self: get().users[UserStore.getCurrentUser()?.id], + }); + }, + })) as ZustandDefinition, + ) +); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts new file mode 100644 index 000000000..46a068ea9 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts @@ -0,0 +1,18 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export type ZustandDefinition = (set: (state: Partial) => void, get: () => T) => T; + +export interface ZustandStore { + (): T; + getState(): T; +} + +export interface PersistedZustandStore extends ZustandStore { + persist: { + rehydrate(): Promise; + }; +} diff --git a/src/equicordplugins/songSpotlight.desktop/lib/utils.ts b/src/equicordplugins/songSpotlight.desktop/lib/utils.ts new file mode 100644 index 000000000..5c5118657 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/utils.ts @@ -0,0 +1,29 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { RenderSongInfo } from "@song-spotlight/api/handlers"; +import type { Song } from "@song-spotlight/api/structs"; +import { classNameFactory } from "@utils/css"; +import { Logger } from "@utils/Logger"; + +export const logger = new Logger("SongSpotlight"); + +export const cl = classNameFactory("vc-songspotlight-"); + +export function formatDurationMs(duration: number) { + const seconds = Math.floor(duration / 1000); + return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`; +} + +export function formatCoverTooltip(song: Song, render: RenderSongInfo) { + if (["user", "artist"].includes(song.type)) { + // "Jane Remover's Top tracks" + return `${render.label}'s ${render.sublabel}`; + } else { + // "Star people by Jane Remover" + return `${render.label} by ${render.sublabel}`; + } +} diff --git a/src/equicordplugins/songSpotlight.desktop/native.ts b/src/equicordplugins/songSpotlight.desktop/native.ts new file mode 100644 index 000000000..ea8fc1929 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/native.ts @@ -0,0 +1,26 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import * as handlers from "@song-spotlight/api/handlers"; +import type { Song } from "@song-spotlight/api/structs"; +import { setFetchHandler } from "@song-spotlight/api/util"; +import { type IpcMainInvokeEvent, net } from "electron"; + +setFetchHandler(net.fetch as unknown as typeof fetch); + +export async function parseLink(_: IpcMainInvokeEvent, link: string) { + return handlers.parseLink(link); +} +export async function renderSong(_: IpcMainInvokeEvent, song: Song) { + return handlers.renderSong(song); +} +export async function validateSong(_: IpcMainInvokeEvent, song: Song) { + return handlers.validateSong(song); +} + +export function clearCache() { + return handlers.clearCache(); +} diff --git a/src/equicordplugins/songSpotlight.desktop/service.ts b/src/equicordplugins/songSpotlight.desktop/service.ts new file mode 100644 index 000000000..e7334d52a --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/service.ts @@ -0,0 +1,29 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { RenderSongInfo } from "@song-spotlight/api/handlers"; +import { Song } from "@song-spotlight/api/structs"; +import { sid } from "@song-spotlight/api/util"; +import { PluginNative } from "@utils/types"; +import { useEffect, useState } from "@webpack/common"; + +export function useRender(song: Song) { + const [failed, setFailed] = useState(false); + const [render, setRender] = useState(null); + + useEffect(() => { + setFailed(false); + setRender(null); + renderSong(song) + .catch(() => null) + .then(info => info ? setRender(info) : setFailed(true)); + }, [sid(song)]); + + return { failed, render }; +} + +export const { parseLink, renderSong, validateSong, clearCache } = VencordNative.pluginHelpers + .SongSpotlight as PluginNative; diff --git a/src/equicordplugins/songSpotlight.desktop/style.css b/src/equicordplugins/songSpotlight.desktop/style.css new file mode 100644 index 000000000..42f8cb293 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/style.css @@ -0,0 +1,367 @@ +.vc-songspotlight-mono { + font-family: var(--font-code); +} + +.vc-songspotlight-icon { + color: var(--interactive-icon-default); +} + +.vc-songspotlight-sub { + color: var(--text-muted); +} + +.vc-songspotlight-errored { + color: var(--text-feedback-critical); +} + +.vc-songspotlight-header { + color: var(--text-strong); +} + +.vc-songspotlight-clamped { + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* components */ +.vc-songspotlight-icon-button { + display: flex; + justify-content: center; + align-items: center; + width: 28px; + height: 28px; + background: var(--opacity-20); + border: 1px solid var(--border-strong); + border-radius: 64px; + transition-property: opacity, background; + transition-duration: 0.18s; + + &:disabled { + cursor: unset; + opacity: 0.5; + } + + &[data-toggled="true"] { + background: var(--opacity-28); + } + + &:enabled:hover { + background: var(--opacity-32); + } +} + +.vc-songspotlight-icon-spinner { + > span { + gap: 2px; + + > span { + width: 5px !important; + height: 5px !important; + margin-right: 0 !important; + } + } +} + +.vc-songspotlight-explicit-tag { + background: var(--opacity-40); + border-radius: 4px; + padding: 0 5px; + flex-shrink: 0; +} + +/* settings */ +.vc-songspotlight-editable-song-container { + position: relative; + + &[data-dragging="true"] { + opacity: 0.5; + } + + &[data-insert="top"]::before, + &[data-insert="bottom"]::after { + content: ""; + position: absolute; + background: var(--green-360); + inset-inline: -8px 0; + height: 2px; + border-radius: 2px; + z-index: 1; + } + + &[data-insert="top"]::before { + top: -4px; + } + + &[data-insert="bottom"]::after { + bottom: -4px; + } +} + +.vc-songspotlight-editable-song { + background: var(--background-surface-higher); + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + cursor: pointer; + transition: 0.18s background; + isolation: isolate; + + &:hover { + background: var(--background-surface-highest); + } +} + +.vc-songspotlight-add-song { + height: 28px; +} + +.vc-songspotlight-empty-song { + background: var(--background-base-low); + height: 30px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} + +.vc-songspotlight-twin-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +/* popout */ +.vc-songspotlight-songs-container { + --base-height: 68px; + --list-height: calc(var(--base-height) + 80px); + --number-size: 32px; + + padding: 8px; + gap: 12px; + overflow: visible; +} + +.user-profile-sidebar .vc-songspotlight-songs-container { + padding: 12px; +} + +.vc-songspotlight-songs-more { + cursor: pointer; + margin-right: 4px; +} + +/* widget */ +.vc-songspotlight-widget-container { + --base-height: 88px; + --list-height: calc(var(--base-height) + 120px); + --number-size: 38px; +} + +/* somg sponglight */ +.vc-songspotlight-song-container { + --spread: 50px; + --time: 0.2s; + + position: relative; + height: var(--base-height); + flex-shrink: 0; + + &.vc-songspotlight-list-song-container { + height: var(--list-height); + } + + &::after { + --anim-duration: 1.8s; + --anim-offset: mod(calc(0.15s * var(--index)), var(--anim-duration)); + + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-image: linear-gradient(90deg, + var(--opacity-12) 0, + var(--opacity-32) var(--spread), + var(--opacity-12) calc(var(--spread) * 2)); + background-size: 600% 200%; + animation: songspotlight-skeleton-shine var(--anim-duration) var(--anim-offset) infinite linear backwards; + pointer-events: none; + transition: var(--time) opacity; + } + + &:has(> .vc-songspotlight-song)::after { + opacity: 0; + } +} + +.user-profile-popout:not(.custom-user-profile-theme) .vc-songspotlight-song-container { + background: var(--opacity-16); +} + +.vc-songspotlight-song { + height: 100%; + animation: songspotlight-fade-in var(--time) linear forwards; + border-radius: inherit; +} + +/* song grid */ +.vc-songspotlight-song-grid { + height: var(--base-height); + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr auto; + gap: 8px; + overflow: visible; + border-radius: inherit; +} + +/* song core */ +.vc-songspotlight-song-core { + grid-column: 1; + grid-row: 1 / 3; + border-radius: inherit; + overflow: visible; +} + +.vc-songspotlight-song-cover { + position: relative; + height: 100%; + aspect-ratio: 1 / 1; + border-radius: inherit; + background: var(--background-mod-muted); + flex-shrink: 0; + box-shadow: 0 3px 12px 0 rgb(0 0 0 / 16%); + transition: 0.18s transform; + overflow: visible; + + &:hover { + transform: scale(1.03); + } + + &:active { + transform: scale(0.97); + } + + > a { + border-radius: inherit; + + > img { + position: relative; + border-radius: inherit; + width: 100%; + height: 100%; + } + } + + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + transition: 0.18s background; + } + + &:hover::after { + background: rgb(0 0 0 / 20%); + } +} + +/* song service */ +.vc-songspotlight-song-service { + grid-column: 2; + grid-row: 1 / 3; +} + +/* song player */ +.vc-songspotlight-song-player { + grid-column: 1 / 3; + grid-row: 2; + transition: 0.18s opacity; + + &[data-idle="true"] { + opacity: 0; + } +} + +.vc-songspotlight-song:hover .vc-songspotlight-song-player { + opacity: 1; +} + +.vc-songspotlight-song-progress-container { + position: relative; + padding: 2px; +} + +.vc-songspotlight-song-progress { + position: absolute; + inset: 0; + color: white; + pointer-events: none; + + > circle { + will-change: stroke-dashoffset; + + &[data-empty="true"] { + transition: 0.18s stroke-dashoffset; + } + } +} + +.vc-songspotlight-song-divider { + width: 100%; + background: var(--user-profile-border); + height: 1px; + margin-top: 6px; +} + +/* song list */ +.vc-songspotlight-song-list { + flex: 1; + padding: 6px 0; +} + +/* song entry */ +.vc-songspotlight-song-entry { + padding-right: 8px; + border-radius: var(--radius-sm); + transition-property: opacity, background, transform; + transition-duration: 0.18s; + cursor: pointer; + + &[aria-disabled="true"] { + cursor: unset; + opacity: 0.5; + } + + &[aria-disabled="false"][data-playing="true"] { + background: var(--opacity-12); + } + + &[aria-disabled="false"]:hover { + background: var(--opacity-20); + } +} + +.vc-songspotlight-song-entry-number { + width: var(--number-size); + text-align: center; + flex-shrink: 0; +} + +@keyframes songspotlight-skeleton-shine { + 0% { + background-position: calc(var(--spread) * -2) 0; + } + + 30%, + 100% { + background-position: calc(500px + var(--spread) * 2) 0; + } +} + +@keyframes songspotlight-fade-in { + from { + opacity: 0; + } +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/common.tsx b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx new file mode 100644 index 000000000..0e5454fad --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx @@ -0,0 +1,224 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/index"; +import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { classes } from "@utils/index"; +import { findByCodeLazy, findCssClassesLazy } from "@webpack"; +import { Tooltip } from "@webpack/common"; +import { ComponentType, HTMLAttributes, JSX } from "react"; + +export const OverlayClasses = findCssClassesLazy("overlay", "inner", "outer"); +export const CardClasses = findCssClassesLazy("card", "headerText", "headerContextMenu"); +export const WidgetClasses = findCssClassesLazy("tabPanelScroller", "tabListScroller"); + +export enum SpinnerTypes { + CHASING_DOTS = "chasingDots", + LOW_MOTION = "lowMotion", + PULSING_ELLIPSIS = "pulsingEllipsis", + SPINNING_CIRCLE = "spinningCircle", + SPINNING_CIRCLE_SIMPLE = "spinningCircleSimple", + WANDERING_CUBES = "wanderingCubes", +} + +type Spinner = + & ComponentType< + Omit, "children"> & { + type?: SpinnerTypes; + } + > + & { + Type: typeof SpinnerTypes; + }; + +export const Spinner = findByCodeLazy('"pulsingEllipsis"') as unknown as Spinner; + +// basic components +export function ExplicitTag({ className }: HTMLAttributes) { + return ( + + {tooltipProps => ( + + E + + )} + + ); +} +interface PlayButtonProps extends HTMLAttributes { + state: boolean; + disabled?: boolean; + onClick(): void; +} + +export function PlayButton({ state, disabled, ...props }: PlayButtonProps) { + const Icon = state ? PauseIcon : PlayIcon; + + return ( + + ); +} + +// icons +export function AppleMusicIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function ImageBrokenIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + + ); +} +export function MoreHorizontalIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function PauseIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function PlayIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function PuzzlePieceIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function SoundcloudIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function SpotifyIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function TrashIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx b/src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx new file mode 100644 index 000000000..144efb75f --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx @@ -0,0 +1,116 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { logger } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { RenderInfoEntry } from "@song-spotlight/api/handlers"; +import { showToast, Toasts, useCallback, useEffect, useMemo, useRef } from "@webpack/common"; +import { RefObject } from "react"; + +interface AudioPlayerProps { + audioRef: RefObject; + list: RenderInfoEntry[]; + playing: number | false; + setPlaying(playing: number | false): void; + setLoaded(index: number, state: boolean): void; +} + +const DEFAULT_VOLUME = 0.35; + +// only allow one song to play at a time +let globalPlaying: HTMLAudioElement | undefined = undefined; + +export default function AudioPlayer({ audioRef, list, playing, setPlaying, setLoaded }: AudioPlayerProps) { + const urls = useMemo(() => list.map(x => x.audio?.previewUrl), [list]); + const audios = useRef(new Map()); + const loaded = useRef(new Set()); + + useEffect(() => { + if (playing !== false) { + const audio = audios.current.get(playing); + if (audio && loaded.current.has(playing)) { + if (globalPlaying) globalPlaying.pause(); + + audio.currentTime = 0; + audio.volume = DEFAULT_VOLUME; + audio.play().catch(error => { + showToast("Failed to play song preview!", Toasts.Type.FAILURE); + logger.error("Failed to play audio", error); + setPlaying(false); + }); + + globalPlaying = audio; + } else { + setPlaying(false); + } + } else { + for (const audio of audios.current.values()) { + if (globalPlaying === audio) audio.pause(); + } + } + + for (const audio of audios.current.values()) { + if (audio !== globalPlaying && !audio.paused) { + audio.pause(); + } else if (audio === globalPlaying && audio.paused) { + globalPlaying = undefined; + audioRef.current = undefined; + } else if (audio === globalPlaying) { + audioRef.current = audio; + } + } + }, [playing]); + + const handleRef = useCallback((index: number, audio: HTMLAudioElement | null) => { + if (audio) audios.current.set(index, audio); + else audios.current.delete(index); + }, []); + + const handleLoaded = useCallback((index: number) => { + loaded.current.add(index); + setLoaded(index, true); + }, [setLoaded]); + + const handleErrored = useCallback((index: number) => { + loaded.current.delete(index); + setLoaded(index, false); + }, [setLoaded]); + + // onPaused runs before onEnded + const justPaused = useRef(undefined); + + const handleEnded = useCallback((index: number) => { + if (justPaused.current !== index && playing !== index) return; + + const nextIndex = urls.findIndex((url, j) => url && j > index); + setPlaying(nextIndex !== -1 ? nextIndex : false); + }, [playing, setPlaying]); + + const handlePaused = useCallback((index: number) => { + if (playing === index) { + justPaused.current = index; + setPlaying(false); + } + }, [playing, setPlaying]); + + return ( + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx b/src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx new file mode 100644 index 000000000..26654a288 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx @@ -0,0 +1,62 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { useEffect, useMemo, useState } from "@webpack/common"; +import { JSX, RefObject } from "react"; + +interface ProgressCircleProps extends SvgProps { + border: number; + audioRef: RefObject; +} +type SvgProps = JSX.IntrinsicElements["svg"]; + +export default function ProgressCircle({ border, audioRef, ...props }: ProgressCircleProps) { + const { radius, stroke, circumference } = useMemo(() => { + const radius = 50 - border * 2; + return { + radius, + stroke: border * 2, + circumference: Math.PI * 2 * radius, + }; + }, [border]); + const [progress, setProgress] = useState(0); + + useEffect(() => { + let handle = requestAnimationFrame(function update() { + const audio = audioRef.current; + if (audio && !Number.isNaN(audio.duration) && !audio.paused) { + setProgress(audio.currentTime / audio.duration); + } else { + setProgress(0); + } + + handle = requestAnimationFrame(update); + }); + + return () => cancelAnimationFrame(handle); + }, [audioRef]); + + return ( + + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx b/src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx new file mode 100644 index 000000000..5d0ead98b --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx @@ -0,0 +1,41 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { AppleMusicIcon, SoundcloudIcon, SpotifyIcon } from "@equicordplugins/songSpotlight.desktop/ui/common"; +import { getServiceLabel } from "@song-spotlight/api/util"; +import { Tooltip, useMemo } from "@webpack/common"; +import { JSX } from "react"; + +const serviceIcons = { + applemusic: AppleMusicIcon, + soundcloud: SoundcloudIcon, + spotify: SpotifyIcon, +}; + +interface ServiceIconProps extends SvgProps { + service: string; +} +type SvgProps = JSX.IntrinsicElements["svg"]; + +export default function ServiceIcon({ service, width, height, ...props }: ServiceIconProps) { + const Icon: typeof SpotifyIcon = useMemo(() => serviceIcons[service], [service]); + const label = useMemo(() => getServiceLabel(service), [service]); + + return Icon && label && ( + + {tooltipProps => ( + + )} + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx new file mode 100644 index 000000000..084ed2534 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx @@ -0,0 +1,119 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Flex } from "@components/Flex"; +import { PlusIcon } from "@components/Icons"; +import { cl, logger } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { parseLink } from "@equicordplugins/songSpotlight.desktop/service"; +import { parsers } from "@song-spotlight/api/handlers"; +import { Song } from "@song-spotlight/api/structs"; +import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; +import { Clickable, TextInput, useState } from "@webpack/common"; + +interface AddSongModalProps { + modalProps: ModalProps; + close(): void; + onAdd(song: Song): string | undefined; +} + +function AddSongModal({ modalProps, close, onAdd }: AddSongModalProps) { + const [url, setURL] = useState(""); + const [error, setError] = useState(); + const [pending, setPending] = useState(false); + + return ( + + + + Add a new song + + + + + Song Spotlight supports these services: {parsers.map(x => x.label).join(", ")} + + { + setURL(value); + try { + if (value) new URL(value); + setError(undefined); + } catch { + setError("Invalid URL"); + } + }} + /> + + + + + + + + + + ); +} + +interface AddSongProps { + onAdd(song: Song): string | undefined; +} + +export default function AddSong({ onAdd }: AddSongProps) { + return ( + { + const key = openModal(modalProps => ( + closeModal(key)} + onAdd={onAdd} + /> + )); + }} + > + + + + Add song + + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx new file mode 100644 index 000000000..f09bdae34 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx @@ -0,0 +1,229 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Flex } from "@components/Flex"; +import { LinkIcon } from "@components/Icons"; +import { Link } from "@components/Link"; +import { apiConstants } from "@equicordplugins/songSpotlight.desktop/lib/api"; +import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { useRender } from "@equicordplugins/songSpotlight.desktop/service"; +import { TrashIcon } from "@equicordplugins/songSpotlight.desktop/ui/common"; +import ServiceIcon from "@equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon"; +import AddSong from "@equicordplugins/songSpotlight.desktop/ui/settings/AddSong"; +import { Song, UserData } from "@song-spotlight/api/structs"; +import { sid } from "@song-spotlight/api/util"; +import { copyWithToast } from "@utils/discord"; +import { + ContextMenuApi, + FluxDispatcher, + Menu, + React, + showToast, + useCallback, + useMemo, + useRef, + useState, +} from "@webpack/common"; +import { DragEvent } from "react"; + +interface EditableSongProps { + song: Song; + insert?: "top" | "bottom"; + setSongRef(div: HTMLAnchorElement | null): void; + onDrag(event: DragEvent): void; + onDrop(song: Song): void; + onRemove(song: Song): void; +} + +function EditableSong({ song, insert, setSongRef, onDrag, onDrop, onRemove }: EditableSongProps) { + const { render, failed } = useRender(song); + const [dragging, setDragging] = useState(false); + + return ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={render?.label} + > + render && copyWithToast(render.link)} + disabled={!render} + /> + onRemove(song)} + /> + + ))} + draggable="true" + data-dragging={dragging} + data-insert={insert} + onDragStart={event => { + setDragging(true); + onDrag(event); + }} + onDrag={onDrag} + onDragEnd={() => { + setDragging(false); + onDrop(song); + }} + useDefaultUnderlineStyles={false} + ref={div => setSongRef(div)} + className={cl("editable-song-container")} + > + + + {failed + ? Failed to load! + : ( + + + {render ? render.label : "..."} + + + {render ? render.sublabel : "..."} + + + )} + + + ); +} + +type Editable = { + slot: "song"; + song: Song; + last: boolean; +} | { + slot: "add" | "empty"; + song: undefined; + last: undefined; +}; + +interface SongListProps { + localData: UserData; + setLocalData(localData: UserData): void; +} + +export default function SongList({ localData, setLocalData }: SongListProps) { + const editable = useMemo( + () => { + if (!localData) return []; + + return new Array(apiConstants.songLimit).fill(null).map((_, i) => { + if (localData[i]) { + return { + slot: "song", + song: localData[i], + last: !localData[i + 1], + }; + } else if (localData[i - 1] || i === 0) return { slot: "add" }; + else return { slot: "empty" }; + }); + }, + [localData], + ); + + const songs = useRef(new Map()); + const [insert, setInsert] = useState(); + + const handleRef = useCallback((index: number, element: HTMLAnchorElement | null) => { + if (element) songs.current.set(index, element); + else songs.current.delete(index); + }, []); + + const handleDrag = useCallback((index: number, event: DragEvent) => { + const mapped = songs.current.entries().map(([index, element]) => { + const rect = element.getBoundingClientRect(); + return { + index, + top: rect.top, + bottom: rect.bottom, + size: rect.height, + }; + }).toArray().sort((a, b) => a.index - b.index); + + const last = mapped[mapped.length - 1]; + const spots = [ + ...mapped.map(({ index, top, size }) => ({ spot: index, position: top, size })), + { spot: last.index + 1, position: last.bottom, size: last.size }, + ].filter(({ spot }) => spot !== index && spot !== index + 1); + + let closest: number | undefined, closestDist = Infinity; + for (const { spot, position, size } of spots) { + const dist = Math.abs(event.clientY - position); + if (dist < closestDist && dist < size) { + closestDist = dist; + closest = spot; + } + } + + setInsert(closest); + }, []); + + const handleDrop = useCallback((song: Song) => { + if (insert === undefined) return; + setInsert(undefined); + + const i = localData.indexOf(song); + if (i === -1) return; + + const adjusted = insert > i ? insert - 1 : insert; + setLocalData(localData.toSpliced(i, 1).toSpliced(adjusted, 0, song)); + }, [localData, insert]); + + const handleAdd = useCallback((song: Song) => { + if (localData.length >= apiConstants.songLimit) return "Not enough space"; + if (localData.some(x => sid(x) === sid(song))) return "You've already added this song"; + + showToast("Added song!"); + setLocalData([ + ...localData, + song, + ]); + }, [localData]); + + const handleRemove = useCallback((song: Song) => { + const i = localData.indexOf(song); + if (i === -1) return; + + showToast("Removed song!"); + setLocalData(localData.toSpliced(i, 1)); + }, [localData]); + + return ( + + {editable.map(({ slot, song, last }, i) => { + if (slot === "song") { + return ( + handleRef(i, element)} + onDrag={event => handleDrag(i, event)} + onDrop={handleDrop} + onRemove={handleRemove} + key={sid(song)} + /> + ); + } else if (slot === "add") { + return ; + } else return
; + })} + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx new file mode 100644 index 000000000..8dd5a166f --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx @@ -0,0 +1,232 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Flex } from "@components/Flex"; +import { apiConstants, deleteData, getData, saveData } from "@equicordplugins/songSpotlight.desktop/lib/api"; +import { presentOAuth2Modal } from "@equicordplugins/songSpotlight.desktop/lib/oauth2"; +import { useAuthorizationStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore"; +import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore"; +import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { validateSong } from "@equicordplugins/songSpotlight.desktop/service"; +import { Spinner } from "@equicordplugins/songSpotlight.desktop/ui/common"; +import SongList from "@equicordplugins/songSpotlight.desktop/ui/settings/SongList"; +import { UserData, UserDataSchema } from "@song-spotlight/api/structs"; +import { sid } from "@song-spotlight/api/util"; +import { readClipboard } from "@utils/clipboard"; +import { copyWithToast } from "@utils/discord"; +import { ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Alerts, Parser, showToast, Toasts, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common"; + +interface ImportButtonProps { + overwrite: boolean; + pending: boolean; + setPending(pending: boolean): void; + onImport(data: UserData): void; +} + +function ImportButton({ overwrite, pending, setPending, onImport }: ImportButtonProps) { + const checkClipboard = useCallback(async () => { + setPending(true); + + let json: unknown; + try { + json = JSON.parse(await readClipboard()); + } catch { + setPending(false); + return showToast("No JSON in clipboard!", Toasts.Type.FAILURE); + } + + const { error, data } = UserDataSchema.max(apiConstants.songLimit).safeParse(json); + if (error) { + setPending(false); + return showToast("Invalid Song Spotlight data in clipboard!", Toasts.Type.FAILURE); + } + + const validated = await Promise.allSettled(data.map(song => validateSong(song))); + if (!validated.every(x => x.status === "fulfilled" && x.value)) { + setPending(false); + return showToast("One or more imported songs were invalid.", Toasts.Type.FAILURE); + } + + onImport(data); + setPending(false); + showToast("Imported songs from clipboard!", Toasts.Type.SUCCESS); + }, [pending]); + + return ( + + ); +} + +interface SettingsProps { + templateData?: UserData; +} + +export default function Settings({ templateData }: SettingsProps) { + const { isAuthorized, deleteToken } = useAuthorizationStore(); + const { self } = useSongStore(); + + const ticked = useRef(false); + const [localData, setLocalData] = useState(templateData ?? self?.data); + useEffect(() => { + // only setLocalData on the second time this effect runs + if (ticked.current) setLocalData(self?.data); + else ticked.current = true; + }, [self?.data]); + const [pending, setPending] = useState(!localData); + + const isSame = useMemo(() => + self?.data && localData + ? self.data.length === localData.length && self.data.map(sid).join(",") === localData.map(sid).join(",") + : true, [self?.data, localData]); + + useEffect(() => { + if (isAuthorized() && !localData) getData().then(() => setPending(false)); + }, [isAuthorized()]); + + if (!isAuthorized()) return ; + + return ( + + + You can also view your songs via the {Parser.parse("")} command! + + {localData + ? ( + + + Songs + {self?.at + && ( + + Last updated {Intl.DateTimeFormat().format(new Date(self.at))} + + )} + + + + + +
+ + +
+ +
+
+ ) + : } + + Authorization +
+ + +
+
+
+ ); +} + +export function SettingsModal({ modalProps, ...props }: SettingsProps & { modalProps: ModalProps; }) { + return ( + + + + Song Spotlight + + +
+ +
+
+
+
+ ); +} + +export function openSettingsModal(templateData?: UserData) { + openModal(modalProps => ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx new file mode 100644 index 000000000..9bc46c95f --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx @@ -0,0 +1,96 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Flex } from "@components/Flex"; +import { LinkIcon, PencilIcon } from "@components/Icons"; +import { listData } from "@equicordplugins/songSpotlight.desktop/lib/api"; +import { useAuthorizationStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore"; +import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore"; +import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { + CardClasses, + MoreHorizontalIcon, + OverlayClasses, + Spinner, +} from "@equicordplugins/songSpotlight.desktop/ui/common"; +import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings"; +import { sid } from "@song-spotlight/api/util"; +import { copyWithToast } from "@utils/discord"; +import { classes } from "@utils/misc"; +import { ContextMenuApi, FluxDispatcher, Menu, React, Tooltip, useEffect, UserStore, useState } from "@webpack/common"; + +import Song from "."; + +interface ProfileSongsProps { + userId: string; +} + +export default function ProfileSongs({ userId }: ProfileSongsProps) { + const [failed, setFailed] = useState(false); + const { isAuthorized } = useAuthorizationStore(); + const { users } = useSongStore(); + + const data = users[userId]?.data, at = users[userId]?.at; + useEffect(() => { + if (isAuthorized() && !data) listData(userId).catch(() => setFailed(true)); + }, [isAuthorized()]); + + const owned = UserStore.getCurrentUser().id === userId; + + if (isAuthorized() && !data && !failed) { + return ; + } else if (!data?.[0]) { + return null; + } + + return ( +
+ + Song Spotlight + + {props => ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={"Profile songs"} + > + {owned && ( + openSettingsModal()} + /> + )} + copyWithToast(JSON.stringify(data))} + /> + + ))} + className={cl("icon", "songs-more")} + /> + )} + + + + {data.map((song, i) => )} + +
+ ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx new file mode 100644 index 000000000..26c613886 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx @@ -0,0 +1,89 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import { Flex } from "@components/Flex"; +import { listData } from "@equicordplugins/songSpotlight.desktop/lib/api"; +import { useAuthorizationStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore"; +import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore"; +import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { Spinner, WidgetClasses } from "@equicordplugins/songSpotlight.desktop/ui/common"; +import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings"; +import { sid } from "@song-spotlight/api/util"; +import { classes } from "@utils/misc"; +import { User } from "@vencord/discord-types"; +import { React, ScrollerThin, useEffect, UserStore, useState } from "@webpack/common"; +import { JSX } from "react"; + +import Song from "."; + +interface WidgetSongsProps { + user: User; +} + +export default function WidgetSongs({ user }: WidgetSongsProps) { + const [failed, setFailed] = useState(false); + const { isAuthorized } = useAuthorizationStore(); + const { users } = useSongStore(); + + const data = users[user.id]?.data; + useEffect(() => { + if (isAuthorized() && !data) listData(user.id).catch(() => setFailed(true)); + }, [isAuthorized()]); + + const owned = UserStore.getCurrentUser().id === user.id; + + let full: JSX.Element | undefined; + if (failed) { + full = ( + + Uh oh! Song Spotlight failed to load. You can check the console for more details. + + ); + } else if (isAuthorized() && !data) { + full = ; + } else if (!data?.[0]) { + full = ( + <> + Looks like there's nothing here! + + {owned + ? "Well? What are you waiting for? Go add some songs to your Song Spotlight!" + : `Tell ${user.globalName || user.username} to add some songs to their Song Spotlight!`} + + {owned && ( + + )} + + ); + } + + return ( + + + {full + || data.map((song, i) => )} + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx new file mode 100644 index 000000000..f78c71819 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx @@ -0,0 +1,360 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Flex } from "@components/Flex"; +import { LinkIcon } from "@components/Icons"; +import { Link } from "@components/Link"; +import { apiConstants } from "@equicordplugins/songSpotlight.desktop/lib/api"; +import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore"; +import { cl, formatCoverTooltip, formatDurationMs } from "@equicordplugins/songSpotlight.desktop/lib/utils"; +import { parseLink, useRender } from "@equicordplugins/songSpotlight.desktop/service"; +import { + CardClasses, + ExplicitTag, + ImageBrokenIcon, + OverlayClasses, + PlayButton, + PuzzlePieceIcon, + TrashIcon, +} from "@equicordplugins/songSpotlight.desktop/ui/common"; +import AudioPlayer from "@equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer"; +import ProgressCircle from "@equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle"; +import ServiceIcon from "@equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon"; +import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings"; +import { RenderInfoEntryBased, RenderSongInfo } from "@song-spotlight/api/handlers"; +import { Song as SongType } from "@song-spotlight/api/structs"; +import { isListLayout, sid } from "@song-spotlight/api/util"; +import { copyWithToast } from "@utils/discord"; +import { classes } from "@utils/misc"; +import { + Clickable, + ContextMenuApi, + FluxDispatcher, + Menu, + React, + ScrollerThin, + showToast, + Toasts, + Tooltip, + useCallback, + useMemo, + useRef, + useState, +} from "@webpack/common"; + +interface SongEntryProps { + entry: RenderInfoEntryBased; + number: string; + isLoaded: boolean; + isPlaying: boolean; + big?: boolean; + onClick(): void; +} + +function SongEntry({ entry, number, isLoaded, isPlaying, big, onClick }: SongEntryProps) { + const baseSize = big ? "md" : "sm"; + const subSize = big ? "sm" : "xs"; + + return ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={entry.label} + > + copyWithToast(entry.link)} + /> + { + const self = useSongStore.getState().self?.data ?? []; + if (self.length >= apiConstants.songLimit) { + return showToast("You don't have enough space!"); + } + + const song = await parseLink(entry.link); + if (!song) { + return showToast("Uh oh, this song doesn't exist!", Toasts.Type.FAILURE); + } + + if (self.find(x => sid(x) === sid(song))) { + return showToast("You already have this song added!"); + } + + openSettingsModal([...self, song]); + }} + /> + + ))} + onClick={onClick} + > + + + {number} + + + + + {entry.label} + + {entry.explicit && } + + + {entry.sublabel} + + + + + ); +} + +interface SongInfoProps { + owned: boolean; + song: SongType; + render: RenderSongInfo; + big?: boolean; +} + +function SongInfo({ owned, song, render, big }: SongInfoProps) { + const [playing, setPlaying] = useState(false); + const [loaded, setLoaded] = useState(new Set()); + const audios = useMemo(() => render.form === "single" ? [render.single] : render.list, [render]); + const audioRef = useRef(undefined); + const duration = useMemo( + () => { + if (playing !== false) { + return audios[playing].audio?.duration; + } else { + return render.form === "single" ? render.single.audio?.duration : undefined; + } + }, + [playing, render], + ); + + const setLoadedAudio = useCallback((index: number, state: boolean) => + setLoaded(ld => { + if (state) ld.add(index); + else ld.delete(index); + return new Set(ld); + }), []); + + const baseSize = big ? "md" : "sm"; + const subSize = big ? "sm" : "xs"; + + return ( + + +
+ + + {props => ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={render.label} + > + copyWithToast(render.link)} + /> + {owned + ? ( + { + const self = useSongStore.getState().self?.data ?? []; + if (self.length >= apiConstants.songLimit) { + return showToast("You don't have enough space!"); + } + if (self.find(x => sid(x) === sid(song))) { + return showToast("You already have this song added!"); + } + + openSettingsModal([...self, song]); + }} + /> + ) + : ( + { + const self = useSongStore.getState().self?.data ?? []; + + const i = self.findIndex(x => sid(x) === sid(song)); + if (i === -1) { + return showToast("You... don't have this song added?"); + } + + openSettingsModal(self.toSpliced(i, 1)); + }} + /> + )} + + ))} + justifyContent="center" + alignItems="center" + gap={0} + className={cl("song-cover")} + > + + {render.thumbnailUrl + ? {render.label} + : ( + + )} + + + )} + + + + + {render.label} + + {render.explicit && } + + + {render.sublabel} + + + + + + + + {duration && ( + + {formatDurationMs(duration)} + + )} +
+ + { + const loadedI = loaded.values().toArray().sort()[0]; + if (loadedI === undefined) return; + + if (playing !== false) setPlaying(false); + else setPlaying(loadedI); + }} + /> +
+
+
+ {render.form === "list" && ( + <> +
+ + + {render.list.map((entry, i) => ( + { + if (!loaded.has(i)) return; + + if (playing !== i) setPlaying(i); + else setPlaying(false); + }} + big={big} + key={entry.link} + /> + ))} + + + + )} + + ); +} + +interface SongInfoContainerProps { + owned: boolean; + song: SongType; + index: number; + big?: boolean; +} + +export default function Song({ owned, song, index, big }: SongInfoContainerProps) { + const { failed, render } = useRender(song); + + if (failed) return null; + + return ( +
+ {render && } +
+ ); +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index c098a5490..643324ec5 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -7,3 +7,7 @@ export function copyToClipboard(text: string): Promise { return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.copy(text) : navigator.clipboard.writeText(text); } + +export function readClipboard(): Promise { + return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.read() : navigator.clipboard.readText(); +}