From 5c933dbe8f4ede68d2cf3dd298902d75b2ae27b2 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 18 Apr 2026 23:11:40 +0300 Subject: [PATCH 01/18] feat: Add draft message badge in sidebar --- .changeset/soft-berries-deliver.md | 6 ++++ .../client/messageBox/createComposerAPI.ts | 2 ++ .../lib/utils/getMessageBoxStorageKey.ts | 3 ++ .../client/sidebar/badges/DraftBadge.tsx | 23 +++++++++++++ .../sidebar/badges/SidebarItemBadges.tsx | 4 +++ .../client/sidebar/hooks/useRoomDraft.ts | 32 +++++++++++++++++++ .../sidebar/badges/SidebarItemBadges.tsx | 4 +++ .../room/composer/messageBox/MessageBox.tsx | 3 +- packages/i18n/src/locales/en.i18n.json | 3 ++ 9 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .changeset/soft-berries-deliver.md create mode 100644 apps/meteor/client/lib/utils/getMessageBoxStorageKey.ts create mode 100644 apps/meteor/client/sidebar/badges/DraftBadge.tsx create mode 100644 apps/meteor/client/sidebar/hooks/useRoomDraft.ts diff --git a/.changeset/soft-berries-deliver.md b/.changeset/soft-berries-deliver.md new file mode 100644 index 0000000000000..e153ec6a25063 --- /dev/null +++ b/.changeset/soft-berries-deliver.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added draft message indicator in sidebar to help users identify rooms with unsaved messages diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 4922bc150d3e6..2dc41441a9ace 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -42,10 +42,12 @@ export const createComposerAPI = ( const persist = withDebouncing({ wait: 300 })(() => { if (input.value) { Accounts.storageLocation.setItem(storageID, input.value); + window.dispatchEvent(new CustomEvent('localStorageChange', { detail: { key: storageID, value: input.value } })); return; } Accounts.storageLocation.removeItem(storageID); + window.dispatchEvent(new CustomEvent('localStorageChange', { detail: { key: storageID, value: null } })); }); const notifyQuotedMessagesUpdate = (): void => { diff --git a/apps/meteor/client/lib/utils/getMessageBoxStorageKey.ts b/apps/meteor/client/lib/utils/getMessageBoxStorageKey.ts new file mode 100644 index 0000000000000..8b72ee8d1d2b3 --- /dev/null +++ b/apps/meteor/client/lib/utils/getMessageBoxStorageKey.ts @@ -0,0 +1,3 @@ +export const getMessageBoxStorageKey = (roomId: string, tmid?: string): string => { + return `messagebox_${roomId}${tmid ? `-${tmid}` : ''}`; +}; diff --git a/apps/meteor/client/sidebar/badges/DraftBadge.tsx b/apps/meteor/client/sidebar/badges/DraftBadge.tsx new file mode 100644 index 0000000000000..ea1f9611818e5 --- /dev/null +++ b/apps/meteor/client/sidebar/badges/DraftBadge.tsx @@ -0,0 +1,23 @@ +import { SidebarV2ItemBadge } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +type DraftBadgeProps = { + roomTitle?: string; +}; + +const DraftBadge = ({ roomTitle }: DraftBadgeProps) => { + const { t } = useTranslation(); + + return ( + + {t('Draft')} + + ); +}; + +export default DraftBadge; diff --git a/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx b/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx index aedfd3610ea9c..09739a3b0b641 100644 --- a/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx +++ b/apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx @@ -2,9 +2,11 @@ import { isInviteSubscription, isOmnichannelRoom } from '@rocket.chat/core-typin import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import UnreadBadge from './UnreadBadge'; +import DraftBadge from './DraftBadge'; import InvitationBadge from '../../components/InvitationBadge'; import OmnichannelBadges from '../../views/omnichannel/components/OmnichannelBadges'; import { useUnreadDisplay } from '../hooks/useUnreadDisplay'; +import { useRoomDraft } from '../hooks/useRoomDraft'; type SidebarItemBadgesProps = { room: SubscriptionWithRoom; @@ -13,12 +15,14 @@ type SidebarItemBadgesProps = { const SidebarItemBadges = ({ room, roomTitle }: SidebarItemBadgesProps) => { const { unreadCount, unreadTitle, unreadVariant, showUnread } = useUnreadDisplay(room); + const showDraft = useRoomDraft(room.rid); return ( <> {showUnread && } {isOmnichannelRoom(room) && } {isInviteSubscription(room) && } + {showDraft && } ); }; diff --git a/apps/meteor/client/sidebar/hooks/useRoomDraft.ts b/apps/meteor/client/sidebar/hooks/useRoomDraft.ts new file mode 100644 index 0000000000000..a3ddf91714f81 --- /dev/null +++ b/apps/meteor/client/sidebar/hooks/useRoomDraft.ts @@ -0,0 +1,32 @@ +import { Accounts } from 'meteor/accounts-base'; +import { useSyncExternalStore } from 'react'; + +import { getMessageBoxStorageKey } from '../../lib/utils/getMessageBoxStorageKey'; + +export const useRoomDraft = (roomId: string): boolean => { + return useSyncExternalStore( + (callback) => { + const storageKey = getMessageBoxStorageKey(roomId); + + const handleStorageChange = (e: StorageEvent | Event | CustomEvent) => { + if ('key' in e && (e.key === storageKey || e.key === null)) { + callback(); + } else if ('detail' in e && e.detail?.key === storageKey) { + callback(); + } + }; + + window.addEventListener('storage', handleStorageChange); + window.addEventListener('localStorageChange', handleStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('localStorageChange', handleStorageChange); + }; + }, + () => { + const draft = Accounts.storageLocation.getItem(getMessageBoxStorageKey(roomId)); + return !!draft && !!draft.trim().length; + }, + ); +}; diff --git a/apps/meteor/client/views/navigation/sidebar/badges/SidebarItemBadges.tsx b/apps/meteor/client/views/navigation/sidebar/badges/SidebarItemBadges.tsx index 587530b5e119d..2cfee35bdabff 100644 --- a/apps/meteor/client/views/navigation/sidebar/badges/SidebarItemBadges.tsx +++ b/apps/meteor/client/views/navigation/sidebar/badges/SidebarItemBadges.tsx @@ -2,8 +2,10 @@ import { isInviteSubscription } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import UnreadBadge from './UnreadBadge'; +import DraftBadge from '../../../../sidebar/badges/DraftBadge'; import InvitationBadge from '../../../../components/InvitationBadge'; import { useUnreadDisplay } from '../hooks/useUnreadDisplay'; +import { useRoomDraft } from '../../../../sidebar/hooks/useRoomDraft'; type SidebarItemBadgesProps = { room: SubscriptionWithRoom; @@ -12,11 +14,13 @@ type SidebarItemBadgesProps = { const SidebarItemBadges = ({ room, roomTitle }: SidebarItemBadgesProps) => { const { unreadCount, unreadTitle, unreadVariant, showUnread } = useUnreadDisplay(room); + const showDraft = useRoomDraft(room.rid); return ( <> {showUnread && } {isInviteSubscription(room) && } + {showDraft && } ); }; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index c653b0a132e55..1586bcc59c9a9 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -31,6 +31,7 @@ import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import type { ComposerAPI } from '../../../../lib/chats/ChatAPI'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; +import { getMessageBoxStorageKey } from '../../../../lib/utils/getMessageBoxStorageKey'; import { keyCodes } from '../../../../lib/utils/keyCodes'; import AudioMessageRecorder from '../../../composer/AudioMessageRecorder'; import VideoMessageRecorder from '../../../composer/VideoMessageRecorder'; @@ -127,7 +128,7 @@ const MessageBox = ({ const textareaRef = useRef(null); const messageComposerRef = useRef(null); - const storageID = `messagebox_${room._id}${tmid ? `-${tmid}` : ''}`; + const storageID = getMessageBoxStorageKey(room._id, tmid); const callbackRef = useCallback( (node: HTMLTextAreaElement) => { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index eb71374580bd1..c2d5a50be0736 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1868,6 +1868,8 @@ "Do_you_want_to_accept": "Do you want to accept?", "Do_you_want_to_change_to_s_question": "Do you want to change to %s?", "Document_Domain": "Document Domain", + "Draft": "Draft", + "Draft_message": "Draft message", "Documentation": "Documentation", "Domain": "Domain", "Domain_added": "domain Added", @@ -7162,6 +7164,7 @@ "__roomName__was_added_to_favorites": "{{roomName}} was added to favorites", "__roomName__was_removed_from_favorites": "{{roomName}} was removed from favorites", "__unreadTitle__from__roomTitle__": "{{unreadTitle}} from {{roomTitle}}", + "__draftMessage__from__roomTitle__": "Draft message from {{roomTitle}}", "Insert_timestamp": "Insert timestamp", "Insert": "Insert", "__username__is_no_longer__role__defined_by__user_by_": "{{username}} is no longer {{role}} by {{user_by}}", From 2f05320434a7e1ecd942442c4008b890483d5236 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 18 Apr 2026 23:47:26 +0300 Subject: [PATCH 02/18] feat: Add draft message badge in sidebar --- .../app/ui-message/client/messageBox/createComposerAPI.ts | 4 ++-- apps/meteor/client/sidebar/badges/DraftBadge.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 2dc41441a9ace..a23e212d40b70 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -42,12 +42,12 @@ export const createComposerAPI = ( const persist = withDebouncing({ wait: 300 })(() => { if (input.value) { Accounts.storageLocation.setItem(storageID, input.value); - window.dispatchEvent(new CustomEvent('localStorageChange', { detail: { key: storageID, value: input.value } })); + window.dispatchEvent(new CustomEvent('localStorageChange', { detail: { key: storageID } })); return; } Accounts.storageLocation.removeItem(storageID); - window.dispatchEvent(new CustomEvent('localStorageChange', { detail: { key: storageID, value: null } })); + window.dispatchEvent(new CustomEvent('localStorageChange', { detail: { key: storageID } })); }); const notifyQuotedMessagesUpdate = (): void => { diff --git a/apps/meteor/client/sidebar/badges/DraftBadge.tsx b/apps/meteor/client/sidebar/badges/DraftBadge.tsx index ea1f9611818e5..4dc28b4d5248a 100644 --- a/apps/meteor/client/sidebar/badges/DraftBadge.tsx +++ b/apps/meteor/client/sidebar/badges/DraftBadge.tsx @@ -13,7 +13,7 @@ const DraftBadge = ({ roomTitle }: DraftBadgeProps) => { variant='warning' title={t('Draft_message')} role='status' - aria-label={t('__draftMessage__from__roomTitle__', { roomTitle })} + aria-label={roomTitle ? t('__draftMessage__from__roomTitle__', { roomTitle }) : t('Draft_message')} > {t('Draft')} From 286677578d7e16f42b8c207f168b2076cee95673 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sun, 19 Apr 2026 03:12:10 +0300 Subject: [PATCH 03/18] Added support for nested lists in markdown messages --- .changeset/real-ties-unite.md | 8 +++ .../room/composer/messageBox/MessageBox.tsx | 6 ++ .../markup/blocks/OrderedListBlock.tsx | 18 +++-- .../markup/blocks/UnorderedListBlock.tsx | 18 +++-- .../gazzodown/src/blocks/OrderedListBlock.tsx | 12 +++- .../src/blocks/UnorderedListBlock.tsx | 10 ++- packages/message-parser/src/definitions.ts | 1 + packages/message-parser/src/grammar.pegjs | 68 +++++++++++++++++-- packages/message-parser/tests/helpers.ts | 3 +- 9 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 .changeset/real-ties-unite.md diff --git a/.changeset/real-ties-unite.md b/.changeset/real-ties-unite.md new file mode 100644 index 0000000000000..dea3a42ad27ab --- /dev/null +++ b/.changeset/real-ties-unite.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/message-parser': minor +'@rocket.chat/pdf-worker': minor +'@rocket.chat/gazzodown': minor +'@rocket.chat/meteor': minor +--- + +Add support for nested/multi-level lists in markdown diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index c653b0a132e55..a0f5caf847515 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -227,6 +227,12 @@ const MessageBox = ({ } switch (event.key) { + case 'Tab': { + event.preventDefault(); + chat.composer?.insertText(' '); + return; + } + case 'Escape': { closeEditing(event); if (!input.value.trim()) onEscape?.(); diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx index 9bdcc0553f223..b57c2a08c70df 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx @@ -14,6 +14,9 @@ const styles = StyleSheet.create({ fontWeight: 700, marginHorizontal: 4, }, + nested: { + marginLeft: 16, + }, }); type OrderedListBlockProps = { @@ -22,10 +25,17 @@ type OrderedListBlockProps = { const OrderedListBlock = ({ items }: OrderedListBlockProps) => ( - {items.map(({ value, number }, index) => ( - - {number}. {value} - + {items.map(({ value, number, children }, index) => ( + + + {number}. {value} + + {children?.length && ( + + + + )} + ))} ); diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx index 964c9023520f0..fbf8b38d9531e 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx @@ -14,6 +14,9 @@ const styles = StyleSheet.create({ bullet: { marginHorizontal: 4, }, + nested: { + marginLeft: 16, + }, }); type UnorderedListBlockProps = { @@ -21,10 +24,17 @@ type UnorderedListBlockProps = { }; const UnorderedListBlock = ({ items }: UnorderedListBlockProps) => ( - {items.map(({ value }, index) => ( - - • - {value} + {items.map(({ value, children }, index) => ( + + + • + {value} + + {children?.length && ( + + + + )} ))} diff --git a/packages/gazzodown/src/blocks/OrderedListBlock.tsx b/packages/gazzodown/src/blocks/OrderedListBlock.tsx index 6c6fc2734e34b..8981f5323fa57 100644 --- a/packages/gazzodown/src/blocks/OrderedListBlock.tsx +++ b/packages/gazzodown/src/blocks/OrderedListBlock.tsx @@ -5,13 +5,19 @@ import InlineElements from '../elements/InlineElements'; type OrderedListBlockProps = { items: MessageParser.ListItem[]; + nested?: boolean; }; -const OrderedListBlock = ({ items }: OrderedListBlockProps): ReactElement => ( -
    - {items.map(({ value, number }, index) => ( +const nestedListStyle = { + paddingInlineStart: '1.5rem', +} as const; + +const OrderedListBlock = ({ items, nested = false }: OrderedListBlockProps): ReactElement => ( +
      + {items.map(({ value, number, children }, index) => (
    1. {value} + {children?.length && }
    2. ))}
    diff --git a/packages/gazzodown/src/blocks/UnorderedListBlock.tsx b/packages/gazzodown/src/blocks/UnorderedListBlock.tsx index 500e525011107..9979d86fc7014 100644 --- a/packages/gazzodown/src/blocks/UnorderedListBlock.tsx +++ b/packages/gazzodown/src/blocks/UnorderedListBlock.tsx @@ -5,13 +5,19 @@ import InlineElements from '../elements/InlineElements'; type UnorderedListBlockProps = { items: MessageParser.ListItem[]; + nested?: boolean; }; -const UnorderedListBlock = ({ items }: UnorderedListBlockProps): ReactElement => ( -
      +const nestedListStyle = { + paddingInlineStart: '1.5rem', +} as const; + +const UnorderedListBlock = ({ items, nested = false }: UnorderedListBlockProps): ReactElement => ( +
        {items.map((item, index) => (
      • {item.value} + {item.children?.length && }
      • ))}
      diff --git a/packages/message-parser/src/definitions.ts b/packages/message-parser/src/definitions.ts index fdc7179b7c6b7..fe9e7777d1d09 100644 --- a/packages/message-parser/src/definitions.ts +++ b/packages/message-parser/src/definitions.ts @@ -17,6 +17,7 @@ export type ListItem = { type: 'LIST_ITEM'; value: Inlines[]; number?: number; + children?: ListItem[]; }; export type Tasks = { diff --git a/packages/message-parser/src/grammar.pegjs b/packages/message-parser/src/grammar.pegjs index 871c661e51a48..774edefc58da7 100644 --- a/packages/message-parser/src/grammar.pegjs +++ b/packages/message-parser/src/grammar.pegjs @@ -42,6 +42,48 @@ let skipBold = false; let skipItalic = false; let skipStrikethrough = false; let skipReferences = false; + +function buildNestedList(items) { + if (!items || !items.length) return items; + + const root = []; + const stack = [{ children: root, indent: -1 }]; + + for (const item of items) { + const indent = item.indent || 0; + + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + + const cleanItem = { + type: item.type, + value: item.value, + ...(item.number !== undefined && { number: item.number }), + }; + + const parent = stack[stack.length - 1]; + parent.children.push(cleanItem); + + cleanItem.children = []; + + stack.push({ children: cleanItem.children, indent }); + } + + function cleanEmptyChildren(items) { + for (const item of items) { + if (item.children && !item.children.length) { + delete item.children; + } else if (item.children) { + cleanEmptyChildren(item.children); + } + } + } + + cleanEmptyChildren(root); + + return root; +} }} Start @@ -174,9 +216,13 @@ TaskFlag = "x" { return true; } / " " { return false; } * 3. Item Three * */ -OrderedList = items:OrderedListItem+ { return orderedList(items); } +OrderedList = items:OrderedListItem+ { return orderedList(buildNestedList(items)); } -OrderedListItem = number:Digits "." [ \t]+ text:Inline { return listItem(text, parseInt(number, 10)); } +OrderedListItem = indent:$[ \t]* number:Digits "." [ \t]+ text:Inline { + const item = listItem(text, parseInt(number, 10)); + item.indent = indent.length; + return item; +} /** * @@ -188,11 +234,19 @@ OrderedListItem = number:Digits "." [ \t]+ text:Inline { return listItem(text, p * * Item Four * */ -UnorderedList = items:(UnorderedListHyphenItem+ / UnorderedListAsteriskItem+) { return unorderedList(items); } - -UnorderedListHyphenItem = "-" [ \t]+ text:Inline { return listItem(text); } - -UnorderedListAsteriskItem = "*" [ \t]+ text:UnorderedListItemContent { return listItem(text); } +UnorderedList = items:(UnorderedListHyphenItem+ / UnorderedListAsteriskItem+) { return unorderedList(buildNestedList(items)); } + +UnorderedListHyphenItem = indent:$[ \t]* "-" [ \t]+ text:Inline { + const item = listItem(text); + item.indent = indent.length; + return item; +} + +UnorderedListAsteriskItem = indent:$[ \t]* "*" [ \t]+ text:UnorderedListItemContent { + const item = listItem(text); + item.indent = indent.length; + return item; +} UnorderedListItemContent = value:UnorderedListItemContentItem+ !"*" EndOfLine? { return reducePlainTexts(value); } diff --git a/packages/message-parser/tests/helpers.ts b/packages/message-parser/tests/helpers.ts index 8c8bf448f08a5..0f1e64f1b7b1c 100644 --- a/packages/message-parser/tests/helpers.ts +++ b/packages/message-parser/tests/helpers.ts @@ -57,10 +57,11 @@ export const mentionChannel = (value: string) => ({ export const orderedList = generate('ORDERED_LIST'); export const unorderedList = generate('UNORDERED_LIST'); -export const listItem = (value: unknown[], number?: number) => ({ +export const listItem = (value: unknown[], number?: number, children?: unknown[]) => ({ type: 'LIST_ITEM' as const, value, ...(number !== undefined ? { number } : {}), + ...(children !== undefined && children.length > 0 ? { children } : {}), }); export const mentionUser = (value: string) => ({ From 412fea97db8e39f179d9e3856c47ab36aedcaa03 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 01:29:47 +0300 Subject: [PATCH 04/18] feat/Adds emoji restriction feature for non-admin users --- .changeset/three-roses-sell.md | 6 ++ apps/meteor/app/emoji/client/helpers.ts | 23 +++++- .../EmojiPickerProvider.tsx | 20 +++++- .../admin/customEmoji/CustomEmojiRoute.tsx | 20 ++++-- .../views/admin/customEmoji/RestrictEmoji.tsx | 71 +++++++++++++++++++ .../composer/EmojiPicker/EmojiPicker.tsx | 13 +++- .../server/settings/emoji-permissions.ts | 11 +++ apps/meteor/server/settings/index.ts | 2 + packages/i18n/src/locales/en.i18n.json | 8 +++ 9 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 .changeset/three-roses-sell.md create mode 100644 apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx create mode 100644 apps/meteor/server/settings/emoji-permissions.ts diff --git a/.changeset/three-roses-sell.md b/.changeset/three-roses-sell.md new file mode 100644 index 0000000000000..9fd7ddb3cacaf --- /dev/null +++ b/.changeset/three-roses-sell.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Add ability to restrict specific emojis from being used by regular users through admin panel diff --git a/apps/meteor/app/emoji/client/helpers.ts b/apps/meteor/app/emoji/client/helpers.ts index d36dc958cf20e..7115b12a8601d 100644 --- a/apps/meteor/app/emoji/client/helpers.ts +++ b/apps/meteor/app/emoji/client/helpers.ts @@ -22,15 +22,17 @@ export const createEmojiListByCategorySubscription = ( recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, setQuickReactions: () => void, + isAdmin: boolean, + restrictedEmojis: string[], ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ReturnType] => { let result: ReturnType = [[], []]; updateRecent(recentEmojis); const sub = (cb: () => void) => { - result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis); setQuickReactions(); return emojiEmitter.on('updated', () => { - result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis); setQuickReactions(); cb(); }); @@ -44,6 +46,8 @@ export const createPickerEmojis = ( actualTone: number, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, + isAdmin: boolean, + restrictedEmojis: string[], ): [EmojiPickerItem[], CategoriesIndexes] => { const categories = getCategoriesList(); const categoriesIndexes: CategoriesIndexes = []; @@ -51,7 +55,7 @@ export const createPickerEmojis = ( const mappedCategories = categories.reduce((acc, category) => { categoriesIndexes.push({ key: category.key, index: acc.length }); acc.push({ category: category.key, i18n: category.i18n }); - acc.push(...createEmojiList(customItemsLimit, category.key, actualTone, recentEmojis, setRecentEmojis)); + acc.push(...createEmojiList(customItemsLimit, category.key, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis)); return acc; }, []); @@ -64,6 +68,8 @@ export const createEmojiList = ( actualTone: number | null, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, + isAdmin: boolean, + restrictedEmojis: string[], ): (RowItem | LoadMoreItem)[] => { const items: RowItem = []; const emojiPackages = Object.values(emoji.packages); @@ -79,6 +85,10 @@ export const createEmojiList = ( for (let i = 0; i < total; i++) { const current = emojiPackage.emojisByCategory[category][i]; + if (!isAdmin && restrictedEmojis.length && restrictedEmojis.includes(current)) { + continue; + } + const tone = actualTone && actualTone > 0 && emojiPackage.toneList.hasOwnProperty(current) ? `_tone${actualTone}` : ''; const emojiToRender = `:${current}${tone}:`; @@ -145,6 +155,8 @@ export const getEmojisBySearchTerm = ( actualTone: number, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, + isAdmin: boolean, + restrictedEmojis: string[], ) => { const emojis = []; const searchRegExp = new RegExp(escapeRegExp(searchTerm.replace(/:/g, '')), 'i'); @@ -159,6 +171,11 @@ export const getEmojisBySearchTerm = ( const { emojiPackage, shortnames = [] } = emojiObject; let tone = ''; current = current.replace(/:/g, ''); + + if (!isAdmin && restrictedEmojis.length && restrictedEmojis.includes(current)) { + continue; + } + const alias = shortnames[0] !== undefined ? shortnames[0].replace(/:/g, '') : shortnames[0]; if (actualTone > 0 && emoji.packages[emojiPackage].toneList.hasOwnProperty(current)) { diff --git a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index 0fcb5ae6e1a4e..53d1a4989714a 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -1,4 +1,5 @@ import { useDebouncedState, useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useRole, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement, ContextType } from 'react'; import { useState, useCallback, useMemo, useSyncExternalStore } from 'react'; @@ -23,14 +24,29 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT); + const isAdmin = useRole('admin'); + const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); + const restrictedEmojis = useMemo(() => { + if (!restrictedEmojisString) return []; + return restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); + }, [restrictedEmojisString]); + const [quickReactions, _setQuickReactions] = useState<{ emoji: string; image: string }[]>(() => getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)), ); const setQuickReactions = useEffectEvent(() => _setQuickReactions(getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)))); const [sub, getSnapshot] = useMemo(() => { - return createEmojiListByCategorySubscription(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions); - }, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions]); + return createEmojiListByCategorySubscription( + customItemsLimit, + actualTone, + recentEmojis, + setRecentEmojis, + setQuickReactions, + isAdmin, + restrictedEmojis, + ); + }, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions, isAdmin, restrictedEmojis]); const [emojiListByCategory, categoriesIndexes] = useSyncExternalStore(sub, getSnapshot); diff --git a/apps/meteor/client/views/admin/customEmoji/CustomEmojiRoute.tsx b/apps/meteor/client/views/admin/customEmoji/CustomEmojiRoute.tsx index 040d7102329e6..1f62959b8695d 100644 --- a/apps/meteor/client/views/admin/customEmoji/CustomEmojiRoute.tsx +++ b/apps/meteor/client/views/admin/customEmoji/CustomEmojiRoute.tsx @@ -1,4 +1,4 @@ -import { Button } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup } from '@rocket.chat/fuselage'; import { ContextualbarHeader, ContextualbarClose, @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import AddCustomEmoji from './AddCustomEmoji'; import CustomEmoji from './CustomEmoji'; import EditCustomEmojiWithData from './EditCustomEmojiWithData'; +import RestrictEmoji from './RestrictEmoji'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; const CustomEmojiRoute = (): ReactElement => { @@ -36,6 +37,10 @@ const CustomEmojiRoute = (): ReactElement => { route.push({ context: 'new' }); }, [route]); + const handleRestrictEmoji = useCallback(() => { + route.push({ context: 'restrict' }); + }, [route]); + const handleClose = (): void => { route.push({}); }; @@ -54,9 +59,14 @@ const CustomEmojiRoute = (): ReactElement => { - + + + + @@ -68,11 +78,13 @@ const CustomEmojiRoute = (): ReactElement => { {context === 'edit' && t('Custom_Emoji_Info')} {context === 'new' && t('Custom_Emoji_Add')} + {context === 'restrict' && t('Restrict_Emoji')} {context === 'edit' && id && } {context === 'new' && } + {context === 'restrict' && } )} diff --git a/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx new file mode 100644 index 0000000000000..39a4cdf7bb066 --- /dev/null +++ b/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx @@ -0,0 +1,71 @@ +import { Box, Button, ButtonGroup, Field, FieldLabel, FieldRow, FieldError, TextAreaInput } from '@rocket.chat/fuselage'; +import { ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch, useSetting, useSettingsDispatch } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ChangeEvent } from 'react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type RestrictEmojiProps = { + close: () => void; +}; + +const RestrictEmoji = ({ close, ...props }: RestrictEmojiProps): ReactElement => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const dispatchSettings = useSettingsDispatch(); + + const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); + const [restrictedEmojis, setRestrictedEmojis] = useState(restrictedEmojisString || ''); + const [error, setError] = useState(false); + + const handleChangeRestrictedEmojis = (e: ChangeEvent): void => { + if (error) { + setError(false); + } + setRestrictedEmojis(e.currentTarget.value); + }; + + const handleSave = useCallback(async () => { + try { + await dispatchSettings([{ _id: 'Emoji_Restricted_For_Users', value: restrictedEmojis }]); + dispatchToastMessage({ type: 'success', message: t('Emoji_Restrictions_Updated_Successfully') }); + close(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [restrictedEmojis, dispatchSettings, dispatchToastMessage, t, close]); + + return ( + <> + + + {t('Restricted_Emojis')} + + + + + + {t('Emoji_Restricted_For_Users_Description')} + + + {error && {t('error-the-field-is-required', { field: t('Restricted_Emojis') })}} + + + + + + + + + + ); +}; + +export default RestrictEmoji; diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx index cef5d6c2fcd7c..e07aa891451fe 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx @@ -9,9 +9,9 @@ import { EmojiPickerListArea, EmojiPickerPreview, } from '@rocket.chat/ui-client'; -import { useTranslation, usePermission, useRoute } from '@rocket.chat/ui-contexts'; +import { useTranslation, usePermission, useRoute, useRole, useSetting } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, KeyboardEvent, MouseEvent, RefObject } from 'react'; -import { useLayoutEffect, useState, useEffect, useRef } from 'react'; +import { useLayoutEffect, useState, useEffect, useRef, useMemo } from 'react'; import type { ListRange, VirtuosoHandle } from 'react-virtuoso'; import CategoriesResult from './CategoriesResult'; @@ -48,6 +48,13 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { const canManageEmoji = usePermission('manage-emoji'); const customEmojiRoute = useRoute('emoji-custom'); + const isAdmin = useRole('admin'); + const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); + const restrictedEmojis = useMemo(() => { + if (!restrictedEmojisString) return []; + return restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); + }, [restrictedEmojisString]); + const [searching, setSearching] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -137,7 +144,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { setCurrentCategory(''); setSearching(e.target.value !== ''); - const emojisResult = getEmojisBySearchTerm(e.target.value, actualTone, recentEmojis, setRecentEmojis); + const emojisResult = getEmojisBySearchTerm(e.target.value, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis); if (emojisResult.filter((emoji) => emoji.image).length === 0) { setCurrentCategory('no-results'); diff --git a/apps/meteor/server/settings/emoji-permissions.ts b/apps/meteor/server/settings/emoji-permissions.ts new file mode 100644 index 0000000000000..3e7487a98ee6b --- /dev/null +++ b/apps/meteor/server/settings/emoji-permissions.ts @@ -0,0 +1,11 @@ +import { settingsRegistry } from '../../app/settings/server'; + +export const createEmojiPermissionsSettings = () => + settingsRegistry.addGroup('Emoji', async function () { + await this.add('Emoji_Restricted_For_Users', '', { + type: 'string', + public: true, + i18nLabel: 'Emoji_Restricted_For_Users', + i18nDescription: 'Emoji_Restricted_For_Users_Description', + }); + }); diff --git a/apps/meteor/server/settings/index.ts b/apps/meteor/server/settings/index.ts index 83d341552e634..5ad012b0e9038 100644 --- a/apps/meteor/server/settings/index.ts +++ b/apps/meteor/server/settings/index.ts @@ -5,6 +5,7 @@ import { createBotsSettings } from './bots'; import { createCasSettings } from './cas'; import { createCrowdSettings } from './crowd'; import { createEmojiSettings } from './custom-emoji'; +import { createEmojiPermissionsSettings } from './emoji-permissions'; import { createSoundsSettings } from './custom-sounds'; import { createDiscussionsSettings } from './discussions'; import { createE2ESettings } from './e2e'; @@ -45,6 +46,7 @@ await Promise.all([ createCasSettings(), createCrowdSettings(), createEmojiSettings(), + createEmojiPermissionsSettings(), createSoundsSettings(), createDiscussionsSettings(), createEmailSettings(), diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index eb71374580bd1..761b3cecc5a07 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1997,6 +1997,14 @@ "Email_verification_isnt_required": "Email verification to login is not required. To require, enable setting in Accounts > Registration", "Email_verified": "Email verified", "Emoji": "Emoji", + "Emoji_Description": "Configure emoji permissions and access control for users.", + "Emoji_Restricted_For_Users": "Restricted Emojis for Users", + "Emoji_Restricted_For_Users_Description": "Comma-separated list of emoji names that regular users cannot use. Leave empty to allow all emojis. Admins can always use all emojis. Example: middle_finger,eggplant,peach", + "Restrict": "Restrict", + "Restrict_Emoji": "Restrict Emoji", + "Restricted_Emojis": "Restricted Emojis", + "Restricted_Emojis_Placeholder": "emoji1, emoji2, emoji3", + "Emoji_Restrictions_Updated_Successfully": "Emoji restrictions updated successfully", "EmojiCustomFilesystem": "Custom Emoji Filesystem", "EmojiCustomFilesystem_Description": "Specify how emojis are stored.", "Emoji_picker": "Emoji picker", From 19e9d5eaf7cc52430a2d85b8b558682e97a33500 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 19:42:44 +0300 Subject: [PATCH 05/18] Added support for nested lists in markdown messages --- .../client/views/room/composer/messageBox/MessageBox.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index a0f5caf847515..c653b0a132e55 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -227,12 +227,6 @@ const MessageBox = ({ } switch (event.key) { - case 'Tab': { - event.preventDefault(); - chat.composer?.insertText(' '); - return; - } - case 'Escape': { closeEditing(event); if (!input.value.trim()) onEscape?.(); From 112980ba5cdb37a0abd9ca4df68fdd3f8c3727ae Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 20:01:27 +0300 Subject: [PATCH 06/18] Adds emoji restriction feature for non-admin users --- .../app/lib/server/functions/sendMessage.ts | 19 +++++++++++++++++++ .../views/admin/customEmoji/RestrictEmoji.tsx | 14 +++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 73fa409999182..7f2c15e05940a 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -185,6 +185,25 @@ export const validateMessage = async (message: any, room: any, user: any) => { messageCustomFields: settings.get('Message_CustomFields'), }); } + + const isAdmin = await hasPermissionAsync(user._id, 'view-privileged-setting'); + if (!isAdmin && message.msg) { + const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); + if (restrictedEmojisString) { + const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); + if (restrictedEmojis.length) { + const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; + const matches = message.msg.matchAll(emojiRegex); + + for (const match of matches) { + const emojiName = match[1]; + if (restrictedEmojis.includes(emojiName)) { + throw new Error(`Emoji :${emojiName}: is restricted`); + } + } + } + } + } }; export function prepareMessageObject( diff --git a/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx index 39a4cdf7bb066..55116836b6116 100644 --- a/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx @@ -9,19 +9,15 @@ type RestrictEmojiProps = { close: () => void; }; -const RestrictEmoji = ({ close, ...props }: RestrictEmojiProps): ReactElement => { +const RestrictEmoji = ({ close }: RestrictEmojiProps): ReactElement => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const dispatchSettings = useSettingsDispatch(); const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); const [restrictedEmojis, setRestrictedEmojis] = useState(restrictedEmojisString || ''); - const [error, setError] = useState(false); const handleChangeRestrictedEmojis = (e: ChangeEvent): void => { - if (error) { - setError(false); - } setRestrictedEmojis(e.currentTarget.value); }; @@ -30,14 +26,15 @@ const RestrictEmoji = ({ close, ...props }: RestrictEmojiProps): ReactElement => await dispatchSettings([{ _id: 'Emoji_Restricted_For_Users', value: restrictedEmojis }]); dispatchToastMessage({ type: 'success', message: t('Emoji_Restrictions_Updated_Successfully') }); close(); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + dispatchToastMessage({ type: 'error', message: errorMessage }); } }, [restrictedEmojis, dispatchSettings, dispatchToastMessage, t, close]); return ( <> - + {t('Restricted_Emojis')} @@ -53,7 +50,6 @@ const RestrictEmoji = ({ close, ...props }: RestrictEmojiProps): ReactElement => {t('Emoji_Restricted_For_Users_Description')} - {error && {t('error-the-field-is-required', { field: t('Restricted_Emojis') })}} From c25595423181e6ce5a3329380fe0de144288820f Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 20:26:03 +0300 Subject: [PATCH 07/18] Adds emoji restriction feature for non-admin users --- .../app/lib/server/functions/sendMessage.ts | 14 +++++++----- .../app/lib/server/methods/updateMessage.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 7f2c15e05940a..5431822e1af40 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -4,6 +4,7 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { isAbsoluteURL } from '@rocket.chat/tools'; import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -186,10 +187,10 @@ export const validateMessage = async (message: any, room: any, user: any) => { }); } - const isAdmin = await hasPermissionAsync(user._id, 'view-privileged-setting'); - if (!isAdmin && message.msg) { - const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); - if (restrictedEmojisString) { + const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); + if (restrictedEmojisString && message.msg) { + const hasManageEmojiPermission = await hasPermissionAsync(user._id, 'manage-emoji'); + if (!hasManageEmojiPermission) { const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); if (restrictedEmojis.length) { const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; @@ -198,7 +199,10 @@ export const validateMessage = async (message: any, room: any, user: any) => { for (const match of matches) { const emojiName = match[1]; if (restrictedEmojis.includes(emojiName)) { - throw new Error(`Emoji :${emojiName}: is restricted`); + throw new Meteor.Error('error-emoji-restricted', `Emoji :${emojiName}: is restricted`, { + method: 'sendMessage', + emoji: emojiName, + }); } } } diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index 833b4403c0eca..52073d2add625 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -86,6 +86,28 @@ export async function executeUpdateMessage( } await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user }); + const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); + if (restrictedEmojisString && message.msg) { + const hasManageEmojiPermission = await hasPermissionAsync(uid, 'manage-emoji'); + if (!hasManageEmojiPermission) { + const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); + if (restrictedEmojis.length) { + const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; + const matches = message.msg.matchAll(emojiRegex); + + for (const match of matches) { + const emojiName = match[1]; + if (restrictedEmojis.includes(emojiName)) { + throw new Meteor.Error('error-emoji-restricted', `Emoji :${emojiName}: is restricted`, { + method: 'updateMessage', + emoji: emojiName, + }); + } + } + } + } + } + message.u = originalMessage.u; return updateMessage(message, user, originalMessage, previewUrls); From f2d0e07c90896568125ed0f0ed2a7ac0288ad93f Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 20:57:21 +0300 Subject: [PATCH 08/18] Adds emoji restriction feature for non-admin users --- .../app/lib/server/functions/sendMessage.ts | 24 ++----------- .../server/lib/validateRestrictedEmojis.ts | 34 +++++++++++++++++++ .../app/lib/server/methods/updateMessage.ts | 23 ++----------- 3 files changed, 38 insertions(+), 43 deletions(-) create mode 100644 apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 5431822e1af40..07028bae97b36 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -4,7 +4,6 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { isAbsoluteURL } from '@rocket.chat/tools'; import { Match, check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -13,6 +12,7 @@ import { settings } from '../../../settings/server'; import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; +import { assertNoRestrictedEmojis } from '../lib/validateRestrictedEmojis'; export type SendMessageOptions = { upsert?: boolean; @@ -187,27 +187,7 @@ export const validateMessage = async (message: any, room: any, user: any) => { }); } - const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); - if (restrictedEmojisString && message.msg) { - const hasManageEmojiPermission = await hasPermissionAsync(user._id, 'manage-emoji'); - if (!hasManageEmojiPermission) { - const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); - if (restrictedEmojis.length) { - const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; - const matches = message.msg.matchAll(emojiRegex); - - for (const match of matches) { - const emojiName = match[1]; - if (restrictedEmojis.includes(emojiName)) { - throw new Meteor.Error('error-emoji-restricted', `Emoji :${emojiName}: is restricted`, { - method: 'sendMessage', - emoji: emojiName, - }); - } - } - } - } - } + await assertNoRestrictedEmojis(user._id, message.msg, 'sendMessage'); }; export function prepareMessageObject( diff --git a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts new file mode 100644 index 0000000000000..f10338f1c1121 --- /dev/null +++ b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { settings } from '../../../settings/server'; + +export const assertNoRestrictedEmojis = async (uid: string, msg: string | undefined, method: string): Promise => { + const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); + if (!restrictedEmojisString || !msg) { + return; + } + + const hasManageEmojiPermission = await hasPermissionAsync(uid, 'manage-emoji'); + if (hasManageEmojiPermission) { + return; + } + + const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); + if (!restrictedEmojis.length) { + return; + } + + const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; + const matches = msg.matchAll(emojiRegex); + + for (const match of matches) { + const emojiName = match[1]; + if (restrictedEmojis.includes(emojiName)) { + throw new Meteor.Error('error-emoji-restricted', `Emoji :${emojiName}: is restricted`, { + method, + emoji: emojiName, + }); + } + } +}; diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index 52073d2add625..5ea18ede22bf1 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -9,6 +9,7 @@ import { canSendMessageAsync } from '../../../authorization/server/functions/can import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { settings } from '../../../settings/server'; +import { assertNoRestrictedEmojis } from '../lib/validateRestrictedEmojis'; import { updateMessage } from '../functions/updateMessage'; const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content', 'e2eMentions']; @@ -86,27 +87,7 @@ export async function executeUpdateMessage( } await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user }); - const restrictedEmojisString = settings.get('Emoji_Restricted_For_Users'); - if (restrictedEmojisString && message.msg) { - const hasManageEmojiPermission = await hasPermissionAsync(uid, 'manage-emoji'); - if (!hasManageEmojiPermission) { - const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); - if (restrictedEmojis.length) { - const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; - const matches = message.msg.matchAll(emojiRegex); - - for (const match of matches) { - const emojiName = match[1]; - if (restrictedEmojis.includes(emojiName)) { - throw new Meteor.Error('error-emoji-restricted', `Emoji :${emojiName}: is restricted`, { - method: 'updateMessage', - emoji: emojiName, - }); - } - } - } - } - } + await assertNoRestrictedEmojis(uid, message.msg, 'updateMessage'); message.u = originalMessage.u; From e62bb94d61771db6d4df6a7cdf57ca4d66fa8aa4 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 21:09:23 +0300 Subject: [PATCH 09/18] Adds emoji restriction feature for non-admin users --- .../server/lib/validateRestrictedEmojis.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts index f10338f1c1121..97387b1cfdc5c 100644 --- a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts +++ b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts @@ -1,3 +1,4 @@ +import emojione from 'emojione'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -31,4 +32,28 @@ export const assertNoRestrictedEmojis = async (uid: string, msg: string | undefi }); } } + + const mappedUnicode = emojione.mapUnicodeToShort(); + const unicodeRegex = emojione.unicodeCharRegex(); + + if (typeof unicodeRegex === 'string') { + const unicodeMatches = msg.matchAll(new RegExp(unicodeRegex, 'g')); + for (const match of unicodeMatches) { + const unicodeChar = match[0]; + const codePoint = Array.from(unicodeChar) + .map((char) => char.codePointAt(0)?.toString(16).padStart(4, '0')) + .join('-'); + + const shortname = mappedUnicode[codePoint]; + if (shortname) { + const emojiName = shortname.replace(/^:/, '').replace(/:$/, ''); + if (restrictedEmojis.includes(emojiName)) { + throw new Meteor.Error('error-emoji-restricted', `Emoji ${unicodeChar} is restricted`, { + method, + emoji: emojiName, + }); + } + } + } + } }; From 0b3be1f4b3a3cc9bd804b973c017215c71bfee86 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Wed, 22 Apr 2026 21:17:31 +0300 Subject: [PATCH 10/18] Adds emoji restriction feature for non-admin users --- .../lib/server/lib/validateRestrictedEmojis.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts index 97387b1cfdc5c..11502ba76a200 100644 --- a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts +++ b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts @@ -36,8 +36,18 @@ export const assertNoRestrictedEmojis = async (uid: string, msg: string | undefi const mappedUnicode = emojione.mapUnicodeToShort(); const unicodeRegex = emojione.unicodeCharRegex(); - if (typeof unicodeRegex === 'string') { - const unicodeMatches = msg.matchAll(new RegExp(unicodeRegex, 'g')); + if (unicodeRegex) { + let regex: RegExp; + if (typeof unicodeRegex === 'string') { + regex = new RegExp(unicodeRegex, 'g'); + } else if (unicodeRegex instanceof RegExp) { + regex = new RegExp(unicodeRegex.source, 'g'); + } else { + return; + } + + const unicodeMatches = msg.matchAll(regex); + for (const match of unicodeMatches) { const unicodeChar = match[0]; const codePoint = Array.from(unicodeChar) @@ -46,7 +56,7 @@ export const assertNoRestrictedEmojis = async (uid: string, msg: string | undefi const shortname = mappedUnicode[codePoint]; if (shortname) { - const emojiName = shortname.replace(/^:/, '').replace(/:$/, ''); + const emojiName = shortname.slice(1, -1); if (restrictedEmojis.includes(emojiName)) { throw new Meteor.Error('error-emoji-restricted', `Emoji ${unicodeChar} is restricted`, { method, From 8c3128d22bd92b40bec1929e2d6055bc8c5df0e7 Mon Sep 17 00:00:00 2001 From: Dan K Date: Sun, 24 May 2026 21:44:12 +0000 Subject: [PATCH 11/18] feat: add message read count & read list --- .changeset/wicked-seals-rescue.md | 9 ++ apps/meteor/app/api/server/v1/chat.ts | 66 +++++++++ .../server/functions/loadMessageHistory.ts | 16 +++ .../server/getMessageReadCount.ts | 45 ++++++ .../read-counter/server/getMessageReaders.ts | 43 ++++++ .../ui-utils/client/lib/LegacyRoomManager.ts | 6 + .../components/message/ReadCountBadge.tsx | 132 ++++++++++++++++++ .../components/message/ReadCountTooltip.tsx | 97 +++++++++++++ .../variants/room/RoomMessageContent.tsx | 3 + apps/meteor/server/lib/readMessages.ts | 2 + .../server/methods/getMessageReadCount.ts | 27 ++++ apps/meteor/server/methods/index.ts | 1 + .../modules/listeners/listeners.module.ts | 4 + .../src/models/ISubscriptionsModel.ts | 9 ++ packages/models/src/models/Subscriptions.ts | 68 +++++++++ packages/rest-typings/src/v1/chat.ts | 47 ++++++- .../src/components/TooltipComponent.tsx | 16 ++- .../src/providers/TooltipProvider.tsx | 67 ++++++++- 18 files changed, 652 insertions(+), 6 deletions(-) create mode 100644 .changeset/wicked-seals-rescue.md create mode 100644 apps/meteor/app/read-counter/server/getMessageReadCount.ts create mode 100644 apps/meteor/app/read-counter/server/getMessageReaders.ts create mode 100644 apps/meteor/client/components/message/ReadCountBadge.tsx create mode 100644 apps/meteor/client/components/message/ReadCountTooltip.tsx create mode 100644 apps/meteor/server/methods/getMessageReadCount.ts diff --git a/.changeset/wicked-seals-rescue.md b/.changeset/wicked-seals-rescue.md new file mode 100644 index 0000000000000..e6d6d024037cf --- /dev/null +++ b/.changeset/wicked-seals-rescue.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Added per-message read tracking that shows how many room members have read each message and who they are. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 383f4312c53b5..bc38fe4b7711f 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -24,6 +24,8 @@ import { isChatSyncThreadMessagesProps, isChatGetStarredMessagesProps, isChatGetDiscussionsProps, + isChatGetMessageReadCountProps, + isChatGetMessageReadersProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; @@ -42,6 +44,8 @@ import { processWebhookMessage } from '../../../lib/server/functions/processWebh import { getSingleMessage } from '../../../lib/server/methods/getSingleMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; +import { getMessageReadCount as getMessageReadCountHelper } from '../../../read-counter/server/getMessageReadCount'; +import { getMessageReaders as getMessageReadersHelper } from '../../../read-counter/server/getMessageReaders'; import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { pinMessage, unpinMessage } from '../../../message-pin/server/pinMessage'; import { starMessage } from '../../../message-star/server/starMessage'; @@ -127,6 +131,68 @@ const isChatFollowMessageLocalProps = ajv.compile(ChatFo const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); +API.v1.addRoute( + 'chat.getMessageReadCount', + { + authRequired: true, + validateParams: isChatGetMessageReadCountProps, + }, + { + async get() { + const { messageId } = this.queryParams; + + const message = await Messages.findOneById(messageId, { projection: { rid: 1 } }); + + if (!message) { + return API.v1.failure('The required "messageId" param is missing or invalid.'); + } + + if (!(await canAccessRoomIdAsync(message.rid, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const result = await getMessageReadCountHelper(messageId, this.userId); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success(result); + }, + }, +); + +API.v1.addRoute( + 'chat.getMessageReaders', + { + authRequired: true, + validateParams: isChatGetMessageReadersProps, + }, + { + async get() { + const { messageId, limit } = this.queryParams; + + const message = await Messages.findOneById(messageId, { projection: { rid: 1 } }); + + if (!message) { + return API.v1.failure('The required "messageId" param is missing or invalid.'); + } + + if (!(await canAccessRoomIdAsync(message.rid, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const result = await getMessageReadersHelper(messageId, this.userId, limit); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success(result); + }, + }, +); + type ChatPinMessage = { messageId: IMessage['_id']; }; diff --git a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts index 5addbd896889b..14f0bef872e64 100644 --- a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts +++ b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts @@ -5,6 +5,8 @@ import type { FindOptions } from 'mongodb'; import { settings } from '../../../settings/server/cached'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { getHiddenSystemMessages } from '../lib/getHiddenSystemMessages'; +import { getMessageReadCount } from '../../../read-counter/server/getMessageReadCount'; +import { SystemLogger } from '../../../../server/lib/logger/system'; export async function loadMessageHistory({ userId, @@ -52,6 +54,20 @@ export async function loadMessageHistory({ ).toArray() : await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray(); const messages = await normalizeMessagesForUser(records, userId); + + if (messages[0]?._id && userId) { + const readCountResult = await getMessageReadCount(messages[0]._id, userId); + + if (readCountResult) { + SystemLogger.info({ + msg: '[read-count demo] newest message readCount', + rid, + mid: messages[0]._id, + readCount: readCountResult.readCount, + uid: userId, + }); + } + } let unreadNotLoaded = 0; let firstUnread; diff --git a/apps/meteor/app/read-counter/server/getMessageReadCount.ts b/apps/meteor/app/read-counter/server/getMessageReadCount.ts new file mode 100644 index 0000000000000..0515d134d12a2 --- /dev/null +++ b/apps/meteor/app/read-counter/server/getMessageReadCount.ts @@ -0,0 +1,45 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Authorization } from '@rocket.chat/core-services'; +import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; + +export const getMessageReadCount = async ( + messageId: IMessage['_id'], + userId: IUser['_id'], +): Promise<{ readCount: number } | null> => { + const message = await Messages.findOneById(messageId, { + projection: { _id: 1, rid: 1, ts: 1, 'u._id': 1 }, + }); + + if (!message) { + return null; + } + + const room = await Rooms.findOneById(message.rid as IRoom['_id'], { + projection: { _id: 1, t: 1 }, + }); + + if (!room) { + return null; + } + + // Exclude DMs from read counter + if (room.t === 'd') { + return null; + } + + // Ensure the requesting user can access the room + if (!(await Authorization.canReadRoom(room, { _id: userId }))) { + return null; + } + + if (!message.ts) { + return { readCount: 0 }; + } + + const excludeUserId = message.u?._id; + + const readCount = await Subscriptions.countReadersByRoomIdAndMessageTs(message.rid, message.ts, excludeUserId); + + return { readCount }; +}; + diff --git a/apps/meteor/app/read-counter/server/getMessageReaders.ts b/apps/meteor/app/read-counter/server/getMessageReaders.ts new file mode 100644 index 0000000000000..61c12513210bd --- /dev/null +++ b/apps/meteor/app/read-counter/server/getMessageReaders.ts @@ -0,0 +1,43 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Authorization } from '@rocket.chat/core-services'; +import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; + +export const getMessageReaders = async ( + messageId: IMessage['_id'], + userId: IUser['_id'], + limit = 50, +): Promise<{ readers: Array> } | null> => { + const message = await Messages.findOneById(messageId, { + projection: { _id: 1, rid: 1, ts: 1, 'u._id': 1 }, + }); + + if (!message) { + return null; + } + + const room = await Rooms.findOneById(message.rid as IRoom['_id'], { + projection: { _id: 1, t: 1 }, + }); + + if (!room) { + return null; + } + + if (room.t === 'd') { + return null; + } + + if (!(await Authorization.canReadRoom(room, { _id: userId }))) { + return null; + } + + if (!message.ts) { + return { readers: [] }; + } + + const excludeUserId = message.u?._id; + + const readers = await Subscriptions.findReaderUsersByRoomIdAndMessageTs(message.rid, message.ts, excludeUserId, limit); + + return { readers }; +}; diff --git a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts index 0c1513e42fbe1..f61d6b6a1c33c 100644 --- a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts @@ -257,6 +257,12 @@ const openRoom = (typeName: string, record: OpenedRoom) => { ({ unread: _, ...r }) => r, ); }), + + sdk.stream('notify-room', [`${record.rid}/readCountsChanged`], () => { + fireGlobalEvent('read-counts-changed', { + rid: record.rid, + }); + }), ], ); diff --git a/apps/meteor/client/components/message/ReadCountBadge.tsx b/apps/meteor/client/components/message/ReadCountBadge.tsx new file mode 100644 index 0000000000000..e2da4fa8b5cda --- /dev/null +++ b/apps/meteor/client/components/message/ReadCountBadge.tsx @@ -0,0 +1,132 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useEndpoint, useTooltipClose, useTooltipOpen, useUserId } from '@rocket.chat/ui-contexts'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useRoom } from '../../views/room/contexts/RoomContext'; + +import { ReadCountTooltip } from './ReadCountTooltip'; + +type ReadCountBadgeProps = { + messageId: IMessage['_id']; + roomId: IRoom['_id']; + senderId: string; +}; + +export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgeProps): ReactElement | null => { + const uid = useUserId(); + const room = useRoom(); + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const ref = useRef(null); + const hideTooltipTimerRef = useRef>(); + const openTooltip = useTooltipOpen(); + const closeTooltip = useTooltipClose(); + + const cancelScheduledTooltipClose = useCallback((): void => { + if (hideTooltipTimerRef.current !== undefined) { + clearTimeout(hideTooltipTimerRef.current); + hideTooltipTimerRef.current = undefined; + } + }, []); + + const scheduleTooltipClose = useCallback((): void => { + cancelScheduledTooltipClose(); + hideTooltipTimerRef.current = setTimeout(() => { + closeTooltip(); + hideTooltipTimerRef.current = undefined; + }, 400); + }, [cancelScheduledTooltipClose, closeTooltip]); + + const getMessageReadCount = useEndpoint('GET', '/v1/chat.getMessageReadCount'); + + const queryKey = ['read-count', roomId, messageId]; + + const shouldFetch = Boolean(uid && uid === senderId && room.t !== 'd'); + + const { data } = useQuery({ + queryKey, + queryFn: () => getMessageReadCount({ messageId }), + staleTime: 15_000, + enabled: shouldFetch, + }); + + useEffect(() => { + const handler = (event: Event) => { + const { detail } = event as CustomEvent<{ rid?: string }>; + if (!detail?.rid || detail.rid !== roomId) { + return; + } + + queryClient.invalidateQueries({ queryKey }); + queryClient.invalidateQueries({ queryKey: ['message-readers', roomId, messageId] }); + }; + + window.addEventListener('read-counts-changed', handler as EventListener); + return () => window.removeEventListener('read-counts-changed', handler as EventListener); + }, [queryClient, queryKey, roomId, messageId]); + + useEffect( + () => () => { + cancelScheduledTooltipClose(); + }, + [cancelScheduledTooltipClose], + ); + + const readCount = data?.readCount ?? 0; + + // Keep this minimal: show only on the current user's own messages. + if (!uid || uid !== senderId) { + return null; + } + + // Exclude DMs + if (room.t === 'd') { + return null; + } + + if (readCount <= 0) { + return null; + } + + // No onMouseLeave close timer: TooltipProvider handles gap-to-portal; a badge timer raced with hover. + + return ( + { + e.stopPropagation(); + e.preventDefault(); + + cancelScheduledTooltipClose(); + + ref.current && + openTooltip( + , + ref.current, + ); + }} + > + + {readCount} + + ); +}; + diff --git a/apps/meteor/client/components/message/ReadCountTooltip.tsx b/apps/meteor/client/components/message/ReadCountTooltip.tsx new file mode 100644 index 0000000000000..e7c95fd90da53 --- /dev/null +++ b/apps/meteor/client/components/message/ReadCountTooltip.tsx @@ -0,0 +1,97 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { normalizeUsername } from '../../../lib/utils/normalizeUsername'; +import { MessageListContext } from './list/MessageListContext'; + +type ReadCountTooltipProps = { + messageId: string; + roomId: string; + onTooltipMouseEnter: () => void; + onTooltipMouseLeave: () => void; +}; + +export const ReadCountTooltip = ({ + messageId, + roomId, + onTooltipMouseEnter, + onTooltipMouseLeave, +}: ReadCountTooltipProps): ReactElement => { + const { t } = useTranslation(); + const { showRealName } = useContext(MessageListContext); + + const getMessageReaders = useEndpoint('GET', '/v1/chat.getMessageReaders'); + + const queryKey = ['message-readers', roomId, messageId]; + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: () => getMessageReaders({ messageId, limit: 50 }), + staleTime: 15_000, + }); + + const readers = data?.readers ?? []; + + const labelFor = (reader: { name?: string; username?: string }) => { + if (showRealName && reader.name) { + return reader.name; + } + + if (reader.username) { + return normalizeUsername(reader.username); + } + + return reader.name ?? t('Mentions_removed_user_label'); + }; + + return ( + { + e.stopPropagation(); + }} + style={{ pointerEvents: 'auto', userSelect: 'text' }} + > + + {t('Read_by')} + + {isLoading && ( + <> + + + + + )} + {!isLoading && readers.length > 0 && ( + { + e.stopPropagation(); + }} + style={{ overscrollBehavior: 'contain' }} + > + {readers.map((reader) => ( + + {labelFor(reader)} + + ))} + + )} + + ); +}; diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 813d63e9f9130..e1769afc04a2c 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { useChat } from '../../../../views/room/contexts/ChatContext'; import MessageContentBody from '../../MessageContentBody'; +import { ReadCountBadge } from '../../ReadCountBadge'; import ReadReceiptIndicator from '../../ReadReceiptIndicator'; import Attachments from '../../content/Attachments'; import BroadcastMetrics from '../../content/BroadcastMetrics'; @@ -124,6 +125,8 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM )} + + {readReceiptEnabled && } ); diff --git a/apps/meteor/server/lib/readMessages.ts b/apps/meteor/server/lib/readMessages.ts index 012effab076b6..31e8617d01fef 100644 --- a/apps/meteor/server/lib/readMessages.ts +++ b/apps/meteor/server/lib/readMessages.ts @@ -1,4 +1,5 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { api } from '@rocket.chat/core-services'; import { NotificationQueue, Subscriptions } from '@rocket.chat/models'; import { callbacks } from './callbacks'; @@ -19,6 +20,7 @@ export async function readMessages(room: IRoom, uid: IUser['_id'], readThreads: const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(room._id, uid, readThreads, alert); if (setAsReadResponse.modifiedCount) { void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, uid); + void api.broadcast('notify.readCountsChanged', { rid: room._id }); } await NotificationQueue.clearQueueByUserId(uid); diff --git a/apps/meteor/server/methods/getMessageReadCount.ts b/apps/meteor/server/methods/getMessageReadCount.ts new file mode 100644 index 0000000000000..d330c14719e43 --- /dev/null +++ b/apps/meteor/server/methods/getMessageReadCount.ts @@ -0,0 +1,27 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { getMessageReadCount as getMessageReadCountHelper } from '../../app/read-counter/server/getMessageReadCount'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getMessageReadCount(options: { messageId: IMessage['_id'] }): { readCount: number } | null; + } +} + +Meteor.methods({ + async getMessageReadCount({ messageId }) { + check(messageId, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getMessageReadCount' }); + } + + return getMessageReadCountHelper(messageId, uid); + }, +}); + diff --git a/apps/meteor/server/methods/index.ts b/apps/meteor/server/methods/index.ts index 544370864f5e2..3020a7f92b2ed 100644 --- a/apps/meteor/server/methods/index.ts +++ b/apps/meteor/server/methods/index.ts @@ -11,6 +11,7 @@ import './channelsList'; import './createDirectMessage'; import './deleteFileMessage'; import './deleteUser'; +import './getMessageReadCount'; import './getRoomById'; import './getRoomIdByNameOrId'; import './getRoomNameById'; diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index db9683cadafd9..207b03acc02ac 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -214,6 +214,10 @@ export class ListenersModule { notifications.notifyRoomInThisInstance(rid, 'messagesRead', { tmid, until }); }); + service.onEvent('notify.readCountsChanged', ({ rid }: { rid: IRoom['_id'] }): void => { + notifications.notifyRoomInThisInstance(rid, 'readCountsChanged', {}); + }); + service.onEvent('watch.subscriptions', ({ clientAction, subscription }) => { if (!subscription.u?._id) { return; diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 8273feec9f517..1fcfe41faf221 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -22,6 +22,15 @@ import type { DocumentWithProjection } from '../types/DocumentWithProjection'; export interface ISubscriptionsModel extends IBaseModel { getBadgeCount(uid: string): Promise; + countReadersByRoomIdAndMessageTs(rid: string, messageTs: Date, excludeUserId?: string): Promise; + + findReaderUsersByRoomIdAndMessageTs( + rid: string, + messageTs: Date, + excludeUserId: string | undefined, + limit: number, + ): Promise>>; + findOneByRoomIdAndUserId(rid: string, uid: string, options?: FindOptions): Promise; findByUserIdAndRoomIds(userId: string, roomIds: Array, options?: FindOptions): FindCursor; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 679080739d3ba..a200dcc1d11a1 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -86,6 +86,74 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return result?.total || 0; } + countReadersByRoomIdAndMessageTs(rid: string, messageTs: Date, excludeUserId?: string): Promise { + const query: Filter = { + rid, + archived: { $ne: true }, + ls: { $gte: messageTs }, + 'u._id': { + ...(excludeUserId ? { $ne: excludeUserId } : {}), + $exists: true, + }, + }; + + return this.countDocuments(query); + } + + async findReaderUsersByRoomIdAndMessageTs( + rid: string, + messageTs: Date, + excludeUserId: string | undefined, + limit: number, + ): Promise>> { + const safeLimit = Math.min(Math.max(limit, 1), 100); + + const pipeline = [ + { + $match: { + rid, + archived: { $ne: true }, + ls: { $gte: messageTs }, + 'u._id': { + ...(excludeUserId ? { $ne: excludeUserId } : {}), + $exists: true, + }, + }, + }, + { + $lookup: { + from: 'users', + localField: 'u._id', + foreignField: '_id', + as: 'user', + }, + }, + { $unwind: '$user' }, + { + $match: { + 'user.active': true, + }, + }, + { + $sort: { + 'user.username': 1, + }, + }, + { + $limit: safeLimit, + }, + { + $project: { + _id: '$user._id', + name: '$user.name', + username: '$user.username', + }, + }, + ]; + + return this.col.aggregate>(pipeline).toArray(); + } + findOneByRoomIdAndUserId(rid: string, uid: string, options: FindOptions = {}): Promise { const query = { rid, diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 8e8d52479e504..45068f3280107 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, MessageAttachment, IReadReceiptWithUser, MessageUrl, IThreadMainMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, MessageAttachment, IReadReceiptWithUser, MessageUrl, IThreadMainMessage } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -496,6 +496,45 @@ const ChatGetMessageReadReceiptsSchema = { export const isChatGetMessageReadReceiptsProps = ajv.compile(ChatGetMessageReadReceiptsSchema); +type ChatGetMessageReadCount = { + messageId: IMessage['_id']; +}; + +const ChatGetMessageReadCountSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +export const isChatGetMessageReadCountProps = ajv.compile(ChatGetMessageReadCountSchema); + +type ChatGetMessageReaders = { + messageId: IMessage['_id']; + limit?: number; +}; + +const ChatGetMessageReadersSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + }, + limit: { + type: 'number', + nullable: true, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +export const isChatGetMessageReadersProps = ajvQuery.compile(ChatGetMessageReadersSchema); + type GetStarredMessages = { roomId: IRoom['_id']; count?: number; @@ -946,6 +985,12 @@ export type ChatEndpoints = { '/v1/chat.getMessageReadReceipts': { GET: (params: ChatGetMessageReadReceipts) => { receipts: IReadReceiptWithUser[] }; }; + '/v1/chat.getMessageReadCount': { + GET: (params: ChatGetMessageReadCount) => { readCount: number }; + }; + '/v1/chat.getMessageReaders': { + GET: (params: ChatGetMessageReaders) => { readers: Array> }; + }; '/v1/chat.getStarredMessages': { GET: (params: GetStarredMessages) => { messages: IMessage[]; diff --git a/packages/ui-client/src/components/TooltipComponent.tsx b/packages/ui-client/src/components/TooltipComponent.tsx index e65c95c063233..03b2b6e3207e8 100644 --- a/packages/ui-client/src/components/TooltipComponent.tsx +++ b/packages/ui-client/src/components/TooltipComponent.tsx @@ -1,7 +1,12 @@ -import { Tooltip, PositionAnimated, AnimatedVisibility } from '@rocket.chat/fuselage'; +import { Box, Tooltip, PositionAnimated, AnimatedVisibility } from '@rocket.chat/fuselage'; import type { ReactNode } from 'react'; import { useRef } from 'react'; +/** Marker on portaled tooltip DOM so TooltipProvider can ignore hover inside the bubble. */ +export const RC_PORTAL_TOOLTIP_ATTR = 'data-rc-portal-tooltip'; + +export const RC_PORTAL_TOOLTIP_SELECTOR = `[${RC_PORTAL_TOOLTIP_ATTR}]`; + type TooltipComponentProps = { title: ReactNode; anchor: Element; @@ -12,7 +17,14 @@ export const TooltipComponent = ({ title, anchor }: TooltipComponentProps) => { return ( - {title} + + {title} + ); }; diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index 8f3808efb16c5..6dcff90567806 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -3,15 +3,29 @@ import { TooltipContext } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useRef, memo, useCallback, useState } from 'react'; -import { TooltipComponent } from '../components/TooltipComponent'; +import { RC_PORTAL_TOOLTIP_SELECTOR, TooltipComponent } from '../components/TooltipComponent'; type TooltipProviderProps = { children?: ReactNode; ownerDocument?: Document; }; +const resolveMouseEventTarget = (raw: EventTarget | null): HTMLElement | null => { + if (!raw) { + return null; + } + if (raw instanceof HTMLElement) { + return raw; + } + if (raw instanceof Text) { + return raw.parentElement; + } + return null; +}; + const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipProviderProps) => { const lastAnchor = useRef(); + const gapCloseTimerRef = useRef>(); const hasHover = !useMediaQuery('(hover: none)'); const [tooltip, setTooltip] = useDebouncedState(null, 300); @@ -30,6 +44,10 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP open: (tooltip: ReactNode, anchor: HTMLElement): void => { const previousAnchor = lastAnchor.current; setTooltip(); + // Opening must not be debounced: custom tooltips use a 300ms debounced state, but the + // document mouseover handler uses a shorter gap timer before close — without flush the + // portal is not in the DOM yet, so hover cannot cancel the scheduled close. + setTooltip.flush(); lastAnchor.current = anchor; if (previousAnchor) { restoreTitle(previousAnchor); @@ -57,29 +75,68 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP return; } + const cancelGapCloseTimer = (): void => { + if (gapCloseTimerRef.current !== undefined) { + clearTimeout(gapCloseTimerRef.current); + gapCloseTimerRef.current = undefined; + } + }; + const handleMouseOver = (e: MouseEvent): void => { - const target = e.target as HTMLElement; + const target = resolveMouseEventTarget(e.target); + if (!target) { + return; + } + + // Still inside the anchor subtree for the currently open custom tooltip (read count, + // reactions, etc.). Must run before `[data-tooltip]` logic — empty `data-tooltip=""` + // otherwise triggers an immediate close. + if (lastAnchor.current?.contains(target)) { + cancelGapCloseTimer(); + return; + } + + // Custom tooltips opened via TooltipContext.open() render inline. The global + // handler below only understands `[title]` / `[data-tooltip]` anchors; without these + // guards, hovering the tooltip bubble closes it (no matching anchor). + if (target.closest(RC_PORTAL_TOOLTIP_SELECTOR) || target.closest('[role="tooltip"]')) { + cancelGapCloseTimer(); + return; + } + if (lastAnchor.current === target) { + cancelGapCloseTimer(); return; } const anchor = target.closest('[title], [data-tooltip]') as HTMLElement; if (lastAnchor.current === anchor) { + cancelGapCloseTimer(); return; } if (!anchor) { - contextValue.close(); + cancelGapCloseTimer(); + gapCloseTimerRef.current = setTimeout(() => { + contextValue.close(); + gapCloseTimerRef.current = undefined; + }, 450); return; } const title = anchor.getAttribute('title') ?? anchor.getAttribute('data-tooltip') ?? ''; if (!title) { + cancelGapCloseTimer(); + if (lastAnchor.current === anchor) { + return; + } contextValue.close(); return; } + cancelGapCloseTimer(); + // eslint-disable-next-line react/no-multi-comp const Handler = () => { const [state, setState] = useState(title); @@ -132,6 +189,10 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP ownerDocument.body.addEventListener('click', dismissOnClick, { capture: true }); return (): void => { + if (gapCloseTimerRef.current !== undefined) { + clearTimeout(gapCloseTimerRef.current); + gapCloseTimerRef.current = undefined; + } contextValue.close(); ownerDocument.body.removeEventListener('mouseover', handleMouseOver); ownerDocument.body.removeEventListener('click', dismissOnClick); From 0db6c61849b8deca86b2b95ca0f17c4343e5c453 Mon Sep 17 00:00:00 2001 From: qmaster0803 Date: Mon, 25 May 2026 01:07:20 +0300 Subject: [PATCH 12/18] fix: remove debug prints --- .../lib/server/functions/loadMessageHistory.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts index 14f0bef872e64..132fdade875e2 100644 --- a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts +++ b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts @@ -5,8 +5,6 @@ import type { FindOptions } from 'mongodb'; import { settings } from '../../../settings/server/cached'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { getHiddenSystemMessages } from '../lib/getHiddenSystemMessages'; -import { getMessageReadCount } from '../../../read-counter/server/getMessageReadCount'; -import { SystemLogger } from '../../../../server/lib/logger/system'; export async function loadMessageHistory({ userId, @@ -55,19 +53,6 @@ export async function loadMessageHistory({ : await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray(); const messages = await normalizeMessagesForUser(records, userId); - if (messages[0]?._id && userId) { - const readCountResult = await getMessageReadCount(messages[0]._id, userId); - - if (readCountResult) { - SystemLogger.info({ - msg: '[read-count demo] newest message readCount', - rid, - mid: messages[0]._id, - readCount: readCountResult.readCount, - uid: userId, - }); - } - } let unreadNotLoaded = 0; let firstUnread; From 2ba5a890c5b1b80a8dc9b67f44688c29988c1069 Mon Sep 17 00:00:00 2001 From: qmaster0803 Date: Wed, 27 May 2026 23:53:10 +0300 Subject: [PATCH 13/18] fix: apply suggested changes --- .../components/message/ReadCountBadge.tsx | 4 ---- packages/models/src/models/Subscriptions.ts | 2 +- .../src/components/TooltipComponent.tsx | 1 - .../src/providers/TooltipProvider.tsx | 19 +++++-------------- 4 files changed, 6 insertions(+), 20 deletions(-) diff --git a/apps/meteor/client/components/message/ReadCountBadge.tsx b/apps/meteor/client/components/message/ReadCountBadge.tsx index e2da4fa8b5cda..bca9dc6b5b7cd 100644 --- a/apps/meteor/client/components/message/ReadCountBadge.tsx +++ b/apps/meteor/client/components/message/ReadCountBadge.tsx @@ -78,12 +78,10 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr const readCount = data?.readCount ?? 0; - // Keep this minimal: show only on the current user's own messages. if (!uid || uid !== senderId) { return null; } - // Exclude DMs if (room.t === 'd') { return null; } @@ -92,8 +90,6 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr return null; } - // No onMouseLeave close timer: TooltipProvider handles gap-to-portal; a badge timer raced with hover. - return ( implements ISubscri excludeUserId: string | undefined, limit: number, ): Promise>> { - const safeLimit = Math.min(Math.max(limit, 1), 100); + const safeLimit = Math.min(Math.max(Math.trunc(limit) || 1, 1), 100); const pipeline = [ { diff --git a/packages/ui-client/src/components/TooltipComponent.tsx b/packages/ui-client/src/components/TooltipComponent.tsx index 03b2b6e3207e8..b07a118653864 100644 --- a/packages/ui-client/src/components/TooltipComponent.tsx +++ b/packages/ui-client/src/components/TooltipComponent.tsx @@ -2,7 +2,6 @@ import { Box, Tooltip, PositionAnimated, AnimatedVisibility } from '@rocket.chat import type { ReactNode } from 'react'; import { useRef } from 'react'; -/** Marker on portaled tooltip DOM so TooltipProvider can ignore hover inside the bubble. */ export const RC_PORTAL_TOOLTIP_ATTR = 'data-rc-portal-tooltip'; export const RC_PORTAL_TOOLTIP_SELECTOR = `[${RC_PORTAL_TOOLTIP_ATTR}]`; diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index 6dcff90567806..a38058ce271bf 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -10,15 +10,15 @@ type TooltipProviderProps = { ownerDocument?: Document; }; -const resolveMouseEventTarget = (raw: EventTarget | null): HTMLElement | null => { +const resolveMouseEventTarget = (raw: EventTarget | null): Element | null => { if (!raw) { return null; } - if (raw instanceof HTMLElement) { - return raw; + if ((raw as Node).nodeType === Node.ELEMENT_NODE) { + return raw as Element; } - if (raw instanceof Text) { - return raw.parentElement; + if ((raw as Node).nodeType === Node.TEXT_NODE) { + return (raw as Text).parentElement; } return null; }; @@ -44,9 +44,6 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP open: (tooltip: ReactNode, anchor: HTMLElement): void => { const previousAnchor = lastAnchor.current; setTooltip(); - // Opening must not be debounced: custom tooltips use a 300ms debounced state, but the - // document mouseover handler uses a shorter gap timer before close — without flush the - // portal is not in the DOM yet, so hover cannot cancel the scheduled close. setTooltip.flush(); lastAnchor.current = anchor; if (previousAnchor) { @@ -88,17 +85,11 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP return; } - // Still inside the anchor subtree for the currently open custom tooltip (read count, - // reactions, etc.). Must run before `[data-tooltip]` logic — empty `data-tooltip=""` - // otherwise triggers an immediate close. if (lastAnchor.current?.contains(target)) { cancelGapCloseTimer(); return; } - // Custom tooltips opened via TooltipContext.open() render inline. The global - // handler below only understands `[title]` / `[data-tooltip]` anchors; without these - // guards, hovering the tooltip bubble closes it (no matching anchor). if (target.closest(RC_PORTAL_TOOLTIP_SELECTOR) || target.closest('[role="tooltip"]')) { cancelGapCloseTimer(); return; From d539e193975f76b1b337662a91f58a788264e490 Mon Sep 17 00:00:00 2001 From: Polina N Date: Sun, 11 Oct 2026 16:25:50 +0300 Subject: [PATCH 14/18] Upgrade feature --- .patch-ci.json | 5 + apps/meteor/app/emoji/client/helpers.ts | 25 ++-- .../server/lib/validateRestrictedEmojis.ts | 46 +------ .../client/contexts/EmojiPickerContext.ts | 6 +- .../EmojiPickerProvider.tsx | 27 ++-- .../views/admin/customEmoji/RestrictEmoji.tsx | 84 ++++++++---- .../composer/EmojiPicker/EmojiPicker.tsx | 29 +++-- apps/meteor/lib/emoji/restrictedEmojis.ts | 35 +++++ .../app/emoji/restrictedEmojiPicker.spec.ts | 62 +++++++++ .../lib/validateRestrictedEmojis.spec.ts | 121 ++++++++++++++++++ .../tests/unit/lib/restrictedEmojis.spec.ts | 73 +++++++++++ packages/i18n/src/locales/en.i18n.json | 6 +- packages/i18n/src/locales/ru.i18n.json | 10 +- 13 files changed, 430 insertions(+), 99 deletions(-) create mode 100644 .patch-ci.json create mode 100644 apps/meteor/lib/emoji/restrictedEmojis.ts create mode 100644 apps/meteor/tests/unit/app/emoji/restrictedEmojiPicker.spec.ts create mode 100644 apps/meteor/tests/unit/app/lib/server/lib/validateRestrictedEmojis.spec.ts create mode 100644 apps/meteor/tests/unit/lib/restrictedEmojis.spec.ts diff --git a/.patch-ci.json b/.patch-ci.json new file mode 100644 index 0000000000000..2037217f63aad --- /dev/null +++ b/.patch-ci.json @@ -0,0 +1,5 @@ +{ + "tests": [ + "yarn workspace @rocket.chat/meteor mocha --config ./.mocharc.base.json tests/unit/lib/restrictedEmojis.spec.ts tests/unit/app/lib/server/lib/validateRestrictedEmojis.spec.ts tests/unit/app/emoji/restrictedEmojiPicker.spec.ts" + ] +} diff --git a/apps/meteor/app/emoji/client/helpers.ts b/apps/meteor/app/emoji/client/helpers.ts index 7115b12a8601d..b85fef2186089 100644 --- a/apps/meteor/app/emoji/client/helpers.ts +++ b/apps/meteor/app/emoji/client/helpers.ts @@ -1,6 +1,7 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { isRestrictedEmoji } from '../../../lib/emoji/restrictedEmojis'; import type { EmojiCategory, EmojiItem } from '.'; import { emoji, emojiEmitter } from './lib'; @@ -22,17 +23,17 @@ export const createEmojiListByCategorySubscription = ( recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, setQuickReactions: () => void, - isAdmin: boolean, + canManageEmoji: boolean, restrictedEmojis: string[], ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ReturnType] => { let result: ReturnType = [[], []]; updateRecent(recentEmojis); const sub = (cb: () => void) => { - result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis); + result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, canManageEmoji, restrictedEmojis); setQuickReactions(); return emojiEmitter.on('updated', () => { - result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis); + result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, canManageEmoji, restrictedEmojis); setQuickReactions(); cb(); }); @@ -46,7 +47,7 @@ export const createPickerEmojis = ( actualTone: number, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, - isAdmin: boolean, + canManageEmoji: boolean, restrictedEmojis: string[], ): [EmojiPickerItem[], CategoriesIndexes] => { const categories = getCategoriesList(); @@ -55,7 +56,9 @@ export const createPickerEmojis = ( const mappedCategories = categories.reduce((acc, category) => { categoriesIndexes.push({ key: category.key, index: acc.length }); acc.push({ category: category.key, i18n: category.i18n }); - acc.push(...createEmojiList(customItemsLimit, category.key, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis)); + acc.push( + ...createEmojiList(customItemsLimit, category.key, actualTone, recentEmojis, setRecentEmojis, canManageEmoji, restrictedEmojis), + ); return acc; }, []); @@ -68,7 +71,7 @@ export const createEmojiList = ( actualTone: number | null, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, - isAdmin: boolean, + canManageEmoji: boolean, restrictedEmojis: string[], ): (RowItem | LoadMoreItem)[] => { const items: RowItem = []; @@ -85,7 +88,11 @@ export const createEmojiList = ( for (let i = 0; i < total; i++) { const current = emojiPackage.emojisByCategory[category][i]; - if (!isAdmin && restrictedEmojis.length && restrictedEmojis.includes(current)) { + if (!current) { + continue; + } + + if (!canManageEmoji && isRestrictedEmoji(current, restrictedEmojis)) { continue; } @@ -155,7 +162,7 @@ export const getEmojisBySearchTerm = ( actualTone: number, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, - isAdmin: boolean, + canManageEmoji: boolean, restrictedEmojis: string[], ) => { const emojis = []; @@ -172,7 +179,7 @@ export const getEmojisBySearchTerm = ( let tone = ''; current = current.replace(/:/g, ''); - if (!isAdmin && restrictedEmojis.length && restrictedEmojis.includes(current)) { + if (!canManageEmoji && isRestrictedEmoji(current, restrictedEmojis)) { continue; } diff --git a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts index 11502ba76a200..22c86498f1593 100644 --- a/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts +++ b/apps/meteor/app/lib/server/lib/validateRestrictedEmojis.ts @@ -1,6 +1,7 @@ import emojione from 'emojione'; import { Meteor } from 'meteor/meteor'; +import { isRestrictedEmoji, parseRestrictedEmojis } from '../../../../lib/emoji/restrictedEmojis'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; @@ -10,60 +11,25 @@ export const assertNoRestrictedEmojis = async (uid: string, msg: string | undefi return; } - const hasManageEmojiPermission = await hasPermissionAsync(uid, 'manage-emoji'); - if (hasManageEmojiPermission) { + if (await hasPermissionAsync(uid, 'manage-emoji')) { return; } - const restrictedEmojis = restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); + const restrictedEmojis = parseRestrictedEmojis(restrictedEmojisString); if (!restrictedEmojis.length) { return; } + const textToCheck = emojione.toShort(msg); const emojiRegex = /:([a-zA-Z0-9_+-]+):/g; - const matches = msg.matchAll(emojiRegex); - for (const match of matches) { + for (const match of textToCheck.matchAll(emojiRegex)) { const emojiName = match[1]; - if (restrictedEmojis.includes(emojiName)) { + if (isRestrictedEmoji(emojiName, restrictedEmojis)) { throw new Meteor.Error('error-emoji-restricted', `Emoji :${emojiName}: is restricted`, { method, emoji: emojiName, }); } } - - const mappedUnicode = emojione.mapUnicodeToShort(); - const unicodeRegex = emojione.unicodeCharRegex(); - - if (unicodeRegex) { - let regex: RegExp; - if (typeof unicodeRegex === 'string') { - regex = new RegExp(unicodeRegex, 'g'); - } else if (unicodeRegex instanceof RegExp) { - regex = new RegExp(unicodeRegex.source, 'g'); - } else { - return; - } - - const unicodeMatches = msg.matchAll(regex); - - for (const match of unicodeMatches) { - const unicodeChar = match[0]; - const codePoint = Array.from(unicodeChar) - .map((char) => char.codePointAt(0)?.toString(16).padStart(4, '0')) - .join('-'); - - const shortname = mappedUnicode[codePoint]; - if (shortname) { - const emojiName = shortname.slice(1, -1); - if (restrictedEmojis.includes(emojiName)) { - throw new Meteor.Error('error-emoji-restricted', `Emoji ${unicodeChar} is restricted`, { - method, - emoji: emojiName, - }); - } - } - } - } }; diff --git a/apps/meteor/client/contexts/EmojiPickerContext.ts b/apps/meteor/client/contexts/EmojiPickerContext.ts index b79239441f188..4c78183525fd5 100644 --- a/apps/meteor/client/contexts/EmojiPickerContext.ts +++ b/apps/meteor/client/contexts/EmojiPickerContext.ts @@ -2,8 +2,12 @@ import { createContext, useContext } from 'react'; import type { EmojiPickerItem, CategoriesIndexes } from '../../app/emoji/client'; +type EmojiPickerOpenOptions = { + keepOpen?: boolean; +}; + type EmojiPickerContextValue = { - open: (ref: Element, callback: (emoji: string) => void) => void; + open: (ref: Element, callback: (emoji: string) => void, options?: EmojiPickerOpenOptions) => void; isOpen: boolean; close: () => void; emojiToPreview: { emoji: string; name: string } | null; diff --git a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index 53d1a4989714a..8d4f499a2e0b3 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -1,10 +1,11 @@ import { useDebouncedState, useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import { useRole, useSetting } from '@rocket.chat/ui-contexts'; +import { usePermission, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement, ContextType } from 'react'; import { useState, useCallback, useMemo, useSyncExternalStore } from 'react'; import { useUpdateCustomEmoji } from './useUpdateCustomEmoji'; import { emoji, getFrequentEmoji, createEmojiListByCategorySubscription } from '../../../app/emoji/client'; +import { parseRestrictedEmojis } from '../../../lib/emoji/restrictedEmojis'; import { EmojiPickerContext } from '../../contexts/EmojiPickerContext'; import EmojiPicker from '../../views/composer/EmojiPicker'; @@ -24,12 +25,9 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT); - const isAdmin = useRole('admin'); - const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); - const restrictedEmojis = useMemo(() => { - if (!restrictedEmojisString) return []; - return restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); - }, [restrictedEmojisString]); + const canManageEmoji = usePermission('manage-emoji'); + const restrictedEmojisSetting = useSetting('Emoji_Restricted_For_Users', ''); + const restrictedEmojis = useMemo(() => parseRestrictedEmojis(restrictedEmojisSetting), [restrictedEmojisSetting]); const [quickReactions, _setQuickReactions] = useState<{ emoji: string; image: string }[]>(() => getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)), @@ -43,10 +41,10 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen recentEmojis, setRecentEmojis, setQuickReactions, - isAdmin, + canManageEmoji, restrictedEmojis, ); - }, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions, isAdmin, restrictedEmojis]); + }, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions, canManageEmoji, restrictedEmojis]); const [emojiListByCategory, categoriesIndexes] = useSyncExternalStore(sub, getSnapshot); @@ -90,8 +88,15 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen [recentEmojis, setRecentEmojis, addFrequentEmojis], ); - const open = useCallback((ref: Element, callback: (emoji: string) => void) => { - return setEmojiPicker( setEmojiPicker(null)} onPickEmoji={(emoji) => callback(emoji)} />); + const open = useCallback((ref: Element, callback: (emoji: string) => void, options?: { keepOpen?: boolean }) => { + return setEmojiPicker( + setEmojiPicker(null)} + onPickEmoji={(emoji) => callback(emoji)} + />, + ); }, []); const handlePreview = useCallback((emoji: string, name: string) => setEmojiToPreview({ emoji, name }), [setEmojiToPreview]); diff --git a/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx index 55116836b6116..aa6acf0e9d8d1 100644 --- a/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/RestrictEmoji.tsx @@ -1,10 +1,14 @@ -import { Box, Button, ButtonGroup, Field, FieldLabel, FieldRow, FieldError, TextAreaInput } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Chip, Field, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useSetting, useSettingsDispatch } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ChangeEvent } from 'react'; -import { useCallback, useState } from 'react'; +import type { ReactElement, MouseEvent } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import Emoji from '../../../components/Emoji'; +import { useEmojiPicker } from '../../../contexts/EmojiPickerContext'; +import { getEmojiNameWithoutTone, parseRestrictedEmojis } from '../../../../lib/emoji/restrictedEmojis'; + type RestrictEmojiProps = { close: () => void; }; @@ -13,43 +17,77 @@ const RestrictEmoji = ({ close }: RestrictEmojiProps): ReactElement => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const dispatchSettings = useSettingsDispatch(); + const { open: openEmojiPicker } = useEmojiPicker(); + const addButtonRef = useRef(null); + + const restrictedEmojisSetting = useSetting('Emoji_Restricted_For_Users', ''); + const [restrictedEmojis, setRestrictedEmojis] = useState(() => parseRestrictedEmojis(restrictedEmojisSetting)); - const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); - const [restrictedEmojis, setRestrictedEmojis] = useState(restrictedEmojisString || ''); + const restrictedEmojisValue = useMemo(() => restrictedEmojis.join(','), [restrictedEmojis]); - const handleChangeRestrictedEmojis = (e: ChangeEvent): void => { - setRestrictedEmojis(e.currentTarget.value); + const handleRemoveEmoji = (emojiName: string) => (): void => { + setRestrictedEmojis((current) => current.filter((item) => item !== emojiName)); }; + const handleAddEmoji = useCallback( + (event: MouseEvent): void => { + openEmojiPicker( + addButtonRef.current ?? event.currentTarget, + (emojiName) => { + const normalized = getEmojiNameWithoutTone(emojiName); + if (!normalized) { + return; + } + + setRestrictedEmojis((current) => (current.includes(normalized) ? current : [...current, normalized])); + }, + { keepOpen: true }, + ); + }, + [openEmojiPicker], + ); + const handleSave = useCallback(async () => { try { - await dispatchSettings([{ _id: 'Emoji_Restricted_For_Users', value: restrictedEmojis }]); + await dispatchSettings([{ _id: 'Emoji_Restricted_For_Users', value: restrictedEmojisValue }]); dispatchToastMessage({ type: 'success', message: t('Emoji_Restrictions_Updated_Successfully') }); close(); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - dispatchToastMessage({ type: 'error', message: errorMessage }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); } - }, [restrictedEmojis, dispatchSettings, dispatchToastMessage, t, close]); + }, [restrictedEmojisValue, dispatchSettings, dispatchToastMessage, t, close]); return ( <> {t('Restricted_Emojis')} - - - - - - {t('Emoji_Restricted_For_Users_Description')} + {restrictedEmojis.length > 0 ? ( + + + {restrictedEmojis.map((emojiName) => ( + + + + + {emojiName} + + + + ))} + + + ) : ( + + {t('Restricted_Emojis_Empty')} + )} + + + {t('Emoji_Restricted_For_Users_Description')} diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx index e07aa891451fe..105411de744fe 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx @@ -9,7 +9,7 @@ import { EmojiPickerListArea, EmojiPickerPreview, } from '@rocket.chat/ui-client'; -import { useTranslation, usePermission, useRoute, useRole, useSetting } from '@rocket.chat/ui-contexts'; +import { useTranslation, usePermission, useRoute, useSetting } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, KeyboardEvent, MouseEvent, RefObject } from 'react'; import { useLayoutEffect, useState, useEffect, useRef, useMemo } from 'react'; import type { ListRange, VirtuosoHandle } from 'react-virtuoso'; @@ -22,6 +22,7 @@ import ToneSelector from './ToneSelector'; import ToneSelectorWrapper from './ToneSelector/ToneSelectorWrapper'; import { emoji, getCategoriesList, getEmojisBySearchTerm } from '../../../../app/emoji/client'; import type { EmojiItem } from '../../../../app/emoji/client'; +import { parseRestrictedEmojis } from '../../../../lib/emoji/restrictedEmojis'; import { usePreviewEmoji, useEmojiPickerData } from '../../../contexts/EmojiPickerContext'; import { useIsVisible } from '../../room/hooks/useIsVisible'; @@ -29,9 +30,10 @@ type EmojiPickerProps = { reference: Element; onClose: () => void; onPickEmoji: (emoji: string) => void; + keepOpen?: boolean; }; -const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { +const EmojiPicker = ({ reference, onClose, onPickEmoji, keepOpen = false }: EmojiPickerProps) => { const t = useTranslation(); const ref = useRef(reference); @@ -48,12 +50,8 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { const canManageEmoji = usePermission('manage-emoji'); const customEmojiRoute = useRoute('emoji-custom'); - const isAdmin = useRole('admin'); - const restrictedEmojisString = useSetting('Emoji_Restricted_For_Users', ''); - const restrictedEmojis = useMemo(() => { - if (!restrictedEmojisString) return []; - return restrictedEmojisString.split(',').map((emoji) => emoji.trim()).filter(Boolean); - }, [restrictedEmojisString]); + const restrictedEmojisSetting = useSetting('Emoji_Restricted_For_Users', ''); + const restrictedEmojis = useMemo(() => parseRestrictedEmojis(restrictedEmojisSetting), [restrictedEmojisSetting]); const [searching, setSearching] = useState(false); const [searchTerm, setSearchTerm] = useState(''); @@ -131,8 +129,10 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { setSearchTerm(''); onPickEmoji(_emoji + tone); - addRecentEmoji(_emoji + tone); - onClose(); + if (!keepOpen) { + addRecentEmoji(_emoji + tone); + onClose(); + } }; useEffect(() => { @@ -144,7 +144,14 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { setCurrentCategory(''); setSearching(e.target.value !== ''); - const emojisResult = getEmojisBySearchTerm(e.target.value, actualTone, recentEmojis, setRecentEmojis, isAdmin, restrictedEmojis); + const emojisResult = getEmojisBySearchTerm( + e.target.value, + actualTone, + recentEmojis, + setRecentEmojis, + canManageEmoji, + restrictedEmojis, + ); if (emojisResult.filter((emoji) => emoji.image).length === 0) { setCurrentCategory('no-results'); diff --git a/apps/meteor/lib/emoji/restrictedEmojis.ts b/apps/meteor/lib/emoji/restrictedEmojis.ts new file mode 100644 index 0000000000000..36d209d1ac23f --- /dev/null +++ b/apps/meteor/lib/emoji/restrictedEmojis.ts @@ -0,0 +1,35 @@ +export const normalizeEmojiName = (name: string | undefined | null): string => { + if (typeof name !== 'string') { + return ''; + } + + return name.trim().toLowerCase().replace(/^:|:$/g, ''); +}; + +export const getEmojiNameWithoutTone = (name: string | undefined | null): string => normalizeEmojiName(name).replace(/_tone\d+$/, ''); + +export const parseRestrictedEmojis = (value: string | undefined | null): string[] => { + if (!value || typeof value !== 'string') { + return []; + } + + return Array.from(new Set(value.split(',').map(getEmojiNameWithoutTone).filter(Boolean))); +}; + +export const isRestrictedEmoji = (emojiName: string | undefined | null, restrictedEmojis: string[]): boolean => { + if (!restrictedEmojis.length) { + return false; + } + + const normalized = normalizeEmojiName(emojiName); + if (!normalized) { + return false; + } + + if (restrictedEmojis.includes(normalized)) { + return true; + } + + const withoutTone = getEmojiNameWithoutTone(normalized); + return withoutTone !== normalized && restrictedEmojis.includes(withoutTone); +}; diff --git a/apps/meteor/tests/unit/app/emoji/restrictedEmojiPicker.spec.ts b/apps/meteor/tests/unit/app/emoji/restrictedEmojiPicker.spec.ts new file mode 100644 index 0000000000000..b142a36d7b187 --- /dev/null +++ b/apps/meteor/tests/unit/app/emoji/restrictedEmojiPicker.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; + +import { createEmojiList, getEmojisBySearchTerm } from '../../../../app/emoji/client/helpers'; +import { emoji } from '../../../../app/emoji/client/lib'; + +const CATEGORY = 'people-test'; + +describe('restricted emoji picker filtering', () => { + const recentEmojis: string[] = []; + const setRecentEmojis = (): void => undefined; + + beforeEach(() => { + emoji.packages.testPkg = { + emojisByCategory: { + [CATEGORY]: ['smile', 'eggplant', 'peach'], + }, + toneList: {}, + renderPicker: () => '', + } as any; + + emoji.list[':smile:'] = { emojiPackage: 'testPkg' } as any; + emoji.list[':eggplant:'] = { emojiPackage: 'testPkg' } as any; + emoji.list[':peach:'] = { emojiPackage: 'testPkg' } as any; + }); + + afterEach(() => { + delete emoji.packages.testPkg; + delete emoji.list[':smile:']; + delete emoji.list[':eggplant:']; + delete emoji.list[':peach:']; + }); + + const flattenEmojis = (rows: ReturnType): string[] => + rows.flatMap((row) => { + if (!Array.isArray(row)) { + return []; + } + + return row.map((item) => item.emoji); + }); + + it('should hide restricted emojis from the picker for regular users', () => { + const rows = createEmojiList(90, CATEGORY, 0, recentEmojis, setRecentEmojis, false, ['eggplant', 'peach']); + expect(flattenEmojis(rows)).to.deep.equal(['smile']); + }); + + it('should keep restricted emojis visible for users with manage-emoji', () => { + const rows = createEmojiList(90, CATEGORY, 0, recentEmojis, setRecentEmojis, true, ['eggplant', 'peach']); + expect(flattenEmojis(rows)).to.deep.equal(['smile', 'eggplant', 'peach']); + }); + + it('should hide restricted emojis from search results for regular users', () => { + const results = getEmojisBySearchTerm('pea', 0, recentEmojis, setRecentEmojis, false, ['peach']); + expect(results.map((item) => item.emoji)).to.not.include('peach'); + }); + + it('should include restricted emojis in search results for users with manage-emoji', () => { + const results = getEmojisBySearchTerm('pea', 0, recentEmojis, setRecentEmojis, true, ['peach']); + expect(results.map((item) => item.emoji)).to.include('peach'); + }); +}); diff --git a/apps/meteor/tests/unit/app/lib/server/lib/validateRestrictedEmojis.spec.ts b/apps/meteor/tests/unit/app/lib/server/lib/validateRestrictedEmojis.spec.ts new file mode 100644 index 0000000000000..d221e6a36c105 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/lib/validateRestrictedEmojis.spec.ts @@ -0,0 +1,121 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import * as restrictedEmojisModule from '../../../../../../lib/emoji/restrictedEmojis'; + +const settingsGetMock = sinon.stub(); +const hasPermissionAsyncMock = sinon.stub(); +const toShortMock = sinon.stub(); + +class MeteorError extends Error { + public error: string; + + public details: unknown; + + constructor(error: string, reason?: string, details?: unknown) { + super(reason || error); + this.error = error; + this.details = details; + } +} + +const { assertNoRestrictedEmojis } = proxyquire.noCallThru().load('../../../../../../app/lib/server/lib/validateRestrictedEmojis.ts', { + 'emojione': { toShort: toShortMock }, + 'meteor/meteor': { Meteor: { Error: MeteorError } }, + '../../../../lib/emoji/restrictedEmojis': restrictedEmojisModule, + '../../../authorization/server/functions/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock }, + '../../../settings/server': { settings: { get: settingsGetMock } }, +}); + +describe('assertNoRestrictedEmojis', () => { + beforeEach(() => { + settingsGetMock.reset(); + hasPermissionAsyncMock.reset(); + toShortMock.reset(); + + settingsGetMock.withArgs('Emoji_Restricted_For_Users').returns('eggplant,peach,middle_finger'); + hasPermissionAsyncMock.resolves(false); + toShortMock.callsFake((msg: string) => msg); + }); + + it('should allow messages when the setting is empty', async () => { + settingsGetMock.withArgs('Emoji_Restricted_For_Users').returns(''); + + await assertNoRestrictedEmojis('user-id', 'hello :eggplant:', 'sendMessage'); + }); + + it('should allow messages when msg is empty', async () => { + await assertNoRestrictedEmojis('user-id', '', 'sendMessage'); + await assertNoRestrictedEmojis('user-id', undefined, 'sendMessage'); + }); + + it('should allow users with manage-emoji permission to use restricted emojis', async () => { + hasPermissionAsyncMock.resolves(true); + + await assertNoRestrictedEmojis('admin-id', 'hello :eggplant:', 'sendMessage'); + expect(hasPermissionAsyncMock.calledWith('admin-id', 'manage-emoji')).to.equal(true); + }); + + it('should allow messages without restricted emojis', async () => { + await assertNoRestrictedEmojis('user-id', 'hello :smile: world', 'sendMessage'); + }); + + it('should block restricted shortcodes on sendMessage', async () => { + try { + await assertNoRestrictedEmojis('user-id', 'look :eggplant:', 'sendMessage'); + expect.fail('expected error-emoji-restricted'); + } catch (error: any) { + expect(error).to.be.instanceOf(MeteorError); + expect(error.error).to.equal('error-emoji-restricted'); + expect(error.details).to.deep.include({ method: 'sendMessage', emoji: 'eggplant' }); + } + }); + + it('should block restricted shortcodes on updateMessage', async () => { + try { + await assertNoRestrictedEmojis('user-id', 'edited :peach:', 'updateMessage'); + expect.fail('expected error-emoji-restricted'); + } catch (error: any) { + expect(error).to.be.instanceOf(MeteorError); + expect(error.error).to.equal('error-emoji-restricted'); + expect(error.details).to.deep.include({ method: 'updateMessage', emoji: 'peach' }); + } + }); + + it('should block tone variants of restricted emojis', async () => { + try { + await assertNoRestrictedEmojis('user-id', ':middle_finger_tone2:', 'sendMessage'); + expect.fail('expected error-emoji-restricted'); + } catch (error: any) { + expect(error.error).to.equal('error-emoji-restricted'); + expect(error.details).to.deep.include({ emoji: 'middle_finger_tone2' }); + } + }); + + it('should block unicode emojis converted to restricted shortnames', async () => { + toShortMock.callsFake((msg: string) => msg.replace('🍆', ':eggplant:')); + + try { + await assertNoRestrictedEmojis('user-id', 'unicode 🍆 here', 'sendMessage'); + expect.fail('expected error-emoji-restricted'); + } catch (error: any) { + expect(error.error).to.equal('error-emoji-restricted'); + expect(error.details).to.deep.include({ emoji: 'eggplant' }); + } + + expect(toShortMock.calledOnce).to.equal(true); + }); + + it('should normalize restricted setting values with colons and casing', async () => { + settingsGetMock.withArgs('Emoji_Restricted_For_Users').returns(' :Eggplant: , Peach '); + + try { + await assertNoRestrictedEmojis('user-id', ':eggplant:', 'sendMessage'); + expect.fail('expected error-emoji-restricted'); + } catch (error: any) { + expect(error.error).to.equal('error-emoji-restricted'); + } + }); +}); diff --git a/apps/meteor/tests/unit/lib/restrictedEmojis.spec.ts b/apps/meteor/tests/unit/lib/restrictedEmojis.spec.ts new file mode 100644 index 0000000000000..3e886097794e9 --- /dev/null +++ b/apps/meteor/tests/unit/lib/restrictedEmojis.spec.ts @@ -0,0 +1,73 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { getEmojiNameWithoutTone, isRestrictedEmoji, normalizeEmojiName, parseRestrictedEmojis } from '../../../lib/emoji/restrictedEmojis'; + +describe('restrictedEmojis helpers', () => { + describe('normalizeEmojiName', () => { + it('should trim, lowercase and strip surrounding colons', () => { + expect(normalizeEmojiName(' :Middle_Finger: ')).to.equal('middle_finger'); + }); + + it('should leave plain names unchanged aside from casing', () => { + expect(normalizeEmojiName('Eggplant')).to.equal('eggplant'); + }); + + it('should return an empty string for non-string values', () => { + expect(normalizeEmojiName(undefined)).to.equal(''); + expect(normalizeEmojiName(null)).to.equal(''); + }); + }); + + describe('getEmojiNameWithoutTone', () => { + it('should strip tone suffixes', () => { + expect(getEmojiNameWithoutTone('middle_finger_tone2')).to.equal('middle_finger'); + expect(getEmojiNameWithoutTone(':peach_tone5:')).to.equal('peach'); + }); + + it('should keep names without tones', () => { + expect(getEmojiNameWithoutTone('smile')).to.equal('smile'); + }); + }); + + describe('parseRestrictedEmojis', () => { + it('should parse, normalize, strip tones and dedupe values', () => { + expect(parseRestrictedEmojis(':eggplant:, Peach , MIDDLE_FINGER, eggplant_tone2')).to.deep.equal([ + 'eggplant', + 'peach', + 'middle_finger', + ]); + }); + + it('should return an empty array for empty values', () => { + expect(parseRestrictedEmojis('')).to.deep.equal([]); + expect(parseRestrictedEmojis(undefined)).to.deep.equal([]); + expect(parseRestrictedEmojis(null)).to.deep.equal([]); + }); + }); + + describe('isRestrictedEmoji', () => { + const restricted = parseRestrictedEmojis('middle_finger,eggplant'); + + it('should match exact names', () => { + expect(isRestrictedEmoji('eggplant', restricted)).to.equal(true); + expect(isRestrictedEmoji('smile', restricted)).to.equal(false); + }); + + it('should match tone variants of a restricted base name', () => { + expect(isRestrictedEmoji('middle_finger_tone2', restricted)).to.equal(true); + }); + + it('should match names wrapped in colons', () => { + expect(isRestrictedEmoji(':eggplant:', restricted)).to.equal(true); + }); + + it('should return false when the restriction list is empty', () => { + expect(isRestrictedEmoji('eggplant', [])).to.equal(false); + }); + + it('should return false for undefined emoji names', () => { + expect(isRestrictedEmoji(undefined, restricted)).to.equal(false); + }); + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 288955e0d1b84..0e7ebef3944b2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1999,12 +1999,12 @@ "Emoji": "Emoji", "Emoji_Description": "Configure emoji permissions and access control for users.", "Emoji_Restricted_For_Users": "Restricted Emojis for Users", - "Emoji_Restricted_For_Users_Description": "Comma-separated list of emoji names that regular users cannot use. Leave empty to allow all emojis. Admins can always use all emojis. Example: middle_finger,eggplant,peach", + "Emoji_Restricted_For_Users_Description": "Select emojis that regular users cannot use. Leave empty to allow all emojis. Users with the manage-emoji permission can always use all emojis.", + "Emoji_Restrictions_Updated_Successfully": "Emoji restrictions updated successfully", "Restrict": "Restrict", "Restrict_Emoji": "Restrict Emoji", "Restricted_Emojis": "Restricted Emojis", - "Restricted_Emojis_Placeholder": "emoji1, emoji2, emoji3", - "Emoji_Restrictions_Updated_Successfully": "Emoji restrictions updated successfully", + "Restricted_Emojis_Empty": "No restricted emojis yet. Use Add emoji to select them.", "EmojiCustomFilesystem": "Custom Emoji Filesystem", "EmojiCustomFilesystem_Description": "Specify how emojis are stored.", "Emoji_picker": "Emoji picker", diff --git a/packages/i18n/src/locales/ru.i18n.json b/packages/i18n/src/locales/ru.i18n.json index d0ecdc864077a..75fae865a9bb0 100644 --- a/packages/i18n/src/locales/ru.i18n.json +++ b/packages/i18n/src/locales/ru.i18n.json @@ -1511,6 +1511,14 @@ "Email_subject": "Тема", "Email_verified": "Адрес электронной почты подтверждён", "Emoji": "Эмодзи ", + "Emoji_Description": "Настройка прав и ограничений на использование эмодзи пользователями.", + "Emoji_Restricted_For_Users": "Запрещённые эмодзи для пользователей", + "Emoji_Restricted_For_Users_Description": "Выберите эмодзи, которые обычные пользователи не могут использовать. Оставьте пустым, чтобы разрешить все эмодзи. Пользователи с правом управления эмодзи всегда могут использовать все эмодзи.", + "Emoji_Restrictions_Updated_Successfully": "Ограничения эмодзи успешно обновлены", + "Restrict": "Ограничить", + "Restrict_Emoji": "Ограничение эмодзи", + "Restricted_Emojis": "Запрещённые эмодзи", + "Restricted_Emojis_Empty": "Запрещённых эмодзи пока нет. Нажмите «Добавить эмодзи», чтобы выбрать их.", "EmojiCustomFilesystem": "Хранилище пользовательских эмодзи", "Empty_title": "Пустой заголовок", "Enable": "Включить", @@ -4982,4 +4990,4 @@ "__username__is_no_longer__role__defined_by__user_by_": "{{username}} больше не {{role}} по решению {{user_by}}", "__username__was_set__role__by__user_by_": "{{username}} был установлен {{role}} по решению {{user_by}}", "__usersCount__people_will_be_invited": "{{usersCount}} человек будет приглашено" -} \ No newline at end of file +} From 375d79e379d840c13e1687f59c7ba6b719729feb Mon Sep 17 00:00:00 2001 From: Polina N Date: Sun, 11 Oct 2026 20:03:35 +0300 Subject: [PATCH 15/18] Added chagnes --- apps/meteor/client/sidebar/badges/DraftBadge.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/sidebar/badges/DraftBadge.tsx b/apps/meteor/client/sidebar/badges/DraftBadge.tsx index 4dc28b4d5248a..187048abf0762 100644 --- a/apps/meteor/client/sidebar/badges/DraftBadge.tsx +++ b/apps/meteor/client/sidebar/badges/DraftBadge.tsx @@ -1,4 +1,4 @@ -import { SidebarV2ItemBadge } from '@rocket.chat/fuselage'; +import { SidebarItemBadge } from '@rocket.chat/fuselage'; import { useTranslation } from 'react-i18next'; type DraftBadgeProps = { @@ -9,14 +9,14 @@ const DraftBadge = ({ roomTitle }: DraftBadgeProps) => { const { t } = useTranslation(); return ( - {t('Draft')} - + ); }; From 1b8f0f0c7c02b13f7102ab0eedc9348a9e81cdb0 Mon Sep 17 00:00:00 2001 From: Polina N Date: Sun, 11 Oct 2026 20:16:21 +0300 Subject: [PATCH 16/18] Added changes --- .../room/composer/messageBox/MessageBox.tsx | 24 ++++ .../markup/blocks/ListBlocks.tsx | 69 ++++++++++ .../markup/blocks/OrderedListBlock.tsx | 44 +------ .../markup/blocks/UnorderedListBlock.tsx | 44 +------ .../markup/blocks/splitListItemRuns.ts | 21 +++ packages/gazzodown/src/blocks/ListBlocks.tsx | 48 +++++++ .../gazzodown/src/blocks/OrderedListBlock.tsx | 27 +--- .../src/blocks/UnorderedListBlock.tsx | 27 +--- .../src/blocks/splitListItemRuns.spec.ts | 27 ++++ .../gazzodown/src/blocks/splitListItemRuns.ts | 21 +++ packages/message-parser/src/grammar.pegjs | 97 +++++++++----- .../message-parser/tests/orderedList.test.ts | 42 +++++- .../tests/unorderedList.test.ts | 121 ++++++++++++++---- 13 files changed, 417 insertions(+), 195 deletions(-) create mode 100644 ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/ListBlocks.tsx create mode 100644 ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/splitListItemRuns.ts create mode 100644 packages/gazzodown/src/blocks/ListBlocks.tsx create mode 100644 packages/gazzodown/src/blocks/splitListItemRuns.spec.ts create mode 100644 packages/gazzodown/src/blocks/splitListItemRuns.ts diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index c653b0a132e55..ea03ac9d081a0 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -227,6 +227,30 @@ const MessageBox = ({ } switch (event.key) { + case 'Tab': { + const { value, selectionStart, selectionEnd } = input; + if (selectionStart !== selectionEnd) { + return; + } + + const lineStart = value.lastIndexOf('\n', Math.max(0, selectionStart - 1)) + 1; + const lineEnd = value.indexOf('\n', selectionStart); + const currentLine = value.slice(lineStart, lineEnd === -1 ? value.length : lineEnd); + const isListLine = /^[ \t]*(?:[-*]|[0-9]+\.)[ \t]/.test(currentLine); + + if (!isListLine) { + return; + } + + event.preventDefault(); + const cursorOffset = selectionStart - lineStart; + input.setSelectionRange(lineStart, lineStart); + chat.composer?.insertText(' '); + const nextCursor = lineStart + 2 + cursorOffset; + input.setSelectionRange(nextCursor, nextCursor); + return; + } + case 'Escape': { closeEditing(event); if (!input.value.trim()) onEscape?.(); diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/ListBlocks.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/ListBlocks.tsx new file mode 100644 index 0000000000000..e72f7ec92f814 --- /dev/null +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/ListBlocks.tsx @@ -0,0 +1,69 @@ +import { StyleSheet, Text, View } from '@react-pdf/renderer'; +import type * as MessageParser from '@rocket.chat/message-parser'; + +import { isOrderedListRun, splitListItemRuns } from './splitListItemRuns'; +import InlineElements from '../elements/InlineElements'; + +const styles = StyleSheet.create({ + wrapper: { + marginTop: 4, + }, + list: { + flexDirection: 'row', + }, + unorderedList: { + display: 'flex', + flexDirection: 'row', + }, + number: { + fontWeight: 700, + marginHorizontal: 4, + }, + bullet: { + marginHorizontal: 4, + }, + nested: { + marginLeft: 16, + }, +}); + +type ListBlockProps = { + items: MessageParser.ListItem[]; +}; + +const NestedLists = ({ items }: { items: MessageParser.ListItem[] }) => ( + <> + {splitListItemRuns(items).map((run, index) => ( + + {isOrderedListRun(run) ? : } + + ))} + +); + +export const OrderedListBlock = ({ items }: ListBlockProps) => ( + + {items.map(({ value, number, children }, index) => ( + + + {number}. {value} + + {children?.length ? : null} + + ))} + +); + +export const UnorderedListBlock = ({ items }: ListBlockProps) => ( + + {items.map(({ value, children }, index) => ( + + + • + {value} + + {children?.length ? : null} + + ))} + +); diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx index b57c2a08c70df..de5a7360566af 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/OrderedListBlock.tsx @@ -1,43 +1 @@ -import { StyleSheet, Text, View } from '@react-pdf/renderer'; -import type * as MessageParser from '@rocket.chat/message-parser'; - -import InlineElements from '../elements/InlineElements'; - -const styles = StyleSheet.create({ - wrapper: { - marginTop: 4, - }, - list: { - flexDirection: 'row', - }, - number: { - fontWeight: 700, - marginHorizontal: 4, - }, - nested: { - marginLeft: 16, - }, -}); - -type OrderedListBlockProps = { - items: MessageParser.ListItem[]; -}; - -const OrderedListBlock = ({ items }: OrderedListBlockProps) => ( - - {items.map(({ value, number, children }, index) => ( - - - {number}. {value} - - {children?.length && ( - - - - )} - - ))} - -); - -export default OrderedListBlock; +export { OrderedListBlock as default } from './ListBlocks'; diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx index fbf8b38d9531e..9a4aa6c15431f 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/UnorderedListBlock.tsx @@ -1,43 +1 @@ -import { StyleSheet, Text, View } from '@react-pdf/renderer'; -import type * as MessageParser from '@rocket.chat/message-parser'; - -import InlineElements from '../elements/InlineElements'; - -const styles = StyleSheet.create({ - wrapper: { - marginTop: 4, - }, - list: { - display: 'flex', - flexDirection: 'row', - }, - bullet: { - marginHorizontal: 4, - }, - nested: { - marginLeft: 16, - }, -}); - -type UnorderedListBlockProps = { - items: MessageParser.ListItem[]; -}; -const UnorderedListBlock = ({ items }: UnorderedListBlockProps) => ( - - {items.map(({ value, children }, index) => ( - - - • - {value} - - {children?.length && ( - - - - )} - - ))} - -); - -export default UnorderedListBlock; +export { UnorderedListBlock as default } from './ListBlocks'; diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/splitListItemRuns.ts b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/splitListItemRuns.ts new file mode 100644 index 0000000000000..5c8ddd46945f4 --- /dev/null +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/blocks/splitListItemRuns.ts @@ -0,0 +1,21 @@ +import type * as MessageParser from '@rocket.chat/message-parser'; + +const isOrderedItem = (item: MessageParser.ListItem): boolean => item.number !== undefined; + +export const splitListItemRuns = (items: MessageParser.ListItem[]): MessageParser.ListItem[][] => { + const runs: MessageParser.ListItem[][] = []; + + for (const item of items) { + const lastRun = runs[runs.length - 1]; + if (!lastRun || isOrderedItem(lastRun[0]) !== isOrderedItem(item)) { + runs.push([item]); + continue; + } + + lastRun.push(item); + } + + return runs; +}; + +export const isOrderedListRun = (items: MessageParser.ListItem[]): boolean => isOrderedItem(items[0]); diff --git a/packages/gazzodown/src/blocks/ListBlocks.tsx b/packages/gazzodown/src/blocks/ListBlocks.tsx new file mode 100644 index 0000000000000..84fd45184699c --- /dev/null +++ b/packages/gazzodown/src/blocks/ListBlocks.tsx @@ -0,0 +1,48 @@ +import type * as MessageParser from '@rocket.chat/message-parser'; +import type { ReactElement } from 'react'; + +import { isOrderedListRun, splitListItemRuns } from './splitListItemRuns'; +import InlineElements from '../elements/InlineElements'; + +type ListBlockProps = { + items: MessageParser.ListItem[]; + nested?: boolean; +}; + +const nestedListStyle = { + paddingInlineStart: '1.5rem', +} as const; + +const NestedLists = ({ items }: { items: MessageParser.ListItem[] }): ReactElement => ( + <> + {splitListItemRuns(items).map((run, index) => + isOrderedListRun(run) ? ( + + ) : ( + + ), + )} + +); + +export const OrderedListBlock = ({ items, nested = false }: ListBlockProps): ReactElement => ( +
        + {items.map(({ value, number, children }, index) => ( +
      1. + {value} + {children?.length ? : null} +
      2. + ))} +
      +); + +export const UnorderedListBlock = ({ items, nested = false }: ListBlockProps): ReactElement => ( +
        + {items.map((item, index) => ( +
      • + {item.value} + {item.children?.length ? : null} +
      • + ))} +
      +); diff --git a/packages/gazzodown/src/blocks/OrderedListBlock.tsx b/packages/gazzodown/src/blocks/OrderedListBlock.tsx index 8981f5323fa57..de5a7360566af 100644 --- a/packages/gazzodown/src/blocks/OrderedListBlock.tsx +++ b/packages/gazzodown/src/blocks/OrderedListBlock.tsx @@ -1,26 +1 @@ -import type * as MessageParser from '@rocket.chat/message-parser'; -import type { ReactElement } from 'react'; - -import InlineElements from '../elements/InlineElements'; - -type OrderedListBlockProps = { - items: MessageParser.ListItem[]; - nested?: boolean; -}; - -const nestedListStyle = { - paddingInlineStart: '1.5rem', -} as const; - -const OrderedListBlock = ({ items, nested = false }: OrderedListBlockProps): ReactElement => ( -
        - {items.map(({ value, number, children }, index) => ( -
      1. - {value} - {children?.length && } -
      2. - ))} -
      -); - -export default OrderedListBlock; +export { OrderedListBlock as default } from './ListBlocks'; diff --git a/packages/gazzodown/src/blocks/UnorderedListBlock.tsx b/packages/gazzodown/src/blocks/UnorderedListBlock.tsx index 9979d86fc7014..9a4aa6c15431f 100644 --- a/packages/gazzodown/src/blocks/UnorderedListBlock.tsx +++ b/packages/gazzodown/src/blocks/UnorderedListBlock.tsx @@ -1,26 +1 @@ -import type * as MessageParser from '@rocket.chat/message-parser'; -import type { ReactElement } from 'react'; - -import InlineElements from '../elements/InlineElements'; - -type UnorderedListBlockProps = { - items: MessageParser.ListItem[]; - nested?: boolean; -}; - -const nestedListStyle = { - paddingInlineStart: '1.5rem', -} as const; - -const UnorderedListBlock = ({ items, nested = false }: UnorderedListBlockProps): ReactElement => ( -
        - {items.map((item, index) => ( -
      • - {item.value} - {item.children?.length && } -
      • - ))} -
      -); - -export default UnorderedListBlock; +export { UnorderedListBlock as default } from './ListBlocks'; diff --git a/packages/gazzodown/src/blocks/splitListItemRuns.spec.ts b/packages/gazzodown/src/blocks/splitListItemRuns.spec.ts new file mode 100644 index 0000000000000..cabd04bc44582 --- /dev/null +++ b/packages/gazzodown/src/blocks/splitListItemRuns.spec.ts @@ -0,0 +1,27 @@ +import { isOrderedListRun, splitListItemRuns } from './splitListItemRuns'; + +const item = (number?: number) => + ({ + type: 'LIST_ITEM' as const, + value: [], + ...(number !== undefined ? { number } : {}), + }) as const; + +describe('splitListItemRuns', () => { + it('keeps a homogeneous ordered run together', () => { + const runs = splitListItemRuns([item(1), item(2)]); + expect(runs).toHaveLength(1); + expect(isOrderedListRun(runs[0])).toBe(true); + }); + + it('keeps a homogeneous unordered run together', () => { + const runs = splitListItemRuns([item(), item()]); + expect(runs).toHaveLength(1); + expect(isOrderedListRun(runs[0])).toBe(false); + }); + + it('splits mixed ordered/unordered siblings into separate runs', () => { + const runs = splitListItemRuns([item(1), item(), item(2), item(3)]); + expect(runs).toEqual([[item(1)], [item()], [item(2), item(3)]]); + }); +}); diff --git a/packages/gazzodown/src/blocks/splitListItemRuns.ts b/packages/gazzodown/src/blocks/splitListItemRuns.ts new file mode 100644 index 0000000000000..5c8ddd46945f4 --- /dev/null +++ b/packages/gazzodown/src/blocks/splitListItemRuns.ts @@ -0,0 +1,21 @@ +import type * as MessageParser from '@rocket.chat/message-parser'; + +const isOrderedItem = (item: MessageParser.ListItem): boolean => item.number !== undefined; + +export const splitListItemRuns = (items: MessageParser.ListItem[]): MessageParser.ListItem[][] => { + const runs: MessageParser.ListItem[][] = []; + + for (const item of items) { + const lastRun = runs[runs.length - 1]; + if (!lastRun || isOrderedItem(lastRun[0]) !== isOrderedItem(item)) { + runs.push([item]); + continue; + } + + lastRun.push(item); + } + + return runs; +}; + +export const isOrderedListRun = (items: MessageParser.ListItem[]): boolean => isOrderedItem(items[0]); diff --git a/packages/message-parser/src/grammar.pegjs b/packages/message-parser/src/grammar.pegjs index 774edefc58da7..3377128c9060a 100644 --- a/packages/message-parser/src/grammar.pegjs +++ b/packages/message-parser/src/grammar.pegjs @@ -45,33 +45,33 @@ let skipReferences = false; function buildNestedList(items) { if (!items || !items.length) return items; - + const root = []; const stack = [{ children: root, indent: -1 }]; - + for (const item of items) { const indent = item.indent || 0; - + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { stack.pop(); } - + const cleanItem = { type: item.type, value: item.value, ...(item.number !== undefined && { number: item.number }), }; - + const parent = stack[stack.length - 1]; parent.children.push(cleanItem); - + cleanItem.children = []; - + stack.push({ children: cleanItem.children, indent }); } - - function cleanEmptyChildren(items) { - for (const item of items) { + + function cleanEmptyChildren(listItems) { + for (const item of listItems) { if (item.children && !item.children.length) { delete item.children; } else if (item.children) { @@ -79,11 +79,40 @@ function buildNestedList(items) { } } } - + cleanEmptyChildren(root); - + return root; } + +let listSegmentMarker = null; +let listSegmentBaseIndent = null; + +function resetListSegment() { + listSegmentMarker = null; + listSegmentBaseIndent = null; +} + +function acceptsListItem(item) { + const indent = item.indent || 0; + const marker = item.marker; + + if (listSegmentMarker === null) { + listSegmentMarker = marker; + listSegmentBaseIndent = indent; + return true; + } + + if (indent > listSegmentBaseIndent) { + return true; + } + + if (indent === listSegmentBaseIndent && marker === listSegmentMarker) { + return true; + } + + return false; +} }} Start @@ -101,8 +130,7 @@ Blocks / Code / Heading / Tasks - / OrderedList - / UnorderedList + / List / Katex / LineBreak @@ -209,42 +237,47 @@ TaskFlag = "x" { return true; } / " " { return false; } /** * - * Ordered List + * Lists (ordered / unordered), including nested and mixed nested markers. * e.g: * 1. Item One + * - Nested unordered * 2. Item Two - * 3. Item Three + * + * - Item One + * 1. Nested ordered + * - Item Two + * + * Same-level marker changes (`-` then `*`, or `-` then `1.`) start a new list. * */ -OrderedList = items:OrderedListItem+ { return orderedList(buildNestedList(items)); } +List = &{ resetListSegment(); return true; } items:ListItemInSegment+ { + resetListSegment(); + const nested = buildNestedList(items); + return items[0].number !== undefined ? orderedList(nested) : unorderedList(nested); +} + +ListItemInSegment = item:AnyListItem &{ return acceptsListItem(item); } { return item; } -OrderedListItem = indent:$[ \t]* number:Digits "." [ \t]+ text:Inline { +AnyListItem = OrderedListItem / UnorderedListHyphenItem / UnorderedListAsteriskItem + +OrderedListItem = indent:$[ \t]* number:Digits "." [ \t]+ text:Inline { const item = listItem(text, parseInt(number, 10)); item.indent = indent.length; + item.marker = 'ordered'; return item; } -/** - * - * Unordered List - * e.g: - * - Item One - * - Item Two - * * Item Three - * * Item Four - * - */ -UnorderedList = items:(UnorderedListHyphenItem+ / UnorderedListAsteriskItem+) { return unorderedList(buildNestedList(items)); } - -UnorderedListHyphenItem = indent:$[ \t]* "-" [ \t]+ text:Inline { +UnorderedListHyphenItem = indent:$[ \t]* "-" [ \t]+ text:Inline { const item = listItem(text); item.indent = indent.length; + item.marker = '-'; return item; } -UnorderedListAsteriskItem = indent:$[ \t]* "*" [ \t]+ text:UnorderedListItemContent { +UnorderedListAsteriskItem = indent:$[ \t]* "*" [ \t]+ text:UnorderedListItemContent { const item = listItem(text); item.indent = indent.length; + item.marker = '*'; return item; } diff --git a/packages/message-parser/tests/orderedList.test.ts b/packages/message-parser/tests/orderedList.test.ts index 9f1ed0fbde291..7d550fca9efdd 100644 --- a/packages/message-parser/tests/orderedList.test.ts +++ b/packages/message-parser/tests/orderedList.test.ts @@ -1,5 +1,5 @@ import { parse } from '../src'; -import { bold, plain, orderedList, listItem, emoji } from './helpers'; +import { bold, plain, orderedList, listItem, emoji, unorderedList } from './helpers'; test.each([ [ @@ -24,6 +24,46 @@ test.each([ ]), ], ], + [ + ` +1. First item +2. Second item + 1. Nested item + 2. Nested item +3. Third item +`.trim(), + [ + orderedList([ + listItem([plain('First item')], 1), + listItem([plain('Second item')], 2, [ + listItem([plain('Nested item')], 1), + listItem([plain('Nested item')], 2), + ]), + listItem([plain('Third item')], 3), + ]), + ], + ], + [ + ` +1. Parent + - Nested unordered + - Another +2. Sibling +`.trim(), + [ + orderedList([ + listItem([plain('Parent')], 1, [listItem([plain('Nested unordered')]), listItem([plain('Another')])]), + listItem([plain('Sibling')], 2), + ]), + ], + ], + [ + ` +1. Ordered +- Unordered sibling +`.trim(), + [orderedList([listItem([plain('Ordered')], 1)]), unorderedList([listItem([plain('Unordered sibling')])])], + ], ])('parses %p', (input, output) => { expect(parse(input)).toEqual(output); }); diff --git a/packages/message-parser/tests/unorderedList.test.ts b/packages/message-parser/tests/unorderedList.test.ts index c7fbd1182767a..3dfb7a905aed9 100644 --- a/packages/message-parser/tests/unorderedList.test.ts +++ b/packages/message-parser/tests/unorderedList.test.ts @@ -1,5 +1,5 @@ import { parse } from '../src'; -import { unorderedList, plain, listItem, bold, emoji } from './helpers'; +import { unorderedList, plain, listItem, bold, emoji, orderedList } from './helpers'; test.each([ [ @@ -36,7 +36,6 @@ test.each([ ]), ], ], - [ ` - First item @@ -49,28 +48,102 @@ test.each([ unorderedList([listItem([plain('Second item')]), listItem([plain('Third item')]), listItem([bold([plain('Fourth item')])])]), ], ], - // [ - // ` - // * First item - // * Second item - // * Third item - // * Indented item - // * Indented item - // * Fourth item - // `.trim(), - // [paragraph([])], - // ], - // [ - // ` - // - First item - // - Second item - // - Third item - // - Indented item - // - Indented item - // - Fourth item - // `.trim(), - // [paragraph([])], - // ], + [ + ` +* First item +* Second item +* Third item + * Indented item + * Indented item +* Fourth item +`.trim(), + [ + unorderedList([ + listItem([plain('First item')]), + listItem([plain('Second item')]), + listItem([plain('Third item')], undefined, [ + listItem([plain('Indented item')]), + listItem([plain('Indented item')]), + ]), + listItem([plain('Fourth item')]), + ]), + ], + ], + [ + ` +- First item +- Second item +- Third item + - Indented item + - Indented item +- Fourth item +`.trim(), + [ + unorderedList([ + listItem([plain('First item')]), + listItem([plain('Second item')]), + listItem([plain('Third item')], undefined, [ + listItem([plain('Indented item')]), + listItem([plain('Indented item')]), + ]), + listItem([plain('Fourth item')]), + ]), + ], + ], + [ + ` +- Level 1 + - Level 2 + - Level 3 +`.trim(), + [ + unorderedList([ + listItem([plain('Level 1')], undefined, [ + listItem([plain('Level 2')], undefined, [listItem([plain('Level 3')])]), + ]), + ]), + ], + ], + [ + ` +- Parent + 1. Nested ordered + 2. Another nested +- Sibling +`.trim(), + [ + unorderedList([ + listItem([plain('Parent')], undefined, [ + listItem([plain('Nested ordered')], 1), + listItem([plain('Another nested')], 2), + ]), + listItem([plain('Sibling')]), + ]), + ], + ], + [ + ` +- Unordered +1. Ordered sibling +`.trim(), + [unorderedList([listItem([plain('Unordered')])]), orderedList([listItem([plain('Ordered sibling')], 1)])], + ], + [ + ` +- Parent + 1. Ordered child + - Unordered child +`.trim(), + [ + unorderedList([ + listItem([plain('Parent')], undefined, [ + listItem([plain('Ordered child')], 1), + listItem([plain('Unordered child')]), + ]), + ]), + ], + ], ])('parses %p', (input, output) => { expect(parse(input)).toEqual(output); }); + From 52e8c33bad6d82339f4cac7945e065e3555720e7 Mon Sep 17 00:00:00 2001 From: Polina N Date: Sun, 11 Oct 2026 20:52:20 +0300 Subject: [PATCH 17/18] Added changes --- apps/meteor/app/api/server/v1/chat.ts | 26 +----- .../server/functions/loadMessageHistory.ts | 1 - .../getAccessibleMessageForReadCounter.ts | 35 ++++++++ .../server/getMessageReadCount.ts | 36 ++------- .../read-counter/server/getMessageReaders.ts | 33 ++------ .../components/message/ReadCountBadge.tsx | 56 ++++++------- .../components/message/ReadCountTooltip.tsx | 5 +- .../variants/thread/ThreadMessageContent.tsx | 3 + .../modules/listeners/listeners.module.ts | 2 +- ...getAccessibleMessageForReadCounter.spec.ts | 56 +++++++++++++ .../read-counter/getMessageReadCount.spec.ts | 57 +++++++++++++ packages/core-services/src/events/Events.ts | 1 + packages/ddp-client/src/types/streams.ts | 1 + packages/models/src/models/Subscriptions.ts | 80 ++++++++++--------- 14 files changed, 242 insertions(+), 150 deletions(-) create mode 100644 apps/meteor/app/read-counter/server/getAccessibleMessageForReadCounter.ts create mode 100644 apps/meteor/tests/unit/app/read-counter/getAccessibleMessageForReadCounter.spec.ts create mode 100644 apps/meteor/tests/unit/app/read-counter/getMessageReadCount.spec.ts diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index bc38fe4b7711f..891defd0305e3 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -141,20 +141,10 @@ API.v1.addRoute( async get() { const { messageId } = this.queryParams; - const message = await Messages.findOneById(messageId, { projection: { rid: 1 } }); - - if (!message) { - return API.v1.failure('The required "messageId" param is missing or invalid.'); - } - - if (!(await canAccessRoomIdAsync(message.rid, this.userId))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - const result = await getMessageReadCountHelper(messageId, this.userId); if (!result) { - return API.v1.failure(); + return API.v1.failure('The required "messageId" param is missing or invalid.'); } return API.v1.success(result); @@ -172,20 +162,10 @@ API.v1.addRoute( async get() { const { messageId, limit } = this.queryParams; - const message = await Messages.findOneById(messageId, { projection: { rid: 1 } }); - - if (!message) { - return API.v1.failure('The required "messageId" param is missing or invalid.'); - } - - if (!(await canAccessRoomIdAsync(message.rid, this.userId))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - - const result = await getMessageReadersHelper(messageId, this.userId, limit); + const result = await getMessageReadersHelper(messageId, this.userId, typeof limit === 'string' ? Number(limit) : limit); if (!result) { - return API.v1.failure(); + return API.v1.failure('The required "messageId" param is missing or invalid.'); } return API.v1.success(result); diff --git a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts index 132fdade875e2..5addbd896889b 100644 --- a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts +++ b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts @@ -52,7 +52,6 @@ export async function loadMessageHistory({ ).toArray() : await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray(); const messages = await normalizeMessagesForUser(records, userId); - let unreadNotLoaded = 0; let firstUnread; diff --git a/apps/meteor/app/read-counter/server/getAccessibleMessageForReadCounter.ts b/apps/meteor/app/read-counter/server/getAccessibleMessageForReadCounter.ts new file mode 100644 index 0000000000000..05be84f46e1ea --- /dev/null +++ b/apps/meteor/app/read-counter/server/getAccessibleMessageForReadCounter.ts @@ -0,0 +1,35 @@ +import { Authorization } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Messages, Rooms } from '@rocket.chat/models'; + +type AccessibleMessageForReadCounter = { + message: Pick & { u?: Pick }; + room: Pick; +}; + +export const getAccessibleMessageForReadCounter = async ( + messageId: IMessage['_id'], + userId: IUser['_id'], +): Promise => { + const message = await Messages.findOneById(messageId, { + projection: { _id: 1, rid: 1, ts: 1, 'u._id': 1 }, + }); + + if (!message) { + return null; + } + + const room = await Rooms.findOneById(message.rid, { + projection: { _id: 1, t: 1 }, + }); + + if (!room || room.t === 'd') { + return null; + } + + if (!(await Authorization.canReadRoom(room, { _id: userId }))) { + return null; + } + + return { message, room }; +}; diff --git a/apps/meteor/app/read-counter/server/getMessageReadCount.ts b/apps/meteor/app/read-counter/server/getMessageReadCount.ts index 0515d134d12a2..5adfd625237e9 100644 --- a/apps/meteor/app/read-counter/server/getMessageReadCount.ts +++ b/apps/meteor/app/read-counter/server/getMessageReadCount.ts @@ -1,45 +1,25 @@ -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; -import { Authorization } from '@rocket.chat/core-services'; -import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; + +import { getAccessibleMessageForReadCounter } from './getAccessibleMessageForReadCounter'; export const getMessageReadCount = async ( messageId: IMessage['_id'], userId: IUser['_id'], ): Promise<{ readCount: number } | null> => { - const message = await Messages.findOneById(messageId, { - projection: { _id: 1, rid: 1, ts: 1, 'u._id': 1 }, - }); - - if (!message) { - return null; - } - - const room = await Rooms.findOneById(message.rid as IRoom['_id'], { - projection: { _id: 1, t: 1 }, - }); - - if (!room) { - return null; - } + const accessible = await getAccessibleMessageForReadCounter(messageId, userId); - // Exclude DMs from read counter - if (room.t === 'd') { + if (!accessible) { return null; } - // Ensure the requesting user can access the room - if (!(await Authorization.canReadRoom(room, { _id: userId }))) { - return null; - } + const { message } = accessible; if (!message.ts) { return { readCount: 0 }; } - const excludeUserId = message.u?._id; - - const readCount = await Subscriptions.countReadersByRoomIdAndMessageTs(message.rid, message.ts, excludeUserId); + const readCount = await Subscriptions.countReadersByRoomIdAndMessageTs(message.rid, message.ts, message.u?._id); return { readCount }; }; - diff --git a/apps/meteor/app/read-counter/server/getMessageReaders.ts b/apps/meteor/app/read-counter/server/getMessageReaders.ts index 61c12513210bd..84b3817ac5dfa 100644 --- a/apps/meteor/app/read-counter/server/getMessageReaders.ts +++ b/apps/meteor/app/read-counter/server/getMessageReaders.ts @@ -1,43 +1,26 @@ -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; -import { Authorization } from '@rocket.chat/core-services'; -import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; + +import { getAccessibleMessageForReadCounter } from './getAccessibleMessageForReadCounter'; export const getMessageReaders = async ( messageId: IMessage['_id'], userId: IUser['_id'], limit = 50, ): Promise<{ readers: Array> } | null> => { - const message = await Messages.findOneById(messageId, { - projection: { _id: 1, rid: 1, ts: 1, 'u._id': 1 }, - }); + const accessible = await getAccessibleMessageForReadCounter(messageId, userId); - if (!message) { + if (!accessible) { return null; } - const room = await Rooms.findOneById(message.rid as IRoom['_id'], { - projection: { _id: 1, t: 1 }, - }); - - if (!room) { - return null; - } - - if (room.t === 'd') { - return null; - } - - if (!(await Authorization.canReadRoom(room, { _id: userId }))) { - return null; - } + const { message } = accessible; if (!message.ts) { return { readers: [] }; } - const excludeUserId = message.u?._id; - - const readers = await Subscriptions.findReaderUsersByRoomIdAndMessageTs(message.rid, message.ts, excludeUserId, limit); + const readers = await Subscriptions.findReaderUsersByRoomIdAndMessageTs(message.rid, message.ts, message.u?._id, limit); return { readers }; }; diff --git a/apps/meteor/client/components/message/ReadCountBadge.tsx b/apps/meteor/client/components/message/ReadCountBadge.tsx index bca9dc6b5b7cd..450d9de22dbba 100644 --- a/apps/meteor/client/components/message/ReadCountBadge.tsx +++ b/apps/meteor/client/components/message/ReadCountBadge.tsx @@ -6,14 +6,13 @@ import type { ReactElement } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useRoom } from '../../views/room/contexts/RoomContext'; - import { ReadCountTooltip } from './ReadCountTooltip'; +import { useRoom } from '../../views/room/contexts/RoomContext'; type ReadCountBadgeProps = { messageId: IMessage['_id']; roomId: IRoom['_id']; - senderId: string; + senderId: IMessage['u']['_id']; }; export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgeProps): ReactElement | null => { @@ -43,8 +42,7 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr const getMessageReadCount = useEndpoint('GET', '/v1/chat.getMessageReadCount'); - const queryKey = ['read-count', roomId, messageId]; - + const queryKey = ['read-count', roomId, messageId] as const; const shouldFetch = Boolean(uid && uid === senderId && room.t !== 'd'); const { data } = useQuery({ @@ -55,19 +53,21 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr }); useEffect(() => { - const handler = (event: Event) => { + const handler = (event: Event): void => { const { detail } = event as CustomEvent<{ rid?: string }>; if (!detail?.rid || detail.rid !== roomId) { return; } - queryClient.invalidateQueries({ queryKey }); - queryClient.invalidateQueries({ queryKey: ['message-readers', roomId, messageId] }); + void queryClient.invalidateQueries({ queryKey }); + void queryClient.invalidateQueries({ queryKey: ['message-readers', roomId, messageId] }); }; - window.addEventListener('read-counts-changed', handler as EventListener); - return () => window.removeEventListener('read-counts-changed', handler as EventListener); - }, [queryClient, queryKey, roomId, messageId]); + window.addEventListener('read-counts-changed', handler); + return () => { + window.removeEventListener('read-counts-changed', handler); + }; + }, [messageId, queryClient, queryKey, roomId]); useEffect( () => () => { @@ -78,15 +78,7 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr const readCount = data?.readCount ?? 0; - if (!uid || uid !== senderId) { - return null; - } - - if (room.t === 'd') { - return null; - } - - if (readCount <= 0) { + if (!shouldFetch || readCount <= 0) { return null; } @@ -108,16 +100,19 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr cancelScheduledTooltipClose(); - ref.current && - openTooltip( - , - ref.current, - ); + if (!ref.current) { + return; + } + + openTooltip( + , + ref.current, + ); }} > @@ -125,4 +120,3 @@ export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgePr
      ); }; - diff --git a/apps/meteor/client/components/message/ReadCountTooltip.tsx b/apps/meteor/client/components/message/ReadCountTooltip.tsx index e7c95fd90da53..c6122ace349b7 100644 --- a/apps/meteor/client/components/message/ReadCountTooltip.tsx +++ b/apps/meteor/client/components/message/ReadCountTooltip.tsx @@ -25,8 +25,7 @@ export const ReadCountTooltip = ({ const { showRealName } = useContext(MessageListContext); const getMessageReaders = useEndpoint('GET', '/v1/chat.getMessageReaders'); - - const queryKey = ['message-readers', roomId, messageId]; + const queryKey = ['message-readers', roomId, messageId] as const; const { data, isLoading } = useQuery({ queryKey, @@ -36,7 +35,7 @@ export const ReadCountTooltip = ({ const readers = data?.readers ?? []; - const labelFor = (reader: { name?: string; username?: string }) => { + const labelFor = (reader: { name?: string; username?: string }): string => { if (showRealName && reader.name) { return reader.name; } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index d137162d0b06d..de5710cd5dd52 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -8,6 +8,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import MessageContentBody from '../../MessageContentBody'; +import { ReadCountBadge } from '../../ReadCountBadge'; import ReadReceiptIndicator from '../../ReadReceiptIndicator'; import Attachments from '../../content/Attachments'; import BroadcastMetrics from '../../content/BroadcastMetrics'; @@ -89,6 +90,8 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem )} + + {readReceiptEnabled && } ); diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index 207b03acc02ac..2d24d416f84d9 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -214,7 +214,7 @@ export class ListenersModule { notifications.notifyRoomInThisInstance(rid, 'messagesRead', { tmid, until }); }); - service.onEvent('notify.readCountsChanged', ({ rid }: { rid: IRoom['_id'] }): void => { + service.onEvent('notify.readCountsChanged', ({ rid }): void => { notifications.notifyRoomInThisInstance(rid, 'readCountsChanged', {}); }); diff --git a/apps/meteor/tests/unit/app/read-counter/getAccessibleMessageForReadCounter.spec.ts b/apps/meteor/tests/unit/app/read-counter/getAccessibleMessageForReadCounter.spec.ts new file mode 100644 index 0000000000000..ef14ba56023eb --- /dev/null +++ b/apps/meteor/tests/unit/app/read-counter/getAccessibleMessageForReadCounter.spec.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const findOneByIdMessage = sandbox.stub(); +const findOneByIdRoom = sandbox.stub(); +const canReadRoom = sandbox.stub(); + +const { getAccessibleMessageForReadCounter } = p.noCallThru().load('../../../../app/read-counter/server/getAccessibleMessageForReadCounter', { + '@rocket.chat/core-services': { Authorization: { canReadRoom } }, + '@rocket.chat/models': { + Messages: { findOneById: findOneByIdMessage }, + Rooms: { findOneById: findOneByIdRoom }, + }, +}); + +describe('getAccessibleMessageForReadCounter', () => { + beforeEach(() => { + sandbox.reset(); + }); + + it('returns null for missing messages', async () => { + findOneByIdMessage.resolves(null); + + expect(await getAccessibleMessageForReadCounter('m1', 'viewer')).to.equal(null); + }); + + it('returns null for direct messages', async () => { + findOneByIdMessage.resolves({ _id: 'm1', rid: 'r1', ts: new Date(), u: { _id: 'author' } }); + findOneByIdRoom.resolves({ _id: 'r1', t: 'd' }); + + expect(await getAccessibleMessageForReadCounter('m1', 'viewer')).to.equal(null); + expect(canReadRoom.called).to.equal(false); + }); + + it('returns null when the user cannot read the room', async () => { + findOneByIdMessage.resolves({ _id: 'm1', rid: 'r1', ts: new Date(), u: { _id: 'author' } }); + findOneByIdRoom.resolves({ _id: 'r1', t: 'c' }); + canReadRoom.resolves(false); + + expect(await getAccessibleMessageForReadCounter('m1', 'viewer')).to.equal(null); + }); + + it('returns the message and room when access is allowed', async () => { + const message = { _id: 'm1', rid: 'r1', ts: new Date(), u: { _id: 'author' } }; + const room = { _id: 'r1', t: 'c' }; + findOneByIdMessage.resolves(message); + findOneByIdRoom.resolves(room); + canReadRoom.resolves(true); + + expect(await getAccessibleMessageForReadCounter('m1', 'viewer')).to.deep.equal({ message, room }); + }); +}); diff --git a/apps/meteor/tests/unit/app/read-counter/getMessageReadCount.spec.ts b/apps/meteor/tests/unit/app/read-counter/getMessageReadCount.spec.ts new file mode 100644 index 0000000000000..90d1d8c04b196 --- /dev/null +++ b/apps/meteor/tests/unit/app/read-counter/getMessageReadCount.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const getAccessibleMessageForReadCounter = sandbox.stub(); +const countReadersByRoomIdAndMessageTs = sandbox.stub(); + +const { getMessageReadCount } = p.noCallThru().load('../../../../app/read-counter/server/getMessageReadCount', { + './getAccessibleMessageForReadCounter': { getAccessibleMessageForReadCounter }, + '@rocket.chat/models': { + Subscriptions: { countReadersByRoomIdAndMessageTs }, + }, +}); + +describe('getMessageReadCount', () => { + beforeEach(() => { + sandbox.reset(); + }); + + it('returns null when the message is not accessible', async () => { + getAccessibleMessageForReadCounter.resolves(null); + + const result = await getMessageReadCount('m1', 'viewer'); + + expect(result).to.equal(null); + expect(countReadersByRoomIdAndMessageTs.called).to.equal(false); + }); + + it('returns read count excluding the message author', async () => { + const ts = new Date('2026-01-01T00:00:00.000Z'); + getAccessibleMessageForReadCounter.resolves({ + message: { _id: 'm1', rid: 'r1', ts, u: { _id: 'author' } }, + room: { _id: 'r1', t: 'c' }, + }); + countReadersByRoomIdAndMessageTs.resolves(3); + + const result = await getMessageReadCount('m1', 'viewer'); + + expect(result).to.deep.equal({ readCount: 3 }); + expect(countReadersByRoomIdAndMessageTs.calledWith('r1', ts, 'author')).to.equal(true); + }); + + it('returns zero when the message has no timestamp', async () => { + getAccessibleMessageForReadCounter.resolves({ + message: { _id: 'm1', rid: 'r1', u: { _id: 'author' } }, + room: { _id: 'r1', t: 'c' }, + }); + + const result = await getMessageReadCount('m1', 'viewer'); + + expect(result).to.deep.equal({ readCount: 0 }); + expect(countReadersByRoomIdAndMessageTs.called).to.equal(false); + }); +}); diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index e0e41503a3dea..80ff59a8b2366 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -116,6 +116,7 @@ export type EventSignatures = { 'notify.updateCustomSound'(data: { soundData: ICustomSound }): void; 'notify.calendar'(uid: string, data: ICalendarNotification): void; 'notify.messagesRead'(data: { rid: string; until: Date; tmid?: string }): void; + 'notify.readCountsChanged'(data: { rid: IRoom['_id'] }): void; 'notify.importedMessages'(data: { roomIds: string[] }): void; 'permission.changed'(data: { clientAction: ClientAction; data: any }): void; 'room'(data: { action: string; room: Partial }): void; diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index 11098a865c9e9..b43c1cec49b94 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -73,6 +73,7 @@ export interface StreamerEvents { { key: `${string}/e2e.keyRequest`; args: [unknown] }, { key: `${string}/videoconf`; args: [id: string] }, { key: `${string}/messagesRead`; args: [{ until: Date; tmid?: string }] }, + { key: `${string}/readCountsChanged`; args: [Record] }, { key: `${string}/messagesImported`; args: [null] }, /* @deprecated over videoconf*/ // { key: `${string}/${string}`; args: [id: string] }, diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 22725d4817c19..750cd8b2a1f2e 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -86,8 +86,8 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return result?.total || 0; } - countReadersByRoomIdAndMessageTs(rid: string, messageTs: Date, excludeUserId?: string): Promise { - const query: Filter = { + private readersByRoomIdAndMessageTsMatch(rid: string, messageTs: Date, excludeUserId?: string): Filter { + return { rid, archived: { $ne: true }, ls: { $gte: messageTs }, @@ -96,29 +96,12 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri $exists: true, }, }; - - return this.countDocuments(query); } - async findReaderUsersByRoomIdAndMessageTs( - rid: string, - messageTs: Date, - excludeUserId: string | undefined, - limit: number, - ): Promise>> { - const safeLimit = Math.min(Math.max(Math.trunc(limit) || 1, 1), 100); - - const pipeline = [ + private readersActiveUsersPipeline(rid: string, messageTs: Date, excludeUserId?: string) { + return [ { - $match: { - rid, - archived: { $ne: true }, - ls: { $gte: messageTs }, - 'u._id': { - ...(excludeUserId ? { $ne: excludeUserId } : {}), - $exists: true, - }, - }, + $match: this.readersByRoomIdAndMessageTsMatch(rid, messageTs, excludeUserId), }, { $lookup: { @@ -134,24 +117,45 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri 'user.active': true, }, }, - { - $sort: { - 'user.username': 1, - }, - }, - { - $limit: safeLimit, - }, - { - $project: { - _id: '$user._id', - name: '$user.name', - username: '$user.username', - }, - }, ]; + } + + async countReadersByRoomIdAndMessageTs(rid: string, messageTs: Date, excludeUserId?: string): Promise { + const [result] = await this.col + .aggregate<{ count: number }>([...this.readersActiveUsersPipeline(rid, messageTs, excludeUserId), { $count: 'count' }]) + .toArray(); - return this.col.aggregate>(pipeline).toArray(); + return result?.count ?? 0; + } + + async findReaderUsersByRoomIdAndMessageTs( + rid: string, + messageTs: Date, + excludeUserId: string | undefined, + limit: number, + ): Promise>> { + const safeLimit = Math.min(Math.max(Math.trunc(limit) || 1, 1), 100); + + return this.col + .aggregate>([ + ...this.readersActiveUsersPipeline(rid, messageTs, excludeUserId), + { + $sort: { + 'user.username': 1, + }, + }, + { + $limit: safeLimit, + }, + { + $project: { + _id: '$user._id', + name: '$user.name', + username: '$user.username', + }, + }, + ]) + .toArray(); } findOneByRoomIdAndUserId(rid: string, uid: string, options: FindOptions = {}): Promise { From a6624793ac2464f8657649d4f4bfa94a144cd4d8 Mon Sep 17 00:00:00 2001 From: Polina N Date: Sun, 11 Oct 2026 21:16:36 +0300 Subject: [PATCH 18/18] Added changes --- .patch-ci.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.patch-ci.json b/.patch-ci.json index 6e537f648f484..6402c54e4b549 100644 --- a/.patch-ci.json +++ b/.patch-ci.json @@ -1,5 +1,7 @@ { "tests": [ - "yarn workspace @rocket.chat/meteor exec ./node_modules/.bin/mocha --config ./.mocharc.base.json tests/unit/lib/restrictedEmojis.spec.ts tests/unit/server/lib/messaging/validateRestrictedEmojis.spec.ts tests/unit/app/emoji/restrictedEmojiPicker.spec.ts" + "yarn workspace @rocket.chat/meteor exec ./node_modules/.bin/mocha --config ./.mocharc.base.json tests/unit/lib/restrictedEmojis.spec.ts tests/unit/server/lib/messaging/validateRestrictedEmojis.spec.ts tests/unit/app/emoji/restrictedEmojiPicker.spec.ts tests/unit/app/read-counter/getAccessibleMessageForReadCounter.spec.ts tests/unit/app/read-counter/getMessageReadCount.spec.ts", + "yarn workspace @rocket.chat/message-parser exec jest tests/orderedList.test.ts tests/unorderedList.test.ts", + "yarn workspace @rocket.chat/gazzodown exec jest src/blocks/splitListItemRuns.spec.ts" ] }