From 4943de62edac142686fde43498d2bf9a9f18e086 Mon Sep 17 00:00:00 2001 From: Artyom Ivanov Date: Sat, 25 Apr 2026 01:32:32 +0300 Subject: [PATCH] feat(sidebar): add UI for personal room categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose “Create room category” in Create New * Allow assigning/removing rooms via room menus * Show empty custom categories in the sidebar * Support deleting categories from the sidebar header * Support renaming categories from the sidebar header --- .../app/api/server/v1/user-room-categories.ts | 36 +++++ .../hooks/UserRoomCategoryForRoomModal.tsx | 153 ++++++++++++++++++ .../meteor/client/hooks/useRoomMenuActions.ts | 50 +++++- .../client/hooks/useUserRoomCategories.ts | 29 ++-- .../actions/CreateUserRoomCategoryModal.tsx | 101 ++++++++++++ .../hooks/useCreateNewMenu.tsx | 23 ++- .../RoomList/RenameUserRoomCategoryModal.tsx | 87 ++++++++++ .../client/sidebar/RoomList/RoomList.tsx | 48 ++++-- .../sidebar/RoomList/RoomListCollapser.tsx | 19 ++- .../RoomList/UserRoomCategoryGroupMenu.tsx | 78 +++++++++ .../client/sidebar/hooks/useRoomList.ts | 8 +- .../Info/ManageUserRoomCategoryModal.tsx | 153 ++++++++++++++++++ .../Info/hooks/useRoomActions.ts | 35 +++- packages/i18n/src/locales/en.i18n.json | 20 +++ packages/i18n/src/locales/ru.i18n.json | 22 ++- .../models/src/models/UserRoomCategories.ts | 27 ++++ .../rest-typings/src/v1/userRoomCategories.ts | 4 + 17 files changed, 850 insertions(+), 43 deletions(-) create mode 100644 apps/meteor/client/hooks/UserRoomCategoryForRoomModal.tsx create mode 100644 apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateUserRoomCategoryModal.tsx create mode 100644 apps/meteor/client/sidebar/RoomList/RenameUserRoomCategoryModal.tsx create mode 100644 apps/meteor/client/sidebar/RoomList/UserRoomCategoryGroupMenu.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Info/ManageUserRoomCategoryModal.tsx diff --git a/apps/meteor/app/api/server/v1/user-room-categories.ts b/apps/meteor/app/api/server/v1/user-room-categories.ts index 81dd2007bffdb..7d563413b790c 100644 --- a/apps/meteor/app/api/server/v1/user-room-categories.ts +++ b/apps/meteor/app/api/server/v1/user-room-categories.ts @@ -24,6 +24,16 @@ const addRoomToCategoryBodySchema = ajv.compile<{ categoryName: string; roomId: additionalProperties: false, }); +const renameCategoryBodySchema = ajv.compile<{ oldName: string; newName: string }>({ + type: 'object', + properties: { + oldName: { type: 'string' }, + newName: { type: 'string' }, + }, + required: ['oldName', 'newName'], + additionalProperties: false, +}); + const categoriesResponseSchema = ajv.compile<{ categories: IUserRoomCategory[] }>({ type: 'object', properties: { @@ -154,3 +164,29 @@ API.v1.post( return API.v1.success({}); }, ); + +API.v1.post( + 'user-room-categories/rename-category', + { + authRequired: true, + body: renameCategoryBodySchema, + response: { 200: emptyResponseSchema, 400: emptyResponseSchema }, + }, + async function action() { + const { oldName, newName } = this.bodyParams; + + check(oldName, String); + check(newName, String); + + const trimmedOldName = oldName.trim(); + const trimmedNewName = newName.trim(); + + if (!trimmedOldName || !trimmedNewName) { + return API.v1.failure('oldName and newName are required'); + } + + await UserRoomCategories.renameCategory(this.userId, trimmedOldName, trimmedNewName); + + return API.v1.success({}); + }, +); diff --git a/apps/meteor/client/hooks/UserRoomCategoryForRoomModal.tsx b/apps/meteor/client/hooks/UserRoomCategoryForRoomModal.tsx new file mode 100644 index 0000000000000..076428240a024 --- /dev/null +++ b/apps/meteor/client/hooks/UserRoomCategoryForRoomModal.tsx @@ -0,0 +1,153 @@ +import type { SelectOption } from '@rocket.chat/fuselage'; +import { + Box, + Button, + Callout, + Field, + FieldGroup, + FieldLabel, + FieldRow, + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalFooterControllers, + ModalHeader, + ModalTitle, + Select, +} from '@rocket.chat/fuselage'; +import { useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; + +import { useUserRoomCategories } from './useUserRoomCategories'; +import CreateUserRoomCategoryModal from '../navbar/NavBarPagesGroup/actions/CreateUserRoomCategoryModal'; + +type UserRoomCategoryForRoomModalProps = { + roomId: string; + roomName?: string; + onClose: () => void; +}; + +const UserRoomCategoryForRoomModal = ({ roomId, roomName, onClose }: UserRoomCategoryForRoomModalProps) => { + const t = useTranslation(); + const titleId = useId(); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + + const { data: categories = [], addRoomToCategory, removeRoomFromCategory, refetch } = useUserRoomCategories(); + + const containingCategory = useMemo(() => categories.find((c) => (c.roomIds ?? []).includes(roomId)), [categories, roomId]); + + const selectOptions = useMemo>(() => categories.map((c) => [c.name, c.name]), [categories]); + const [selectedCategory, setSelectedCategory] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (containingCategory?.name) { + setSelectedCategory(containingCategory.name); + return; + } + if (categories[0]?.name) { + setSelectedCategory((prev) => (categories.some((c) => c.name === prev) ? prev : categories[0].name)); + } + }, [categories, containingCategory?.name]); + + const openCreateCategoryModal = useCallback(() => { + setModal( + { + void refetch(); + setModal(); + }} + />, + ); + }, [onClose, refetch, roomId, roomName, setModal]); + + const handleApply = async () => { + if (!selectedCategory) { + return; + } + setSubmitting(true); + try { + await addRoomToCategory(selectedCategory, roomId); + dispatchToastMessage({ type: 'success', message: t('User_room_category_assign_success', { name: selectedCategory }) }); + onClose(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + dispatchToastMessage({ type: 'error', message: message || t('Something_went_wrong') }); + } finally { + setSubmitting(false); + } + }; + + const handleRemove = async () => { + if (!containingCategory) { + return; + } + setSubmitting(true); + try { + await removeRoomFromCategory(containingCategory.name, roomId); + dispatchToastMessage({ type: 'success', message: t('User_room_category_remove_success') }); + onClose(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + dispatchToastMessage({ type: 'error', message: message || t('Something_went_wrong') }); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {t('Add_to_user_room_category')} + {roomName ? `: ${roomName}` : ''} + + + + + + {t('User_room_category_add_description')} + {categories.length === 0 ? ( + <> + + {t('User_room_category_empty_hint')} + + + + ) : ( + <> + + {t('User_room_category_select')} + + setSelectedCategory(String(value))} /> + + + + + {containingCategory && ( + + )} + + + + )} + + + + + + + + + ); +}; + +export default ManageUserRoomCategoryModal; diff --git a/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts b/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts index 201e0131646b4..fd7f16b5ecbea 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts +++ b/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts @@ -1,7 +1,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { useMemo } from 'react'; +import { useSetModal, useUserId } from '@rocket.chat/ui-contexts'; +import { createElement, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import ManageUserRoomCategoryModal from '../ManageUserRoomCategoryModal'; import { useRoomConvertToTeam } from './actions/useRoomConvertToTeam'; import { useRoomLeave } from './actions/useRoomLeave'; import { useRoomMoveToTeam } from './actions/useRoomMoveToTeam'; @@ -18,6 +20,12 @@ export const useRoomActions = (room: IRoom, options: UseRoomActionsOptions) => { const { onClickEnterRoom, onClickEdit, resetState } = options; const { t } = useTranslation(); + const userId = useUserId(); + const setModal = useSetModal(); + + const openUserRoomCategoryModal = useCallback(() => { + setModal(createElement(ManageUserRoomCategoryModal, { room, onClose: () => setModal(null) })); + }, [room, setModal]); const handleLeave = useRoomLeave(room); const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload: resetState }); @@ -55,6 +63,16 @@ export const useRoomActions = (room: IRoom, options: UseRoomActionsOptions) => { }, ] : []), + ...(userId && !['l'].includes(room.t) + ? [ + { + id: 'user-room-category', + content: t('Add_to_user_room_category'), + icon: 'sort-amount-down' as const, + onClick: openUserRoomCategoryModal, + }, + ] + : []), ...(handleLeave ? [ { @@ -100,5 +118,18 @@ export const useRoomActions = (room: IRoom, options: UseRoomActionsOptions) => { }; return memoizedActions; - }, [canDeleteRoom, handleConvertToTeam, handleDelete, handleHide, handleLeave, handleMoveToTeam, onClickEdit, onClickEnterRoom, t]); + }, [ + canDeleteRoom, + handleConvertToTeam, + handleDelete, + handleHide, + handleLeave, + handleMoveToTeam, + onClickEdit, + onClickEnterRoom, + openUserRoomCategoryModal, + room.t, + t, + userId, + ]); }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2abb096ac43b2..7fc199b196556 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -473,6 +473,7 @@ "Add_topic": "Add topic", "Add_user": "Add user", "Add_users": "Add users", + "Add_to_user_room_category": "Add to personal room category", "Added__username__to_team": "added @{{user_added}} to this Team", "Added__username__to_this_team": "added @{{user_added}} to this team", "Adding_OAuth_Services": "Adding OAuth Services", @@ -1592,6 +1593,7 @@ "Create_trigger": "Create trigger", "Create_unique_rules_for_this_channel": "Create unique rules for this channel", "Create_unit": "Create unit", + "Create_user_room_category": "Create room category", "Created": "Created", "Created_as": "Created as", "Created_at": "Created at", @@ -5713,6 +5715,24 @@ "User_sent_a_message_on_channel": "{{username}} sent a message on {{channel}}", "User_sent_a_message_to_you": "{{username}} sent you a message", "User_started_a_new_conversation": "{{username}} started a new conversation", + "User_room_category_add_description": "Choose a sidebar category for this room. The room will be removed from any other personal category.", + "User_room_category_assign_success": "Room added to \"{{name}}\"", + "User_room_category_created": "Room category created", + "User_room_category_create_failed": "Could not create room category", + "User_room_category_delete": "Delete category", + "User_room_category_delete_confirm": "Remove category \"{{name}}\"? Rooms will stay in your sidebar in their default sections.", + "User_room_category_deleted": "Room category removed", + "User_room_category_empty_hint": "Create a room category from the Create new menu first.", + "User_room_category_manage": "Personal sidebar category", + "User_room_category_name": "Category name", + "User_room_category_name_required": "Enter a category name", + "User_room_category_rename": "Rename category", + "User_room_category_rename_title": "Rename room category", + "User_room_category_rename_success": "Room category renamed", + "User_room_category_remove_from": "Remove from \"{{name}}\"", + "User_room_category_remove_success": "Removed from personal category", + "User_room_category_remove_room": "Remove from my category", + "User_room_category_select": "Category", "User_status_disabled": "User status temporarily disabled to maintain performance.", "User_status_disabled_learn_more": "User status disabled", "User_status_disabled_learn_more_description": "Due to high volume of active connections, the service that handles user status is temporarily disabled. Administrators can re-enable this manually in the workspace settings.", diff --git a/packages/i18n/src/locales/ru.i18n.json b/packages/i18n/src/locales/ru.i18n.json index d0ecdc864077a..6e8582a98bc38 100644 --- a/packages/i18n/src/locales/ru.i18n.json +++ b/packages/i18n/src/locales/ru.i18n.json @@ -327,6 +327,7 @@ "Add_monitor": "Добавить монитор", "Add_user": "Добавить пользователя", "Add_users": "Добавить пользователей", + "Add_to_user_room_category": "Добавить в пользовательскую категорию", "Added__username__to_team": "добавил(-а) @{{user_added}} в эту рабочую группу", "Added__username__to_this_team": "добавил @{{user_added}} в эту Команду", "Adding_OAuth_Services": "Добавление сервисы OAuth ", @@ -1215,6 +1216,7 @@ "Create_channels": "Создать канал", "Create_new": "Создать", "Create_new_members": "Создание новых участников", + "Create_user_room_category": "Создать новую категорию комнат", "Create_unique_rules_for_this_channel": "Создать выделенные правила для этого канала", "Created": "Создан", "Created_as": "Создан как", @@ -4035,6 +4037,24 @@ "User_sent_a_message_on_channel": "{{username}} отправил сообщение в{{channel}}", "User_sent_a_message_to_you": "{{username}} отправил Вам сообщение", "User_started_a_new_conversation": "{{username}} начал новую беседу", + "User_room_category_add_description": "Выберите пользовательскую категорию сайдбара для этой комнаты. Комната будет убрана из любой другой личной категории.", + "User_room_category_assign_success": "Комната добавлена в \"{{name}}\"", + "User_room_category_created": "Категория комнат создана", + "User_room_category_create_failed": "Не удалось создать категорию комнат", + "User_room_category_delete": "Удалить категорию", + "User_room_category_delete_confirm": "Удалить категорию \"{{name}}\"? Комнаты останутся в сайдбаре в своих обычных разделах.", + "User_room_category_deleted": "Категория комнат удалена", + "User_room_category_empty_hint": "Сначала создайте категорию комнат через меню \"Создать\".", + "User_room_category_manage": "Пользовательская категория сайдбара", + "User_room_category_name": "Название категории", + "User_room_category_name_required": "Введите название категории", + "User_room_category_rename": "Переименовать категорию", + "User_room_category_rename_title": "Переименовать категорию комнат", + "User_room_category_rename_success": "Категория комнат переименована", + "User_room_category_remove_from": "Убрать из \"{{name}}\"", + "User_room_category_remove_success": "Убрано из пользовательской категории", + "User_room_category_remove_room": "Убрать из моей категории", + "User_room_category_select": "Категория", "User_unmuted_by": "Пользователь {{user_unmuted}} перестал быть заглушенным благодаря пользователю {{user_by}}.", "User_unmuted_in_room": "Пользователь перестал быть заглушенным в комнате", "User_updated_successfully": "Пользователь успешно обновлен", @@ -4982,4 +5002,4 @@ "__username__is_no_longer__role__defined_by__user_by_": "{{username}} больше не {{role}} по решению {{user_by}}", "__username__was_set__role__by__user_by_": "{{username}} был установлен {{role}} по решению {{user_by}}", "__usersCount__people_will_be_invited": "{{usersCount}} человек будет приглашено" -} \ No newline at end of file +} diff --git a/packages/models/src/models/UserRoomCategories.ts b/packages/models/src/models/UserRoomCategories.ts index c28aa6c0aa123..bfbb1a9e84831 100644 --- a/packages/models/src/models/UserRoomCategories.ts +++ b/packages/models/src/models/UserRoomCategories.ts @@ -107,4 +107,31 @@ export class UserRoomCategoriesRaw extends BaseRaw implemen return this.updateOne({ userId }, { $set: { categories } }); } + + async renameCategory(userId: string, oldName: string, newName: string): Promise { + const doc = await this.findByUserId(userId); + if (!doc) { + throw new Error('User categories document not found'); + } + + const trimmedOldName = oldName.trim(); + const trimmedNewName = newName.trim(); + + if (!trimmedOldName || !trimmedNewName) { + throw new Error('oldName and newName are required'); + } + + const targetIndex = doc.categories.findIndex((c) => c.name === trimmedOldName); + if (targetIndex === -1) { + throw new Error('Category not found'); + } + + if (doc.categories.some((c) => c.name === trimmedNewName)) { + throw new Error('Category already exists'); + } + + const categories = doc.categories.map((c, index) => (index === targetIndex ? { ...c, name: trimmedNewName } : c)); + + return this.updateOne({ userId }, { $set: { categories } }); + } } diff --git a/packages/rest-typings/src/v1/userRoomCategories.ts b/packages/rest-typings/src/v1/userRoomCategories.ts index 21783d0268035..5e6b46fbc75ba 100644 --- a/packages/rest-typings/src/v1/userRoomCategories.ts +++ b/packages/rest-typings/src/v1/userRoomCategories.ts @@ -18,4 +18,8 @@ export type UserRoomCategoriesEndpoints = { '/v1/user-room-categories/remove-category': { POST: (params: { name: string }) => void; }; + + '/v1/user-room-categories/rename-category': { + POST: (params: { oldName: string; newName: string }) => void; + }; };