From eca90777d122f4fe50ca6e80815dfa93035536b9 Mon Sep 17 00:00:00 2001 From: prism Date: Tue, 3 Mar 2026 16:18:41 +0100 Subject: [PATCH] add plugin: ClickableRoles (#864) * add plugin: ClickableRoles * Update index.tsx --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/plugins/clickableRoles/index.tsx | 187 +++++++++++++++++++++++++++ src/plugins/clickableRoles/style.css | 80 ++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 src/plugins/clickableRoles/index.tsx create mode 100644 src/plugins/clickableRoles/style.css diff --git a/src/plugins/clickableRoles/index.tsx b/src/plugins/clickableRoles/index.tsx new file mode 100644 index 000000000..8f86916c0 --- /dev/null +++ b/src/plugins/clickableRoles/index.tsx @@ -0,0 +1,187 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { openUserProfile } from "@utils/discord"; +import { isTruthy } from "@utils/guards"; +import { Logger } from "@utils/Logger"; +import definePlugin from "@utils/types"; +import type { Role } from "@vencord/discord-types"; +import { findByPropsLazy } from "@webpack"; +import { Constants, GuildRoleStore, IconUtils, Popout, RestAPI, ScrollerThin, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common"; + +const logger = new Logger("ClickableRoles"); + +const cl = classNameFactory("vc-clickableroles-"); + +const GuildActions = findByPropsLazy("requestMembersById", "banUser"); + +const MAX_VISIBLE_MEMBERS = 20; +const CACHE_TTL = 60_000; + +const memberCache = new Map(); + +function getCachedMemberIds(guildId: string, roleId: string) { + const key = `${guildId}-${roleId}`; + const cached = memberCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.ids; + return null; +} + +function setCachedMemberIds(guildId: string, roleId: string, ids: string[]) { + memberCache.set(`${guildId}-${roleId}`, { ids, timestamp: Date.now() }); +} + +function RoleMembersList({ roleId, guildId, closePopout, setPopoutRef }: { roleId: string; guildId: string; closePopout(): void; setPopoutRef(ref: HTMLDivElement | null): void; }) { + const [memberIds, setMemberIds] = useState([]); + const [loading, setLoading] = useState(true); + + const role = useStateFromStores([GuildRoleStore], () => GuildRoleStore.getRole(guildId, roleId)); + + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + let cancelled = false; + + const cached = getCachedMemberIds(guildId, roleId); + if (cached) { + setTotalCount(cached.length); + const visible = cached.slice(0, MAX_VISIBLE_MEMBERS); + setMemberIds(visible); + if (visible.length) GuildActions.requestMembersById(guildId, visible, false); + setLoading(false); + } else { + RestAPI.get({ + url: Constants.Endpoints.GUILD_ROLE_MEMBER_IDS(guildId, roleId), + }).then(res => { + if (cancelled) return; + const ids = res.body as string[]; + setCachedMemberIds(guildId, roleId, ids); + setTotalCount(ids.length); + const visible = ids.slice(0, MAX_VISIBLE_MEMBERS); + setMemberIds(visible); + if (visible.length) GuildActions.requestMembersById(guildId, visible, false); + setLoading(false); + }).catch(e => { + if (cancelled) return; + logger.error("Failed to fetch role members", e); + setLoading(false); + }); + } + + return () => { cancelled = true; }; + }, [guildId, roleId]); + + const users = useStateFromStores( + [UserStore], + () => memberIds.map(id => UserStore.getUser(id)).filter(isTruthy), + [memberIds] + ); + + return ( +
+
+ + {role?.name ?? "Unknown Role"} + {totalCount} +
+ + {loading ? ( +
Loading members...
+ ) : users.length === 0 ? ( +
No members found.
+ ) : users.map(user => ( +
{ + closePopout(); + openUserProfile(user.id); + }} + > + + {user.globalName ?? user.username} +
+ ))} + {totalCount > MAX_VISIBLE_MEMBERS && ( +
and {totalCount - MAX_VISIBLE_MEMBERS} more
+ )} +
+
+ ); +} + +function ClickableRole({ roleId, guildId, children }: { roleId: string; guildId: string; children: React.ReactNode; }) { + const ref = useRef(null); + + return ( + ( + + )} + > + {popoutProps => ( +
+ {children} +
+ )} +
+ ); +} + +const WrappedClickableRole = ErrorBoundary.wrap(ClickableRole, { noop: true }); + +export default definePlugin({ + name: "ClickableRoles", + description: "Click on roles in user profiles and the member list to see which members have them.", + authors: [Devs.prism], + + patches: [ + { + find: "#{intl::T+3Adg::raw}", + replacement: { + match: /(colors:\i\}\);)return(.*?enableTooltip:!1\}\):null,\i\]\}\))/, + replace: "$1return $self.wrapRolePill(arguments[0],()=>$2)", + }, + }, + { + find: 'tutorialId:"whos-online"', + replacement: { + match: /\((function\(\i\)\{let\{id:.*?"\\xa0— ".{0,15})\}\);/, + replace: "($self.wrapRoleGroup($1}));", + }, + }, + ], + + wrapRoleGroup(originalFn: (props: { id: string; guildId: string; }) => React.ReactNode) { + return (props: { id: string; guildId: string; }) => { + const result = originalFn(props); + if (!GuildRoleStore.getRole(props.guildId, props.id)) return result; + return ( + + {result} + + ); + }; + }, + + wrapRolePill(props: { role: Role; guildId: string; }, renderOriginal: () => React.ReactNode) { + return ( + + {renderOriginal()} + + ); + }, +}); diff --git a/src/plugins/clickableRoles/style.css b/src/plugins/clickableRoles/style.css new file mode 100644 index 000000000..59a1ce523 --- /dev/null +++ b/src/plugins/clickableRoles/style.css @@ -0,0 +1,80 @@ +.vc-clickableroles-trigger { + cursor: pointer; +} + +.vc-clickableroles-popout { + background-color: var(--background-surface-highest); + border-radius: 8px; + padding: 12px; + width: 250px; + max-height: var(--reference-position-layer-max-height, 350px); + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.vc-clickableroles-list { + max-height: 260px; + min-height: 0; + flex: 1; +} + +.vc-clickableroles-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.vc-clickableroles-color { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.vc-clickableroles-name { + font-weight: 600; + color: var(--interactive-text-default); +} + +.vc-clickableroles-count { + color: var(--text-muted); + margin-left: auto; + font-size: 12px; +} + +.vc-clickableroles-empty { + padding: 8px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.vc-clickableroles-user { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + cursor: pointer; + border-radius: 4px; + color: var(--interactive-text-default); + font-size: 14px; +} + +.vc-clickableroles-user:hover { + background-color: var(--interactive-background-hover); +} + +.vc-clickableroles-avatar { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.vc-clickableroles-overflow { + padding: 6px; + text-align: center; + color: var(--text-muted); + font-size: 12px; +}