Merge branch 'dev'
This commit is contained in:
commit
5012f5c810
57 changed files with 3177 additions and 377 deletions
|
|
@ -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
7
.rules
|
|
@ -62,8 +62,8 @@
|
|||
|
||||
## Anti-Patterns
|
||||
|
||||
- `value !== null && value !== undefined` ⟹ `value`
|
||||
- `array && array.length > 0` ⟹ `array.length`
|
||||
- `value !== null && value !== undefined` ⟹ `value != null`
|
||||
- `array && array.length > 0` ⟹ `array.length` (only when array is guaranteed to exist)
|
||||
- `settings?.store?.value` ⟹ `settings.store.value`
|
||||
- `value || defaultValue` ⟹ `value ?? defaultValue`
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ replace: "$&,$self.renderTooltip(arguments[0].guild)"
|
|||
**Wrapping:**
|
||||
|
||||
```js
|
||||
find: "#{intl::THREE_USERS_TYPING}"
|
||||
find: "#{intl::SEVERAL_USERS_TYPING_STRONG}"
|
||||
match: /(?<="aria-atomic":!0,children:)\i/
|
||||
replace: "$self.renderTypingUsers({ children: $& })"
|
||||
```
|
||||
|
|
@ -171,6 +171,7 @@ Use `definePluginSettings` from `@api/Settings`, not inline settings object.
|
|||
From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs.
|
||||
|
||||
- `IconUtils.getUserAvatarURL(user, canAnimate?, size?)`
|
||||
- `IconUtils.getDefaultAvatarURL(id)`
|
||||
- `IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })`
|
||||
- `IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })`
|
||||
- `IconUtils.getGuildBannerURL(guild, canAnimate?)`
|
||||
|
|
|
|||
165
misc/navIds.md
Normal file
165
misc/navIds.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
20
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
});
|
||||
|
|
@ -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 })"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
|
|
|
|||
334
src/equicordplugins/declutter/index.tsx
Normal file
334
src/equicordplugins/declutter/index.tsx
Normal 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,
|
||||
}
|
||||
],
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,)/,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default definePlugin({
|
|||
}
|
||||
},
|
||||
{
|
||||
find: '"flattenMarkdown"',
|
||||
find: 'before:"@silent"',
|
||||
replacement: {
|
||||
match: /mention:{type:/,
|
||||
replace: "magnet:{type:\"inlineObject\"},$&",
|
||||
|
|
|
|||
91
src/equicordplugins/songSpotlight.desktop/index.tsx
Normal file
91
src/equicordplugins/songSpotlight.desktop/index.tsx
Normal 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 }),
|
||||
});
|
||||
136
src/equicordplugins/songSpotlight.desktop/lib/api.ts
Normal file
136
src/equicordplugins/songSpotlight.desktop/lib/api.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
46
src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx
Normal file
46
src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
@ -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>,
|
||||
)
|
||||
);
|
||||
18
src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts
vendored
Normal file
18
src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts
vendored
Normal 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>;
|
||||
};
|
||||
}
|
||||
29
src/equicordplugins/songSpotlight.desktop/lib/utils.ts
Normal file
29
src/equicordplugins/songSpotlight.desktop/lib/utils.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
26
src/equicordplugins/songSpotlight.desktop/native.ts
Normal file
26
src/equicordplugins/songSpotlight.desktop/native.ts
Normal 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();
|
||||
}
|
||||
28
src/equicordplugins/songSpotlight.desktop/service.ts
Normal file
28
src/equicordplugins/songSpotlight.desktop/service.ts
Normal 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")>;
|
||||
367
src/equicordplugins/songSpotlight.desktop/style.css
Normal file
367
src/equicordplugins/songSpotlight.desktop/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
224
src/equicordplugins/songSpotlight.desktop/ui/common.tsx
Normal file
224
src/equicordplugins/songSpotlight.desktop/ui/common.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
232
src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx
Normal file
232
src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx
Normal 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} />);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
360
src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx
Normal file
360
src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue