From f4d86a7b8a2df4af0a3e8eaaa358e5fd0b443830 Mon Sep 17 00:00:00 2001 From: kzkken Date: Wed, 25 Feb 2026 18:12:26 +0300 Subject: [PATCH 1/8] add important message marker permission --- .../server/constant/permissions.ts | 2 + .../useChangeImportantMessageMarkerAction.ts | 59 ++++ .../actions/useUserInfoActions.ts | 265 ++++++++++++++++++ .../useUserInfoActions/useUserInfoActions.ts | 4 + .../views/room/lib/getRoomDirectives.ts | 26 +- apps/meteor/definition/IRoomTypeConfig.ts | 1 + packages/i18n/src/locales/en.i18n.json | 7 + 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 49855eb7d32f1..f6c5a29db996c 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -67,6 +67,7 @@ export const permissions = [ { _id: 'set-owner', roles: ['admin', 'owner'] }, { _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] }, { _id: 'set-leader', roles: ['admin', 'owner'] }, + { _id: 'set-important-message-marker', roles: ['admin', 'owner'] }, { _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] }, { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, { _id: 'unarchive-room', roles: ['admin'] }, @@ -260,4 +261,5 @@ export const permissions = [ { _id: 'manage-moderation-actions', roles: ['admin'] }, { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, { _id: 'export-messages-as-pdf', roles: ['admin', 'user'] }, + { _id: 'mark-message-as-important', roles: ['admin', 'owner', 'moderator'] }, ]; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts new file mode 100644 index 0000000000000..fe646f266e2fd --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts @@ -0,0 +1,59 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { + usePermission, + useUserRoom, + useUserSubscription, + useToastMessageDispatch, +} from '@rocket.chat/ui-contexts'; +import { useMemo, useState } from 'react'; + +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; +import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions'; + +export const useChangeImportantMessageMarkerAction = ( + user: Pick, + rid: IRoom['_id'], +): UserInfoAction | undefined => { + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetImportantMessageMarker = usePermission('set-important-message-marker', rid); + const userSubscription = useUserSubscription(rid); + const dispatchToastMessage = useToastMessageDispatch(); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanSetImportantMessageMarker } = getRoomDirectives({ room, showingUserId: uid, userSubscription }); + const initialHasRole = useUserHasRoomRole(uid, rid, 'important-message-marker'); + const [hasRole, setHasRole] = useState(initialHasRole); + + const changeRoleAction = useEffectEvent(async () => { + // TODO: Implement API call when endpoints are ready + setHasRole(!hasRole); + + dispatchToastMessage({ + type: 'success', + message: hasRole + ? 'Removed ability to mark important messages (demo mode)' + : 'Granted ability to mark important messages (demo mode)' + }); + }); + + const changeRoleOption = useMemo( + () => + roomCanSetImportantMessageMarker && userCanSetImportantMessageMarker + ? { + content: hasRole ? 'Remove ability to mark important messages' : 'Grant ability to mark important messages', + icon: 'flag' as const, + onClick: changeRoleAction, + type: 'privileges' as UserInfoActionType, + } + : undefined, + [hasRole, roomCanSetImportantMessageMarker, userCanSetImportantMessageMarker, changeRoleAction], + ); + + return changeRoleOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts new file mode 100644 index 0000000000000..eb6c23974ce3e --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts @@ -0,0 +1,265 @@ +// Note: +// 1.if we need to create a role that can only edit channel message, but not edit group message +// then we can define edit--message instead of edit-message +// 2. admin, moderator, and user roles should not be deleted as they are referenced in the code. +export const permissions = [ + { _id: 'access-permissions', roles: ['admin'] }, + { _id: 'access-marketplace', roles: ['admin', 'user'] }, + { _id: 'access-setting-permissions', roles: ['admin'] }, + { _id: 'add-oauth-service', roles: ['admin'] }, + { _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'add-user-to-any-c-room', roles: ['admin'] }, + { _id: 'add-user-to-any-p-room', roles: [] }, + { _id: 'kick-user-from-any-c-room', roles: ['admin'] }, + { _id: 'kick-user-from-any-p-room', roles: [] }, + { _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] }, + { _id: 'archive-room', roles: ['admin', 'owner'] }, + { _id: 'assign-admin-role', roles: ['admin'] }, + { _id: 'assign-roles', roles: ['admin'] }, + { _id: 'ban-user', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'bulk-register-user', roles: ['admin'] }, + { _id: 'change-livechat-room-visitor', roles: ['admin', 'livechat-manager', 'livechat-agent'] }, + { _id: 'create-c', roles: ['admin', 'user', 'bot', 'app'] }, + { _id: 'create-d', roles: ['admin', 'user', 'bot', 'app'] }, + { _id: 'create-p', roles: ['admin', 'user', 'bot', 'app'] }, + { _id: 'create-personal-access-tokens', roles: ['admin', 'user'] }, + { _id: 'create-user', roles: ['admin'] }, + { _id: 'clean-channel-history', roles: ['admin'] }, + { _id: 'delete-c', roles: ['admin', 'owner'] }, + { _id: 'delete-d', roles: ['admin'] }, + { _id: 'delete-message', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-own-message', roles: ['admin', 'user'] }, + { _id: 'delete-p', roles: ['admin', 'owner'] }, + { _id: 'delete-user', roles: ['admin'] }, + { _id: 'edit-message', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-other-user-active-status', roles: ['admin'] }, + { _id: 'edit-other-user-info', roles: ['admin'] }, + { _id: 'edit-other-user-password', roles: ['admin'] }, + { _id: 'edit-other-user-avatar', roles: ['admin'] }, + { _id: 'edit-other-user-e2ee', roles: ['admin'] }, + { _id: 'edit-other-user-totp', roles: ['admin'] }, + { _id: 'edit-privileged-setting', roles: ['admin'] }, + { _id: 'edit-room', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-room-avatar', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-room-retention-policy', roles: ['admin'] }, + { _id: 'force-delete-message', roles: ['admin', 'owner'] }, + { _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] }, + { _id: 'leave-c', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, + { _id: 'leave-p', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, + { _id: 'logout-other-user', roles: ['admin'] }, + { _id: 'manage-assets', roles: ['admin'] }, + { _id: 'manage-email-inbox', roles: ['admin'] }, + { _id: 'manage-emoji', roles: ['admin'] }, + { _id: 'manage-user-status', roles: ['admin'] }, + { _id: 'manage-outgoing-integrations', roles: ['admin'] }, + { _id: 'manage-incoming-integrations', roles: ['admin'] }, + { _id: 'manage-own-outgoing-integrations', roles: ['admin'] }, + { _id: 'manage-own-incoming-integrations', roles: ['admin'] }, + { _id: 'manage-oauth-apps', roles: ['admin'] }, + { _id: 'manage-selected-settings', roles: ['admin'] }, + { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'mute-user', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'remove-user', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'run-import', roles: ['admin'] }, + { _id: 'run-migration', roles: ['admin'] }, + { _id: 'set-moderator', roles: ['admin', 'owner'] }, + { _id: 'set-owner', roles: ['admin', 'owner'] }, + { _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] }, + { _id: 'set-leader', roles: ['admin', 'owner'] }, + { _id: 'set-important-message-marker', roles: ['admin', 'owner'] }, + { _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] }, + { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, + { _id: 'unarchive-room', roles: ['admin'] }, + { _id: 'view-c-room', roles: ['admin', 'user', 'bot', 'app', 'anonymous'] }, + { _id: 'user-generate-access-token', roles: ['admin'] }, + { _id: 'view-d-room', roles: ['admin', 'user', 'bot', 'app', 'guest'] }, + { _id: 'view-device-management', roles: ['admin'] }, + { _id: 'view-engagement-dashboard', roles: ['admin'] }, + { _id: 'view-full-other-user-info', roles: ['admin'] }, + { _id: 'view-joined-room', roles: ['guest', 'bot', 'app', 'anonymous'] }, + { _id: 'view-join-code', roles: ['admin'] }, + { _id: 'view-logs', roles: ['admin'] }, + { _id: 'view-other-user-channels', roles: ['admin'] }, + { _id: 'view-p-room', roles: ['admin', 'user', 'anonymous', 'guest'] }, + { _id: 'view-privileged-setting', roles: ['admin'] }, + { _id: 'view-room-administration', roles: ['admin'] }, + { _id: 'view-statistics', roles: ['admin'] }, + { _id: 'view-user-administration', roles: ['admin'] }, + { _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }, + { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'view-broadcast-member-list', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'call-management', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] }, + { + _id: 'view-l-room', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'create-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'update-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'view-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'delete-livechat-contact', + roles: ['livechat-manager', 'admin'], + }, + { + _id: 'view-livechat-contact-history', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'view-omnichannel-contact-center', + roles: ['livechat-manager', 'livechat-agent', 'livechat-monitor', 'admin'], + }, + { _id: 'edit-omnichannel-contact', roles: ['livechat-manager', 'livechat-agent', 'admin'] }, + { _id: 'view-livechat-rooms', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'close-livechat-room', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'close-others-livechat-room', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'on-hold-livechat-room', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'on-hold-others-livechat-room', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'save-others-livechat-room-info', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'remove-closed-livechat-rooms', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'view-livechat-analytics', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'view-livechat-queue', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'transfer-livechat-guest', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { _id: 'manage-livechat-managers', roles: ['livechat-manager', 'admin'] }, + { _id: 'manage-livechat-agents', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'manage-livechat-departments', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'view-livechat-departments', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'add-livechat-department-agents', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-current-chats', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-real-time-monitoring', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'view-livechat-triggers', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-customfields', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-installation', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-appearance', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-webhooks', roles: ['livechat-manager', 'admin'] }, + { + _id: 'view-livechat-business-hours', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-room-closed-same-department', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-room-closed-by-another-agent', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-room-customfields', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'edit-livechat-room-customfields', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] }, + { _id: 'mail-messages', roles: ['admin'] }, + { _id: 'toggle-room-e2e-encryption', roles: ['owner', 'admin'] }, + { _id: 'message-impersonate', roles: ['bot', 'app'] }, + { _id: 'create-team', roles: ['admin', 'user'] }, + { _id: 'delete-team', roles: ['admin', 'owner'] }, + { _id: 'convert-team', roles: ['admin', 'owner'] }, + { _id: 'edit-team', roles: ['admin', 'owner'] }, + { _id: 'add-team-member', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-team-member', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'move-room-to-team', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-team-group', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-team-group', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'remove-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'view-all-team-channels', roles: ['admin', 'owner'] }, + { _id: 'view-all-teams', roles: ['admin'] }, + { _id: 'remove-closed-livechat-room', roles: ['livechat-manager', 'admin'] }, + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, + + // VOIP Permissions + // allows to manage voip calls configuration + { _id: 'manage-voip-call-settings', roles: ['livechat-manager', 'admin'] }, + { _id: 'manage-voip-contact-center-settings', roles: ['livechat-manager', 'admin'] }, + // allows agent-extension association. + { _id: 'manage-agent-extension-association', roles: ['admin'] }, + { _id: 'view-agent-extension-association', roles: ['livechat-manager', 'admin', 'livechat-agent'] }, + // allows to receive a voip call + { _id: 'inbound-voip-calls', roles: ['livechat-agent'] }, + + // Allow managing team collab voip extensions + { _id: 'manage-voip-extensions', roles: ['admin'] }, + // Allow viewing the extension number of other users + { _id: 'view-user-voip-extension', roles: ['admin', 'user'] }, + // Allow viewing details of an extension + { _id: 'view-voip-extension-details', roles: ['admin', 'user'] }, + + // New Media calls permissions + { _id: 'allow-internal-voice-calls', roles: ['admin', 'user'] }, + { _id: 'allow-external-voice-calls', roles: ['admin', 'user'] }, + + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, + { _id: 'manage-apps', roles: ['admin'] }, + { _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'set-readonly', roles: ['admin', 'owner'] }, + { _id: 'set-react-when-readonly', roles: ['admin', 'owner'] }, + { _id: 'manage-cloud', roles: ['admin'] }, + { _id: 'manage-sounds', roles: ['admin'] }, + { _id: 'access-mailer', roles: ['admin'] }, + { _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] }, + { _id: 'mobile-upload-file', roles: ['user', 'admin'] }, + { _id: 'send-mail', roles: ['admin'] }, + { _id: 'view-federation-data', roles: ['admin'] }, + { _id: 'access-federation', roles: ['admin', 'user'] }, + { _id: 'add-all-to-room', roles: ['admin'] }, + { _id: 'get-server-info', roles: ['admin'] }, + { _id: 'register-on-cloud', roles: ['admin'] }, + { _id: 'test-admin-options', roles: ['admin'] }, + { _id: 'test-push-notifications', roles: ['admin', 'user'] }, + { _id: 'sync-auth-services-users', roles: ['admin'] }, + { _id: 'restart-server', roles: ['admin'] }, + { _id: 'remove-slackbridge-links', roles: ['admin'] }, + { _id: 'view-import-operations', roles: ['admin'] }, + { _id: 'clear-oembed-cache', roles: ['admin'] }, + { _id: 'videoconf-ring-users', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'view-moderation-console', roles: ['admin'] }, + { _id: 'manage-moderation-actions', roles: ['admin'] }, + { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, + { _id: 'export-messages-as-pdf', roles: ['admin', 'user'] }, + { _id: 'mark-message-as-important', roles: ['admin', 'owner', 'moderator'] }, +]; \ No newline at end of file diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index a4422e3d6765b..101337fbd264d 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -10,6 +10,7 @@ import { useBlockUserAction } from './actions/useBlockUserAction'; import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; import { useChangeModeratorAction } from './actions/useChangeModeratorAction'; import { useChangeOwnerAction } from './actions/useChangeOwnerAction'; +import { useChangeImportantMessageMarkerAction } from './actions/useChangeImportantMessageMarkerAction'; import { useDirectMessageAction } from './actions/useDirectMessageAction'; import { useIgnoreUserAction } from './actions/useIgnoreUserAction'; import { useMuteUserAction } from './actions/useMuteUserAction'; @@ -69,6 +70,7 @@ export const useUserInfoActions = ({ const blockUser = useBlockUserAction(user, rid); const changeLeader = useChangeLeaderAction(user, rid); const changeModerator = useChangeModeratorAction(user, rid); + const changeImportantMessageMarker = useChangeImportantMessageMarkerAction(user, rid); const openModerationConsole = useRedirectModerationConsole(user._id); const changeOwner = useChangeOwnerAction(user, rid); const openDirectMessage = useDirectMessageAction(user, rid); @@ -90,6 +92,7 @@ export const useUserInfoActions = ({ ...(isMember && changeOwner && { changeOwner }), ...(isMember && changeLeader && { changeLeader }), ...(isMember && changeModerator && { changeModerator }), + ...(isMember && changeImportantMessageMarker && { changeImportantMessageMarker }), ...(isMember && openModerationConsole && { openModerationConsole }), ...(isMember && ignoreUser && { ignoreUser }), ...(isMember && muteUser && { muteUser }), @@ -105,6 +108,7 @@ export const useUserInfoActions = ({ changeOwner, changeLeader, changeModerator, + changeImportantMessageMarker, ignoreUser, muteUser, blockUser, diff --git a/apps/meteor/client/views/room/lib/getRoomDirectives.ts b/apps/meteor/client/views/room/lib/getRoomDirectives.ts index f697fc7b51a6f..14cdf51dbbcaf 100644 --- a/apps/meteor/client/views/room/lib/getRoomDirectives.ts +++ b/apps/meteor/client/views/room/lib/getRoomDirectives.ts @@ -7,6 +7,7 @@ type getRoomDirectiesType = { roomCanSetOwner: boolean; roomCanSetLeader: boolean; roomCanSetModerator: boolean; + roomCanSetImportantMessageMarker: boolean; roomCanIgnore: boolean; roomCanBlock: boolean; roomCanMute: boolean; @@ -25,11 +26,22 @@ export const getRoomDirectives = ({ }): getRoomDirectiesType => { const roomDirectives = room?.t && roomCoordinator.getRoomDirectives(room.t); - const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove, roomCanInvite] = [ + const [ + roomCanSetOwner, + roomCanSetLeader, + roomCanSetModerator, + roomCanSetImportantMessageMarker, + roomCanIgnore, + roomCanBlock, + roomCanMute, + roomCanRemove, + roomCanInvite, + ] = [ ...((roomDirectives && [ roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR, showingUserId, userSubscription), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_IMPORTANT_MESSAGE_MARKER, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE, showingUserId, userSubscription), @@ -39,5 +51,15 @@ export const getRoomDirectives = ({ []), ]; - return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove, roomCanInvite }; + return { + roomCanSetOwner, + roomCanSetLeader, + roomCanSetModerator, + roomCanSetImportantMessageMarker, + roomCanIgnore, + roomCanBlock, + roomCanMute, + roomCanRemove, + roomCanInvite, + }; }; diff --git a/apps/meteor/definition/IRoomTypeConfig.ts b/apps/meteor/definition/IRoomTypeConfig.ts index 0a8e7161c6f06..d4f6091433bdd 100644 --- a/apps/meteor/definition/IRoomTypeConfig.ts +++ b/apps/meteor/definition/IRoomTypeConfig.ts @@ -43,6 +43,7 @@ export const RoomMemberActions = { SET_AS_OWNER: 'setAsOwner', SET_AS_LEADER: 'setAsLeader', SET_AS_MODERATOR: 'setAsModerator', + SET_AS_IMPORTANT_MESSAGE_MARKER: 'setAsImportantMessageMarker', LEAVE: 'leave', REMOVE_USER: 'removeUser', JOIN: 'join', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9636df6ef6a9e..738d707f90ad0 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2051,6 +2051,7 @@ "Export_Messages": "Export messages", "Export_My_Data": "Export My Data (JSON)", "export-messages-as-pdf": "Export messages as PDF", + "export-messages-as-pdf_description": "Permission to export messages as PDF", "Export_most_recent_logs": "Export most recent logs", "Export_as_PDF": "Export as PDF", "Export_as_file": "Export as file", @@ -2058,6 +2059,8 @@ "Export_enabled_at_the_end_of_the_conversation": "Export enabled at the end of the conversation", "Extended": "Extended", "Extension": "Extension", + "mark-message-as-important": "Mark Message as Important", + "mark-message-as-important_description": "Permission to mark messages as important requiring acknowledgment from recipients", "Extension_Number": "Extension Number", "Extension_Status": "Extension Status", "Extension_removed": "Extension removed", @@ -6790,6 +6793,10 @@ "set-moderator_description": "Permission to set other users as moderator of a channel", "set-owner": "Set Owner", "set-owner_description": "Permission to set other users as owner of a channel", + "set-important-message-marker": "Set Important Message Marker", + "set-important-message-marker_description": "Permission to grant other users the ability to mark messages as important", + "Set_as_important_message_marker": "Grant ability to mark important messages", + "Remove_as_important_message_marker": "Remove ability to mark important messages", "set-react-when-readonly": "Set React When ReadOnly", "set-react-when-readonly_description": "Permission to set the ability to react to messages in a read only channel", "set-readonly": "Set ReadOnly", From d8e405545718b0f266b35e51640b884a4a0c1dcc Mon Sep 17 00:00:00 2001 From: kzkken Date: Wed, 25 Feb 2026 19:02:38 +0300 Subject: [PATCH 2/8] add important message marker permission --- .../actions/useUserInfoActions.ts | 265 ------------------ .../useUserInfoActions/useUserInfoActions.ts | 1 + 2 files changed, 1 insertion(+), 265 deletions(-) delete mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts deleted file mode 100644 index eb6c23974ce3e..0000000000000 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserInfoActions.ts +++ /dev/null @@ -1,265 +0,0 @@ -// Note: -// 1.if we need to create a role that can only edit channel message, but not edit group message -// then we can define edit--message instead of edit-message -// 2. admin, moderator, and user roles should not be deleted as they are referenced in the code. -export const permissions = [ - { _id: 'access-permissions', roles: ['admin'] }, - { _id: 'access-marketplace', roles: ['admin', 'user'] }, - { _id: 'access-setting-permissions', roles: ['admin'] }, - { _id: 'add-oauth-service', roles: ['admin'] }, - { _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'add-user-to-any-c-room', roles: ['admin'] }, - { _id: 'add-user-to-any-p-room', roles: [] }, - { _id: 'kick-user-from-any-c-room', roles: ['admin'] }, - { _id: 'kick-user-from-any-p-room', roles: [] }, - { _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] }, - { _id: 'archive-room', roles: ['admin', 'owner'] }, - { _id: 'assign-admin-role', roles: ['admin'] }, - { _id: 'assign-roles', roles: ['admin'] }, - { _id: 'ban-user', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'bulk-register-user', roles: ['admin'] }, - { _id: 'change-livechat-room-visitor', roles: ['admin', 'livechat-manager', 'livechat-agent'] }, - { _id: 'create-c', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-d', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-p', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-personal-access-tokens', roles: ['admin', 'user'] }, - { _id: 'create-user', roles: ['admin'] }, - { _id: 'clean-channel-history', roles: ['admin'] }, - { _id: 'delete-c', roles: ['admin', 'owner'] }, - { _id: 'delete-d', roles: ['admin'] }, - { _id: 'delete-message', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'delete-own-message', roles: ['admin', 'user'] }, - { _id: 'delete-p', roles: ['admin', 'owner'] }, - { _id: 'delete-user', roles: ['admin'] }, - { _id: 'edit-message', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'edit-other-user-active-status', roles: ['admin'] }, - { _id: 'edit-other-user-info', roles: ['admin'] }, - { _id: 'edit-other-user-password', roles: ['admin'] }, - { _id: 'edit-other-user-avatar', roles: ['admin'] }, - { _id: 'edit-other-user-e2ee', roles: ['admin'] }, - { _id: 'edit-other-user-totp', roles: ['admin'] }, - { _id: 'edit-privileged-setting', roles: ['admin'] }, - { _id: 'edit-room', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'edit-room-avatar', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'edit-room-retention-policy', roles: ['admin'] }, - { _id: 'force-delete-message', roles: ['admin', 'owner'] }, - { _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] }, - { _id: 'leave-c', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, - { _id: 'leave-p', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, - { _id: 'logout-other-user', roles: ['admin'] }, - { _id: 'manage-assets', roles: ['admin'] }, - { _id: 'manage-email-inbox', roles: ['admin'] }, - { _id: 'manage-emoji', roles: ['admin'] }, - { _id: 'manage-user-status', roles: ['admin'] }, - { _id: 'manage-outgoing-integrations', roles: ['admin'] }, - { _id: 'manage-incoming-integrations', roles: ['admin'] }, - { _id: 'manage-own-outgoing-integrations', roles: ['admin'] }, - { _id: 'manage-own-incoming-integrations', roles: ['admin'] }, - { _id: 'manage-oauth-apps', roles: ['admin'] }, - { _id: 'manage-selected-settings', roles: ['admin'] }, - { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'mute-user', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'remove-user', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'run-import', roles: ['admin'] }, - { _id: 'run-migration', roles: ['admin'] }, - { _id: 'set-moderator', roles: ['admin', 'owner'] }, - { _id: 'set-owner', roles: ['admin', 'owner'] }, - { _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] }, - { _id: 'set-leader', roles: ['admin', 'owner'] }, - { _id: 'set-important-message-marker', roles: ['admin', 'owner'] }, - { _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] }, - { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, - { _id: 'unarchive-room', roles: ['admin'] }, - { _id: 'view-c-room', roles: ['admin', 'user', 'bot', 'app', 'anonymous'] }, - { _id: 'user-generate-access-token', roles: ['admin'] }, - { _id: 'view-d-room', roles: ['admin', 'user', 'bot', 'app', 'guest'] }, - { _id: 'view-device-management', roles: ['admin'] }, - { _id: 'view-engagement-dashboard', roles: ['admin'] }, - { _id: 'view-full-other-user-info', roles: ['admin'] }, - { _id: 'view-joined-room', roles: ['guest', 'bot', 'app', 'anonymous'] }, - { _id: 'view-join-code', roles: ['admin'] }, - { _id: 'view-logs', roles: ['admin'] }, - { _id: 'view-other-user-channels', roles: ['admin'] }, - { _id: 'view-p-room', roles: ['admin', 'user', 'anonymous', 'guest'] }, - { _id: 'view-privileged-setting', roles: ['admin'] }, - { _id: 'view-room-administration', roles: ['admin'] }, - { _id: 'view-statistics', roles: ['admin'] }, - { _id: 'view-user-administration', roles: ['admin'] }, - { _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }, - { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'view-broadcast-member-list', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'call-management', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] }, - { - _id: 'view-l-room', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { - _id: 'create-livechat-contact', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { - _id: 'update-livechat-contact', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { - _id: 'view-livechat-contact', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { - _id: 'delete-livechat-contact', - roles: ['livechat-manager', 'admin'], - }, - { - _id: 'view-livechat-contact-history', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'view-omnichannel-contact-center', - roles: ['livechat-manager', 'livechat-agent', 'livechat-monitor', 'admin'], - }, - { _id: 'edit-omnichannel-contact', roles: ['livechat-manager', 'livechat-agent', 'admin'] }, - { _id: 'view-livechat-rooms', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'close-livechat-room', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { _id: 'close-others-livechat-room', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'on-hold-livechat-room', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { - _id: 'on-hold-others-livechat-room', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { _id: 'save-others-livechat-room-info', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'remove-closed-livechat-rooms', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { _id: 'view-livechat-analytics', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'view-livechat-queue', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { _id: 'transfer-livechat-guest', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { _id: 'manage-livechat-managers', roles: ['livechat-manager', 'admin'] }, - { _id: 'manage-livechat-agents', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'manage-livechat-departments', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { _id: 'view-livechat-departments', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, - { - _id: 'add-livechat-department-agents', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { - _id: 'view-livechat-current-chats', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { - _id: 'view-livechat-real-time-monitoring', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { _id: 'view-livechat-triggers', roles: ['livechat-manager', 'admin'] }, - { _id: 'view-livechat-customfields', roles: ['livechat-manager', 'admin'] }, - { _id: 'view-livechat-installation', roles: ['livechat-manager', 'admin'] }, - { _id: 'view-livechat-appearance', roles: ['livechat-manager', 'admin'] }, - { _id: 'view-livechat-webhooks', roles: ['livechat-manager', 'admin'] }, - { - _id: 'view-livechat-business-hours', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { - _id: 'view-livechat-room-closed-same-department', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { - _id: 'view-livechat-room-closed-by-another-agent', - roles: ['livechat-manager', 'livechat-monitor', 'admin'], - }, - { - _id: 'view-livechat-room-customfields', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { - _id: 'edit-livechat-room-customfields', - roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], - }, - { _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] }, - { _id: 'mail-messages', roles: ['admin'] }, - { _id: 'toggle-room-e2e-encryption', roles: ['owner', 'admin'] }, - { _id: 'message-impersonate', roles: ['bot', 'app'] }, - { _id: 'create-team', roles: ['admin', 'user'] }, - { _id: 'delete-team', roles: ['admin', 'owner'] }, - { _id: 'convert-team', roles: ['admin', 'owner'] }, - { _id: 'edit-team', roles: ['admin', 'owner'] }, - { _id: 'add-team-member', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'edit-team-member', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'move-room-to-team', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'create-team-channel', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'create-team-group', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'delete-team-channel', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'delete-team-group', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'edit-team-channel', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'remove-team-channel', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'view-all-team-channels', roles: ['admin', 'owner'] }, - { _id: 'view-all-teams', roles: ['admin'] }, - { _id: 'remove-closed-livechat-room', roles: ['livechat-manager', 'admin'] }, - { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, - - // VOIP Permissions - // allows to manage voip calls configuration - { _id: 'manage-voip-call-settings', roles: ['livechat-manager', 'admin'] }, - { _id: 'manage-voip-contact-center-settings', roles: ['livechat-manager', 'admin'] }, - // allows agent-extension association. - { _id: 'manage-agent-extension-association', roles: ['admin'] }, - { _id: 'view-agent-extension-association', roles: ['livechat-manager', 'admin', 'livechat-agent'] }, - // allows to receive a voip call - { _id: 'inbound-voip-calls', roles: ['livechat-agent'] }, - - // Allow managing team collab voip extensions - { _id: 'manage-voip-extensions', roles: ['admin'] }, - // Allow viewing the extension number of other users - { _id: 'view-user-voip-extension', roles: ['admin', 'user'] }, - // Allow viewing details of an extension - { _id: 'view-voip-extension-details', roles: ['admin', 'user'] }, - - // New Media calls permissions - { _id: 'allow-internal-voice-calls', roles: ['admin', 'user'] }, - { _id: 'allow-external-voice-calls', roles: ['admin', 'user'] }, - - { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, - { _id: 'manage-apps', roles: ['admin'] }, - { _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'set-readonly', roles: ['admin', 'owner'] }, - { _id: 'set-react-when-readonly', roles: ['admin', 'owner'] }, - { _id: 'manage-cloud', roles: ['admin'] }, - { _id: 'manage-sounds', roles: ['admin'] }, - { _id: 'access-mailer', roles: ['admin'] }, - { _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] }, - { _id: 'mobile-upload-file', roles: ['user', 'admin'] }, - { _id: 'send-mail', roles: ['admin'] }, - { _id: 'view-federation-data', roles: ['admin'] }, - { _id: 'access-federation', roles: ['admin', 'user'] }, - { _id: 'add-all-to-room', roles: ['admin'] }, - { _id: 'get-server-info', roles: ['admin'] }, - { _id: 'register-on-cloud', roles: ['admin'] }, - { _id: 'test-admin-options', roles: ['admin'] }, - { _id: 'test-push-notifications', roles: ['admin', 'user'] }, - { _id: 'sync-auth-services-users', roles: ['admin'] }, - { _id: 'restart-server', roles: ['admin'] }, - { _id: 'remove-slackbridge-links', roles: ['admin'] }, - { _id: 'view-import-operations', roles: ['admin'] }, - { _id: 'clear-oembed-cache', roles: ['admin'] }, - { _id: 'videoconf-ring-users', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'view-moderation-console', roles: ['admin'] }, - { _id: 'manage-moderation-actions', roles: ['admin'] }, - { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, - { _id: 'export-messages-as-pdf', roles: ['admin', 'user'] }, - { _id: 'mark-message-as-important', roles: ['admin', 'owner', 'moderator'] }, -]; \ No newline at end of file diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index 101337fbd264d..7445eaa5134d3 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -152,3 +152,4 @@ export const useUserInfoActions = ({ return actionSpread; }; + From cb0fa66fa05863c7f0d613aa0a8823698ab3f53a Mon Sep 17 00:00:00 2001 From: kzkken Date: Tue, 17 Mar 2026 22:11:52 +0300 Subject: [PATCH 3/8] updates some permission --- .../useChangeImportantMessageMarkerAction.ts | 150 ++++++++++++++---- .../useUserInfoActions/useUserInfoActions.ts | 2 +- 2 files changed, 124 insertions(+), 28 deletions(-) diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts index fe646f266e2fd..a520dc8a7b64f 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts @@ -5,21 +5,70 @@ import { useUserRoom, useUserSubscription, useToastMessageDispatch, + useUserId, + useUser, } from '@rocket.chat/ui-contexts'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { getRoomDirectives } from '../../../lib/getRoomDirectives'; import { useUserHasRoomRole } from '../../useUserHasRoomRole'; import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions'; +// Глобальное хранилище для демо-режима +const demoStore: { + roles: Record; + listeners: Set<() => void>; +} = { + roles: {}, + listeners: new Set(), +}; + +const subscribe = (listener: () => void) => { + demoStore.listeners.add(listener); + return () => { + demoStore.listeners.delete(listener); + }; +}; + +const updateDemoRole = (key: string, value: boolean) => { + demoStore.roles[key] = value; + demoStore.listeners.forEach((listener) => listener()); +}; + +const useDemoRole = (key: string, serverValue: boolean) => { + const [value, setValue] = useState(() => { + return demoStore.roles[key] !== undefined ? demoStore.roles[key] : serverValue; + }); + + useEffect(() => { + const unsubscribe = subscribe(() => { + setValue(demoStore.roles[key] !== undefined ? demoStore.roles[key] : serverValue); + }); + + if (demoStore.roles[key] === undefined) { + setValue(serverValue); + } + + return unsubscribe; + }, [key, serverValue]); + + return [value, (newValue: boolean) => updateDemoRole(key, newValue)] as const; +}; + export const useChangeImportantMessageMarkerAction = ( - user: Pick, + user: Pick, rid: IRoom['_id'], ): UserInfoAction | undefined => { const room = useUserRoom(rid); - const { _id: uid } = user; - const userCanSetImportantMessageMarker = usePermission('set-important-message-marker', rid); + const { _id: uid, username } = user; + const currentUserId = useUserId(); + const currentUser = useUser(); + + const currentUserRoles = currentUser?.roles || []; + const userSubscription = useUserSubscription(rid); + + const userCanSetImportantMessageMarker = usePermission('set-important-message-marker', rid); const dispatchToastMessage = useToastMessageDispatch(); if (!room) { @@ -27,33 +76,80 @@ export const useChangeImportantMessageMarkerAction = ( } const { roomCanSetImportantMessageMarker } = getRoomDirectives({ room, showingUserId: uid, userSubscription }); - const initialHasRole = useUserHasRoomRole(uid, rid, 'important-message-marker'); - const [hasRole, setHasRole] = useState(initialHasRole); + + const isCurrentUserPrivileged = currentUserRoles.some((role: string) => + ['admin', 'owner'].includes(role) + ); + + const isTargetOwner = useUserHasRoomRole(uid, rid, 'owner'); + const isTargetModerator = useUserHasRoomRole(uid, rid, 'moderator'); + + // УБРАЛИ leader из проверки + const isTargetPrivileged = isTargetOwner || isTargetModerator; + + const demoKey = `${uid}-${rid}-important-message-marker`; + const serverHasRole = useUserHasRoomRole(uid, rid, 'important-message-marker'); + + const [hasRole, setHasRole] = useDemoRole(demoKey, serverHasRole); const changeRoleAction = useEffectEvent(async () => { - // TODO: Implement API call when endpoints are ready - setHasRole(!hasRole); - - dispatchToastMessage({ - type: 'success', - message: hasRole - ? 'Removed ability to mark important messages (demo mode)' - : 'Granted ability to mark important messages (demo mode)' - }); + try { + const newRoleState = !hasRole; + setHasRole(newRoleState); + + dispatchToastMessage({ + type: 'success', + message: newRoleState + ? `Granted ability to mark important messages to @${username} (demo mode)` + : `Removed ability to mark important messages from @${username} (demo mode)` + }); + } catch (error) { + dispatchToastMessage({ + type: 'error', + message: 'Failed to change role' + }); + } }); - const changeRoleOption = useMemo( - () => - roomCanSetImportantMessageMarker && userCanSetImportantMessageMarker - ? { - content: hasRole ? 'Remove ability to mark important messages' : 'Grant ability to mark important messages', - icon: 'flag' as const, - onClick: changeRoleAction, - type: 'privileges' as UserInfoActionType, - } - : undefined, - [hasRole, roomCanSetImportantMessageMarker, userCanSetImportantMessageMarker, changeRoleAction], - ); + const changeRoleOption = useMemo(() => { + if (uid === currentUserId) { + return undefined; + } + + if (!roomCanSetImportantMessageMarker) { + return undefined; + } + + if (!userCanSetImportantMessageMarker) { + return undefined; + } + + if (!isCurrentUserPrivileged) { + return undefined; + } + + if (isTargetPrivileged) { + return undefined; + } + + return { + content: hasRole + ? 'Remove ability to mark important messages' + : 'Grant ability to mark important messages', + icon: 'flag' as const, + onClick: changeRoleAction, + type: 'privileges' as UserInfoActionType, + }; + }, [ + hasRole, + roomCanSetImportantMessageMarker, + userCanSetImportantMessageMarker, + isCurrentUserPrivileged, + isTargetPrivileged, + changeRoleAction, + uid, + currentUserId + ]); return changeRoleOption; }; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index 7445eaa5134d3..0cd960af6df09 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -52,7 +52,7 @@ type UserMenuAction = { }[]; type UserInfoActionsParams = { - user: Pick; + user: Pick; rid: IRoom['_id']; reload?: () => void; size?: number; From 956d2373edc83e2d75f9d52d4e28d6ff534e9a9f Mon Sep 17 00:00:00 2001 From: kzkken Date: Tue, 24 Mar 2026 13:07:05 +0300 Subject: [PATCH 4/8] iteration2: button on the toolbar --- .../server/constant/permissions.ts | 2 +- .../app/lib/server/methods/sendMessage.ts | 1 + .../theme/client/imports/general/base_old.css | 7 + .../message/variants/RoomMessage.tsx | 2 + apps/meteor/client/hooks/useRoomRolesQuery.ts | 4 +- apps/meteor/client/lib/chats/ChatAPI.ts | 1 + .../client/lib/chats/flows/sendMessage.ts | 7 +- .../views/room/composer/ComposerMessage.tsx | 3 + .../room/composer/messageBox/MessageBox.tsx | 15 +- .../MessageBoxActionsToolbar.tsx | 46 +++++- .../useChangeImportantMessageMarkerAction.ts | 133 ++++++++++-------- .../methods/addRoomImportantMessageMarker.ts | 118 ++++++++++++++++ apps/meteor/server/methods/getUserRoomRole.ts | 33 +++++ apps/meteor/server/methods/index.ts | 3 + .../removeRoomImportantMessageMarker.ts | 124 ++++++++++++++++ .../startup/importantMessageMarkerRole.ts | 29 ++++ apps/meteor/server/startup/index.ts | 1 + .../core-typings/src/IMessage/IMessage.ts | 1 + packages/i18n/src/locales/en.i18n.json | 1 + 19 files changed, 457 insertions(+), 74 deletions(-) create mode 100644 apps/meteor/server/methods/addRoomImportantMessageMarker.ts create mode 100644 apps/meteor/server/methods/getUserRoomRole.ts create mode 100644 apps/meteor/server/methods/removeRoomImportantMessageMarker.ts create mode 100644 apps/meteor/server/startup/importantMessageMarkerRole.ts diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index f6c5a29db996c..e8cf88d1f375c 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -261,5 +261,5 @@ export const permissions = [ { _id: 'manage-moderation-actions', roles: ['admin'] }, { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, { _id: 'export-messages-as-pdf', roles: ['admin', 'user'] }, - { _id: 'mark-message-as-important', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'mark-message-as-important', roles: ['admin', 'owner', 'important-message-marker'] }, ]; diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 6aa7b7e21e062..3a4bc0da82bc0 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -134,6 +134,7 @@ Meteor.methods({ federation: Match.Maybe(Object), groupable: Match.Maybe(Boolean), sentByEmail: Match.Maybe(Boolean), + isImportant: Match.Maybe(Boolean), }); const uid = Meteor.userId(); diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index eb41aa2b13131..87b13fe96979e 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -88,6 +88,13 @@ &.highlight { animation: highlight 6s; } + + &.rcx-message--important { + background-color: rgba(245, 69, 92, 0.08) !important; + border-left: 3px solid #f5455c; + padding-left: 8px; + margin: 2px 0; + } } .page-loading { diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index a939487015af0..ada6395cb8746 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -84,7 +84,9 @@ const RoomMessage = ({ data-sequential={sequential} data-own={message.u._id === uid} data-qa-type='message' + data-important={message.isImportant} aria-busy={message.temp} + className={message.isImportant ? 'rcx-message--important' : undefined} {...props} > diff --git a/apps/meteor/client/hooks/useRoomRolesQuery.ts b/apps/meteor/client/hooks/useRoomRolesQuery.ts index cdc012b1b7832..48903199dcc22 100644 --- a/apps/meteor/client/hooks/useRoomRolesQuery.ts +++ b/apps/meteor/client/hooks/useRoomRolesQuery.ts @@ -52,7 +52,7 @@ export const useRoomRolesQuery = (rid: IRoom['_id'], option case 'removed': { const { _id: roleId, scope, u } = role; - if (!!scope || !u) return; + if (!u) return; queryClient.setQueryData(roomsQueryKeys.roles(rid), (data: RoomRoles[] | undefined = []) => { const index = data?.findIndex((record) => record.rid === rid && record.u._id === u._id) ?? -1; @@ -65,6 +65,8 @@ export const useRoomRolesQuery = (rid: IRoom['_id'], option return [...data]; }); + + queryClient.invalidateQueries({ queryKey: roomsQueryKeys.roles(rid) }); break; } } diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index ef90b95cbb29f..d42e658951d76 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -149,6 +149,7 @@ export type ChatAPI = { tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; + isImportant?: boolean; }) => Promise; readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise; readonly processTooLongMessage: (message: IMessage) => Promise; diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index d592729e295b6..a5ef09677ec05 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -45,7 +45,8 @@ export const sendMessage = async ( tshow, previewUrls, isSlashCommandAllowed, - }: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }, + isImportant, + }: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; isImportant?: boolean }, ): Promise => { if (!(await chat.data.isSubscribedToRoom())) { try { @@ -72,6 +73,10 @@ export const sendMessage = async ( originalMessage: mid ? await chat.data.findMessageByID(mid) : null, }); + if (isImportant) { + message.isImportant = true; + } + if (mid) { const originalMessage = await chat.data.findMessageByID(mid); diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index cefe16936beeb..123768cc94a7b 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -45,11 +45,13 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps): Reac tshow, previewUrls, isSlashCommandAllowed, + isImportant, }: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; + isImportant?: boolean; }): Promise => { try { await chat?.action.stop('typing'); @@ -58,6 +60,7 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps): Reac tshow, previewUrls, isSlashCommandAllowed, + isImportant, }); if (newMessageSent) onSend?.(); } catch (error) { diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 5c2fb4519a125..5569a4d77dc96 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -15,7 +15,7 @@ import { import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { ReactElement, FormEvent, MouseEvent, ClipboardEvent } from 'react'; -import { memo, useRef, useReducer, useCallback, useSyncExternalStore } from 'react'; +import { memo, useRef, useReducer, useCallback, useSyncExternalStore, useState } from 'react'; import MessageBoxActionsToolbar from './MessageBoxActionsToolbar'; import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar'; @@ -48,7 +48,6 @@ import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled const reducer = (_: unknown, event: FormEvent): boolean => { const target = event.target as HTMLInputElement; - return Boolean(target.value.trim()); }; @@ -61,7 +60,6 @@ const handleFormattingShortcut = (event: KeyboardEvent, formattingButtons: Forma } const key = event.key.toLowerCase(); - const formatter = formattingButtons.find((formatter) => 'command' in formatter && formatter.command === key); if (!formatter || !('pattern' in formatter)) { @@ -79,7 +77,7 @@ const getEmptyArray = () => a; type MessageBoxProps = { tmid?: IMessage['_id']; - onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise; + onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; isImportant?: boolean }) => Promise; onJoin?: () => Promise; onResize?: () => void; onTyping?: () => void; @@ -115,6 +113,7 @@ const MessageBox = ({ const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room); const quoteChainLimit = useSetting('Message_QuoteChainLimit', 2); const [typing, setTyping] = useReducer(reducer, false); + const [isImportantActive, setIsImportantActive] = useState(false); const { isMobile } = useLayout(); const sendOnEnterBehavior = useUserPreference<'normal' | 'alternative' | 'desktop'>('sendOnEnter') || isMobile; @@ -124,7 +123,7 @@ const MessageBox = ({ throw new Error('Chat context not found'); } - const textareaRef = useRef(null); + const textareaRef = useRef(null); const messageComposerRef = useRef(null); const storageID = `messagebox_${room._id}${tmid ? `-${tmid}` : ''}`; @@ -169,7 +168,10 @@ const MessageBox = ({ tshow, previewUrls, isSlashCommandAllowed, + isImportant: isImportantActive, }); + + setIsImportantActive(false); }); const closeEditing = (event: KeyboardEvent | MouseEvent) => { @@ -335,7 +337,6 @@ const MessageBox = ({ } const imageExtension = fileItem ? getImageExtensionFromMime(fileItem.type) : undefined; - const extension = imageExtension ? `.${imageExtension}` : ''; Object.defineProperty(fileItem, 'name', { @@ -460,6 +461,8 @@ const MessageBox = ({ tmid={tmid} isRecording={isRecording} variant={sizes.inlineSize < 480 ? 'small' : 'large'} + isImportantActive={isImportantActive} + onImportantToggle={setIsImportantActive} /> diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx index ef4ce8c174141..be0d9a6efe895 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx @@ -3,7 +3,8 @@ import type { Icon } from '@rocket.chat/fuselage'; import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client'; import { MessageComposerAction, MessageComposerActionsDivider } from '@rocket.chat/ui-composer'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTranslation, useLayoutHiddenActions } from '@rocket.chat/ui-contexts'; +import { useTranslation, useLayoutHiddenActions, useUserId, usePermission, useUserSubscription, useMethod } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import type { ComponentProps, MouseEvent } from 'react'; import { memo } from 'react'; @@ -28,6 +29,8 @@ type MessageBoxActionsToolbarProps = { isRecording: boolean; rid: IRoom['_id']; tmid?: IMessage['_id']; + isImportantActive?: boolean; + onImportantToggle?: (active: boolean) => void; }; const isHidden = (hiddenActions: Array, action: GenericMenuItemProps) => { @@ -45,6 +48,8 @@ const MessageBoxActionsToolbar = ({ tmid, variant = 'large', isMicrophoneDenied, + isImportantActive = false, + onImportantToggle, }: MessageBoxActionsToolbarProps) => { const t = useTranslation(); const chatContext = useChat(); @@ -54,6 +59,27 @@ const MessageBoxActionsToolbar = ({ } const room = useRoom(); + const userId = useUserId(); + const subscription = useUserSubscription(rid); + const getUserRoomRole = useMethod('getUserRoomRole'); + + const { data: hasRoleFromQuery = false } = useQuery({ + queryKey: ['user-room-role-toolbar', userId, rid, 'important-message-marker'], + queryFn: async () => { + if (!userId) return false; + try { + return await getUserRoomRole(rid, userId, 'important-message-marker'); + } catch (error) { + return false; + } + }, + staleTime: 0, + enabled: !!userId, + }); + + const hasPermission = usePermission('mark-message-as-important', rid); + const hasRole = subscription?.roles?.includes('important-message-marker') ?? hasRoleFromQuery; + const canMarkMessagesAsImportant = hasPermission || hasRole; const audioMessageAction = useAudioMessageAction(!canSend || typing || isRecording || isMicrophoneDenied, isMicrophoneDenied); const videoMessageAction = useVideoMessageAction(!canSend || typing || isRecording); @@ -91,7 +117,11 @@ const MessageBoxActionsToolbar = ({ featured.push(allActions.audioMessageAction, allActions.fileUploadAction); createNew.push(allActions.videoMessageAction); } else { - featured.push(allActions.audioMessageAction, allActions.videoMessageAction, allActions.fileUploadAction); + featured.push( + allActions.audioMessageAction, + allActions.videoMessageAction, + allActions.fileUploadAction + ); } if (allActions.webdavActions) { @@ -113,7 +143,7 @@ const MessageBoxActionsToolbar = ({ .map((item) => ({ id: item.id, icon: item.icon as ComponentProps['name'], - content: t(item.label), + content: t(item.label as TranslationKey), onClick: (event?: MouseEvent) => item.action({ rid, @@ -146,6 +176,16 @@ const MessageBoxActionsToolbar = ({ <> {featured.map((action) => action && renderAction(action))} + {canMarkMessagesAsImportant && ( + onImportantToggle?.(!isImportantActive)} + pressed={isImportantActive} + /> + )} ; - listeners: Set<() => void>; -} = { - roles: {}, - listeners: new Set(), -}; - -const subscribe = (listener: () => void) => { - demoStore.listeners.add(listener); - return () => { - demoStore.listeners.delete(listener); - }; -}; - -const updateDemoRole = (key: string, value: boolean) => { - demoStore.roles[key] = value; - demoStore.listeners.forEach((listener) => listener()); -}; - -const useDemoRole = (key: string, serverValue: boolean) => { - const [value, setValue] = useState(() => { - return demoStore.roles[key] !== undefined ? demoStore.roles[key] : serverValue; - }); - - useEffect(() => { - const unsubscribe = subscribe(() => { - setValue(demoStore.roles[key] !== undefined ? demoStore.roles[key] : serverValue); - }); - - if (demoStore.roles[key] === undefined) { - setValue(serverValue); - } - - return unsubscribe; - }, [key, serverValue]); - - return [value, (newValue: boolean) => updateDemoRole(key, newValue)] as const; -}; - export const useChangeImportantMessageMarkerAction = ( user: Pick, rid: IRoom['_id'], @@ -70,6 +31,9 @@ export const useChangeImportantMessageMarkerAction = ( const userCanSetImportantMessageMarker = usePermission('set-important-message-marker', rid); const dispatchToastMessage = useToastMessageDispatch(); + const getUserRoomRole = useMethod('getUserRoomRole'); + const queryClient = useQueryClient(); + const subscribeToNotifyLogged = useStream('notify-logged'); if (!room) { throw Error('Room not provided'); @@ -81,32 +45,77 @@ export const useChangeImportantMessageMarkerAction = ( ['admin', 'owner'].includes(role) ); - const isTargetOwner = useUserHasRoomRole(uid, rid, 'owner'); - const isTargetModerator = useUserHasRoomRole(uid, rid, 'moderator'); + useEffect(() => { + const unsubscribe = subscribeToNotifyLogged('roles-change', (role) => { + if (role.u?._id === uid && (role.scope === rid || !role.scope)) { + queryClient.invalidateQueries({ + queryKey: ['user-room-role', uid, rid] + }); + } + }); + + return unsubscribe; + }, [subscribeToNotifyLogged, uid, rid, queryClient]); - // УБРАЛИ leader из проверки - const isTargetPrivileged = isTargetOwner || isTargetModerator; + const { data: isTargetOwner = false } = useQuery({ + queryKey: ['user-room-role', uid, rid, 'owner'], + queryFn: async () => { + try { + return await getUserRoomRole(rid, uid, 'owner'); + } catch (error) { + return false; + } + }, + staleTime: 0, + }); - const demoKey = `${uid}-${rid}-important-message-marker`; - const serverHasRole = useUserHasRoomRole(uid, rid, 'important-message-marker'); - - const [hasRole, setHasRole] = useDemoRole(demoKey, serverHasRole); + const isTargetPrivileged = isTargetOwner; + + const { data: hasRole = false, refetch } = useQuery({ + queryKey: ['user-room-role', uid, rid, 'important-message-marker'], + queryFn: async () => { + try { + return await getUserRoomRole(rid, uid, 'important-message-marker'); + } catch (error) { + return false; + } + }, + staleTime: 0, + }); const changeRoleAction = useEffectEvent(async () => { try { const newRoleState = !hasRole; - setHasRole(newRoleState); - - dispatchToastMessage({ - type: 'success', - message: newRoleState - ? `Granted ability to mark important messages to @${username} (demo mode)` - : `Removed ability to mark important messages from @${username} (demo mode)` + + await Meteor.callAsync( + newRoleState + ? 'addRoomImportantMessageMarker' + : 'removeRoomImportantMessageMarker', + rid, + uid + ); + + // Refetch the role status and invalidate all role queries for this user + await refetch(); + await queryClient.invalidateQueries({ + queryKey: ['user-room-role', uid, rid] + }); + + dispatchToastMessage({ + type: 'success', + message: newRoleState + ? `Granted ability to mark important messages to @${username} in this room` + : `Removed ability to mark important messages from @${username} in this room`, }); } catch (error) { - dispatchToastMessage({ - type: 'error', - message: 'Failed to change role' + const message = + error && typeof error === 'object' && 'message' in error + ? (error as any).message + : String(error); + + dispatchToastMessage({ + type: 'error', + message: `Failed to change role: ${message}`, }); } }); @@ -134,7 +143,7 @@ export const useChangeImportantMessageMarkerAction = ( return { content: hasRole - ? 'Remove ability to mark important messages' + ? 'Remove ability to mark important messages' : 'Grant ability to mark important messages', icon: 'flag' as const, onClick: changeRoleAction, diff --git a/apps/meteor/server/methods/addRoomImportantMessageMarker.ts b/apps/meteor/server/methods/addRoomImportantMessageMarker.ts new file mode 100644 index 0000000000000..2851b0a52a802 --- /dev/null +++ b/apps/meteor/server/methods/addRoomImportantMessageMarker.ts @@ -0,0 +1,118 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; +import { settings } from '../../app/settings/server'; +import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; + +declare module '@rocket.chat/ddp-client' { + interface ServerMethods { + addRoomImportantMessageMarker(rid: string, userId: string): boolean; + removeRoomImportantMessageMarker(rid: string, userId: string): boolean; + } +} + +export const addRoomImportantMessageMarker = async ( + fromUserId: string, + rid: string, + userId: string, +): Promise => { + check(rid, String); + check(userId, String); + + const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addRoomImportantMessageMarker', + }); + } + + if (!(await hasPermissionAsync(fromUserId, 'set-important-message-marker', rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'addRoomImportantMessageMarker', + }); + } + + const user = await Users.findOneById(userId); + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomImportantMessageMarker', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'addRoomImportantMessageMarker', + }); + } + + if (subscription.roles?.includes('important-message-marker')) { + return true; + } + + await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'user' }); + + await Subscriptions.addRoleById(subscription._id, 'important-message-marker'); + + await syncRoomRolePriorityForUserAndRoom( + userId, + rid, + subscription.roles?.concat(['important-message-marker']) || ['important-message-marker'], + ); + + if (subscription._id) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + + await Message.saveSystemMessage( + 'subscription-role-added', + rid, + user.username, + fromUser!, + { role: 'important-message-marker' }, + ); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.addRolesToMember(team._id, userId, ['important-message-marker']); + } + + const event = { + type: 'added', + _id: 'important-message-marker', + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + scope: rid, + } as const; + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + + return true; +}; + +// Register Meteor method +Meteor.methods({ + async addRoomImportantMessageMarker(rid: string, userId: string) { + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomImportantMessageMarker', + }); + } + + return await addRoomImportantMessageMarker(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/methods/getUserRoomRole.ts b/apps/meteor/server/methods/getUserRoomRole.ts new file mode 100644 index 0000000000000..c62298ea281b2 --- /dev/null +++ b/apps/meteor/server/methods/getUserRoomRole.ts @@ -0,0 +1,33 @@ +import { Subscriptions } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +declare module '@rocket.chat/ddp-client' { + interface ServerMethods { + getUserRoomRole(rid: string, userId: string, role: string): boolean; + } +} + +Meteor.methods({ + async getUserRoomRole(rid: string, userId: string, role: string): Promise { + check(rid, String); + check(userId, String); + check(role, String); + + const currentUserId = Meteor.userId(); + if (!currentUserId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getUserRoomRole', + }); + } + + // Get the target user's subscription + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); + + if (!subscription) { + return false; + } + + return subscription.roles?.includes(role) ?? false; + }, +}); diff --git a/apps/meteor/server/methods/index.ts b/apps/meteor/server/methods/index.ts index dd738080e05d6..1c79db9c9c37f 100644 --- a/apps/meteor/server/methods/index.ts +++ b/apps/meteor/server/methods/index.ts @@ -2,6 +2,7 @@ import '../../imports/personal-access-tokens/server/api/methods'; import './OEmbedCacheCleanup'; import './addAllUserToRoom'; +import './addRoomImportantMessageMarker'; import './addRoomLeader'; import './addRoomModerator'; import './addRoomOwner'; @@ -16,6 +17,7 @@ import './getAvatarSuggestion'; import './getRoomById'; import './getRoomIdByNameOrId'; import './getRoomNameById'; +import './getUserRoomRole'; import './getSetupWizardParameters'; import './getTotalChannels'; import './getUsersOfRoom'; @@ -36,6 +38,7 @@ import './registerUser'; import './removeRoomLeader'; import './removeRoomModerator'; import './removeRoomOwner'; +import './removeRoomImportantMessageMarker'; import './removeUserFromRoom'; import './requestDataDownload'; import './resetAvatar'; diff --git a/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts b/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts new file mode 100644 index 0000000000000..eb6de55c5fc72 --- /dev/null +++ b/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts @@ -0,0 +1,124 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; +import { settings } from '../../app/settings/server'; +import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; + +declare module '@rocket.chat/ddp-client' { + interface ServerMethods { + removeRoomImportantMessageMarker(rid: IRoom['_id'], userId: IUser['_id']): boolean; + } +} + +export const removeRoomImportantMessageMarker = async ( + fromUserId: IUser['_id'], + rid: IRoom['_id'], + userId: IUser['_id'], +): Promise => { + check(rid, String); + check(userId, String); + + const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'removeRoomImportantMessageMarker', + }); + } + + // Проверяем права текущего пользователя + if (!(await hasPermissionAsync(fromUserId, 'set-important-message-marker', rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'removeRoomImportantMessageMarker', + }); + } + + const user = await Users.findOneById(userId); + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomImportantMessageMarker', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'removeRoomImportantMessageMarker', + }); + } + + if (!Array.isArray(subscription.roles) || !subscription.roles.includes('important-message-marker')) { + throw new Meteor.Error('error-user-does-not-have-role', 'User does not have the role', { + method: 'removeRoomImportantMessageMarker', + }); + } + + // Callback перед удалением роли + await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'user' }); + + // Удаляем роль + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'important-message-marker'); + await syncRoomRolePriorityForUserAndRoom( + userId, + rid, + subscription.roles?.filter((r) => r !== 'important-message-marker') || [], + ); + + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomImportantMessageMarker', + }); + } + + // Сохраняем системное сообщение + await Message.saveSystemMessage('subscription-role-removed', rid, user.username, fromUser, { role: 'important-message-marker' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.removeRolesFromMember(team._id, userId, ['important-message-marker']); + } + + // Бродкастим событие + const event = { + type: 'removed', + _id: 'important-message-marker', + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + scope: rid, + } as const; + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + + return true; +}; + +Meteor.methods({ + async removeRoomImportantMessageMarker(rid, userId) { + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomImportantMessageMarker', + }); + } + + return removeRoomImportantMessageMarker(uid, rid, userId); + }, +}); \ No newline at end of file diff --git a/apps/meteor/server/startup/importantMessageMarkerRole.ts b/apps/meteor/server/startup/importantMessageMarkerRole.ts new file mode 100644 index 0000000000000..6c09b8a3fff66 --- /dev/null +++ b/apps/meteor/server/startup/importantMessageMarkerRole.ts @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Roles } from '@rocket.chat/models'; +import type { IRole } from '@rocket.chat/core-typings'; + +Meteor.startup(async () => { + const roleName = 'important-message-marker'; + + // Проверяем, есть ли уже роль + const existingRole: IRole | null = await Roles.findOne({ name: roleName }); + + if (!existingRole) { + // Создаём новую роль с нужными полями для комнат (scope: 'Subscriptions') + await Roles.insertOne({ + name: roleName, + description: 'Role to allow marking messages as important in rooms', + scope: 'Subscriptions', // Ключевой момент для комнатной роли + protected: false, // необязательная, можно удалить + mandatory2fa: false, // необязательная, можно удалить + }); + console.log(`Role ${roleName} created`); + } else { + // Если роль уже есть, можно обновить описание + await Roles.updateOne( + { _id: existingRole._id }, + { $set: { description: 'Role to allow marking messages as important in rooms', scope: 'Subscriptions' } } + ); + console.log(`Role ${roleName} already exists and updated`); + } +}); \ No newline at end of file diff --git a/apps/meteor/server/startup/index.ts b/apps/meteor/server/startup/index.ts index cfc22d97afaae..30df59d119c5e 100644 --- a/apps/meteor/server/startup/index.ts +++ b/apps/meteor/server/startup/index.ts @@ -2,6 +2,7 @@ import './appcache'; import './callbacks'; import { startCronJobs } from './cron'; import './initialData'; +import './importantMessageMarkerRole'; import './serverRunning'; import './coreApps'; import { generateFederationKeys } from './generateKeys'; diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index d0442adc148d4..db1296b9fba39 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -186,6 +186,7 @@ export interface IMessage extends IRocketChatRecord { pinnedAt?: Date; pinnedBy?: Pick; unread?: boolean; + isImportant?: boolean; temp?: boolean; drid?: RoomID; tlm?: Date; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 738d707f90ad0..1900fb897237d 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6487,6 +6487,7 @@ "manage-voip-contact-center-settings_description": "Permission to manage voip contact center settings", "manage-voip-extensions": "Manage Voice Calls", "manage-voip-extensions_description": "Permission to manage voice calls and assign extensions to users", + "Mark_as_important": "Mark as important", "marketplace_featured_section_community_featured": "Featured Community Apps", "marketplace_featured_section_community_supported": "Community Supported Apps", "marketplace_featured_section_enterprise": "Featured Enterprise Apps", From f05793646bb69effe57cb98e17c6f3a42531bff2 Mon Sep 17 00:00:00 2001 From: kzkken Date: Mon, 6 Apr 2026 22:48:16 +0300 Subject: [PATCH 5/8] updates iteration2 --- apps/meteor/app/theme/client/imports/general/base_old.css | 4 ++-- .../client/views/room/composer/messageBox/MessageBox.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index 87b13fe96979e..73f504e09c21a 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -90,8 +90,8 @@ } &.rcx-message--important { - background-color: rgba(245, 69, 92, 0.08) !important; - border-left: 3px solid #f5455c; + background-color: rgba(245, 69, 92, 0.03) !important; + border-left: 3px solid rgba(245, 69, 92, 0.4); padding-left: 8px; margin: 2px 0; } diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 5569a4d77dc96..15bd300b35368 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -174,6 +174,11 @@ const MessageBox = ({ setIsImportantActive(false); }); + const handleImportantToggle = useEffectEvent((active: boolean) => { + setIsImportantActive(active); + textareaRef.current?.focus(); + }); + const closeEditing = (event: KeyboardEvent | MouseEvent) => { const mid = chat.currentEditingMessage.getMID(); if (mid) { @@ -462,7 +467,7 @@ const MessageBox = ({ isRecording={isRecording} variant={sizes.inlineSize < 480 ? 'small' : 'large'} isImportantActive={isImportantActive} - onImportantToggle={setIsImportantActive} + onImportantToggle={handleImportantToggle} /> From 1a957bd1e2bf3a3483cbfbbd3a24dbc77db00642 Mon Sep 17 00:00:00 2001 From: kzkken Date: Thu, 16 Apr 2026 15:47:49 +0300 Subject: [PATCH 6/8] adding a button (mark as read/message read) below the message --- .../variants/ImportantMessageReadButton.tsx | 57 +++++++++++++++++++ .../message/variants/RoomMessage.tsx | 6 +- apps/meteor/server/methods/index.ts | 1 + .../methods/toggleImportantMessageRead.ts | 52 +++++++++++++++++ .../core-typings/src/IMessage/IMessage.ts | 1 + 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx create mode 100644 apps/meteor/server/methods/toggleImportantMessageRead.ts diff --git a/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx b/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx new file mode 100644 index 0000000000000..9a6e1e971815e --- /dev/null +++ b/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx @@ -0,0 +1,57 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Button } from '@rocket.chat/fuselage'; +import { useUserId, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { memo, useState, useEffect } from 'react'; + +type ImportantMessageReadButtonProps = { + message: IMessage; +}; + +const ImportantMessageReadButton = ({ message }: ImportantMessageReadButtonProps): ReactElement | null => { + const userId = useUserId(); + const toggleImportantMessageRead = useMethod('toggleImportantMessageRead'); + const dispatchToastMessage = useToastMessageDispatch(); + + const serverIsRead = message.importantReadBy?.includes(userId || '') ?? false; + const [isRead, setIsRead] = useState(serverIsRead); + + useEffect(() => { + setIsRead(serverIsRead); + }, [serverIsRead]); + + if (!message.isImportant || !userId) { + return null; + } + + const handleToggle = async () => { + const newState = !isRead; + setIsRead(newState); + + try { + await toggleImportantMessageRead(message._id); + } catch (error) { + setIsRead(!newState); + dispatchToastMessage({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); + } + }; + + return ( + + ); +}; + +export default memo(ImportantMessageReadButton); diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index ada6395cb8746..2fea76d628c31 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -21,6 +21,7 @@ import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; +import ImportantMessageReadButton from './ImportantMessageReadButton'; import RoomMessageContent from './room/RoomMessageContent'; type RoomMessageProps = { @@ -110,7 +111,10 @@ const RoomMessage = ({ {ignored ? ( ) : ( - + <> + + {message.isImportant && } + )} {!message.private && message?.e2e !== 'pending' && !selecting && } diff --git a/apps/meteor/server/methods/index.ts b/apps/meteor/server/methods/index.ts index 1c79db9c9c37f..c13311e71565a 100644 --- a/apps/meteor/server/methods/index.ts +++ b/apps/meteor/server/methods/index.ts @@ -50,6 +50,7 @@ import './setAvatarFromService'; import './setUserActiveStatus'; import './setUserPassword'; import './toggleFavorite'; +import './toggleImportantMessageRead'; import './unmuteUserInRoom'; import './userPresence'; import './userSetUtcOffset'; diff --git a/apps/meteor/server/methods/toggleImportantMessageRead.ts b/apps/meteor/server/methods/toggleImportantMessageRead.ts new file mode 100644 index 0000000000000..48f9b9930e76a --- /dev/null +++ b/apps/meteor/server/methods/toggleImportantMessageRead.ts @@ -0,0 +1,52 @@ +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +declare module '@rocket.chat/ddp-client' { + interface ServerMethods { + toggleImportantMessageRead(messageId: string): boolean; + } +} + +Meteor.methods({ + async toggleImportantMessageRead(messageId: string): Promise { + check(messageId, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'toggleImportantMessageRead', + }); + } + + const message = await Messages.findOneById(messageId); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { + method: 'toggleImportantMessageRead', + }); + } + + if (!message.isImportant) { + throw new Meteor.Error('error-not-important-message', 'Message is not marked as important', { + method: 'toggleImportantMessageRead', + }); + } + + const importantReadBy = message.importantReadBy || []; + const isRead = importantReadBy.includes(userId); + + if (isRead) { + await Messages.updateOne( + { _id: messageId }, + { $pull: { importantReadBy: userId } } + ); + } else { + await Messages.updateOne( + { _id: messageId }, + { $addToSet: { importantReadBy: userId } } + ); + } + + return !isRead; + }, +}); diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index db1296b9fba39..1c0abede0b509 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -187,6 +187,7 @@ export interface IMessage extends IRocketChatRecord { pinnedBy?: Pick; unread?: boolean; isImportant?: boolean; + importantReadBy?: string[]; temp?: boolean; drid?: RoomID; tlm?: Date; From 4714e1a1b9d841cd5d274c2a72a629d2e3d6d1d9 Mon Sep 17 00:00:00 2001 From: kzkken Date: Sat, 18 Apr 2026 23:14:15 +0300 Subject: [PATCH 7/8] list of those who read it has been added --- .../variants/ImportantMessageReadButton.tsx | 34 ++-- .../variants/ImportantMessageReadInfo.tsx | 170 ++++++++++++++++++ .../getUsersWhoReadImportantMessage.ts | 51 ++++++ apps/meteor/server/methods/index.ts | 1 + 4 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx create mode 100644 apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts diff --git a/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx b/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx index 9a6e1e971815e..5d38642566692 100644 --- a/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx +++ b/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx @@ -1,9 +1,12 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { Button } from '@rocket.chat/fuselage'; +import { Button, Box } from '@rocket.chat/fuselage'; import { useUserId, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import { memo, useState, useEffect } from 'react'; +import ImportantMessageReadInfo from './ImportantMessageReadInfo'; + type ImportantMessageReadButtonProps = { message: IMessage; }; @@ -12,6 +15,7 @@ const ImportantMessageReadButton = ({ message }: ImportantMessageReadButtonProps const userId = useUserId(); const toggleImportantMessageRead = useMethod('toggleImportantMessageRead'); const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); const serverIsRead = message.importantReadBy?.includes(userId || '') ?? false; const [isRead, setIsRead] = useState(serverIsRead); @@ -30,6 +34,9 @@ const ImportantMessageReadButton = ({ message }: ImportantMessageReadButtonProps try { await toggleImportantMessageRead(message._id); + queryClient.invalidateQueries({ + queryKey: ['important-message-readers', message._id] + }); } catch (error) { setIsRead(!newState); dispatchToastMessage({ @@ -40,17 +47,20 @@ const ImportantMessageReadButton = ({ message }: ImportantMessageReadButtonProps }; return ( - + + + + ); }; diff --git a/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx b/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx new file mode 100644 index 0000000000000..de18da26b5b49 --- /dev/null +++ b/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx @@ -0,0 +1,170 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Box, Icon, TextInput } from '@rocket.chat/fuselage'; +import { useMethod, useUserId, usePermission, useUserSubscription, useStream } from '@rocket.chat/ui-contexts'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ReactElement, ChangeEvent } from 'react'; +import { memo, useState, useMemo, useEffect } from 'react'; + +type ImportantMessageReadInfoProps = { + message: IMessage; +}; + +type User = { + _id: string; + username: string; + name?: string; +}; + +const ImportantMessageReadInfo = ({ message }: ImportantMessageReadInfoProps): ReactElement | null => { + const [showList, setShowList] = useState(false); + const [searchText, setSearchText] = useState(''); + const getUsersWhoRead = useMethod('getUsersWhoReadImportantMessage'); + const getUserRoomRole = useMethod('getUserRoomRole'); + const userId = useUserId(); + const subscription = useUserSubscription(message.rid); + const queryClient = useQueryClient(); + const subscribeToRoomMessages = useStream('room-messages'); + + useEffect(() => { + const unsubscribe = subscribeToRoomMessages(message.rid, (msg) => { + if (msg._id === message._id && msg.importantReadBy) { + queryClient.invalidateQueries({ + queryKey: ['important-message-readers', message._id] + }); + } + }); + + return unsubscribe; + }, [subscribeToRoomMessages, message.rid, message._id, queryClient]); + + const { data: hasRoleFromQuery = false } = useQuery({ + queryKey: ['user-room-role-info', userId, message.rid, 'important-message-marker'], + queryFn: async () => { + if (!userId) return false; + try { + return await getUserRoomRole(message.rid, userId, 'important-message-marker'); + } catch (error) { + return false; + } + }, + staleTime: 0, + enabled: !!userId, + }); + + const hasPermission = usePermission('mark-message-as-important', message.rid); + const hasRole = subscription?.roles?.includes('important-message-marker') ?? hasRoleFromQuery; + const canMarkMessagesAsImportant = hasPermission || hasRole; + + const { data: users = [], isLoading } = useQuery({ + queryKey: ['important-message-readers', message._id], + queryFn: async () => { + try { + const result = await getUsersWhoRead(message._id); + return result || []; + } catch (error) { + console.error('Error fetching users who read:', error); + return []; + } + }, + enabled: showList, + refetchInterval: showList ? 5000 : false, + }); + + const filteredUsers = useMemo(() => { + if (!searchText.trim()) { + return users; + } + const search = searchText.toLowerCase(); + return users.filter( + (user) => + user.username.toLowerCase().includes(search) || + (user.name && user.name.toLowerCase().includes(search)) + ); + }, [users, searchText]); + + const readCount = message.importantReadBy?.length || 0; + + if (!message.isImportant || !canMarkMessagesAsImportant) { + return null; + } + + const handleClick = () => { + setShowList(!showList); + if (!showList) { + setSearchText(''); + } + }; + + const handleSearchChange = (e: ChangeEvent) => { + setSearchText(e.target.value); + }; + + return ( + + + + + + {showList && ( + + + Read by ({readCount}): + + + {users.length > 3 && ( + + + + )} + + {isLoading ? ( + Loading... + ) : filteredUsers.length > 0 ? ( + + {filteredUsers.map((user) => ( + + @{user.username} {user.name && `(${user.name})`} + + ))} + + ) : searchText ? ( + No users found matching "{searchText}" + ) : ( + No one has read this message yet + )} + + )} + + ); +}; + +export default memo(ImportantMessageReadInfo); diff --git a/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts b/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts new file mode 100644 index 0000000000000..b211a5a062309 --- /dev/null +++ b/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts @@ -0,0 +1,51 @@ +import { Messages, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +declare module '@rocket.chat/ddp-client' { + interface ServerMethods { + getUsersWhoReadImportantMessage(messageId: string): Array<{ _id: string; username: string; name?: string }>; + } +} + +Meteor.methods({ + async getUsersWhoReadImportantMessage(messageId: string): Promise> { + check(messageId, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getUsersWhoReadImportantMessage', + }); + } + + const message = await Messages.findOneById(messageId); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { + method: 'getUsersWhoReadImportantMessage', + }); + } + + if (!message.isImportant) { + throw new Meteor.Error('error-not-important-message', 'Message is not marked as important', { + method: 'getUsersWhoReadImportantMessage', + }); + } + + const userIds = message.importantReadBy || []; + if (userIds.length === 0) { + return []; + } + + const users = await Users.find( + { _id: { $in: userIds } }, + { projection: { _id: 1, username: 1, name: 1 } } + ).toArray(); + + return users.map(user => ({ + _id: user._id, + username: user.username || '', + name: user.name, + })); + }, +}); diff --git a/apps/meteor/server/methods/index.ts b/apps/meteor/server/methods/index.ts index c13311e71565a..9c308b52a5f84 100644 --- a/apps/meteor/server/methods/index.ts +++ b/apps/meteor/server/methods/index.ts @@ -18,6 +18,7 @@ import './getRoomById'; import './getRoomIdByNameOrId'; import './getRoomNameById'; import './getUserRoomRole'; +import './getUsersWhoReadImportantMessage'; import './getSetupWizardParameters'; import './getTotalChannels'; import './getUsersOfRoom'; From e1ea1bb76fe486d6fbb3ccfc76278f306be7a77d Mon Sep 17 00:00:00 2001 From: kzkken Date: Wed, 22 Apr 2026 11:33:59 +0300 Subject: [PATCH 8/8] iteration 4: logging --- .../app/lib/server/methods/sendMessage.ts | 17 ++++++++++++++++- .../variants/ImportantMessageReadButton.tsx | 4 ++++ .../variants/ImportantMessageReadInfo.tsx | 8 ++++++-- .../client/lib/chats/flows/sendMessage.ts | 1 + .../room/composer/messageBox/MessageBox.tsx | 1 + .../useChangeImportantMessageMarkerAction.ts | 12 +++++++++++- .../methods/addRoomImportantMessageMarker.ts | 8 ++++++++ apps/meteor/server/methods/getUserRoomRole.ts | 6 ++++-- .../methods/getUsersWhoReadImportantMessage.ts | 7 +++++++ .../methods/removeRoomImportantMessageMarker.ts | 14 +++++++++----- .../methods/toggleImportantMessageRead.ts | 6 ++++++ 11 files changed, 73 insertions(+), 11 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 3a4bc0da82bc0..348518694080a 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -144,13 +144,28 @@ Meteor.methods({ }); } + if (message.isImportant) { + console.log('[sendMessage] Received important message:', { + messageId: message._id, + userId: uid, + roomId: message.rid + }); + } + if (MessageTypes.isSystemMessage(message)) { throw new Error("Cannot send system messages using 'sendMessage'"); } try { - return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls)); + const result = await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls)); + if (message.isImportant) { + console.log('[sendMessage] Important message saved successfully:', { messageId: result._id }); + } + return result; } catch (error: any) { + if (message.isImportant) { + console.error('[sendMessage] Error saving important message:', error); + } if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { throw new Meteor.Error(error.error || error.message, error.reason, { method: 'sendMessage', diff --git a/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx b/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx index 5d38642566692..70c7a9632c904 100644 --- a/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx +++ b/apps/meteor/client/components/message/variants/ImportantMessageReadButton.tsx @@ -32,13 +32,17 @@ const ImportantMessageReadButton = ({ message }: ImportantMessageReadButtonProps const newState = !isRead; setIsRead(newState); + console.log('[ImportantMessageReadButton] Toggling read status:', { messageId: message._id, newState }); + try { await toggleImportantMessageRead(message._id); queryClient.invalidateQueries({ queryKey: ['important-message-readers', message._id] }); + console.log('[ImportantMessageReadButton] Read status toggled successfully'); } catch (error) { setIsRead(!newState); + console.error('[ImportantMessageReadButton] Error toggling read status:', error); dispatchToastMessage({ type: 'error', message: error instanceof Error ? error.message : String(error), diff --git a/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx b/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx index de18da26b5b49..b19b5e19bbbd5 100644 --- a/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx +++ b/apps/meteor/client/components/message/variants/ImportantMessageReadInfo.tsx @@ -42,7 +42,8 @@ const ImportantMessageReadInfo = ({ message }: ImportantMessageReadInfoProps): R queryFn: async () => { if (!userId) return false; try { - return await getUserRoomRole(message.rid, userId, 'important-message-marker'); + const result = await getUserRoomRole(message.rid, userId, 'important-message-marker'); + return result ?? false; } catch (error) { return false; } @@ -89,6 +90,7 @@ const ImportantMessageReadInfo = ({ message }: ImportantMessageReadInfoProps): R } const handleClick = () => { + console.log('[ImportantMessageReadInfo] Toggling list visibility:', { messageId: message._id, currentState: showList }); setShowList(!showList); if (!showList) { setSearchText(''); @@ -96,7 +98,9 @@ const ImportantMessageReadInfo = ({ message }: ImportantMessageReadInfoProps): R }; const handleSearchChange = (e: ChangeEvent) => { - setSearchText(e.target.value); + const value = e.target.value; + console.log('[ImportantMessageReadInfo] Search text changed:', { messageId: message._id, searchText: value }); + setSearchText(value); }; return ( diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index a5ef09677ec05..3ea8b7bbb4ab3 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -75,6 +75,7 @@ export const sendMessage = async ( if (isImportant) { message.isImportant = true; + console.log('[sendMessage] Marking message as important'); } if (mid) { diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 15bd300b35368..0f184ad9d2826 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -175,6 +175,7 @@ const MessageBox = ({ }); const handleImportantToggle = useEffectEvent((active: boolean) => { + console.log('[MessageBox] Important toggle clicked:', { active }); setIsImportantActive(active); textareaRef.current?.focus(); }); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts index 3ec978984cb87..64a888f8eaa69 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeImportantMessageMarkerAction.ts @@ -87,6 +87,13 @@ export const useChangeImportantMessageMarkerAction = ( try { const newRoleState = !hasRole; + console.log('[useChangeImportantMessageMarkerAction] Changing role:', { + rid, + userId: uid, + username, + newRoleState + }); + await Meteor.callAsync( newRoleState ? 'addRoomImportantMessageMarker' @@ -95,12 +102,13 @@ export const useChangeImportantMessageMarkerAction = ( uid ); - // Refetch the role status and invalidate all role queries for this user await refetch(); await queryClient.invalidateQueries({ queryKey: ['user-room-role', uid, rid] }); + console.log('[useChangeImportantMessageMarkerAction] Role changed successfully'); + dispatchToastMessage({ type: 'success', message: newRoleState @@ -108,6 +116,8 @@ export const useChangeImportantMessageMarkerAction = ( : `Removed ability to mark important messages from @${username} in this room`, }); } catch (error) { + console.error('[useChangeImportantMessageMarkerAction] Error changing role:', error); + const message = error && typeof error === 'object' && 'message' in error ? (error as any).message diff --git a/apps/meteor/server/methods/addRoomImportantMessageMarker.ts b/apps/meteor/server/methods/addRoomImportantMessageMarker.ts index 2851b0a52a802..ef72263d1cf23 100644 --- a/apps/meteor/server/methods/addRoomImportantMessageMarker.ts +++ b/apps/meteor/server/methods/addRoomImportantMessageMarker.ts @@ -24,14 +24,18 @@ export const addRoomImportantMessageMarker = async ( check(rid, String); check(userId, String); + console.log('[addRoomImportantMessageMarker] Starting:', { fromUserId, rid, userId }); + const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); if (!room) { + console.error('[addRoomImportantMessageMarker] Room not found:', rid); throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'addRoomImportantMessageMarker', }); } if (!(await hasPermissionAsync(fromUserId, 'set-important-message-marker', rid))) { + console.error('[addRoomImportantMessageMarker] Permission denied:', { fromUserId, rid }); throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addRoomImportantMessageMarker', }); @@ -39,6 +43,7 @@ export const addRoomImportantMessageMarker = async ( const user = await Users.findOneById(userId); if (!user?.username) { + console.error('[addRoomImportantMessageMarker] User not found:', userId); throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addRoomImportantMessageMarker', }); @@ -46,12 +51,14 @@ export const addRoomImportantMessageMarker = async ( const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); if (!subscription) { + console.error('[addRoomImportantMessageMarker] Subscription not found:', { rid, userId }); throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { method: 'addRoomImportantMessageMarker', }); } if (subscription.roles?.includes('important-message-marker')) { + console.log('[addRoomImportantMessageMarker] User already has role:', { rid, userId }); return true; } @@ -100,6 +107,7 @@ export const addRoomImportantMessageMarker = async ( } void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + console.log('[addRoomImportantMessageMarker] Role added successfully:', { rid, userId }); return true; }; diff --git a/apps/meteor/server/methods/getUserRoomRole.ts b/apps/meteor/server/methods/getUserRoomRole.ts index c62298ea281b2..e125db4857e98 100644 --- a/apps/meteor/server/methods/getUserRoomRole.ts +++ b/apps/meteor/server/methods/getUserRoomRole.ts @@ -21,13 +21,15 @@ Meteor.methods({ }); } - // Get the target user's subscription const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); if (!subscription) { + console.log('[getUserRoomRole] Subscription not found:', { rid, userId, role }); return false; } - return subscription.roles?.includes(role) ?? false; + const hasRole = subscription.roles?.includes(role) ?? false; + console.log('[getUserRoomRole] Checked role:', { rid, userId, role, hasRole }); + return hasRole; }, }); diff --git a/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts b/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts index b211a5a062309..fd3453af9cc20 100644 --- a/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts +++ b/apps/meteor/server/methods/getUsersWhoReadImportantMessage.ts @@ -19,14 +19,18 @@ Meteor.methods({ }); } + console.log('[getUsersWhoReadImportantMessage] Fetching readers for message:', messageId); + const message = await Messages.findOneById(messageId); if (!message) { + console.error('[getUsersWhoReadImportantMessage] Message not found:', messageId); throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'getUsersWhoReadImportantMessage', }); } if (!message.isImportant) { + console.error('[getUsersWhoReadImportantMessage] Message not marked as important:', messageId); throw new Meteor.Error('error-not-important-message', 'Message is not marked as important', { method: 'getUsersWhoReadImportantMessage', }); @@ -34,6 +38,7 @@ Meteor.methods({ const userIds = message.importantReadBy || []; if (userIds.length === 0) { + console.log('[getUsersWhoReadImportantMessage] No readers yet:', messageId); return []; } @@ -42,6 +47,8 @@ Meteor.methods({ { projection: { _id: 1, username: 1, name: 1 } } ).toArray(); + console.log('[getUsersWhoReadImportantMessage] Found readers:', { messageId, count: users.length }); + return users.map(user => ({ _id: user._id, username: user.username || '', diff --git a/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts b/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts index eb6de55c5fc72..5c6cf38c60bbb 100644 --- a/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts +++ b/apps/meteor/server/methods/removeRoomImportantMessageMarker.ts @@ -25,15 +25,18 @@ export const removeRoomImportantMessageMarker = async ( check(rid, String); check(userId, String); + console.log('[removeRoomImportantMessageMarker] Starting:', { fromUserId, rid, userId }); + const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); if (!room) { + console.error('[removeRoomImportantMessageMarker] Room not found:', rid); throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'removeRoomImportantMessageMarker', }); } - // Проверяем права текущего пользователя if (!(await hasPermissionAsync(fromUserId, 'set-important-message-marker', rid))) { + console.error('[removeRoomImportantMessageMarker] Permission denied:', { fromUserId, rid }); throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'removeRoomImportantMessageMarker', }); @@ -41,6 +44,7 @@ export const removeRoomImportantMessageMarker = async ( const user = await Users.findOneById(userId); if (!user?.username) { + console.error('[removeRoomImportantMessageMarker] User not found:', userId); throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'removeRoomImportantMessageMarker', }); @@ -48,21 +52,21 @@ export const removeRoomImportantMessageMarker = async ( const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); if (!subscription) { + console.error('[removeRoomImportantMessageMarker] Subscription not found:', { rid, userId }); throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { method: 'removeRoomImportantMessageMarker', }); } if (!Array.isArray(subscription.roles) || !subscription.roles.includes('important-message-marker')) { + console.error('[removeRoomImportantMessageMarker] User does not have role:', { rid, userId }); throw new Meteor.Error('error-user-does-not-have-role', 'User does not have the role', { method: 'removeRoomImportantMessageMarker', }); } - // Callback перед удалением роли await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'user' }); - // Удаляем роль const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'important-message-marker'); await syncRoomRolePriorityForUserAndRoom( userId, @@ -76,12 +80,12 @@ export const removeRoomImportantMessageMarker = async ( const fromUser = await Users.findOneById(fromUserId); if (!fromUser) { + console.error('[removeRoomImportantMessageMarker] FromUser not found:', fromUserId); throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'removeRoomImportantMessageMarker', }); } - // Сохраняем системное сообщение await Message.saveSystemMessage('subscription-role-removed', rid, user.username, fromUser, { role: 'important-message-marker' }); const team = await Team.getOneByMainRoomId(rid); @@ -89,7 +93,6 @@ export const removeRoomImportantMessageMarker = async ( await Team.removeRolesFromMember(team._id, userId, ['important-message-marker']); } - // Бродкастим событие const event = { type: 'removed', _id: 'important-message-marker', @@ -107,6 +110,7 @@ export const removeRoomImportantMessageMarker = async ( void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + console.log('[removeRoomImportantMessageMarker] Role removed successfully:', { rid, userId }); return true; }; diff --git a/apps/meteor/server/methods/toggleImportantMessageRead.ts b/apps/meteor/server/methods/toggleImportantMessageRead.ts index 48f9b9930e76a..6a8ebd0691db7 100644 --- a/apps/meteor/server/methods/toggleImportantMessageRead.ts +++ b/apps/meteor/server/methods/toggleImportantMessageRead.ts @@ -19,14 +19,18 @@ Meteor.methods({ }); } + console.log('[toggleImportantMessageRead] Starting:', { messageId, userId }); + const message = await Messages.findOneById(messageId); if (!message) { + console.error('[toggleImportantMessageRead] Message not found:', messageId); throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'toggleImportantMessageRead', }); } if (!message.isImportant) { + console.error('[toggleImportantMessageRead] Message not marked as important:', messageId); throw new Meteor.Error('error-not-important-message', 'Message is not marked as important', { method: 'toggleImportantMessageRead', }); @@ -40,11 +44,13 @@ Meteor.methods({ { _id: messageId }, { $pull: { importantReadBy: userId } } ); + console.log('[toggleImportantMessageRead] Marked as unread:', { messageId, userId }); } else { await Messages.updateOne( { _id: messageId }, { $addToSet: { importantReadBy: userId } } ); + console.log('[toggleImportantMessageRead] Marked as read:', { messageId, userId }); } return !isRead;