Plugin

BetterRoleContext

Adds options to copy role color / edit role / view role icon when right clicking roles in the user profile or in the member list

Roles Appearance
index.tsx
Download

Source

src/plugins/betterRoleContext/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { definePluginSettings } from "@api/Settings";
8import { getUserSettingLazy } from "@api/UserSettings";
9import { CopyIdIcon, ImageIcon } from "@components/Icons";
10import { copyToClipboard } from "@utils/clipboard";
11import { Devs } from "@utils/constants";
12import { getCurrentChannel, getCurrentGuild, getIntlMessage, openImageModal } from "@utils/discord";
13import { isTruthy } from "@utils/guards";
14import { classes } from "@utils/misc";
15import definePlugin, { OptionType } from "@utils/types";
16import { Guild, PopoutProps, Role } from "@vencord/discord-types";
17import { findByCodeLazy, findByPropsLazy, findCssClassesLazy } from "@webpack";
18import { ContextMenuApi, GuildRoleStore, Menu, PermissionStore, Popout, useRef } from "@webpack/common";
19import { ComponentType } from "react";
20
21const GuildSettingsActions = findByPropsLazy("open", "selectRole", "updateGuild");
22const MenuItemClasses = findCssClassesLazy("item", "labelContainer", "colorDefault", "label", "iconContainer");
23const loadRoleMembers = findByCodeLazy(".GUILD_ROLE_MEMBER_IDS(", "requestMembersById");
24
25const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!;
26
27function PencilIcon() {
28 return (
29 <svg
30 role="img"
31 width="18"
32 height="18"
33 fill="none"
34 viewBox="0 0 24 24"
35 >
36 <path fill="currentColor" d="m13.96 5.46 4.58 4.58a1 1 0 0 0 1.42 0l1.38-1.38a2 2 0 0 0 0-2.82l-3.18-3.18a2 2 0 0 0-2.82 0l-1.38 1.38a1 1 0 0 0 0 1.42ZM2.11 20.16l.73-4.22a3 3 0 0 1 .83-1.61l7.87-7.87a1 1 0 0 1 1.42 0l4.58 4.58a1 1 0 0 1 0 1.42l-7.87 7.87a3 3 0 0 1-1.6.83l-4.23.73a1.5 1.5 0 0 1-1.73-1.73Z" />
37 </svg>
38 );
39}
40
41function AppearanceIcon() {
42 return (
43 <svg width="18" height="18" viewBox="0 0 24 24">
44 <path fill="currentColor" d="M 12,0 C 5.3733333,0 0,5.3733333 0,12 c 0,6.626667 5.3733333,12 12,12 1.106667,0 2,-0.893333 2,-2 0,-0.52 -0.2,-0.986667 -0.52,-1.346667 -0.306667,-0.346666 -0.506667,-0.813333 -0.506667,-1.32 0,-1.106666 0.893334,-2 2,-2 h 2.36 C 21.013333,17.333333 24,14.346667 24,10.666667 24,4.7733333 18.626667,0 12,0 Z M 4.6666667,12 c -1.1066667,0 -2,-0.893333 -2,-2 0,-1.1066667 0.8933333,-2 2,-2 1.1066666,0 2,0.8933333 2,2 0,1.106667 -0.8933334,2 -2,2 z M 8.666667,6.6666667 c -1.106667,0 -2.0000003,-0.8933334 -2.0000003,-2 0,-1.1066667 0.8933333,-2 2.0000003,-2 1.106666,0 2,0.8933333 2,2 0,1.1066666 -0.893334,2 -2,2 z m 6.666666,0 c -1.106666,0 -2,-0.8933334 -2,-2 0,-1.1066667 0.893334,-2 2,-2 1.106667,0 2,0.8933333 2,2 0,1.1066666 -0.893333,2 -2,2 z m 4,5.3333333 c -1.106666,0 -2,-0.893333 -2,-2 0,-1.1066667 0.893334,-2 2,-2 1.106667,0 2,0.8933333 2,2 0,1.106667 -0.893333,2 -2,2 z" />
45 </svg>
46 );
47}
48
49function RoleMembersIcon() {
50 return (
51 <svg width="18" height="18" viewBox="0 0 24 24">
52 <path fill="currentColor" d="M12 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM11.53 11A9.53 9.53 0 0 0 2 20.53c0 .81.66 1.47 1.47 1.47h.22c.24 0 .44-.17.5-.4.29-1.12.84-2.17 1.32-2.91.14-.21.43-.1.4.15l-.26 2.61c-.02.3.2.55.5.55h11.7a.5.5 0 0 0 .5-.55l-.27-2.6c-.02-.26.27-.37.41-.16.48.74 1.03 1.8 1.32 2.9.06.24.26.41.5.41h.22c.81 0 1.47-.66 1.47-1.47A9.53 9.53 0 0 0 12.47 11h-.94Z" />
53 </svg>
54 );
55}
56
57const settings = definePluginSettings({
58 roleIconFileFormat: {
59 type: OptionType.SELECT,
60 description: "File format to use when viewing role icons",
61 options: [
62 {
63 label: "png",
64 value: "png",
65 default: true
66 },
67 {
68 label: "webp",
69 value: "webp",
70 },
71 {
72 label: "jpg",
73 value: "jpg"
74 }
75 ]
76 }
77});
78
79interface RoleMemberPopoutProps {
80 popoutProps: PopoutProps;
81 guildId: string;
82 channelId: string;
83 roleId: string;
84}
85type RoleMemberPopout = ComponentType<RoleMemberPopoutProps>;
86
87let RoleMemberPopout: RoleMemberPopout = () => null;
88
89export function buildExtraRoleContextMenuItems(role: Role, guild: Guild, popoutRef?: React.RefObject<any>) {
90 if (!role) return { before: [], after: [] };
91
92 const before = [
93 PermissionStore.getGuildPermissionProps(guild).canManageRoles && (
94 <Menu.MenuItem
95 key="vc-edit-role"
96 id="vc-edit-role"
97 label="Edit Role"
98 action={async () => {
99 await GuildSettingsActions.open(guild.id, "ROLES");
100 GuildSettingsActions.selectRole(role.id);
101 }}
102 icon={PencilIcon}
103 />
104 ),
105 role.colorString && (
106 <Menu.MenuItem
107 key="vc-copy-role-color"
108 id="vc-copy-role-color"
109 label="Copy Role Color"
110 action={() => copyToClipboard(role.colorString!)}
111 icon={AppearanceIcon}
112 />
113 )
114 ].filter(isTruthy);
115
116 const after = [
117 role.icon && (
118 <Menu.MenuItem
119 key="vc-view-role-icon"
120 id="vc-view-role-icon"
121 label="View Role Icon"
122 action={() => {
123 openImageModal({
124 url: `${location.protocol}class="ts-cmt">//${window.GLOBAL_ENV.CDN_HOST}/role-icons/${role.id}/${role.icon}.${settings.store.roleIconFileFormat}`,
125 height: 128,
126 width: 128
127 });
128 }}
129 icon={ImageIcon}
130 />
131 ),
132 popoutRef && (
133 <Menu.MenuItem
134 key="vc-view-role-members"
135 id="vc-view-role-members"
136 label="View Role Members"
137 render={() => (
138 <Popout
139 position="right"
140 align="center"
141 targetElementRef={popoutRef}
142 preload={() => loadRoleMembers(guild.id, role.id)}
143 renderPopout={popoutProps => (
144 <RoleMemberPopout
145 popoutProps={popoutProps}
146 guildId={guild.id}
147 channelId={getCurrentChannel()!.id}
148 roleId={role.id}
149 />
150 )}
151 >
152 {popoutProps => (
153 <div
154 className={classes(MenuItemClasses.item, MenuItemClasses.labelContainer, MenuItemClasses.colorDefault)}
155 ref={popoutRef}
156 role="menuitem"
157 {...popoutProps}
158 >
159 <div className={MenuItemClasses.label}>View Role Members</div>
160 <div className={MenuItemClasses.iconContainer}>
161 <RoleMembersIcon />
162 </div>
163 </div>
164 )}
165 </Popout>
166 )}
167 />
168 )
169 ].filter(isTruthy);
170
171 return { before, after };
172}
173
174export function openRoleContextMenu(event: React.MouseEvent<HTMLElement>, { guildId, id: roleId }: { guildId: string; id: string; }) {
175 const guild = getCurrentGuild();
176 if (!guild || guild.id !== guildId) return;
177
178 const role = GuildRoleStore.getRole(guildId, roleId);
179 if (!role) return;
180
181 ContextMenuApi.openContextMenu(event, () => {
182 const popoutRef = useRef(null);
183 const { before, after } = buildExtraRoleContextMenuItems(role, guild, popoutRef);
184
185 return (
186 <Menu.Menu
187 navId="vc-better-role-context-member-list"
188 onClose={ContextMenuApi.closeContextMenu}
189 aria-label="Role Actions"
190 >
191 {before}
192 {after}
193 <Menu.MenuItem
194 key="vc-better-role-context-copy-role-id"
195 id="vc-better-role-context-copy-role-id"
196 label={getIntlMessage("COPY_ID_ROLE")}
197 icon={CopyIdIcon}
198 action={() => copyToClipboard(role.id)}
199 />
200 </Menu.Menu>
201 );
202 });
203}
204
205export default definePlugin({
206 name: "BetterRoleContext",
207 description: "Adds options to copy role color / edit role / view role icon when right clicking roles in the user profile or in the member list",
208 tags: ["Roles", "Appearance"],
209 authors: [Devs.Ven, Devs.goodbee, Devs.nightmaresan],
210 dependencies: ["UserSettingsAPI"],
211 settings,
212 openRoleContextMenu,
213 patches: [
214 {
215 find: ".ROLE_MENTION)",
216 replacement: {
217 match: /function (\i)(?=.+?renderPopout:.{0,20}\1,\{guildId:\i,channelId:\i)/,
218 replace: "$self.RoleMembers=$1;$&"
219 }
220 },
221 // Conflicts with RoleColorEverywhere which changes the code at the end of our match. (and also uses same find & similar match)
222 // However, BetterRoleContext applies first (alphabetic order), so it's not an issue
223 {
224 find: &#039;tutorialId:"whos-online&#039;,
225 replacement: {
226 match: /(?<=#{intl::CHANNEL_MEMBERS_A11Y_LABEL}.{0,200}?"aria-hidden":!0,)children:.{0,200}?(?:—|\\u2014) ",\i\]\}\)\]/,
227 replace: "onContextMenu:e=>$self.openRoleContextMenu(e,arguments[0]),$&"
228 }
229 }
230 ],
231
232 start() {
233 // DeveloperMode needs to be enabled for the context menu to be shown
234 DeveloperMode.updateSetting(true);
235 },
236
237 set RoleMembers(component: RoleMemberPopout) {
238 RoleMemberPopout = component;
239 },
240
241 contextMenus: {
242 "dev-context"(children, { id }: { id: string; }) {
243 const popoutRef = useRef(null);
244
245 const guild = getCurrentGuild();
246 if (!guild) return;
247
248 const role = GuildRoleStore.getRole(guild.id, id);
249 if (!role) return;
250
251 const { before, after } = buildExtraRoleContextMenuItems(role, guild, popoutRef);
252 children.unshift(...before);
253 children.push(...after);
254 }
255 }
256});
257