Merge branch 'dev' into page

This commit is contained in:
thororen1234 2026-03-02 17:25:43 -05:00
commit 62052bbf77
No known key found for this signature in database
57 changed files with 3177 additions and 377 deletions

View file

@ -32,7 +32,7 @@ import { EquicordDevs } from "@utils/constants";
export default definePlugin({
name: "PluginName", // PascalCase, matches directory name
description: "Does something.", // Capital first, period at end
description: "Does something", // Capital first
authors: [EquicordDevs.Name], // EquicordDevs for new, Devs for upstream
});
```
@ -76,8 +76,7 @@ Flag these and suggest the fix:
| `value \|\| defaultValue` | `value ?? defaultValue` | `\|\|` falsifies `0`, `""`, `false` |
| `` `${classA} ${classB}` `` | `classes(classA, classB)` | Handles null/false gracefully |
| `"vc-plugin-class"` hardcoded | `cl("class")` via `classNameFactory` | Consistent, typo-proof |
| `console.log/warn/error` | Remove it | No logging in plugin code |
| `new Logger(...)` / `logger.log(...)` | Remove it | No logging in plugin code |
| `console.log/warn` | Remove it | No logging in plugin code |
| `cdn.discordapp.com/avatars/...` | `IconUtils.getUserAvatarURL(user)` | Handles animated, sizing, CDN |
| `cdn.discordapp.com/icons/...` | `IconUtils.getGuildIconURL(...)` | Same |
| `cdn.discordapp.com/banners/...` | `IconUtils.getUserBannerURL(...)` | Same |
@ -246,7 +245,7 @@ Vencord.Plugins.plugins["OtherPlugin"].someFunction();
## Text and Descriptions
- Plugin and setting descriptions: capital first letter, end with period.
- Plugin and setting descriptions: capital first letter.
- Error messages and toasts: natural human text, no dashes or robotic formatting.
```typescript
@ -317,8 +316,7 @@ stop() {}
- **No overengineering.** No premature abstractions, no "just in case" handling, no features not requested. Three similar lines beats a one-use helper. If code can be half the size and do the same thing, it should be.
- **No bloat.** Flag unnecessary wrapper functions, redundant type annotations, excessive error handling for things that can't fail, useless try/catch around synchronous code, or 50 lines doing what 10 could do. Code should be lean.
- **No unused imports.** Every import must be referenced.
- **No logging in plugin code.** No `console.log/warn/error`, no `Logger`. Remove all logging statements.
- **No empty catch blocks.** Handle every error (show toast, return fallback, rethrow, etc.).
- **No logging in plugin code besides for errors.** No `console.log/warn`.
---
@ -378,6 +376,6 @@ If a PR reimplements any of these, flag it. They already exist:
## Review Severity
1. **CRITICAL** — `any` types, hardcoded CDN/API URLs, direct DOM manipulation, security issues, massive overengineering/bloat, suspected AI slop (note it, but still review everything)
2. **HIGH** — Missing useEffect cleanup, forbidden React APIs, hardcoded class names, logging statements (console or Logger), missing definePluginSettings, unnecessary abstractions
2. **HIGH** — Missing useEffect cleanup, forbidden React APIs, hardcoded class names, console logging statements, missing definePluginSettings, unnecessary abstractions
3. **MEDIUM** — Anti-patterns (`||` instead of `??`), utility reimplementations, commented-out code, bad description format, excessive comments
4. **LOW** — Style preferences, minor performance wins, organization

7
.rules
View file

@ -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?)`

165
misc/navIds.md Normal file
View file

@ -0,0 +1,165 @@
# 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(
Object.values(wp.m).flatMap(f =>
[...Function.prototype.toString.call(f)
.matchAll(/navId:\s*["'`]([^"'`]+)["'`]/g)]
.map(m => m[1])
)
)].sort().join("\n");
console.log(navIds);
copy(navIds);
```
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
```

View file

@ -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",
@ -94,7 +95,7 @@
"typescript-transform-paths": "^3.5.5",
"zip-local": "^0.3.5"
},
"packageManager": "pnpm@10.29.2",
"packageManager": "pnpm@10.30.3",
"pnpm": {
"patchedDependencies": {
"@types/less@3.0.8": "patches/@types__less@3.0.8.patch"

20
pnpm-lock.yaml generated
View file

@ -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

View file

@ -729,3 +729,23 @@ export const ClockIcon = (props?: any) => {
</Icon>
);
};
export const QrCodeIcon = (props?: any) => {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M4 6c0-1.1.9-2 2-2h3a1 1 0 0 0 0-2H6a4 4 0 0 0-4 4v3a1 1 0 0 0 2 0V6ZM4 18c0 1.1.9 2 2 2h3a1 1 0 1 1 0 2H6a4 4 0 0 1-4-4v-3a1 1 0 1 1 2 0v3ZM20 6a2 2 0 0 0-2-2h-3a1 1 0 1 1 0-2h3a4 4 0 0 1 4 4v3a1 1 0 1 1-2 0V6Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
fill={props.fill || "currentColor"}
d="M5 7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7Zm2 0h2v2H7V7ZM5 15c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2Zm2 0h2v2H7v-2ZM13 7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V7Zm2 0h2v2h-2V7ZM17.08 13a1.5 1.5 0 0 0-1.42 1.03c-.09.25-.3.47-.56.47H15a2 2 0 0 0-2 2V20c0 1.1.9 2 2 2h6a2 2 0 0 0 2-2v-3.5a2 2 0 0 0-2-2h-.1c-.26 0-.47-.22-.56-.47A1.5 1.5 0 0 0 18.92 13h-1.84ZM20 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
/>
</Icon>
);
};

View file

@ -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<HTMLAnchorElement> {
export interface LinkProps extends DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement> {
disabled?: boolean;
useDefaultUnderlineStyles?: boolean;
}
@ -24,6 +24,7 @@ export function Link({
children,
...restProps
}: PropsWithChildren<LinkProps>) {
const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href);
const needsSafeAttrs = href && !isInternal;

View file

@ -72,7 +72,7 @@ export default definePlugin({
// regardless of if the "discodo" effect is enabled or not. This is due to the volume setter
// internally calling the ensureAudio function which is where the internal sounds module is loaded
// by default. To account for this, the module is force loaded in the first patch in the above group.
find: "UPDATE_OPEN_ON_STARTUP",
find: '"UPDATE_OPEN_ON_STARTUP"',
group: true,
replacement: [
{

View file

@ -16,7 +16,7 @@ export default definePlugin({
{
find: '?"BACK_FORWARD_NAVIGATION":',
replacement: {
match: /(?<="HELP".{0,150}className:\i\}\))(?=\])/,
match: /(?<="HELP"===.{0,150}className:\i\.\i\}\))(?=\])/,
replace: ",...Vencord.Api.HeaderBar._addHeaderBarButtons()"
}
},

View file

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

View file

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

View file

@ -43,7 +43,7 @@ export default definePlugin({
},
{
// Show all activities in the user popout/sidebar
find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/,
find: /onOpenUserProfileModal:\i,onClose:\i\}\),null/,
replacement: {
match: /((\i)=.{0,10}(\i)\.id\).*?,onInteraction:\i\}\),).{0,250}onClose:\i\}\)/,
replace: "$1$self.showAllActivitiesComponent({ activity: $2, user: $3 })"

View file

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

View file

@ -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 <div
className={cl("tab", {
"tab-compact": compact,
"tab-selected": isTabSelected(id),
"tab-entering": isEntering,
"tab-closing": isClosing,
"tab-dragging": isDragging,
"tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks
})}
className={cl("tab",
ThemeStore.theme === "light" ? "light-theme" : "dark-theme",
{
"tab-compact": compact,
"tab-selected": isTabSelected(id),
"tab-entering": isEntering,
"tab-closing": isClosing,
"tab-dragging": isDragging,
"tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks
})}
key={index}
ref={ref}
onMouseEnter={() => setIsHovered(true)}

View file

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

View file

@ -98,18 +98,11 @@ export default definePlugin({
},
// enables clips
{
find: "2022-11_clips_experiment",
find: "2026-03-clips-experiment",
replacement: {
match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,showClipsHeaderEntrypoint:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\d,enableAdvancedSignals:!\d\}/,
replace: "defaultConfig:{enableClips:!0,ignorePlatformRestriction:$self.settings.store.ignorePlatformRestriction,showClipsHeaderEntrypoint:!0,enableScreenshotKeybind:$self.settings.store.enableScreenshotKeybind,enableVoiceOnlyClips:$self.settings.store.enableVoiceOnlyClips,enableAdvancedSignals:$self.settings.store.enableAdvancedSignals}"
}
},
{
find: "2023-10_viewer_clipping",
replacement: {
match: /defaultConfig:\{enableViewerClipping:!\d,ignoreSenderPreference:!\d\}/,
replace: "defaultConfig:{enableViewerClipping:!0,ignoreSenderPreference:!0}"
}
}
],

View file

@ -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("Declutter", 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 (
<>
<hr style={{ width: "100%" }} />
<HeadingSecondary>{title}</HeadingSecondary>
<hr style={{ width: "100%" }} />
</>
);
}
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,150}let{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,
}
],
});

View file

@ -4,17 +4,27 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { ChannelRTCStore, IconUtils, UserStore, VoiceStateStore } from "@webpack/common";
import definePlugin, { OptionType } from "@utils/types";
import { ChannelRTCStore, ChannelStore, GuildMemberStore, IconUtils, UserStore, VoiceStateStore } from "@webpack/common";
import style from "./style.css?managed";
const settings = definePluginSettings({
useServerProfileAvatars: {
type: OptionType.BOOLEAN,
description: "Use server profile avatars in guild voice channels when available.",
default: false
}
});
export default definePlugin({
name: "FullVCPFP",
description: "Makes avatars take up the entire vc tile",
authors: [EquicordDevs.mochienya],
settings,
patches: [
{
find: "\"data-selenium-video-tile\":",
@ -29,15 +39,37 @@ export default definePlugin({
if (!className.includes("tile") || !participantUserId) return;
const user = UserStore.getUser(participantUserId);
const channelId = VoiceStateStore.getVoiceStateForUser(participantUserId)?.channelId!;
if (!user) return;
const channelId = VoiceStateStore.getVoiceStateForUser(participantUserId)?.channelId;
if (!channelId) return;
const isSpeaking = ChannelRTCStore.getSpeakingParticipants(channelId).some(p => p.user.id === participantUserId && p.speaking);
const avatarUrl = IconUtils.getUserAvatarURL(user, isSpeaking, 1024);
const avatarUrl = settings.store.useServerProfileAvatars
? this.getServerAvatarUrl(participantUserId, channelId, isSpeaking) ?? IconUtils.getUserAvatarURL(user, isSpeaking, 1024)
: IconUtils.getUserAvatarURL(user, isSpeaking, 1024);
return {
"--full-res-avatar": `url(${avatarUrl})`
};
},
getServerAvatarUrl(userId: string, channelId: string, canAnimate: boolean) {
const guildId = ChannelStore.getChannel(channelId)?.guild_id;
if (!guildId) return;
const guildAvatar = GuildMemberStore.getMember(guildId, userId)?.avatar;
if (!guildAvatar) return;
return IconUtils.getGuildMemberAvatarURLSimple({
userId,
guildId,
avatar: guildAvatar,
canAnimate,
size: 1024
});
},
start() {
enableStyle(style);
},

View file

@ -19,14 +19,14 @@ import { FluxDispatcher, Menu, React, Tooltip, UserStore } from "@webpack/common
interface CallUpdate {
ringing: string[];
ongoingRings: Record<number, string>;
ongoingRings: string[];
messageId: string;
region: string;
}
const args: CallUpdate = {
ringing: [],
ongoingRings: {},
ongoingRings: [],
messageId: "",
region: "",
};
@ -34,6 +34,8 @@ const args: CallUpdate = {
const ignoredChannelIds = new Set<string>();
const cl = classNameFactory("vc-ignore-calls-");
const Deafen = findComponentByCodeLazy("0-1.02-.1H3.05a9");
const filterOngoingRings = (currentUserId: string): CallUpdate["ongoingRings"] =>
args.ongoingRings.filter((id: string) => id !== currentUserId);
const ContextMenuPatch: NavContextMenuPatchCallback = (children, { channel }: { channel: Channel; }) => {
if (!channel || (!channel.isDM() && !channel.isGroupDM())) return;
@ -109,7 +111,7 @@ export default definePlugin({
flux: {
async CALL_UPDATE({ ringing, ongoingRings, messageId, region }) {
args.ringing = ringing || [];
args.ongoingRings = ongoingRings || {};
args.ongoingRings = Array.isArray(ongoingRings) ? ongoingRings : [];
args.messageId = messageId;
args.region = region;
}
@ -122,7 +124,7 @@ export default definePlugin({
type: "CALL_UPDATE",
channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
ongoingRings: filterOngoingRings(currentUserId),
messageId: args.messageId,
region: args.region
});
@ -143,7 +145,7 @@ export default definePlugin({
type: "CALL_UPDATE",
channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
ongoingRings: filterOngoingRings(currentUserId),
messageId: args.messageId,
region: args.region
});

View file

@ -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(
<Menu.MenuItem
id="vc-jump-to-first"
label="Jump To First Message"
action={() => jumpToFirstMessage(channel.id, channel.guild_id)}
action={() => jumpToFirstMessage(targetChannel.id, targetChannel.guild_id)}
/>,
<Menu.MenuItem
id="vc-jump-to-last"
label="Jump To Last Message"
action={() => 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(
<Menu.MenuItem
id="vc-jump-to-first"
@ -99,11 +101,9 @@ const UserMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }:
const MessageMenuPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
if (!message) return;
const channelId = SelectedChannelStore.getChannelId();
const guildId = SelectedGuildStore.getGuildId();
if (!channelId || !guildId) return;
children.push(
<Menu.MenuItem
id="vc-jump-to-first-user"
@ -129,4 +129,4 @@ export default definePlugin({
"user-context": UserMenuPatch,
"message": MessageMenuPatch
}
});
});

View file

@ -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({

View file

@ -32,6 +32,4 @@ type Spinner = ComponentType<Omit<HTMLAttributes<HTMLDivElement>, "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-");

View file

@ -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 {

View file

@ -229,7 +229,7 @@ function LogsContent({ visibleMessages, canLoadMore, sortNewest, tab, reset, han
);
}
const LogsContentMemo = LazyComponent(() => React.memo(LogsContent));
const LogsContentMemo = LazyComponent(() => LogsContent);
function NoResults({ tab }: { tab: LogTabs; }) {
const generateSuggestedTabs = (tab: LogTabs) => {

View file

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

View file

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

View file

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

View file

@ -1229,7 +1229,7 @@ export default definePlugin({
},
{
// Hides Quests tab in the DMs tab list.
find: "QUEST_HOME_V2):",
find: ".QUEST_HOME):",
replacement: [
{
match: /(?<="family-center"\):null,)/,

View file

@ -22,7 +22,7 @@ export default definePlugin({
}
},
{
find: '"flattenMarkdown"',
find: 'before:"@silent"',
replacement: {
match: /mention:{type:/,
replace: "magnet:{type:\"inlineObject\"},$&",

View file

@ -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 { Native } 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: () => <Settings />,
},
}),
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}\)}\).{0,100}]}\)(?=\]\}\))/,
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
Native.clearCache();
useSongStore.getState().$refresh();
useAuthorizationStore.persist.rehydrate();
},
renderProfileSongs: ErrorBoundary.wrap(ProfileSongs, { noop: true }),
renderWidgetSongs: ErrorBoundary.wrap(WidgetSongs, { noop: true }),
});

View file

@ -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("<body>") && 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<UserData | undefined> {
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<UserData | undefined> {
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<true> {
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<true> {
return await authFetch(new URL("api/data", apiConstants.api), {
method: "DELETE",
})
.then(res => res?.json())
.then(json => {
useSongStore.getState().delete();
return json;
});
}

View file

@ -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 => (
<OAuth2AuthorizeModal
{...props}
clientId={apiConstants.oauth2.clientId}
scopes={["applications.commands", "identify"]}
integrationType={ApplicationIntegrationType.USER_INSTALL}
permissions={0n}
responseType="code"
redirectUri={apiConstants.oauth2.redirectURL}
cancelCompletesFlow={false}
callback={async ({ location }) => {
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);
}
}}
/>
));
}

View file

@ -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<string, string>;
getToken(): string | undefined;
setToken(token: string): void;
deleteToken(): void;
isAuthorized(): boolean;
}
export const useAuthorizationStore: PersistedZustandStore<AuthorizationState> = 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<AuthorizationState>,
{
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 }),
},
),
)
);

View file

@ -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<string, Data>;
self?: Data;
update(props: {
userId?: string;
data: UserData;
at?: string;
}): void;
delete(userId?: string): void;
$refresh(): void;
}
export const useSongStore: ZustandStore<SongState> = 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<SongState>,
)
);

View file

@ -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<T> = (set: (state: Partial<T>) => void, get: () => T) => T;
export interface ZustandStore<T> {
(): T;
getState(): T;
}
export interface PersistedZustandStore<T> extends ZustandStore<T> {
persist: {
rehydrate(): Promise<void>;
};
}

View file

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

View file

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

View file

@ -0,0 +1,28 @@
/*
* 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<RenderSongInfo | null>(null);
useEffect(() => {
setFailed(false);
setRender(null);
Native.renderSong(song)
.catch(() => null)
.then(info => info ? setRender(info) : setFailed(true));
}, [sid(song)]);
return { failed, render };
}
export const Native = VencordNative.pluginHelpers.SongSpotlight as PluginNative<typeof import("./native")>;

View file

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

View file

@ -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<HTMLAttributes<HTMLDivElement>, "children"> & {
type?: SpinnerTypes;
}
>
& {
Type: typeof SpinnerTypes;
};
export const Spinner = findByCodeLazy('"pulsingEllipsis"') as unknown as Spinner;
// basic components
export function ExplicitTag({ className }: HTMLAttributes<HTMLDivElement>) {
return (
<Tooltip text="Explicit">
{tooltipProps => (
<BaseText
{...tooltipProps}
size="xs"
weight="medium"
className={classes(cl("explicit-tag"), className)}
>
E
</BaseText>
)}
</Tooltip>
);
}
interface PlayButtonProps extends HTMLAttributes<HTMLButtonElement> {
state: boolean;
disabled?: boolean;
onClick(): void;
}
export function PlayButton({ state, disabled, ...props }: PlayButtonProps) {
const Icon = state ? PauseIcon : PlayIcon;
return (
<button
{...props}
disabled={disabled}
data-toggled={state}
className={classes(cl("icon-button"), props.className)}
>
{disabled
? <Spinner type={Spinner.Type.PULSING_ELLIPSIS} className={cl("icon-spinner")} />
: <Icon width={16} height={16} className={cl("icon")} />}
</button>
);
}
// icons
export function AppleMusicIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M19.7 1C21.5 1 23 2.5 23 4.3v15.4c0 1.8-1.5 3.3-3.3 3.3H4.3A3.3 3.3 0 0 1 1 19.7V4.3C1 2.5 2.5 1 4.3 1h15.4Zm-2.4 3.8s0-.9-.7-.7L9.3 5.6s-.6.1-.6.8v8.7c0 .5-.7.8-2 .9-2.4.4-2 4.5 1.3 3.4 1.3-.5 1.6-1.7 1.6-3V9.6c0-.3 0-.5.4-.5l6-1.2c.2 0 .4 0 .4.4v5.2c0 .5-.6.8-2 1-2.4.3-2 4.4 1.4 3.3 1.3-.5 1.5-1.7 1.5-3v-10Z"
/>
</svg>
);
}
export function ImageBrokenIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M5 2a3 3 0 0 0-3 3v4.7l3.33 3.33 3.39-3.38a1.5 1.5 0 0 1 2.12 0l3.38 3.38 3.39-3.38a1.5 1.5 0 0 1 2.12 0L22 11.92V5a3 3 0 0 0-3-3H5Z"
/>
<path
fill={props.fill || "currentColor"}
d="m22 14.75-3.33-3.34-3.39 3.39a1.5 1.5 0 0 1-2.12 0L9.78 11.4 6.39 14.8a1.5 1.5 0 0 1-2.12 0L2 12.53V19a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4.25Z"
/>
</svg>
);
}
export function MoreHorizontalIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M4 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm8 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
/>
</svg>
);
}
export function PauseIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M6 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H6ZM15 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3Z"
/>
</svg>
);
}
export function PlayIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M9.25 3.35C7.87 2.45 6 3.38 6 4.96v14.08c0 1.58 1.87 2.5 3.25 1.61l10.85-7.04a1.9 1.9 0 0 0 0-3.22L9.25 3.35Z"
/>
</svg>
);
}
export function PuzzlePieceIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M16 4a3 3 0 1 1-5.98-.31c.03-.35-.21-.69-.56-.69H7a3 3 0 0 0-3 3v2.5c0 .28-.23.5-.5.54a3 3 0 0 0 0 5.92c.27.04.5.26.5.54V18a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3h-2.46c-.35 0-.6.34-.56.69L16 4Z"
/>
</svg>
);
}
export function SoundcloudIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M1 15.8q0 .4.3.7t.6.1q.3 0 .5-.2l.1-.6V13a.7.7 0 0 0-.2-.5.7.7 0 0 0-.6-.2.7.7 0 0 0-.5.2.7.7 0 0 0-.2.5zm2.4 1.3q0 .3.2.4a1 1 0 0 0 .5.2l.6-.2q.2-.1.2-.4v-6.8a.7.7 0 0 0-.3-.5.7.7 0 0 0-.5-.2.7.7 0 0 0-.5.2.7.7 0 0 0-.2.5zm2.3.3q0 .3.2.5a1 1 0 0 0 .6.1 1 1 0 0 0 .5-.1q.2-.2.2-.5v-6.2a.7.7 0 0 0-.2-.5.7.7 0 0 0-.5-.2.8.8 0 0 0-.6.2.7.7 0 0 0-.2.5zm2.4 0q0 .6.7.6.8 0 .8-.6v-10q0-.9-.6-1l-.6.2Q8 7 8 7.4zm2.3.3v-11q0-.4.4-.5a6 6 0 0 1 4.2.5q1.4.7 2.2 2t1 2.8q.6-.3 1.3-.3a3.4 3.4 0 0 1 2.5 1q1 1 1 2.4T22 17a3.4 3.4 0 0 1-2.5 1h-8.9a.2.2 0 0 1-.1-.1.3.3 0 0 1 0-.2"
/>
</svg>
);
}
export function SpotifyIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M12 1a11 11 0 1 0 0 22 11 11 0 0 0 0-22Zm4.8 15.8a.7.7 0 0 1-1 .2c-2.5-1.6-5.8-2-9.6-1a.7.7 0 1 1-.3-1.4A14 14 0 0 1 16.6 16c.3.2.4.6.2.9Zm1.3-3a.9.9 0 0 1-1.1.3c-3-1.8-7.5-2.3-11-1.3a.9.9 0 0 1-.5-1.6c4-1.2 9-.6 12.4 1.4.4.3.5.8.2 1.2Zm1.5-3.5a1 1 0 0 1-1.4.4A18.1 18.1 0 0 0 5.6 9.4a1 1 0 0 1-.6-2C8.8 6.4 15.2 6.6 19.3 9c.5.3.6 1 .4 1.4Z"
/>
</svg>
);
}
export function TrashIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill={props.fill || "currentColor"}
d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z"
/>
<path
fill={props.fill || "currentColor"}
d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z"
clipRule="evenodd"
fillRule="evenodd"
/>
</svg>
);
}

View file

@ -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<HTMLAudioElement | undefined>;
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<number, HTMLAudioElement>());
const loaded = useRef(new Set<number>());
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<number>(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 (
<div style={{ display: "none" }} aria-hidden="true">
{urls.map((url, i) =>
url && (
<audio
key={url}
src={url}
preload="metadata"
ref={audio => handleRef(i, audio)}
onLoadedData={() => handleLoaded(i)}
onError={() => handleErrored(i)}
onEnded={() => handleEnded(i)}
onPause={() => handlePaused(i)}
/>
)
)}
</div>
);
}

View file

@ -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<HTMLAudioElement | undefined>;
}
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 (
<svg
{...props}
viewBox="0 0 100 100"
>
<circle
cx={50}
cy={50}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
strokeLinecap="round"
transform="rotate(-90 50 50)"
data-empty={progress === 0}
/>
</svg>
);
}

View file

@ -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 && (
<Tooltip text={label}>
{tooltipProps => (
<Icon
{...tooltipProps}
{...props}
className={cl("icon")}
width={width ?? 20}
height={height ?? 20}
/>
)}
</Tooltip>
);
}

View file

@ -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 { Native } 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<string>();
const [pending, setPending] = useState(false);
return (
<ErrorBoundary>
<ModalRoot {...modalProps}>
<ModalHeader>
<BaseText size="xl" weight="bold">Add a new song</BaseText>
</ModalHeader>
<ModalContent>
<Flex flexDirection="column" gap="10px">
<BaseText size="md" weight="normal" className={cl("sub")}>
Song Spotlight supports these services: <b>{parsers.map(x => x.label).join(", ")}</b>
</BaseText>
<TextInput
placeholder="https://open.spotify.com/..."
error={error}
onChange={value => {
setURL(value);
try {
if (value) new URL(value);
setError(undefined);
} catch {
setError("Invalid URL");
}
}}
/>
</Flex>
</ModalContent>
<ModalFooter>
<Flex justifyContent="flex-end" gap={0}>
<Button
variant="primary"
onClick={async () => {
setPending(true);
try {
const parsed = await Native.parseLink(url);
if (!parsed) {
setError("Invalid link");
return setPending(false);
}
const result = onAdd(parsed);
if (result) {
setError(result);
return setPending(false);
}
close();
} catch (error) {
logger.error("parseLink error", error);
setError("Failed to parse link");
setPending(false);
}
}}
disabled={!url || !!error || pending}
>
Add song
</Button>
</Flex>
</ModalFooter>
</ModalRoot>
</ErrorBoundary>
);
}
interface AddSongProps {
onAdd(song: Song): string | undefined;
}
export default function AddSong({ onAdd }: AddSongProps) {
return (
<Clickable
onClick={() => {
const key = openModal(modalProps => (
<AddSongModal
modalProps={modalProps}
close={() => closeModal(key)}
onAdd={onAdd}
/>
));
}}
>
<Flex alignItems="center" gap="12px" className={cl("editable-song", "add-song")}>
<PlusIcon width={28} height={28} className={cl("icon")} />
<Flex flexDirection="column" justifyContent="center" gap={0}>
<BaseText size="md" weight="medium">Add song</BaseText>
</Flex>
</Flex>
</Clickable>
);
}

View file

@ -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<HTMLAnchorElement>): 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 (
<Link
href={render?.link}
onContextMenu={e =>
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="vc-songspotlight-editable-song"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={render?.label}
>
<Menu.MenuItem
id="copy-link"
label="Copy link"
icon={LinkIcon}
action={() => render && copyWithToast(render.link)}
disabled={!render}
/>
<Menu.MenuItem
id="remove-song"
color="danger"
label="Remove song"
icon={TrashIcon}
action={() => onRemove(song)}
/>
</Menu.Menu>
))}
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")}
>
<Flex alignItems="center" gap="12px" className={cl("editable-song")}>
<ServiceIcon width={28} height={28} service={song.service} />
{failed
? <BaseText size="md" weight="normal" className={cl("errored")}>Failed to load!</BaseText>
: (
<Flex flexDirection="column" justifyContent="center" gap={0}>
<BaseText size="md" weight="medium" className={cl("clamped")}>
{render ? render.label : "..."}
</BaseText>
<BaseText size="sm" weight="normal" className={cl("clamped", "sub")}>
{render ? render.sublabel : "..."}
</BaseText>
</Flex>
)}
</Flex>
</Link>
);
}
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<Editable[]>(
() => {
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<number, HTMLAnchorElement>());
const [insert, setInsert] = useState<number>();
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<HTMLAnchorElement>) => {
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 (
<Flex flexDirection="column" gap="6px">
{editable.map(({ slot, song, last }, i) => {
if (slot === "song") {
return (
<EditableSong
song={song}
insert={last && insert === i + 1 ? "bottom" : insert === i ? "top" : undefined}
setSongRef={element => handleRef(i, element)}
onDrag={event => handleDrag(i, event)}
onDrop={handleDrop}
onRemove={handleRemove}
key={sid(song)}
/>
);
} else if (slot === "add") {
return <AddSong onAdd={handleAdd} key={slot} />;
} else return <div className={cl("empty-song")} key={`${slot}-${i}`} />;
})}
</Flex>
);
}

View file

@ -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 { Native } 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 => Native.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 (
<Button
variant="secondary"
onClick={async () => {
if (overwrite) {
Alerts.show({
title: "Are you sure?",
body: "This will overwrite your current songs.",
onConfirm: checkClipboard,
confirmText: "Continue",
cancelText: "Nevermind",
});
} else checkClipboard();
}}
disabled={pending}
>
Import from clipboard
</Button>
);
}
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 <Button onClick={() => presentOAuth2Modal()}>Sign in to Song Spotlight</Button>;
return (
<Flex flexDirection="column" gap="20px">
<BaseText size="md" weight="normal">
You can also view your songs via the {Parser.parse("</songspotlight:1468320979938971802>")} command!
</BaseText>
{localData
? (
<Flex flexDirection="column" gap="12px">
<Flex flexDirection="column" gap={0}>
<BaseText size="lg" weight="semibold">Songs</BaseText>
{self?.at
&& (
<BaseText size="xs" weight="normal" className={cl("sub")}>
Last updated <b>{Intl.DateTimeFormat().format(new Date(self.at))}</b>
</BaseText>
)}
</Flex>
<Flex flexDirection="column" gap="6px">
<SongList
localData={localData}
setLocalData={setLocalData}
/>
</Flex>
<Flex flexDirection="column" gap="8px">
<div className={cl("twin-buttons")}>
<Button
variant="secondary"
onClick={() => copyWithToast(JSON.stringify(localData))}
disabled={pending}
>
Copy to clipboard
</Button>
<ImportButton
overwrite={!!localData[0]}
pending={pending}
setPending={setPending}
onImport={setLocalData}
/>
</div>
<Button
variant="primary"
onClick={async () => {
setPending(true);
try {
await saveData(localData);
showToast("Successfully saved songs!", Toasts.Type.SUCCESS);
} finally {
setPending(false);
}
}}
disabled={isSame || pending}
>
Save
</Button>
</Flex>
</Flex>
)
: <Spinner type={Spinner.Type.WANDERING_CUBES} />}
<Flex flexDirection="column" gap="12px">
<BaseText size="lg" weight="semibold">Authorization</BaseText>
<div className={cl("twin-buttons")}>
<Button
variant="dangerPrimary"
onClick={() => {
deleteToken();
showToast("Successfully signed out!", Toasts.Type.SUCCESS);
}}
disabled={pending}
>
Sign out
</Button>
<Button
variant="dangerSecondary"
onClick={() =>
Alerts.show({
title: "Are you sure?",
body: "This will permanently delete all of your songs.",
onConfirm: async () => {
setPending(true);
try {
await deleteData();
deleteToken();
showToast("Successfully deleted songs!", Toasts.Type.SUCCESS);
} finally {
setPending(false);
}
},
confirmColor: "danger",
confirmText: "Delete",
cancelText: "Nevermind",
})}
disabled={!self?.data[0] || pending}
>
Delete songs
</Button>
</div>
</Flex>
</Flex>
);
}
export function SettingsModal({ modalProps, ...props }: SettingsProps & { modalProps: ModalProps; }) {
return (
<ErrorBoundary>
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<BaseText size="xl" weight="bold">Song Spotlight</BaseText>
</ModalHeader>
<ModalContent>
<div style={{ marginBottom: "20px" }}>
<Settings {...props} />
</div>
</ModalContent>
</ModalRoot>
</ErrorBoundary>
);
}
export function openSettingsModal(templateData?: UserData) {
openModal(modalProps => <SettingsModal modalProps={modalProps} templateData={templateData} />);
}

View file

@ -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 <Spinner type={Spinner.Type.WANDERING_CUBES} />;
} else if (!data?.[0]) {
return null;
}
return (
<div
className={classes(OverlayClasses.overlay, CardClasses.card, cl("songs-container"))}
key="song-spotlight-profile-songs"
>
<Flex justifyContent="space-between">
<BaseText size="xs" weight="semibold" className={cl("header")}>Song Spotlight</BaseText>
<Tooltip text="More">
{props => (
<MoreHorizontalIcon
{...props}
width={16}
height={16}
onClick={e =>
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="vc-songspotlight-profile-songs"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={"Profile songs"}
>
{owned && (
<Menu.MenuItem
id="edit-songs"
label="Edit songs"
icon={PencilIcon}
action={() => openSettingsModal()}
/>
)}
<Menu.MenuItem
id="copy-link"
label="Copy JSON"
icon={LinkIcon}
action={() => copyWithToast(JSON.stringify(data))}
/>
</Menu.Menu>
))}
className={cl("icon", "songs-more")}
/>
)}
</Tooltip>
</Flex>
<Flex flexDirection="column" gap="6px">
{data.map((song, i) => <Song owned={owned} song={song} index={i} key={sid(song)} />)}
</Flex>
</div>
);
}

View file

@ -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 = (
<BaseText size="md" weight="semibold" className={cl("errored")}>
Uh oh! Song Spotlight failed to load. You can check the console for more details.
</BaseText>
);
} else if (isAuthorized() && !data) {
full = <Spinner type={Spinner.Type.SPINNING_CIRCLE} />;
} else if (!data?.[0]) {
full = (
<>
<BaseText size="lg" weight="semibold">Looks like there's nothing here!</BaseText>
<BaseText size="md" weight="normal">
{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!`}
</BaseText>
{owned && (
<Button
variant="secondary"
style={{ marginTop: "14px", flexShrink: 1 }}
onClick={() => openSettingsModal()}
>
Edit songs
</Button>
)}
</>
);
}
return (
<ScrollerThin
fade
className={classes(WidgetClasses.tabPanelScroller, cl("widget-container"))}
key="song-spotlight-widget-songs"
>
<Flex
flexDirection="column"
justifyContent={full && "center"}
alignItems={full && "center"}
gap={full ? "8px" : "10px"}
style={full && { flex: 1, textAlign: "center" }}
>
{full
|| data.map((song, i) => <Song owned={owned} song={song} index={i} big key={sid(song)} />)}
</Flex>
</ScrollerThin>
);
}

View file

@ -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 { Native, 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 (
<Clickable
onContextMenu={e =>
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="vc-songspotlight-song-entry"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={entry.label}
>
<Menu.MenuItem
id="copy-link"
label="Copy link"
icon={LinkIcon}
action={() => copyWithToast(entry.link)}
/>
<Menu.MenuItem
id="steal-song"
label="Steal song"
icon={PuzzlePieceIcon}
action={async () => {
const self = useSongStore.getState().self?.data ?? [];
if (self.length >= apiConstants.songLimit) {
return showToast("You don't have enough space!");
}
const song = await Native.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]);
}}
/>
</Menu.Menu>
))}
onClick={onClick}
>
<Flex
alignItems="center"
gap="4px"
className={cl("song-entry")}
aria-disabled={!isLoaded}
data-playing={isPlaying}
>
<Flex alignItems="center" justifyContent="center" gap={0} className={cl("song-entry-number")}>
<BaseText size={baseSize} weight="medium">{number}</BaseText>
</Flex>
<Flex flexDirection="column" justifyContent="center" gap={0}>
<Flex alignItems="center" gap="6px">
<BaseText size={subSize} weight="semibold" className={cl("clamped")} title={entry.label}>
{entry.label}
</BaseText>
{entry.explicit && <ExplicitTag />}
</Flex>
<BaseText size={subSize} weight="normal" className={cl("clamped", "sub")} title={entry.sublabel}>
{entry.sublabel}
</BaseText>
</Flex>
</Flex>
</Clickable>
);
}
interface SongInfoProps {
owned: boolean;
song: SongType;
render: RenderSongInfo;
big?: boolean;
}
function SongInfo({ owned, song, render, big }: SongInfoProps) {
const [playing, setPlaying] = useState<number | false>(false);
const [loaded, setLoaded] = useState(new Set<number>());
const audios = useMemo(() => render.form === "single" ? [render.single] : render.list, [render]);
const audioRef = useRef<HTMLAudioElement>(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 (
<Flex flexDirection="column" gap={0} className={cl("song")}>
<AudioPlayer
audioRef={audioRef}
list={audios}
playing={playing}
setPlaying={setPlaying}
setLoaded={setLoadedAudio}
/>
<div className={cl("song-grid")}>
<Flex gap="8px" alignItems="center" className={cl("song-core")}>
<Tooltip text={formatCoverTooltip(song, render)}>
{props => (
<Flex
{...props}
onContextMenu={e =>
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="vc-songspotlight-song"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={render.label}
>
<Menu.MenuItem
id="copy-link"
label="Copy link"
icon={LinkIcon}
action={() => copyWithToast(render.link)}
/>
{owned
? (
<Menu.MenuItem
id="steal-song"
label="Steal song"
icon={PuzzlePieceIcon}
action={() => {
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]);
}}
/>
)
: (
<Menu.MenuItem
id="remove-song"
color="danger"
label="Remove song"
icon={TrashIcon}
action={() => {
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));
}}
/>
)}
</Menu.Menu>
))}
justifyContent="center"
alignItems="center"
gap={0}
className={cl("song-cover")}
>
<Link href={render.link} style={{ display: "contents" }}>
{render.thumbnailUrl
? <img src={render.thumbnailUrl} alt={render.label} />
: (
<ImageBrokenIcon
className={cl("icon")}
/>
)}
</Link>
</Flex>
)}
</Tooltip>
<Flex
flexDirection="column"
justifyContent="center"
gap={0}
className={cl("clamped")}
>
<Flex alignItems="center" gap="6px">
<BaseText size={baseSize} weight="semibold" className={cl("clamped")} title={render.label}>
{render.label}
</BaseText>
{render.explicit && <ExplicitTag />}
</Flex>
<BaseText
size={baseSize}
weight="normal"
className={cl("clamped", "sub")}
title={render.sublabel}
>
{render.sublabel}
</BaseText>
</Flex>
</Flex>
<Flex justifyContent="flex-end" className={cl("song-service")}>
<ServiceIcon service={song.service} />
</Flex>
<Flex
justifyContent="flex-end"
alignItems="flex-end"
gap="6px"
className={cl("song-player")}
data-idle={playing === false && !big}
>
{duration && (
<BaseText size={subSize} weight="medium" className={cl("mono", "sub")}>
{formatDurationMs(duration)}
</BaseText>
)}
<div className={cl("song-progress-container")}>
<ProgressCircle
border={2}
audioRef={audioRef}
className={cl("song-progress")}
/>
<PlayButton
state={playing !== false}
disabled={loaded.size < 1}
onClick={() => {
const loadedI = loaded.values().toArray().sort()[0];
if (loadedI === undefined) return;
if (playing !== false) setPlaying(false);
else setPlaying(loadedI);
}}
/>
</div>
</Flex>
</div>
{render.form === "list" && (
<>
<div className={cl("song-divider")} />
<ScrollerThin fade className={cl("song-list")}>
<Flex flexDirection="column" gap="3px">
{render.list.map((entry, i) => (
<SongEntry
entry={entry}
number={(i + 1).toString()}
isLoaded={loaded.has(i)}
isPlaying={playing === i}
onClick={() => {
if (!loaded.has(i)) return;
if (playing !== i) setPlaying(i);
else setPlaying(false);
}}
big={big}
key={entry.link}
/>
))}
</Flex>
</ScrollerThin>
</>
)}
</Flex>
);
}
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 (
<div
className={classes(
OverlayClasses.overlay,
CardClasses.card,
cl("song-container", {
"list-song-container": isListLayout(song, render || undefined),
}),
)}
style={{
["--index" as any]: index.toString(),
}}
>
{render && <SongInfo owned={owned} song={song} render={render} big={big} />}
</div>
);
}

View file

@ -118,7 +118,7 @@ function makeContextCallback(
children.push(
<Menu.MenuItem
id={`c98-view-${name}-raw`}
label="View Raw"
label="View Raw Variant"
action={() => action(value)}
icon={CopyIcon}
/>,

View file

@ -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 (
<svg
@ -109,17 +134,12 @@ export default definePlugin({
}
if (role.icon) {
const roleIcon = role.icon;
children.push(
<Menu.MenuItem
id="vc-view-role-icon"
label="View Role Icon"
action={() => {
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}
/>

View file

@ -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;"
}
},

View file

@ -30,12 +30,20 @@ export default definePlugin({
{
find: '["VIDEO","CLIP","AUDIO"]',
replacement: {
match: /(\[\i>0&&\i\.length>0.{0,150}?children:)(\i.slice\(\i\))(?<=showDownload:(\i).+?isVisualMediaType:(\i).+?)/,
replace: (_, rest, origChildren, showDownload, isVisualMediaType) => `${rest}[${showDownload}&&${isVisualMediaType}&&$self.PictureInPictureButton(),...${origChildren}]`
match: /(\[\i>0&&\i\.length>0.{0,150}?children:)(\i.slice\(\i\))(?<=mimeType:(\i),downloadURL:(\i).+?showDownload:(\i).+?isVisualMediaType:(\i).+?)/,
replace: (_, rest, origChildren, mimeType, downloadURL, showDownload, isVisualMediaType) =>
`${rest}[${showDownload}&&${isVisualMediaType}&&$self.shouldShowButton(${mimeType},${downloadURL})&&$self.PictureInPictureButton(),...${origChildren}]`
}
}
],
shouldShowButton(mimeType: string[] = [], downloadURL?: string) {
const normalizedMimeType = mimeType.join("/");
if (normalizedMimeType.startsWith("video/")) return true;
if (!downloadURL) return false;
return /\.(mp4|webm|mov|m4v|ogv|avi)(?:$|[?#])/i.test(downloadURL);
},
PictureInPictureButton: ErrorBoundary.wrap(() => {
return (
<Tooltip text="Toggle Picture in Picture">

View file

@ -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<string, Session>;
};
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; }) => (
<Tooltip text={tooltip}>
@ -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;

View file

@ -20,7 +20,7 @@ import { classNameFactory, ModalCloseButton, ModalContent, ModalFooter, ModalHea
import definePlugin, { OptionType } from "@utils/types";
import { GuildMember, Message, User } from "@vencord/discord-types";
import { findByCodeLazy, findStoreLazy } from "@webpack";
import { ChannelStore, GuildMemberStore, GuildRoleStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
import { ChannelStore, GuildMemberStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
import { JSX } from "react";
const SMYNC = classNameFactory();
@ -480,9 +480,7 @@ function renderUsername(
const ircColorsEnabled = isPluginEnabled(ircColors.name);
const authorTopRole = (author as GuildMember)?.guildId && (author as any)?.highestRoleId;
const authorTopRoleColor = authorTopRole && GuildRoleStore.getRole((author as GuildMember).guildId, authorTopRole)?.colorStrings;
const authorColorStrings = colorStrings || (author as any)?.colorStrings || authorTopRoleColor || null;
const authorColorStrings = colorStrings || (author as any)?.colorStrings || null;
const authorDisplayNameStyles = (!inGuild && !ircColorsEnabled && (author as any)?.displayNameStyles) || null;
const effectType = authorDisplayNameStyles ? getEffectType(authorDisplayNameStyles.effectId) : null;
const effectCSSVars = authorDisplayNameStyles ? computeEffectCSSVars(authorDisplayNameStyles) : {};
@ -492,9 +490,9 @@ function renderUsername(
const shouldAnimateEffect = shouldShowEffect && !AccessibilityStore.useReducedMotion;
const canUseGradient = ((author as GuildMember)?.guildId ? (GuildStore.getGuild((author as GuildMember).guildId) ?? {}).premiumFeatures?.features.includes("ENHANCED_ROLE_COLORS") : !inGuild);
const useTopStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const hasGradient = !!topStyle?.gradient && Object.keys(topStyle.gradient).length > 0;
const useTopRoleStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topRoleStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const hasGradient = !!topRoleStyle?.gradient && Object.keys(topRoleStyle.gradient).length > 0;
const textMutedValue = getComputedStyle(document.documentElement)?.getPropertyValue("--text-muted")?.trim() || "#72767d";
const options = splitTemplate(includedNames);
@ -649,7 +647,7 @@ function renderUsername(
// Only mentions and reactions popouts should patch in the gradient glow or else a double glow will appear on messages.
const hoveringClass = (isHovering ? " smyn-gradient-hovered" : "");
const gradientClasses = useTopStyle
const gradientClasses = useTopRoleStyle
? "smyn-gradient smyn-gradient-inherit-bg" + hoveringClass
: "smyn-gradient smyn-gradient-unset-bg" + hoveringClass;
@ -681,7 +679,7 @@ function renderUsername(
<span
style={{
...topLevelStyle,
...(topStyle?.normal.original || {})
...(topRoleStyle?.normal.original || {})
}}
className="smyn-container"
>
@ -690,7 +688,7 @@ function renderUsername(
<span
className={SMYNC(firstGroupClasses, { [gradientClasses]: shouldGradientGlow })}
data-text={shouldGradientGlow ? firstDataText : undefined}
style={(shouldGradientGlow && useTopStyle && topStyle ? topStyle.gradient.animated : undefined) as React.CSSProperties}
style={(shouldGradientGlow && useTopRoleStyle && topRoleStyle ? topRoleStyle.gradient.animated : undefined) as React.CSSProperties}
>
<span
className={SMYNC(firstNameClasses, {
@ -701,12 +699,12 @@ function renderUsername(
data-username-with-effects={needsEffectDataAttr && shouldShowEffect ? first.name : undefined}
style={shouldShowEffect
? undefined
: topStyle ?
shouldAnimateGradients && topStyle.gradient
? topStyle.gradient.animated
: topStyle.gradient
? topStyle.gradient.static.original
: topStyle.normal.original
: topRoleStyle ?
shouldAnimateGradients && topRoleStyle.gradient
? topRoleStyle.gradient.animated
: topRoleStyle.gradient
? topRoleStyle.gradient.static.original
: topRoleStyle.normal.original
: undefined
}>
{first.wrapped}</span>

View file

@ -7,3 +7,7 @@
export function copyToClipboard(text: string): Promise<void> {
return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.copy(text) : navigator.clipboard.writeText(text);
}
export function readClipboard(): Promise<string> {
return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.read() : navigator.clipboard.readText();
}

View file

@ -1260,6 +1260,10 @@ export const EquicordDevs = Object.freeze({
name: "yash",
id: 889150838658977874n,
},
Leon135: {
name: "Leon135",
id: 309275452231385088n,
}
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly