Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5c933db
feat: Add draft message badge in sidebar
Polinarr60 Apr 18, 2026
2f05320
feat: Add draft message badge in sidebar
Polinarr60 Apr 18, 2026
2866775
Added support for nested lists in markdown messages
Polinarr60 Apr 19, 2026
412fea9
feat/Adds emoji restriction feature for non-admin users
Polinarr60 Apr 21, 2026
19e9d5e
Added support for nested lists in markdown messages
Polinarr60 Apr 22, 2026
e5fa4d4
Merge branch 'develop' into fix/markdown-nested-lists
Polinarr60 Apr 22, 2026
112980b
Adds emoji restriction feature for non-admin users
Polinarr60 Apr 22, 2026
93ff532
Merge branch 'develop' into feat/emoji-restrict
Polinarr60 Apr 22, 2026
15dd8ee
Merge branch 'develop' into feat/draft-message-badge
Polinarr60 Apr 22, 2026
4996569
Merge branch 'develop' into feat/draft-message-badge
Polinarr60 Apr 22, 2026
c255954
Adds emoji restriction feature for non-admin users
Polinarr60 Apr 22, 2026
0291f22
Merge branch 'develop' into feat/emoji-restrict
Polinarr60 Apr 22, 2026
f2d0e07
Adds emoji restriction feature for non-admin users
Polinarr60 Apr 22, 2026
e62bb94
Adds emoji restriction feature for non-admin users
Polinarr60 Apr 22, 2026
0b3be1f
Adds emoji restriction feature for non-admin users
Polinarr60 Apr 22, 2026
6da9c8a
Merge branch 'develop' into feat/emoji-restrict
Polinarr60 Apr 22, 2026
8c3128d
feat: add message read count & read list
qmaster0803 May 24, 2026
0db6c61
fix: remove debug prints
qmaster0803 May 24, 2026
95c6cff
Merge branch 'develop' into feat/read-count
Polinarr60 May 25, 2026
2ba5a89
fix: apply suggested changes
qmaster0803 May 27, 2026
d539e19
Upgrade feature
Oct 11, 2026
d42f99f
Merge branch 'develop' into feat/emoji-restrict
Oct 11, 2026
ca51f6c
Merge branch 'develop' into feat/emoji-restrict
Oct 11, 2026
995c069
Merge branch 'develop' into feat/draft-message-badge
Oct 11, 2026
375d79e
Added chagnes
Oct 11, 2026
1b8f0f0
Added changes
Oct 11, 2026
81af673
Merge branch 'develop' into fix/markdown-nested-lists
Oct 11, 2026
52e8c33
Added changes
Oct 11, 2026
e0b645a
Merge branch 'develop' into feat/read-count
Oct 11, 2026
1aa37ea
Merge branch 'fix/markdown-nested-lists' into release
Oct 11, 2026
6057b81
Merge branch 'feat/draft-message-badge' into release
Oct 11, 2026
6e38ff2
Merge branch 'feat/emoji-restrict' into release
Oct 11, 2026
a662479
Added changes
Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/real-ties-unite.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .changeset/soft-berries-deliver.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .changeset/three-roses-sell.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .changeset/wicked-seals-rescue.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .patch-ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +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 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"
]
}
Original file line number Diff line number Diff line change
@@ -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<IMessage, '_id' | 'rid' | 'ts'> & { u?: Pick<IUser, '_id'> };
room: Pick<IRoom, '_id' | 't'>;
};

export const getAccessibleMessageForReadCounter = async (
messageId: IMessage['_id'],
userId: IUser['_id'],
): Promise<AccessibleMessageForReadCounter | 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, {
projection: { _id: 1, t: 1 },
});

if (!room || room.t === 'd') {
return null;
}

if (!(await Authorization.canReadRoom(room, { _id: userId }))) {
return null;
}

return { message, room };
};
25 changes: 25 additions & 0 deletions apps/meteor/app/read-counter/server/getMessageReadCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 accessible = await getAccessibleMessageForReadCounter(messageId, userId);

if (!accessible) {
return null;
}

const { message } = accessible;

if (!message.ts) {
return { readCount: 0 };
}

const readCount = await Subscriptions.countReadersByRoomIdAndMessageTs(message.rid, message.ts, message.u?._id);

return { readCount };
};
26 changes: 26 additions & 0 deletions apps/meteor/app/read-counter/server/getMessageReaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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<Pick<IUser, '_id' | 'name' | 'username'>> } | null> => {
const accessible = await getAccessibleMessageForReadCounter(messageId, userId);

if (!accessible) {
return null;
}

const { message } = accessible;

if (!message.ts) {
return { readers: [] };
}

const readers = await Subscriptions.findReaderUsersByRoomIdAndMessageTs(message.rid, message.ts, message.u?._id, limit);

return { readers };
};
122 changes: 122 additions & 0 deletions apps/meteor/client/components/message/ReadCountBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 { ReadCountTooltip } from './ReadCountTooltip';
import { useRoom } from '../../views/room/contexts/RoomContext';

type ReadCountBadgeProps = {
messageId: IMessage['_id'];
roomId: IRoom['_id'];
senderId: IMessage['u']['_id'];
};

export const ReadCountBadge = ({ messageId, roomId, senderId }: ReadCountBadgeProps): ReactElement | null => {
const uid = useUserId();
const room = useRoom();
const { t } = useTranslation();
const queryClient = useQueryClient();
const ref = useRef<HTMLDivElement>(null);
const hideTooltipTimerRef = useRef<ReturnType<typeof setTimeout>>();
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] as const;
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): void => {
const { detail } = event as CustomEvent<{ rid?: string }>;
if (!detail?.rid || detail.rid !== roomId) {
return;
}

void queryClient.invalidateQueries({ queryKey });
void queryClient.invalidateQueries({ queryKey: ['message-readers', roomId, messageId] });
};

window.addEventListener('read-counts-changed', handler);
return () => {
window.removeEventListener('read-counts-changed', handler);
};
}, [messageId, queryClient, queryKey, roomId]);

useEffect(
() => () => {
cancelScheduledTooltipClose();
},
[cancelScheduledTooltipClose],
);

const readCount = data?.readCount ?? 0;

if (!shouldFetch || readCount <= 0) {
return null;
}

return (
<Box
ref={ref}
display='inline-flex'
alignItems='center'
alignSelf='flex-start'
width='max-content'
maxWidth='100%'
flexShrink={0}
fontSize='x12'
color='hint'
aria-label={`${t('Read_by')} ${readCount}`}
onMouseEnter={(e): void => {
e.stopPropagation();
e.preventDefault();

cancelScheduledTooltipClose();

if (!ref.current) {
return;
}

openTooltip(
<ReadCountTooltip
messageId={messageId}
roomId={roomId}
onTooltipMouseEnter={cancelScheduledTooltipClose}
onTooltipMouseLeave={scheduleTooltipClose}
/>,
ref.current,
);
}}
>
<Icon name='eye' size='x12' mie={4} />
{readCount}
</Box>
);
};
96 changes: 96 additions & 0 deletions apps/meteor/client/components/message/ReadCountTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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] as const;

const { data, isLoading } = useQuery({
queryKey,
queryFn: () => getMessageReaders({ messageId, limit: 50 }),
staleTime: 15_000,
});

const readers = data?.readers ?? [];

const labelFor = (reader: { name?: string; username?: string }): string => {
if (showRealName && reader.name) {
return reader.name;
}

if (reader.username) {
return normalizeUsername(reader.username);
}

return reader.name ?? t('Mentions_removed_user_label');
};

return (
<Box
maxWidth='x300'
display='flex'
flexDirection='column'
gap={4}
onMouseEnter={onTooltipMouseEnter}
onMouseLeave={onTooltipMouseLeave}
onWheel={(e): void => {
e.stopPropagation();
}}
style={{ pointerEvents: 'auto', userSelect: 'text' }}
>
<Box fontScale='p2m' color='default'>
{t('Read_by')}
</Box>
{isLoading && (
<>
<Skeleton width='x240' variant='text' />
<Skeleton width='x200' variant='text' />
<Skeleton width='x220' variant='text' />
</>
)}
{!isLoading && readers.length > 0 && (
<Box
display='flex'
flexDirection='column'
gap={2}
maxHeight='x120'
minHeight={0}
overflowY='auto'
pis={4}
onWheel={(e): void => {
e.stopPropagation();
}}
style={{ overscrollBehavior: 'contain' }}
>
{readers.map((reader) => (
<Box key={reader._id} fontScale='p2' title={reader.username ? `@${reader.username}` : undefined}>
{labelFor(reader)}
</Box>
))}
</Box>
)}
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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';
Expand Down Expand Up @@ -135,6 +136,8 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
<BroadcastMetrics username={messageUser.username} message={normalizedMessage} />
)}

<ReadCountBadge messageId={normalizedMessage._id} roomId={normalizedMessage.rid} senderId={normalizedMessage.u._id} />

{readReceiptEnabled && <ReadReceiptIndicator mid={normalizedMessage._id} unread={normalizedMessage.unread} />}
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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';
Expand Down Expand Up @@ -104,6 +105,8 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => {
<BroadcastMetrics username={messageUser.username} message={normalizedMessage} />
)}

<ReadCountBadge messageId={normalizedMessage._id} roomId={normalizedMessage.rid} senderId={normalizedMessage.u._id} />

{readReceiptEnabled && <ReadReceiptIndicator mid={normalizedMessage._id} unread={normalizedMessage.unread} />}
</>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/meteor/client/contexts/EmojiPickerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import { createContext, useContext, useMemo } from 'react';

import type { EmojiPickerItem, CategoriesIndexes } from '../lib/emoji';

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;
Expand Down
Loading