feat(guild): duplicate role, plus delete and hover in roles sidebar (#2105)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-30 00:12:36 +02:00
1 parent 19efbd3d61
commit 8f20b29b16
6 files changed
+198 -16

No files matched your search

@@ -254,10 +254,11 @@ export async function updateVanityURL(guildId: string, code: string | null): Pro
}
}
export async function createRole(guildId: string, name: string): Promise<void> {
export async function createRole(guildId: string, name: string): Promise<GuildRole> {
try {
await http.post(Endpoints.GUILD_ROLES(guildId), {body: {name}});
const response = await http.post<GuildRole>(Endpoints.GUILD_ROLES(guildId), {body: {name}});
logger.debug(`Created role "${name}" in guild ${guildId}`);
return response.body;
} catch (error) {
logger.error(`Failed to create role in guild ${guildId}:`, error);
throw error;
@@ -262,7 +262,8 @@
background: transparent;
}
.overwriteItem:not(.overwriteItemSelected):hover {
.overwriteItem:not(.overwriteItemSelected):hover,
.overwriteItem:not(.overwriteItemSelected).overwriteItemHovered {
background: var(--background-modifier-hover);
}
@@ -28,6 +28,7 @@ import {RoleUpdateFailedModal} from '@app/features/moderation/components/alerts/
import Permission from '@app/features/permissions/state/Permission';
import PermissionLayout from '@app/features/permissions/state/PermissionLayout';
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
@@ -43,10 +44,16 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
const logger = new Logger('GuildRolesTab');
const NEW_ROLE_DESCRIPTOR = msg({
message: 'New role',
comment: 'Default name for a newly created role in the community roles settings tab. Short label.',
});
const ROLE_COPY_NAME_DESCRIPTOR = msg({
message: '{name} copy',
comment:
'Default name for a role created by duplicating another role in the community roles settings tab. {name} is the source role name; translate the whole label so "copy" can move or inflect as the language needs.',
});
const DELETE_ROLE_DESCRIPTOR = msg({
message: 'Delete role',
comment:
@@ -341,6 +348,55 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
);
}
}, [guild]);
const handleDuplicateRole = useCallback(
async (sourceRoleId: string) => {
if (!guild || !canManageRoles) return;
const sourceRole = guild.roles[sourceRoleId];
if (!sourceRole) return;
const copyName = i18n._(ROLE_COPY_NAME_DESCRIPTOR, {name: sourceRole.name}).slice(0, 100);
const grantablePermissions = sourceRole.permissions & currentUserPermissions;
pendingRoleCreationRef.current = true;
try {
const createdRole = await GuildCommands.createRole(guild.id, copyName);
await GuildCommands.updateRole(guild.id, createdRole.id, {
color: sourceRole.color,
hoist: sourceRole.hoist,
mentionable: sourceRole.mentionable,
permissions: grantablePermissions.toString(),
});
try {
const orderedRoleIds = roles.map((role: GuildRole) => role.id);
const sourceIndex = orderedRoleIds.indexOf(sourceRoleId);
const insertIndex = sourceIndex === -1 ? orderedRoleIds.length : sourceIndex + 1;
orderedRoleIds.splice(insertIndex, 0, createdRole.id);
const submittableRoleOrder = createSubmittableRoleOrderIds({
guildId: guild.id,
orderedRoleIds,
isRoleLocked: (roleId) => {
if (roleId === createdRole.id) return false;
const role = guild.roles[roleId];
if (!role) return true;
return isRoleLocked(role);
},
});
if (submittableRoleOrder.length > 0) {
await GuildCommands.setRoleOrder(guild.id, submittableRoleOrder);
}
} catch (error) {
logger.error(`Failed to position duplicated role in guild ${guild.id}:`, error);
}
ToastCommands.createToast({type: 'success', children: <Trans>Role created successfully</Trans>});
} catch (_error) {
pendingRoleCreationRef.current = false;
ModalCommands.push(
modal(() => (
<RoleCreateFailedModal data-flx="guild.guild-tabs.guild-roles-tab.handle-duplicate-role.role-create-failed-modal" />
)),
);
}
},
[guild, canManageRoles, roles, isRoleLocked, currentUserPermissions, i18n],
);
const handleDeleteRole = useCallback(() => {
if (!selectedRole || selectedRole.isEveryone || !guild) return;
const currentIndex = roles.findIndex((r: GuildRole) => r.id === selectedRole.id);
@@ -386,6 +442,30 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
)),
);
}, [selectedRole, guild, roles]);
const handleContextMenuDeleteRole = useCallback(
async (roleId: string) => {
if (!guild) return;
const role = guild.roles[roleId];
if (!role || role.isEveryone || isRoleLocked(role)) return;
const currentIndex = roles.findIndex((r: GuildRole) => r.id === roleId);
const nextRole = roles[currentIndex + 1] ?? roles[0];
try {
await GuildCommands.deleteRole(guild.id, roleId);
ToastCommands.createToast({type: 'success', children: <Trans>Role deleted successfully</Trans>});
setSelectedRoleId((current) => (current === roleId ? (nextRole?.id ?? null) : current));
} catch (_error) {
ModalCommands.push(
modal(() => (
<RoleDeleteFailedModal
roleName={role.name}
data-flx="guild.guild-tabs.guild-roles-tab.handle-context-menu-delete-role.role-delete-failed-modal"
/>
)),
);
}
},
[guild, roles, isRoleLocked],
);
const evaluateRoleMove = useCallback(
(draggedRoleId: string, targetRoleId: string | null, position: 'before' | 'after') => {
if (!guild) return null;
@@ -500,6 +580,8 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
isRoleLocked={isRoleLocked}
onSelectRole={setSelectedRoleId}
onCreateRole={handleCreateRole}
onDuplicateRole={handleDuplicateRole}
onDeleteRole={handleContextMenuDeleteRole}
onEnterHoistOrderMode={handleEnterHoistOrderMode}
onExitHoistOrderMode={handleExitHoistOrderMode}
onResetHoistOrder={handleResetHoistOrder}
@@ -519,6 +601,8 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
isGuildOwner,
canManageRoles,
handleCreateRole,
handleDuplicateRole,
handleContextMenuDeleteRole,
evaluateRoleMove,
handleRoleDrop,
evaluateHoistMove,
@@ -2,6 +2,7 @@
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
import {DropIndicator} from '@app/features/app/components/layout/DropIndicator';
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import styles from '@app/features/guild/components/modals/guild_tabs/GuildRolesTab.module.css';
import {
@@ -17,6 +18,7 @@ import {
} from '@app/features/guild/components/modals/guild_tabs/RoleReorderStateMachine';
import type {GuildRole} from '@app/features/guild/models/GuildRole';
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -43,6 +45,8 @@ interface RoleItemProps {
isTerminal: boolean;
canManageRoles: boolean;
onClick: () => void;
onDuplicate: (roleId: string) => void;
onDelete: (roleId: string) => void;
onEvaluateMove: (
draggedRoleId: string,
targetRoleId: string | null,
@@ -52,15 +56,34 @@ interface RoleItemProps {
}
export const RoleItem: React.FC<RoleItemProps> = observer(
({role, isSelected, isLocked, isGuildOwner, isTerminal, canManageRoles, onClick, onEvaluateMove, onCommitMove}) => {
({
role,
isSelected,
isLocked,
isGuildOwner,
isTerminal,
canManageRoles,
onClick,
onDuplicate,
onDelete,
onEvaluateMove,
onCommitMove,
}) => {
const {i18n} = useLingui();
const elementRef = useRef<HTMLButtonElement | null>(null);
const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null);
const contextMenuOpen = useContextMenuHoverState(elementRef, !MobileLayout.enabled);
const canDelete = canManageRoles && !role.isEveryone && !isLocked;
const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
openRoleContextMenu(event, role.id);
openRoleContextMenu(event, role.id, {
canDuplicate: canManageRoles,
onDuplicate: () => onDuplicate(role.id),
canDelete,
onDelete: () => onDelete(role.id),
});
},
[role.id],
[role.id, canManageRoles, canDelete, onDuplicate, onDelete],
);
const dragItem = useMemo<RoleDragItem>(
() => ({type: ROLE_DND_TYPE, id: role.id, isEveryone: role.isEveryone, isLocked}),
@@ -204,6 +227,7 @@ export const RoleItem: React.FC<RoleItemProps> = observer(
styles.overwriteItem,
styles.roleButton,
{[styles.overwriteItemSelected]: isSelected},
contextMenuOpen && styles.overwriteItemHovered,
isDragging && styles.dragging,
!canDrag && styles.noDrag,
)}
@@ -44,6 +44,8 @@ interface RoleSidebarProps {
isRoleLocked: (role: GuildRole) => boolean;
onSelectRole: (roleId: string) => void;
onCreateRole: () => void;
onDuplicateRole: (roleId: string) => void;
onDeleteRole: (roleId: string) => void;
onEnterHoistOrderMode: () => void;
onExitHoistOrderMode: () => void;
onResetHoistOrder: () => void;
@@ -74,6 +76,8 @@ export const RoleSidebar: React.FC<RoleSidebarProps> = observer(
isRoleLocked,
onSelectRole,
onCreateRole,
onDuplicateRole,
onDeleteRole,
onEnterHoistOrderMode,
onExitHoistOrderMode,
onResetHoistOrder,
@@ -155,6 +159,8 @@ export const RoleSidebar: React.FC<RoleSidebarProps> = observer(
isTerminal={index === hoistedRoles.length - 1}
canManageRoles={canManageRoles}
onClick={() => onSelectRole(role.id)}
onDuplicate={onDuplicateRole}
onDelete={onDeleteRole}
onEvaluateMove={onEvaluateHoistMove}
onCommitMove={onHoistDrop}
data-flx="guild.guild-tabs.guild-roles-tab.sidebar-content.role-item.set-selected-role-id"
@@ -251,6 +257,8 @@ export const RoleSidebar: React.FC<RoleSidebarProps> = observer(
isTerminal={index === roles.length - 1}
canManageRoles={canManageRoles}
onClick={() => onSelectRole(role.id)}
onDuplicate={onDuplicateRole}
onDelete={onDeleteRole}
onEvaluateMove={onEvaluateRoleMove}
onCommitMove={onRoleDrop}
data-flx="guild.guild-tabs.guild-roles-tab.sidebar-content.role-item.set-selected-role-id--2"
@@ -1,32 +1,96 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {CopyIcon, DeleteIcon} from '@app/features/ui/action_menu/ContextMenuIcons';
import {CopyRoleIdMenuItem} from '@app/features/ui/action_menu/items/CopyMenuItems';
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
import {MenuItem} from '@app/features/ui/action_menu/MenuItem';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type React from 'react';
interface RoleContextMenuProps {
const DUPLICATE_ROLE_DESCRIPTOR = msg({
message: 'Duplicate role',
comment:
'Context menu action in the community roles settings tab that creates a new role copying the settings of the one right-clicked. Keep it short.',
});
const DELETE_ROLE_DESCRIPTOR = msg({
message: 'Delete role',
comment:
'Destructive context menu action in the community roles settings tab that immediately removes the right-clicked role from every member. Keep it short.',
});
export interface RoleContextMenuActions {
canDuplicate?: boolean;
onDuplicate?: () => void;
canDelete?: boolean;
onDelete?: () => void;
}
interface RoleContextMenuProps extends RoleContextMenuActions {
roleId: string;
onClose: () => void;
}
export const RoleContextMenu: React.FC<RoleContextMenuProps> = ({roleId, onClose}) => {
export const RoleContextMenu: React.FC<RoleContextMenuProps> = ({
roleId,
onClose,
canDuplicate,
onDuplicate,
canDelete,
onDelete,
}) => {
const {i18n} = useLingui();
return (
<MenuGroup data-flx="ui.action-menu.role-context-menu.menu-group">
<CopyRoleIdMenuItem
roleId={roleId}
onClose={onClose}
data-flx="ui.action-menu.role-context-menu.copy-role-id-menu-item"
/>
</MenuGroup>
<>
<MenuGroup data-flx="ui.action-menu.role-context-menu.menu-group">
<CopyRoleIdMenuItem
roleId={roleId}
onClose={onClose}
data-flx="ui.action-menu.role-context-menu.copy-role-id-menu-item"
/>
{canDuplicate && onDuplicate && (
<MenuItem
icon={<CopyIcon data-flx="ui.action-menu.role-context-menu.duplicate-role-menu-item.copy-icon" />}
onClick={() => {
onClose();
onDuplicate();
}}
data-flx="ui.action-menu.role-context-menu.duplicate-role-menu-item"
>
{i18n._(DUPLICATE_ROLE_DESCRIPTOR)}
</MenuItem>
)}
</MenuGroup>
{canDelete && onDelete && (
<MenuGroup data-flx="ui.action-menu.role-context-menu.menu-group--danger">
<MenuItem
danger
icon={<DeleteIcon data-flx="ui.action-menu.role-context-menu.delete-role-menu-item.delete-icon" />}
onClick={() => {
onClose();
onDelete();
}}
data-flx="ui.action-menu.role-context-menu.delete-role-menu-item"
>
{i18n._(DELETE_ROLE_DESCRIPTOR)}
</MenuItem>
</MenuGroup>
)}
</>
);
};
export function openRoleContextMenu(event: React.MouseEvent | MouseEvent, roleId: string): void {
export function openRoleContextMenu(
event: React.MouseEvent | MouseEvent,
roleId: string,
actions?: RoleContextMenuActions,
): void {
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<RoleContextMenu
roleId={roleId}
onClose={onClose}
{...actions}
data-flx="ui.action-menu.role-context-menu.open-role-context-menu.role-context-menu"
/>
));