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
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { definePluginSettings } from "@api/Settings";8
import { getUserSettingLazy } from "@api/UserSettings";9
import { CopyIdIcon, ImageIcon } from "@components/Icons";10
import { copyToClipboard } from "@utils/clipboard";11
import { Devs } from "@utils/constants";12
import { getCurrentChannel, getCurrentGuild, getIntlMessage, openImageModal } from "@utils/discord";13
import { isTruthy } from "@utils/guards";14
import { classes } from "@utils/misc";15
import definePlugin, { OptionType } from "@utils/types";16
import { Guild, PopoutProps, Role } from "@vencord/discord-types";17
import { findByCodeLazy, findByPropsLazy, findCssClassesLazy } from "@webpack";18
import { ContextMenuApi, GuildRoleStore, Menu, PermissionStore, Popout, useRef } from "@webpack/common";19
import { ComponentType } from "react";20
21
const GuildSettingsActions = findByPropsLazy("open", "selectRole", "updateGuild");22
const MenuItemClasses = findCssClassesLazy("item", "labelContainer", "colorDefault", "label", "iconContainer");23
const loadRoleMembers = findByCodeLazy(".GUILD_ROLE_MEMBER_IDS(", "requestMembersById");24
25
const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!;26
27
function PencilIcon() {28
return (29
<svg30
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
41
function 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
49
function 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
57
const 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: true66
},67
{68
label: "webp",69
value: "webp",70
},71
{72
label: "jpg",73
value: "jpg"74
}75
]76
}77
});78
79
interface RoleMemberPopoutProps {80
popoutProps: PopoutProps;81
guildId: string;82
channelId: string;83
roleId: string;84
}85
type RoleMemberPopout = ComponentType<RoleMemberPopoutProps>;86
87
let RoleMemberPopout: RoleMemberPopout = () => null;88
89
export 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.MenuItem95
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.MenuItem107
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.MenuItem119
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: 128127
});128
}}129
icon={ImageIcon}130
/>131
),132
popoutRef && (133
<Menu.MenuItem134
key="vc-view-role-members"135
id="vc-view-role-members"136
label="View Role Members"137
render={() => (138
<Popout139
position="right"140
align="center"141
targetElementRef={popoutRef}142
preload={() => loadRoleMembers(guild.id, role.id)}143
renderPopout={popoutProps => (144
<RoleMemberPopout145
popoutProps={popoutProps}146
guildId={guild.id}147
channelId={getCurrentChannel()!.id}148
roleId={role.id}149
/>150
)}151
>152
{popoutProps => (153
<div154
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
174
export 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.Menu187
navId="vc-better-role-context-member-list"188
onClose={ContextMenuApi.closeContextMenu}189
aria-label="Role Actions"190
>191
{before}192
{after}193
<Menu.MenuItem194
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
205
export 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 issue223
{224
find: 039;tutorialId:"whos-online039;,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 shown234
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