diff --git a/src/api/PluginManager.ts b/src/api/PluginManager.ts index c3332f856..d0568cb0a 100644 --- a/src/api/PluginManager.ts +++ b/src/api/PluginManager.ts @@ -42,6 +42,7 @@ export { Plugins as plugins }; import { traceFunction } from "../debug/Tracer"; import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer"; import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar"; +import { addProfileCollection, removeProfileCollection } from "./ProfileCollections"; import { addUserAreaButton, removeUserAreaButton } from "./UserArea"; const logger = new Logger("PluginManager", "#a6d189"); @@ -196,7 +197,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton, // Custom - renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton + renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollections } = p; if (p.start) { @@ -267,6 +268,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: } if (audioProcessor) addAudioProcessor(name, audioProcessor); if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority); + if (renderProfileCollections) addProfileCollection(name, renderProfileCollections, userAreaButton?.priority); return true; }, p => `startPlugin ${p.name}`); @@ -277,7 +279,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton, // Custom - renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton + renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollections } = p; if (p.stop) { @@ -342,6 +344,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu } if (audioProcessor) removeAudioProcessor(name); if (userAreaButton) removeUserAreaButton(name); + if (renderProfileCollections) removeProfileCollection(name); return true; }, p => `stopPlugin ${p.name}`); @@ -354,7 +357,7 @@ export const initPluginManager = onlyOnce(function init() { "onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick", "renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton", // Custom - "renderNicknameIcon" + "renderNicknameIcon", "renderProfileCollections" ]; const neededApiPlugins = new Set(); @@ -396,6 +399,7 @@ export const initPluginManager = onlyOnce(function init() { if (p.headerBarButton) neededApiPlugins.add("HeaderBarAPI"); if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI"); if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI"); + if (p.renderProfileCollections) neededApiPlugins.add("ProfileCollectionsAPI"); for (const key of pluginKeysToBind) { p[key] &&= p[key].bind(p) as any; diff --git a/src/api/ProfileCollections.tsx b/src/api/ProfileCollections.tsx new file mode 100644 index 000000000..898332b62 --- /dev/null +++ b/src/api/ProfileCollections.tsx @@ -0,0 +1,79 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Logger } from "@utils/Logger"; +import { useEffect, useState } from "@webpack/common"; +import type { JSX } from "react"; + +const logger = new Logger("ProfileCollectionAPI"); + +export type ProfileCollectionFactory = Function; + +export interface ProfileCollectionData { + /** Function that renders the collection component, receives the full profile props */ + render: ProfileCollectionFactory; + /** Higher priority collections appear first in the list. Default: 0 */ + priority?: number; +} + +interface CollectionEntry { + render: ProfileCollectionFactory; + priority: number; +} + +const profileCollections = new Map(); +const profileCollectionListeners = new Set<() => void>(); + +/** + * Adds a collection to the user profile panel. + * + * @param id - Unique identifier for the collection (e.g., "my-plugin-profile-collection") + * @param render - Function that returns the collection JSX. Receives the full profile props object. + * @param priority - Higher values appear first. Default: 0 + * + * @example + * addProfileCollection("my-collection", (props) => ( + *
Custom content for {props.user.id}
+ * )); + */ +export function addProfileCollection(id: string, render: ProfileCollectionFactory, priority = 0) { + profileCollections.set(id, { render, priority }); + profileCollectionListeners.forEach(listener => listener()); +} + +/** + * Removes a collection from the user profile panel. + * + * @param id - The identifier used when adding the collection + */ +export function removeProfileCollection(id: string) { + profileCollections.delete(id); + profileCollectionListeners.forEach(listener => listener()); +} + +function ProfileCollections({ props }: { props: any; }) { + const [, forceUpdate] = useState(0); + + useEffect(() => { + const listener = () => forceUpdate(n => n + 1); + profileCollectionListeners.add(listener); + return () => { profileCollectionListeners.delete(listener); }; + }, []); + + return Array.from(profileCollections) + .sort(([, a], [, b]) => b.priority - a.priority) + .map(([id, { render: Collection }]) => ( + logger.error(`Failed to render profile collection: ${id}`, e.error)}> + {Collection({ ...props, user: props.user ?? props.currentUser })} + + )); +} + +/** @internal Injected by ProfileCollectionAPI patch (do NOT call directly) */ +export function renderProfileCollections(props: any) { + return ; +} diff --git a/src/api/index.ts b/src/api/index.ts index 3f237e6e7..eb1d32335 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -34,6 +34,7 @@ import * as $Notices from "./Notices"; import * as $Notifications from "./Notifications"; import * as $UserArea from "./UserArea"; export * as PluginManager from "./PluginManager"; +import * as $ProfileCollections from "./ProfileCollections"; import * as $ServerList from "./ServerList"; import * as $Settings from "./Settings"; import * as $Styles from "./Styles"; @@ -170,3 +171,8 @@ export const UserArea = $UserArea; * Just used to identify if user is on Equicord as Vencord doesn't have this. */ export const isEquicord = true; + +/** + * An API allowing you to add other collections where discords game collection is. + */ +export const ProfileCollections = $ProfileCollections; diff --git a/src/equicordplugins/_api/profileCollections.ts b/src/equicordplugins/_api/profileCollections.ts new file mode 100644 index 000000000..388a76ad8 --- /dev/null +++ b/src/equicordplugins/_api/profileCollections.ts @@ -0,0 +1,37 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "ProfileCollectionsAPI", + description: "API to add collections to the user profile panel like discords game collection.", + authors: [Devs.thororen], + patches: [ + { + find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/, + replacement: { + match: /user:\i,widgets:.{0,100}?\}\),/, + replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),", + } + }, + { + find: '"UserProfileAccountPopout"', + replacement: { + match: /user:\i,widgets:.{0,100}}\),/, + replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),", + }, + }, + { + find: ".SIDEBAR,disableToolbar:", + replacement: { + match: /user:(\i),widgets:.{0,100}?\}\),/, + replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections({...arguments[0],isSideBar:true})," + } + } + ] +}); diff --git a/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx b/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx index 1f7feec48..617ac8d3d 100644 --- a/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx +++ b/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx @@ -15,6 +15,7 @@ import { Clickable, React, useEffect, UserProfileStore, useState } from "@webpac import { ReposModal } from "./ReposModal"; +const DMSideBarClasses = findCssClassesLazy("widgetPreviews"); const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer", "card", "container"); const ProfileCardContainerClasses = findCssClassesLazy("innerContainer", "icons", "icon", "displayCount", "displayCountText", "displayCountTextColor", "breadcrumb"); const ProfileCardOverlayClasses = findCssClassesLazy("overlay", "isPrivate", "outer"); @@ -41,7 +42,7 @@ function getLanguageIconUrl(language: string | null): string { return `https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/${normalized}/${normalized}-original.svg`; } -export function ProfilePopoutComponent({ id, theme }: { id: string, theme: string; }) { +export function ProfilePopoutComponent({ id, isSideBar = false }: { id: string, isSideBar?: boolean; }) { const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -92,13 +93,13 @@ export function ProfilePopoutComponent({ id, theme }: { id: string, theme: strin const topRepos = repos.slice(0, 4); - return ( + const reposSection = (
  • - + GitHub Repositories {!!repos.length && ( @@ -115,8 +116,8 @@ export function ProfilePopoutComponent({ id, theme }: { id: string, theme: strin /> {showCount && (
    - - +{repos.length - 3} + + +{repos.length - 4}
    )} @@ -131,4 +132,8 @@ export function ProfilePopoutComponent({ id, theme }: { id: string, theme: strin
); + + return isSideBar + ?
{reposSection}
+ : reposSection; } diff --git a/src/equicordplugins/githubRepos/index.tsx b/src/equicordplugins/githubRepos/index.tsx index 74282ba0f..2a572d914 100644 --- a/src/equicordplugins/githubRepos/index.tsx +++ b/src/equicordplugins/githubRepos/index.tsx @@ -33,11 +33,6 @@ export const settings = definePluginSettings({ description: "Show repository language", default: true }, - showRepositoryTab: { - type: OptionType.BOOLEAN, - description: "Show repositories tab in profile modal (hides button in connections when enabled)", - default: true - }, }); export default definePlugin({ @@ -47,30 +42,12 @@ export default definePlugin({ settings, patches: [ - // User Popout - { - find: /onOpenUserProfileModal:\i\}\),\i/, - replacement: { - match: /user:\i,widgets:.{0,100}?\}\),/, - replace: "$&$self.ProfileRepositoriesPopout(arguments[0])," - } - }, - // User Profile Modal v2 - { - find: ".MODAL_V2,onClose:", - replacement: { - match: /displayProfile:(\i).*?connections:\i.{0,25}\i.\i\}\)\}\)/, - replace: "$&,$self.ProfileRepositoriesPopout({ user: arguments[0].user, displayProfile: $1 }),", - predicate: () => !settings.store.showRepositoryTab, - } - }, // User Profile Modal v2 tab bar { find: "#{intl::USER_PROFILE_ACTIVITY}", replacement: { match: /\.MUTUAL_GUILDS\}\)\)(?=,(\i))/, replace: '$&,$1.push({text:"GitHub",section:"GITHUB"})', - predicate: () => settings.store.showRepositoryTab, } }, // User Profile Modal v2 tab content @@ -78,24 +55,19 @@ export default definePlugin({ find: ".WIDGETS?", replacement: { match: /(\i)===\i\.\i\.WISHLIST/, - replace: '$1==="GITHUB"?$self.ProfileRepositoriesTab(arguments[0]):$&' + replace: '$1==="GITHUB"?$self.renderProfileRepositoriesTab(arguments[0]):$&', } } ], - ProfileRepositoriesPopout: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => { + renderProfileCollections: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => { return ( ); - }, - { - noop: true - } - ), - ProfileRepositoriesTab: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => { + }, { noop: true }), + renderProfileRepositoriesTab: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => { return ( ); - }, - { - noop: true - } - ) + }, { noop: true }), }); diff --git a/src/equicordplugins/songSpotlight.desktop/index.tsx b/src/equicordplugins/songSpotlight.desktop/index.tsx index b6b44da53..ffaed2656 100644 --- a/src/equicordplugins/songSpotlight.desktop/index.tsx +++ b/src/equicordplugins/songSpotlight.desktop/index.tsx @@ -23,30 +23,6 @@ export default definePlugin({ authors: [EquicordDevs.nexpid], settings, patches: [ - // Personal profile popout - { - find: '"UserProfileAccountPopout"', - 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,isSidebar:true})", - }, - }, // Full profile modal sections (lazy loaded) { find: ".MUTUAL_GUILDS})),", @@ -80,6 +56,6 @@ export default definePlugin({ useAuthorizationStore.persist.rehydrate(); }, - renderProfileSongs: ErrorBoundary.wrap(ProfileSongs, { noop: true }), + renderProfileCollections: ErrorBoundary.wrap(ProfileSongs, { noop: true }), renderWidgetSongs: ErrorBoundary.wrap(WidgetSongs, { noop: true }), }); diff --git a/src/equicordplugins/songSpotlight.desktop/settings.tsx b/src/equicordplugins/songSpotlight.desktop/settings.tsx index 97af8cc3f..c374c6612 100644 --- a/src/equicordplugins/songSpotlight.desktop/settings.tsx +++ b/src/equicordplugins/songSpotlight.desktop/settings.tsx @@ -20,7 +20,7 @@ export default definePluginSettings({ type: OptionType.SLIDER, description: "How many songs are shown when initially clicking on a user", default: apiConstants.songLimit, - markers: makeRange(1, apiConstants.songLimit), + markers: makeRange(1, 3), }, manager: { type: OptionType.COMPONENT, diff --git a/src/equicordplugins/songSpotlight.desktop/ui/common.tsx b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx index eda340ce4..e5cdc1df5 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/common.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx @@ -22,6 +22,8 @@ export const ContainerClasses = findCssClassesLazy( ); export const CardClasses = findCssClassesLazy("card", "headerText", "headerContextMenu"); export const WidgetClasses = findCssClassesLazy("tabPanelScroller", "tabListScroller"); +export const DMSideBarClasses = findCssClassesLazy("widgetPreviews"); +export const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer", "card", "container"); export enum SpinnerTypes { CHASING_DOTS = "chasingDots", diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/CollapsedProfileSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/CollapsedProfileSongs.tsx index 804afcb09..61909fea3 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/songs/CollapsedProfileSongs.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/CollapsedProfileSongs.tsx @@ -7,9 +7,10 @@ import { BaseText } from "@components/index"; import { Native } from "@equicordplugins/songSpotlight.desktop/service"; import { - CardClasses, ContainerClasses, + DMSideBarClasses, OverlayClasses, + ProfileCardClasses, Spinner, } from "@equicordplugins/songSpotlight.desktop/ui/common"; import { RenderSongInfo } from "@song-spotlight/api/handlers"; @@ -34,9 +35,10 @@ interface CollapsedProfileSongsProps extends ProfileSongsProps { data?: UserData; } -export default function CollapsedProfileSongs({ data, userId, isSidebar }: CollapsedProfileSongsProps) { +export default function CollapsedProfileSongs({ data, user, isSideBar }: CollapsedProfileSongsProps) { const [renders, setRenders] = useState(new Map()); const previews = useMemo(() => data?.slice(0, shownSongs), [data]); + const userId = user?.id; useEffect(() => { setRenders(new Map()); @@ -52,57 +54,67 @@ export default function CollapsedProfileSongs({ data, userId, isSidebar }: Colla } }, [previews]); - return ( -
{ - const user = UserStore.getUser(userId); - if (!user) return; + const songsSection = ( +
+
    +
  • +
    { + const user = UserStore.getUser(userId); + if (!user) return; - const guildId = SelectedGuildStore.getGuildId(); - UserProfileActions.openUserProfileModal({ - userId, - guildId, - channelId: SelectedChannelStore.getChannelId(), - analyticsLocation: { - page: guildId ? "Guild Channel" : "DM Channel", - section: "Profile Popout", - }, - tabSection: "SONG_SPOTLIGHT", - }); - }} - > -
    - Song Spotlight -
    - {previews && data - ? previews.map((song, i) => { - const render = renders.get(sid(song)); - const extra = i === shownSongs - 1 && data.length > shownSongs; + const guildId = SelectedGuildStore.getGuildId(); + UserProfileActions.openUserProfileModal({ + userId, + guildId, + channelId: SelectedChannelStore.getChannelId(), + analyticsLocation: { + page: guildId ? "Guild Channel" : "DM Channel", + section: "Profile Popout", + }, + tabSection: "SONG_SPOTLIGHT", + }); + }} + > +
    + Song Spotlight +
    + {previews && data + ? previews.map((song, i) => { + const render = renders.get(sid(song)); + const extra = i === shownSongs - 1 && data.length > shownSongs; - return ( -
    - {render?.thumbnailUrl && ( - {render.label} - )} - {extra && ( -
    - +{data.length - shownSongs} -
    - )} -
    - ); - }) - : } -
    -
    -
    + return ( +
    + {render?.thumbnailUrl && ( + {render.label} + )} + {extra && ( +
    + +{data.length - shownSongs} +
    + )} +
    + ); + }) + : } +
    +
    +
+ + + ); + + return isSideBar + ?
{songsSection}
+ : songsSection; } diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx index 8137349c4..3f8428c3f 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx @@ -23,6 +23,7 @@ import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/set import { sid } from "@song-spotlight/api/util"; import { copyWithToast } from "@utils/discord"; import { classes } from "@utils/index"; +import { User } from "@vencord/discord-types"; import { ContextMenuApi, FluxDispatcher, @@ -39,16 +40,16 @@ import Song from "."; import CollapsedProfileSongs from "./CollapsedProfileSongs"; export interface ProfileSongsProps { - userId: string; - isSidebar: boolean; + user: User; + isSideBar: boolean; } -export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) { +export default function ProfileSongs({ user, isSideBar }: ProfileSongsProps) { const [failed, setFailed] = useState(false); const { isAuthorized } = useAuthorizationStore(); const { users } = useSongStore(); const { profileSongsLimit, collapseSongList } = settings.use(); - + const userId = user?.id; const data = users[userId]?.data; useEffect(() => { if (isAuthorized() && !data) listData(userId).catch(() => setFailed(true)); @@ -70,8 +71,8 @@ export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) { return ( ); } else if (pending) { @@ -81,14 +82,14 @@ export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) { return (
- + Song Spotlight diff --git a/src/plugins/reviewDB/index.tsx b/src/plugins/reviewDB/index.tsx index c3f4f21ac..3342e2d27 100644 --- a/src/plugins/reviewDB/index.tsx +++ b/src/plugins/reviewDB/index.tsx @@ -82,26 +82,6 @@ export default definePlugin({ "user-profile-overflow-menu": userContextPatch }, - patches: [ - // In the user popout. eg: when clicking the name in chat - { - // DM profile sidebar - find: ".SIDEBAR,disableToolbar:", - replacement: { - match: /user:(\i),widgets:.{0,100}?\}\),/, - replace: "$&$self.renderProfileComponent({user:$1,isSideBar:true})," - } - }, - { - // User popout - find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/, - replacement: { - match: /user:(\i),widgets:.{0,100}?\}\),/, - replace: "$&$self.renderProfileComponent({user:$1})," - } - } - ], - flux: { CONNECTION_OPEN: initAuth, }, @@ -156,7 +136,7 @@ export default definePlugin({ }, 4000); }, - renderProfileComponent: ErrorBoundary.wrap(({ user, isSideBar = false }: { user: User; isSideBar?: boolean; }) => { + renderProfileCollections: ErrorBoundary.wrap(({ user, isSideBar = false }: { user: User; isSideBar?: boolean; }) => { const [reviewData] = useAwaiter(() => getReviews(user.id, { limit: 4 }), { deps: [user.id], fallbackValue: null }); // Discord are masters at using a crap ton of html elements and css classes to create a simple ui that could have @@ -192,7 +172,7 @@ export default definePlugin({ {showCount && (
- +{reviewData.reviewCount - 3} + +{reviewData.reviewCount - 4}
)} diff --git a/src/utils/types.ts b/src/utils/types.ts index b80dc8a10..54defc959 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -27,6 +27,7 @@ import type { MessageDecorationFactory } from "@api/MessageDecorations"; import type { MessageClickListener, MessageEditListener, MessageSendListener } from "@api/MessageEvents"; import type { MessagePopoverButtonData, MessagePopoverButtonFactory } from "@api/MessagePopover"; import type { NicknameIconFactory } from "@api/NicknameIcons"; +import { ProfileCollectionFactory } from "@api/ProfileCollections"; import type { UserAreaButtonData } from "@api/UserArea"; import type { Command, FluxEvents } from "@vencord/discord-types"; import type { ReactNode } from "react"; @@ -208,6 +209,7 @@ export interface PluginDef { headerBarButton?: HeaderBarButtonData; audioProcessor?: AudioProcessor; userAreaButton?: UserAreaButtonData; + renderProfileCollections?: ProfileCollectionFactory; // TODO: Remove eventually /**