diff --git a/fluxer_app/src/features/guild/commands/GuildCommands.ts b/fluxer_app/src/features/guild/commands/GuildCommands.ts index 87baccff1..62afea4ae 100644 --- a/fluxer_app/src/features/guild/commands/GuildCommands.ts +++ b/fluxer_app/src/features/guild/commands/GuildCommands.ts @@ -254,10 +254,11 @@ export async function updateVanityURL(guildId: string, code: string | null): Pro } } -export async function createRole(guildId: string, name: string): Promise { +export async function createRole(guildId: string, name: string): Promise { try { - await http.post(Endpoints.GUILD_ROLES(guildId), {body: {name}}); + const response = await http.post(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; diff --git a/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.module.css b/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.module.css index b462d8dbd..c461a8fb4 100644 --- a/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.module.css +++ b/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.module.css @@ -262,7 +262,8 @@ background: transparent; } -.overwriteItem:not(.overwriteItemSelected):hover { +.overwriteItem:not(.overwriteItemSelected):hover, +.overwriteItem:not(.overwriteItemSelected).overwriteItemHovered { background: var(--background-modifier-hover); } diff --git a/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.tsx b/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.tsx index 43c77598e..c341afe10 100644 --- a/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.tsx +++ b/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildRolesTab.tsx @@ -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: Role created successfully}); + } catch (_error) { + pendingRoleCreationRef.current = false; + ModalCommands.push( + 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: Role deleted successfully}); + setSelectedRoleId((current) => (current === roleId ? (nextRole?.id ?? null) : current)); + } catch (_error) { + ModalCommands.push( + 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, diff --git a/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleItem.tsx b/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleItem.tsx index 369d69618..0b738e1b1 100644 --- a/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleItem.tsx +++ b/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleItem.tsx @@ -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 = 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(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) => { - 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( () => ({type: ROLE_DND_TYPE, id: role.id, isEveryone: role.isEveryone, isLocked}), @@ -204,6 +227,7 @@ export const RoleItem: React.FC = observer( styles.overwriteItem, styles.roleButton, {[styles.overwriteItemSelected]: isSelected}, + contextMenuOpen && styles.overwriteItemHovered, isDragging && styles.dragging, !canDrag && styles.noDrag, )} diff --git a/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleSidebar.tsx b/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleSidebar.tsx index 1b616d9a1..83619446d 100644 --- a/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleSidebar.tsx +++ b/fluxer_app/src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleSidebar.tsx @@ -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 = observer( isRoleLocked, onSelectRole, onCreateRole, + onDuplicateRole, + onDeleteRole, onEnterHoistOrderMode, onExitHoistOrderMode, onResetHoistOrder, @@ -155,6 +159,8 @@ export const RoleSidebar: React.FC = 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 = 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" diff --git a/fluxer_app/src/features/ui/action_menu/RoleContextMenu.tsx b/fluxer_app/src/features/ui/action_menu/RoleContextMenu.tsx index 2d91ec133..1edf1732d 100644 --- a/fluxer_app/src/features/ui/action_menu/RoleContextMenu.tsx +++ b/fluxer_app/src/features/ui/action_menu/RoleContextMenu.tsx @@ -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 = ({roleId, onClose}) => { +export const RoleContextMenu: React.FC = ({ + roleId, + onClose, + canDuplicate, + onDuplicate, + canDelete, + onDelete, +}) => { + const {i18n} = useLingui(); return ( - - - + <> + + + {canDuplicate && onDuplicate && ( + } + onClick={() => { + onClose(); + onDuplicate(); + }} + data-flx="ui.action-menu.role-context-menu.duplicate-role-menu-item" + > + {i18n._(DUPLICATE_ROLE_DESCRIPTOR)} + + )} + + {canDelete && onDelete && ( + + } + onClick={() => { + onClose(); + onDelete(); + }} + data-flx="ui.action-menu.role-context-menu.delete-role-menu-item" + > + {i18n._(DELETE_ROLE_DESCRIPTOR)} + + + )} + ); }; -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}) => ( ));