Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/meteor/app/authorization/server/constant/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const permissions = [
{ _id: 'set-owner', roles: ['admin', 'owner'] },
{ _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] },
{ _id: 'set-leader', roles: ['admin', 'owner'] },
{ _id: 'set-important-message-marker', roles: ['admin', 'owner'] },
{ _id: 'start-discussion', roles: ['admin', 'user', 'federated-external', 'guest', 'app'] },
{ _id: 'start-discussion-other-user', roles: ['admin', 'user', 'federated-external', 'owner', 'app'] },
{ _id: 'unarchive-room', roles: ['admin'] },
Expand Down Expand Up @@ -239,4 +240,5 @@ export const permissions = [
{ _id: 'manage-moderation-actions', roles: ['admin'] },
{ _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] },
{ _id: 'export-messages-as-pdf', roles: ['admin', 'user'] },
{ _id: 'mark-message-as-important', roles: ['admin', 'owner', 'important-message-marker'] },
];
18 changes: 17 additions & 1 deletion apps/meteor/app/lib/server/methods/sendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ Meteor.methods<ServerMethods>({
federation: Match.Maybe(Object),
groupable: Match.Maybe(Boolean),
sentByEmail: Match.Maybe(Boolean),
isImportant: Match.Maybe(Boolean),
});

const user = (await Meteor.userAsync()) as IUser;
Expand All @@ -158,13 +159,28 @@ Meteor.methods<ServerMethods>({
});
}

if (message.isImportant) {
console.log('[sendMessage] Received important message:', {
messageId: message._id,
userId: user._id,
roomId: message.rid
});
}

if (MessageTypes.isSystemMessage(message)) {
throw new Error("Cannot send system messages using 'sendMessage'");
}

try {
return await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls }));
const result = await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls }));
if (message.isImportant) {
console.log('[sendMessage] Important message saved successfully:', { messageId: result._id });
}
return result;
} catch (error: any) {
if (message.isImportant) {
console.error('[sendMessage] Error saving important message:', error);
}
if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) {
throw new Meteor.Error(error.error || error.message, error.reason, {
method: 'sendMessage',
Expand Down
7 changes: 7 additions & 0 deletions apps/meteor/app/theme/client/imports/general/base_old.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@
&.highlight {
animation: highlight 6s;
}

&.rcx-message--important {
background-color: rgba(245, 69, 92, 0.03) !important;
border-left: 3px solid rgba(245, 69, 92, 0.4);
padding-left: 8px;
margin: 2px 0;
}
}

.page-loading {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Button, Box } from '@rocket.chat/fuselage';
import { useUserId, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { memo, useState, useEffect } from 'react';

import ImportantMessageReadInfo from './ImportantMessageReadInfo';

type ImportantMessageReadButtonProps = {
message: IMessage;
};

const ImportantMessageReadButton = ({ message }: ImportantMessageReadButtonProps): ReactElement | null => {
const userId = useUserId();
const toggleImportantMessageRead = useMethod('toggleImportantMessageRead');
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();

const serverIsRead = message.importantReadBy?.includes(userId || '') ?? false;
const [isRead, setIsRead] = useState(serverIsRead);

useEffect(() => {
setIsRead(serverIsRead);
}, [serverIsRead]);

if (!message.isImportant || !userId) {
return null;
}

const handleToggle = async () => {
const newState = !isRead;
setIsRead(newState);

console.log('[ImportantMessageReadButton] Toggling read status:', { messageId: message._id, newState });

try {
await toggleImportantMessageRead(message._id);
queryClient.invalidateQueries({
queryKey: ['important-message-readers', message._id]
});
console.log('[ImportantMessageReadButton] Read status toggled successfully');
} catch (error) {
setIsRead(!newState);
console.error('[ImportantMessageReadButton] Error toggling read status:', error);
dispatchToastMessage({
type: 'error',
message: error instanceof Error ? error.message : String(error),
});
}
};

return (
<Box display='flex' alignItems='center'>
<Button
small
onClick={handleToggle}
primary={!isRead}
success={isRead}
mis='x8'
mbs='x4'
style={{ width: 'fit-content', minWidth: 'auto' }}
>
{isRead ? 'Message read' : 'Mark as read'}
</Button>
<ImportantMessageReadInfo message={message} />
</Box>
);
};

export default memo(ImportantMessageReadButton);
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Box, Icon, TextInput } from '@rocket.chat/fuselage';
import { useMethod, useUserId, usePermission, useUserSubscription, useStream } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReactElement, ChangeEvent } from 'react';
import { memo, useState, useMemo, useEffect } from 'react';

type ImportantMessageReadInfoProps = {
message: IMessage;
};

type User = {
_id: string;
username: string;
name?: string;
};

const ImportantMessageReadInfo = ({ message }: ImportantMessageReadInfoProps): ReactElement | null => {
const [showList, setShowList] = useState(false);
const [searchText, setSearchText] = useState('');
const getUsersWhoRead = useMethod('getUsersWhoReadImportantMessage');
const getUserRoomRole = useMethod('getUserRoomRole');
const userId = useUserId();
const subscription = useUserSubscription(message.rid);
const queryClient = useQueryClient();
const subscribeToRoomMessages = useStream('room-messages');

useEffect(() => {
const unsubscribe = subscribeToRoomMessages(message.rid, (msg) => {
if (msg._id === message._id && msg.importantReadBy) {
queryClient.invalidateQueries({
queryKey: ['important-message-readers', message._id]
});
}
});

return unsubscribe;
}, [subscribeToRoomMessages, message.rid, message._id, queryClient]);

const { data: hasRoleFromQuery = false } = useQuery({
queryKey: ['user-room-role-info', userId, message.rid, 'important-message-marker'],
queryFn: async () => {
if (!userId) return false;
try {
const result = await getUserRoomRole(message.rid, userId, 'important-message-marker');
return result ?? false;
} catch (error) {
return false;
}
},
staleTime: 0,
enabled: !!userId,
});

const hasPermission = usePermission('mark-message-as-important', message.rid);
const hasRole = subscription?.roles?.includes('important-message-marker') ?? hasRoleFromQuery;
const canMarkMessagesAsImportant = hasPermission || hasRole;

const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['important-message-readers', message._id],
queryFn: async () => {
try {
const result = await getUsersWhoRead(message._id);
return result || [];
} catch (error) {
console.error('Error fetching users who read:', error);
return [];
}
},
enabled: showList,
refetchInterval: showList ? 5000 : false,
});

const filteredUsers = useMemo(() => {
if (!searchText.trim()) {
return users;
}
const search = searchText.toLowerCase();
return users.filter(
(user) =>
user.username.toLowerCase().includes(search) ||
(user.name && user.name.toLowerCase().includes(search))
);
}, [users, searchText]);

const readCount = message.importantReadBy?.length || 0;

if (!message.isImportant || !canMarkMessagesAsImportant) {
return null;
}

const handleClick = () => {
console.log('[ImportantMessageReadInfo] Toggling list visibility:', { messageId: message._id, currentState: showList });
setShowList(!showList);
if (!showList) {
setSearchText('');
}
};

const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
console.log('[ImportantMessageReadInfo] Search text changed:', { messageId: message._id, searchText: value });
setSearchText(value);
};

return (
<Box mis='x4'>
<Box
is='button'
onClick={handleClick}
title='Read by information'
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle'
}}
>
<Icon name='info-circled' size='x16' />
</Box>

{showList && (
<Box
mbs='x4'
padding='x8'
style={{
backgroundColor: 'var(--rcx-color-surface-tint, #f7f8fa)',
borderRadius: '4px',
fontSize: '14px',
maxWidth: '300px',
border: '1px solid var(--rcx-color-stroke-light, #e4e7ea)',
color: 'var(--rcx-color-font-default, #2f343d)'
}}
>
<Box fontWeight='bold' mbe='x4'>
Read by ({readCount}):
</Box>

{users.length > 3 && (
<Box mbe='x8'>
<TextInput
placeholder='Search by username or name...'
value={searchText}
onChange={handleSearchChange}
small
/>
</Box>
)}

{isLoading ? (
<Box>Loading...</Box>
) : filteredUsers.length > 0 ? (
<Box style={{ maxHeight: '200px', overflowY: 'auto' }}>
{filteredUsers.map((user) => (
<Box key={user._id} mbe='x4'>
@{user.username} {user.name && `(${user.name})`}
</Box>
))}
</Box>
) : searchText ? (
<Box>No users found matching "{searchText}"</Box>
) : (
<Box>No one has read this message yet</Box>
)}
</Box>
)}
</Box>
);
};

export default memo(ImportantMessageReadInfo);
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import IgnoredContent from '../IgnoredContent';
import MessageHeader from '../MessageHeader';
import MessageToolbarHolder from '../MessageToolbarHolder';
import StatusIndicators from '../StatusIndicators';
import ImportantMessageReadButton from './ImportantMessageReadButton';
import RoomMessageContent from './room/RoomMessageContent';
import { useMessageListReadReceipts } from '../list/MessageListContext';

Expand Down Expand Up @@ -106,7 +107,10 @@ const RoomMessage = ({
data-unread={unread}
data-sequential={sequential}
data-own={message.u._id === uid}
data-qa-type='message'
data-important={message.isImportant}
aria-busy={message.temp}
className={message.isImportant ? 'rcx-message--important' : undefined}
{...props}
>
<MessageLeftContainer>
Expand All @@ -130,7 +134,10 @@ const RoomMessage = ({
{ignored ? (
<IgnoredContent messageId={message._id} onShowMessageIgnored={toggleDisplayIgnoredMessage} />
) : (
<RoomMessageContent message={message} unread={unread} mention={mention} all={all} searchText={searchText} />
<>
<RoomMessageContent message={message} unread={unread} mention={mention} all={all} searchText={searchText} />
{message.isImportant && <ImportantMessageReadButton message={message} />}
</>
)}
</MessageContainer>
{!message.private && message?.e2e !== 'pending' && !selecting && <MessageToolbarHolder message={message} context={context} />}
Expand Down
4 changes: 3 additions & 1 deletion apps/meteor/client/hooks/useRoomRolesQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const useRoomRolesQuery = <TData = RoomRoles[]>(rid: IRoom['_id'], option

case 'removed': {
const { _id: roleId, scope, u } = role;
if (!!scope || !u) return;
if (!u) return;

queryClient.setQueryData(roomsQueryKeys.roles(rid), (data: RoomRoles[] | undefined = []) => {
const index = data?.findIndex((record) => record.rid === rid && record.u._id === u._id) ?? -1;
Expand All @@ -65,6 +65,8 @@ export const useRoomRolesQuery = <TData = RoomRoles[]>(rid: IRoom['_id'], option

return [...data];
});

queryClient.invalidateQueries({ queryKey: roomsQueryKeys.roles(rid) });
break;
}
}
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/client/lib/chats/ChatAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export type ChatAPI = {
tshow?: boolean;
previewUrls?: string[];
isSlashCommandAllowed?: boolean;
isImportant?: boolean;
tmid?: IMessage['tmid'];
}) => Promise<boolean>;
readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise<boolean>;
Expand Down
10 changes: 7 additions & 3 deletions apps/meteor/client/lib/chats/flows/sendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export const sendMessage = async (
tshow,
previewUrls,
isSlashCommandAllowed,
}: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; tmid?: IMessage['tmid'] },
isImportant,
}: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; isImportant?: boolean; tmid?: IMessage['tmid'] },
): Promise<boolean> => {
if (!(await chat.data.isSubscribedToRoom())) {
try {
Expand Down Expand Up @@ -92,8 +93,11 @@ export const sendMessage = async (
originalMessage: mid ? await chat.data.findMessageByID(mid) : null,
});

// When editing an encrypted message with files, preserve the original attachments/files
// This ensures they're included in the re-encryption process
if (isImportant) {
message.isImportant = true;
console.log('[sendMessage] Marking message as important');
}

if (mid) {
const originalMessage = await chat.data.findMessageByID(mid);

Expand Down
Loading