Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions .changeset/rare-llamas-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/models': minor
'@rocket.chat/meteor': minor
'@rocket.chat/ui-composer': minor
'@rocket.chat/i18n': minor
---
Comment thread
Polinarr60 marked this conversation as resolved.

Implemented complete message scheduling functionality, allowing users to schedule messages to be sent at a specific date and time.
8 changes: 8 additions & 0 deletions .changeset/tasty-hoops-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/ui-composer': minor
'@rocket.chat/models': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Added scheduled messages
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import './v1/calendar';
import './v1/call-history';
import './v1/channels';
import './v1/chat';
import './v1/chat.scheduleMessage';
import './v1/cloud';
import './v1/commands';
import './v1/e2e';
Expand Down
185 changes: 185 additions & 0 deletions apps/meteor/app/api/server/v1/chat.scheduleMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage';
import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';

API.v1.post(
'chat.scheduleMessage',
{
authRequired: true,
response: {
200: ajv.compile<{ message: IMessage }>({
type: 'object',
properties: {
message: { $ref: '#/components/schemas/IMessage' },
success: { type: 'boolean', enum: [true] },
},
required: ['message', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action(this: any) {
check(this.bodyParams, {
roomId: String,
message: String,
scheduledAt: String,
tmid: Match.Maybe(String),
});

const { roomId, message, scheduledAt, tmid } = this.bodyParams as {
roomId: string;
message: string;
scheduledAt: string;
tmid?: string;
};

const scheduledDate = new Date(scheduledAt);
if (isNaN(scheduledDate.getTime())) {
throw new Meteor.Error('error-invalid-date', 'Invalid date format', { method: 'chat.scheduleMessage' });
}

if (scheduledDate <= new Date()) {
throw new Meteor.Error('error-past-date', 'Scheduled date must be in the future', { method: 'chat.scheduleMessage' });
}

if (tmid) {
const parentMessage = await Messages.findOneById(tmid, { projection: { rid: 1 } });
if (!parentMessage || parentMessage.rid !== roomId) {
throw new Meteor.Error('error-invalid-tmid', 'Thread message does not belong to this room', { method: 'chat.scheduleMessage' });
}
}

await canSendMessageAsync(roomId, this.user);

const messageData = {
rid: roomId,
msg: message,
u: {
_id: this.user._id,
username: this.user.username,
name: this.user.name,
},
ts: new Date(),
scheduledAt: scheduledDate,
scheduled: true,
...(tmid && { tmid }),
};

const result = await Messages.insertOne(messageData as IMessage);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return API.v1.success({
message: {
...messageData,
_id: result.insertedId,
_updatedAt: new Date(),
},
});
},
);

API.v1.get(
'chat.getScheduledMessages',
{
authRequired: true,
response: {
200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({
type: 'object',
properties: {
messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['messages', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action(this: any) {
const { roomId } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);

check(roomId, String);

await canSendMessageAsync(roomId, this.user);

const messages = await Messages.find(
{
rid: roomId,
'u._id': this.userId,
scheduled: true,
scheduledAt: { $exists: true },
},
{
sort: { scheduledAt: 1 },
skip: Number(offset),
limit: Number(count),
},
).toArray();

const total = await Messages.countDocuments({
rid: roomId,
'u._id': this.userId,
scheduled: true,
scheduledAt: { $exists: true },
});

return API.v1.success({
messages,
count: messages.length,
offset,
total,
});
},
);

API.v1.post(
'chat.cancelScheduledMessage',
{
authRequired: true,
response: {
200: ajv.compile<{ success: boolean }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action(this: any) {
check(this.bodyParams, {
messageId: String,
});

const { messageId } = this.bodyParams as { messageId: string };

const result = await Messages.deleteOne({
_id: messageId,
'u._id': this.userId,
scheduled: true,
});

if (result.deletedCount === 0) {
throw new Meteor.Error('error-message-not-found', 'Scheduled message not found or already sent', {
method: 'chat.cancelScheduledMessage',
});
}

return API.v1.success({ success: true });
},
);
31 changes: 31 additions & 0 deletions apps/meteor/client/lib/chats/flows/scheduleMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { IRoom } from '@rocket.chat/core-typings';

import { t } from '../../../../app/utils/lib/i18n';
import { sdk } from '../../../../app/utils/client/lib/SDKClient';
import { dispatchToastMessage } from '../../../lib/toast';

export const scheduleMessage = async ({
rid,
msg,
scheduledAt,
tmid,
}: {
rid: IRoom['_id'];
msg: string;
scheduledAt: Date;
tmid?: string;
}): Promise<void> => {
try {
await sdk.rest.post('/v1/chat.scheduleMessage', {
roomId: rid,
message: msg,
scheduledAt: scheduledAt.toISOString(),
...(tmid && { tmid }),
});

dispatchToastMessage({ type: 'success', message: t('Message_sent') });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading success toast — message was scheduled, not sent.

t('Message_sent') will be shown after the user merely schedules a message, which contradicts the actual action and may make users think delivery already happened. Use a scheduling-specific i18n key (e.g., Message_scheduled) and add the corresponding entry to the locale files.

🛠️ Suggested change
-		dispatchToastMessage({ type: 'success', message: t('Message_sent') });
+		dispatchToastMessage({ type: 'success', message: t('Message_scheduled') });

The same issue exists in useScheduleMessageButton.tsx line 38; please update both call sites consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/meteor/client/lib/chats/flows/scheduleMessage.ts` at line 26, Replace
the misleading success toast that uses t('Message_sent') when a message is
scheduled: update both dispatchToastMessage call sites in scheduleMessage.ts
(the dispatchToastMessage(...) in the scheduling flow) and
useScheduleMessageButton.tsx (the dispatchToastMessage(...) at line ~38) to use
a scheduling-specific i18n key such as t('Message_scheduled'), and add the
corresponding "Message_scheduled" entries to all locale files so translations
are available; ensure the toast type remains 'success' and the message string
uses the new key consistently in both locations.

} catch (error: any) {
dispatchToastMessage({ type: 'error', message: error.message || t('Error') });
throw error;
}
};
Comment on lines +7 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for any importer of the scheduleMessage flow
rg -nP -C2 "from\s+['\"][^'\"]*chats/flows/scheduleMessage['\"]"
rg -nP -C2 "scheduleMessage\s*\(" -g '!**/scheduleMessage.ts'

Repository: RocketChat/Rocket.Chat

Length of output: 48


🏁 Script executed:

# More flexible search for imports - check different import styles
rg -n "scheduleMessage" apps/meteor/client/lib/chats/flows/
# Search for the function name in all TypeScript/JavaScript files
rg -n "scheduleMessage" -t ts -t tsx --max-count=20
# Check if it's exported from an index file
rg -n "scheduleMessage" apps/meteor/client/lib/chats/ -t ts -t tsx
# Look at useScheduleMessageButton to see what it actually imports
fd "useScheduleMessageButton" --type f

Repository: RocketChat/Rocket.Chat

Length of output: 430


🏁 Script executed:

# Read the useScheduleMessageButton file to see what it actually uses
cat -n apps/meteor/client/views/room/composer/messageBox/hooks/useScheduleMessageButton.tsx

Repository: RocketChat/Rocket.Chat

Length of output: 3510


🏁 Script executed:

# Final comprehensive search - look for any import or require of scheduleMessage
# excluding the definition file itself
rg -n "scheduleMessage" --type ts --max-count=50 | grep -v "scheduleMessage.ts:7:" | grep -v "scheduleMessage.ts:19:"
# Also check if it's exported from any barrel/index files
fd "index\.(ts|js)" apps/meteor/client/lib/chats/flows/ -exec cat -n {} \;

Repository: RocketChat/Rocket.Chat

Length of output: 2077


🏁 Script executed:

# Check if flows directory has an index file
find apps/meteor/client/lib/chats/flows -name "index.*" -exec cat {} \;
# Also verify sendMessage is actually imported and used to confirm the pattern difference
rg -n "from.*chats/flows/sendMessage" --max-count=10

Repository: RocketChat/Rocket.Chat

Length of output: 193


The scheduleMessage flow is dead code and should be removed or refactored for consistency.

scheduleMessage is exported but never imported anywhere in the codebase. In contrast, sendMessage (a similar flow in the same directory) is actively used in apps/meteor/app/ui/client/lib/ChatMessages.ts. The useScheduleMessageButton.tsx hook calls useEndpoint('POST', '/v1/chat.scheduleMessage') directly rather than using the scheduleMessage flow. Either remove this unused export or refactor useScheduleMessageButton to use the flow for consistency with the established pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/meteor/client/lib/chats/flows/scheduleMessage.ts` around lines 7 - 31,
The exported function scheduleMessage is dead code; either remove it or refactor
callers to use it for consistency. Option A: delete the scheduleMessage export
and its file-level export if no other references exist (remove scheduleMessage
from apps/meteor/client/lib/chats/flows/scheduleMessage.ts). Option B: update
useScheduleMessageButton to call the scheduleMessage flow instead of directly
using useEndpoint('POST', '/v1/chat.scheduleMessage')—import scheduleMessage
into useScheduleMessageButton, adapt its payload to match scheduleMessage({ rid,
msg, scheduledAt, tmid }) and handle success/error the same way as sendMessage
is used in ChatMessages.ts. Ensure only one consistent pattern remains across
sendMessage, scheduleMessage, and useScheduleMessageButton.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import MessageBoxHint from './MessageBoxHint';
import MessageBoxReplies from './MessageBoxReplies';
import MessageComposerFiles from './MessageComposerFiles';
import { handleSelectionWrapping } from './wrapSelection';
import { useScheduleMessageButton } from './hooks/useScheduleMessageButton';
import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI';
import type { FormattingButton } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting';
import { formattingButtons } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting';
Expand Down Expand Up @@ -148,6 +149,11 @@ const MessageBox = ({

const useEmojis = useUserPreference<boolean>('useEmojis');

const { scheduleMenu } = useScheduleMessageButton(
room._id,
tmid,
);

const handleOpenEmojiPicker = useEffectEvent((e: MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
Expand Down Expand Up @@ -496,6 +502,7 @@ const MessageBox = ({
{canSend && (
<>
{isEditing && <MessageComposerButton onClick={closeEditing}>{t('Cancel')}</MessageComposerButton>}
{!isEditing && scheduleMenu}
<MessageComposerAction
aria-label={t('Send')}
icon='send'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useShareLocationAction } from './hooks/useShareLocationAction';
import { useTimestampAction } from './hooks/useTimestampAction';
import { useVideoMessageAction } from './hooks/useVideoMessageAction';
import { useWebdavActions } from './hooks/useWebdavActions';
import { useViewScheduledMessagesAction } from './hooks/useViewScheduledMessagesAction';
import { messageBox } from '../../../../../../app/ui-utils/client';
import { useMessageboxAppsActionButtons } from '../../../../../hooks/useMessageboxAppsActionButtons';
import { useChat } from '../../../contexts/ChatContext';
Expand Down Expand Up @@ -64,6 +65,7 @@ const MessageBoxActionsToolbar = ({
const createDiscussionAction = useCreateDiscussionAction(room);
const shareLocationAction = useShareLocationAction(room, tmid);
const timestampAction = useTimestampAction(chatContext.composer);
const viewScheduledMessagesAction = useViewScheduledMessagesAction(rid);

const apps = useMessageboxAppsActionButtons();
const { composerToolbox: hiddenActions } = useLayoutHiddenActions();
Expand All @@ -76,6 +78,7 @@ const MessageBoxActionsToolbar = ({
...(!isHidden(hiddenActions, shareLocationAction) && { shareLocationAction }),
...(timestampAction && !isHidden(hiddenActions, timestampAction) && { timestampAction }),
...(!hiddenActions.includes('webdav-add') && webdavActions && { webdavActions }),
...(!isHidden(hiddenActions, viewScheduledMessagesAction) && { viewScheduledMessagesAction }),
};

const featured = [];
Expand All @@ -85,6 +88,10 @@ const MessageBoxActionsToolbar = ({

createNew.push(allActions.createDiscussionAction);

if (allActions.viewScheduledMessagesAction) {
createNew.push(allActions.viewScheduledMessagesAction);
}

if (allActions.timestampAction) {
insert.push(allActions.timestampAction);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';

import { MessageBoxScheduledMessagesModal } from '../../MessageBoxScheduledMessagesModal';

export const useViewScheduledMessagesAction = (roomId: string): GenericMenuItemProps => {
const { t } = useTranslation();
const setModal = useSetModal();

return useMemo<GenericMenuItemProps>(
() => ({
id: 'view-scheduled-messages',
content: t('View_Scheduled_Messages'),
icon: 'clock',
disabled: false,
onClick: () => {
setModal(<MessageBoxScheduledMessagesModal roomId={roomId} onClose={() => setModal(null)} />);
},
}),
[t, roomId, setModal],
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';

export const useScheduleMenuItems = (): GenericMenuItemProps[] => {
const { t } = useTranslation();

return [
{
id: 'schedule-new-message',
icon: 'clock',
content: t('Schedule_new_message'),
},
{
id: 'view-scheduled-messages',
icon: 'list',
content: t('View_scheduled_messages'),
},
];
};
Loading