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
8 changes: 8 additions & 0 deletions .changeset/red-maps-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/ui-client': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Adds a new "Drafts" group to the sidebar, providing quick access to all rooms with unfinished messages.
> This feature is available under the `Drafts in sidebar` feature preview and needs to be enabled in settings to be tested.
26 changes: 26 additions & 0 deletions apps/meteor/app/api/server/v1/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
isSubscriptionsGetOneProps,
isSubscriptionsReadProps,
isSubscriptionsUnreadProps,
isSubscriptionsSaveDraftProps,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';

import { readMessages } from '../../../../server/lib/readMessages';
import { getSubscriptions } from '../../../../server/publications/subscription';
import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener';
import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages';
import { API } from '../api';

Expand Down Expand Up @@ -115,3 +117,27 @@ API.v1.addRoute(
},
},
);

API.v1.addRoute(
'subscriptions.saveDraft',
{
authRequired: true,
validateParams: isSubscriptionsSaveDraftProps,
},
{
async post() {
const { rid, draft } = this.bodyParams;

const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, this.userId);
if (!subscription) {
throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription');
}

await Subscriptions.updateDraftById(subscription._id, draft || undefined);

void notifyOnSubscriptionChangedById(subscription._id);

return API.v1.success();
},
},
);
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { Accounts } from 'meteor/accounts-base';
import type { RefObject } from 'react';

import { limitQuoteChain } from './limitQuoteChain';
Expand All @@ -11,7 +10,8 @@ import { withDebouncing } from '../../../../lib/utils/highOrderFunctions';

export const createComposerAPI = (
input: HTMLTextAreaElement,
storageID: string,
persistDraft: (value: string) => void,
initialDraft: string,
quoteChainLimit: number,
composerRef: RefObject<HTMLElement>,
): ComposerAPI => {
Expand All @@ -36,13 +36,12 @@ export const createComposerAPI = (

let _quotedMessages: IMessage[] = [];

let ready = false;

const persist = withDebouncing({ wait: 300 })(() => {
if (input.value) {
Accounts.storageLocation.setItem(storageID, input.value);
return;
if (ready) {
persistDraft(input.value);
}

Accounts.storageLocation.removeItem(storageID);
});

const notifyQuotedMessagesUpdate = (): void => {
Expand Down Expand Up @@ -269,10 +268,12 @@ export const createComposerAPI = (

const insertNewLine = (): void => insertText('\n');

setText(Accounts.storageLocation.getItem(storageID) ?? '', {
setText(initialDraft, {
skipFocus: true,
});

ready = true;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P2: The ready guard is ineffective with debouncing: the initialization input event is queued while ready is false, but executes after ready = true, so initialDraft is still persisted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts, line 275:

<comment>The `ready` guard is ineffective with debouncing: the initialization `input` event is queued while `ready` is false, but executes after `ready = true`, so `initialDraft` is still persisted.</comment>

<file context>
@@ -269,10 +268,12 @@ export const createComposerAPI = (
 		skipFocus: true,
 	});
 
+	ready = true;
+
 	// Gets the text that is connected to the cursor and replaces it with the given text
</file context>
Fix with Cubic


// Gets the text that is connected to the cursor and replaces it with the given text
const replaceText = (text: string, selection: { readonly start: number; readonly end: number }): void => {
const { selectionStart, selectionEnd } = input;
Expand Down
11 changes: 11 additions & 0 deletions apps/meteor/client/sidebar/hooks/useRoomList.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts';
import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts';
import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf';
Expand All @@ -19,6 +20,7 @@ const order = [
'Open_Livechats',
'On_Hold_Chats',
'Unread',
'Drafts',
'Favorites',
'Teams',
'Discussions',
Expand All @@ -40,6 +42,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
const showOmnichannel = useOmnichannelEnabled();
const sidebarGroupByType = useUserPreference('sidebarGroupByType');
const favoritesEnabled = useUserPreference('sidebarShowFavorites');
const sidebarDrafts = useFeaturePreview('sidebarDrafts');
const sidebarOrder = useUserPreference<typeof order>('sidebarSectionsOrder') ?? order;
const isDiscussionEnabled = useSetting('Discussion_enabled');
const sidebarShowUnread = useUserPreference('sidebarShowUnread');
Expand All @@ -58,6 +61,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
useMemo(() => {
const isCollapsed = (groupTitle: string) => collapsedGroups?.includes(groupTitle);

const drafts = new Set();
const incomingCall = new Set();
const favorite = new Set();
const team = new Set();
Expand All @@ -82,6 +86,10 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
return unread.add(room);
}

if (sidebarDrafts && room.draft) {
return drafts.add(room);
}

if (favoritesEnabled && room.f) {
return favorite.add(room);
}
Expand Down Expand Up @@ -122,6 +130,8 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })

sidebarShowUnread && unread.size && groups.set('Unread', unread);

sidebarDrafts && drafts.size && groups.set('Drafts', drafts);

favoritesEnabled && favorite.size && groups.set('Favorites', favorite);

sidebarGroupByType && team.size && groups.set('Teams', team);
Expand Down Expand Up @@ -193,6 +203,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
rooms,
showOmnichannel,
inquiries.enabled,
sidebarDrafts,
queue,
sidebarShowUnread,
favoritesEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ import AudioMessageRecorder from '../../../composer/AudioMessageRecorder';
import VideoMessageRecorder from '../../../composer/VideoMessageRecorder';
import { useChat } from '../../contexts/ChatContext';
import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext';
import { useRoom } from '../../contexts/RoomContext';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
import ComposerBoxPopup from '../ComposerBoxPopup';
import ComposerBoxPopupPreview from '../ComposerBoxPopupPreview';
import ComposerUserActionIndicator from '../ComposerUserActionIndicator';
import { useAutoGrow } from '../RoomComposer/hooks/useAutoGrow';
import { useComposerBoxPopup } from '../hooks/useComposerBoxPopup';
import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview';
import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs';
import { useDraft } from './hooks/useDraft';
import { useMessageBoxAutoFocus } from './hooks/useMessageBoxAutoFocus';
import { useMessageBoxPlaceholder } from './hooks/useMessageBoxPlaceholder';
import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled';
Expand Down Expand Up @@ -126,20 +127,22 @@ const MessageBox = ({
const textareaRef = useRef(null);
const messageComposerRef = useRef<HTMLElement>(null);

const storageID = `messagebox_${room._id}${tmid ? `-${tmid}` : ''}`;
const subscription = useRoomSubscription();
const { initialValue, persistLocal, flushDraft } = useDraft(room._id, tmid ? undefined : subscription?.draft, tmid);

const callbackRef = useCallback(
(node: HTMLTextAreaElement) => {
if (node === null && chat.composer) {
flushDraft();
return chat.setComposerAPI();
}

if (chat.composer) {
return;
}
chat.setComposerAPI(createComposerAPI(node, storageID, quoteChainLimit, messageComposerRef));
chat.setComposerAPI(createComposerAPI(node, persistLocal, initialValue, quoteChainLimit, messageComposerRef));
},
[chat, storageID, quoteChainLimit],
[chat, persistLocal, flushDraft, initialValue, quoteChainLimit],
);

const autofocusRef = useMessageBoxAutoFocus(!isMobile);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback, useRef } from 'react';

export const useDraft = (rid: string, serverDraft?: string, tmid?: string) => {
const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
const [localDraft, setLocalDraft] = useLocalStorage<string>(storageKey, '');
const saveDraft = useEndpoint('POST', '/v1/subscriptions.saveDraft');
const initialValueRef = useRef(serverDraft || localDraft);
const draftRef = useRef<string | null>(null);
Comment on lines +9 to +10
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
set -euo pipefail

# 1) Locate MessageBox usage and verify whether remount is forced via a room/thread-based key.
rg -n --type=tsx '<MessageBox\b|MessageBox\(' apps/meteor -C3
rg -n --type=tsx 'key=\{[^}]*(_id|rid|tmid)' apps/meteor -C2

# 2) Inspect where useRoomSubscription is consumed to confirm whether subscription can be undefined initially then populated.
rg -n --type=tsx '\buseRoomSubscription\s*\(' apps/meteor -C3

# 3) Confirm all useDraft call sites and their argument sources.
rg -n --type=ts --type=tsx '\buseDraft\s*\(' apps/meteor packages -C3

Repository: RocketChat/Rocket.Chat

Length of output: 93


🏁 Script executed:

# 1) Locate MessageBox usage and verify whether remount is forced via a room/thread-based key.
rg -n -t ts -t js 'MessageBox' apps/meteor/client/views/room --max-count=20 -A 3 -B 1

# 2) Find and read the useDraft.ts hook implementation
cat -n apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts

# 3) Locate useDraft call sites
rg -n -t ts -t js 'useDraft\s*\(' apps/meteor packages -C 3

# 4) Find MessageBox component definition
find apps/meteor -name 'MessageBox*' -type f

Repository: RocketChat/Rocket.Chat

Length of output: 32226


🏁 Script executed:

# Check if there are any useEffect dependencies on tmid that reset the composer state
rg -n -t ts -t js 'useEffect.*tmid|tmid.*useEffect' apps/meteor/client/views/room/composer --max-count=20 -A 5

# Check MessageBox component for any useEffect that resets state when tmid changes
sed -n '97,200p' apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx | head -100

# Check if ChatContext or chat.composer handles thread changes
rg -n -t ts -t js 'chat\.composer.*tmid|setComposer.*tmid' apps/meteor/client/views/room --max-count=10 -B 2 -A 2

Repository: RocketChat/Rocket.Chat

Length of output: 2996


🏁 Script executed:

# Check what happens when tmid changes - look for any state reset logic
sed -n '97,400p' apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx | grep -n "tmid\|useEffect\|composer"

# Check createComposerAPI to see how initialValue is used
rg -n 'createComposerAPI' apps/meteor --max-count=5 -A 20 -B 2

Repository: RocketChat/Rocket.Chat

Length of output: 9446


🏁 Script executed:

# Check if there's any cleanup or reset logic when tmid changes in chat.composer or ChatContext
rg -n 'setComposerAPI\|clear.*composer\|reset.*composer' apps/meteor/client/views/room/contexts/ChatContext.ts -A 5 -B 5

# Check if there's a useEffect that handles room or thread changes to reset composer
sed -n '1,500p' apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx | grep -n "useEffect\|cleanup\|return"

# Verify where chat.composer gets cleared/reset when navigating between threads
rg -n 'chat\.setComposerAPI\|composer.*clear' apps/meteor/client/views/room -A 3 -B 1 | head -50

Repository: RocketChat/Rocket.Chat

Length of output: 996


🏁 Script executed:

# Look at the full ChatContext implementation to understand composer lifecycle
cat -n apps/meteor/client/views/room/contexts/ChatContext.ts | head -100

# Check if there's logic that clears composer state when switching threads
rg -n 'clearComposerAPI\|setComposerAPI\|null' apps/meteor/client/views/room/contexts/ChatContext.ts -B 2 -A 2

# Look for where initialValue is actually used in the composer
cat -n apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts | sed -n '1,80p'

Repository: RocketChat/Rocket.Chat

Length of output: 3343


🏁 Script executed:

# Continue reading createComposerAPI to see how initialDraft is used
cat -n apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts | sed -n '80,180p'

# Search for where the initialDraft parameter is applied to the input
rg -n 'initialDraft\|input\.value\s*=' apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts -A 2 -B 2

Repository: RocketChat/Rocket.Chat

Length of output: 2947


🏁 Script executed:

# Search for initialDraft usage - it should be set somewhere on the input
rg -n 'initialDraft' apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts

# Look at the return statement of createComposerAPI to see the full API
cat -n apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts | tail -100

Repository: RocketChat/Rocket.Chat

Length of output: 3241


🏁 Script executed:

# Check the full callbackRef useCallback and see what happens when it's recreated
sed -n '130,160p' apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx

# Check if there's any useEffect that monitors tmid/room changes and resets composer state
sed -n '200,300p' apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx | grep -n "useEffect\|tmid"

# Search for any cleanup when MessageBox unmounts or when room/tmid changes
rg -n 'useEffect.*\[\]|useEffect.*\[room\|useEffect.*\[tmid' apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx -A 8

Repository: RocketChat/Rocket.Chat

Length of output: 967


🏁 Script executed:

# Check ComposerMessage more carefully - is MessageBox keyed by both room AND tmid?
sed -n '80,100p' apps/meteor/client/views/room/composer/ComposerMessage.tsx

# Look for any other MessageBox usages to see if they use keys
rg -n '<MessageBox' apps/meteor/client/views/room -B 2 -A 2

Repository: RocketChat/Rocket.Chat

Length of output: 10716


Stale initialValue when switching threads without room change.

useRef(serverDraft || localDraft) on line 9 initializes once. When tmid changes within the same room (thread switching), MessageBox is NOT remounted because it's keyed only by room._id (see ComposerMessage.tsx:93). The useDraft hook receives new parameters, but initialValueRef.current never updates. This causes the composer to initialize with the previous thread's draft value instead of the current thread's draft.

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

In `@apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts` around
lines 9 - 10, initialValueRef is initialized once with useRef(serverDraft ||
localDraft) inside the useDraft hook and therefore becomes stale when tmid
changes without remounting (ComposerMessage is keyed by room._id); update
initialValueRef.current whenever the inputs change by adding a useEffect in
useDraft that sets initialValueRef.current = serverDraft || localDraft and also
reset draftRef.current (if applicable) when serverDraft, localDraft or tmid
change so the composer re-initializes to the correct thread draft.


const persistLocal = useCallback(
(value: string) => {
draftRef.current = value;
setLocalDraft(value);
},
[setLocalDraft],
);

const flushDraft = useCallback(() => {
if (draftRef.current === null || tmid) {
return;
}

void saveDraft({ rid, draft: draftRef.current });
draftRef.current = null;
}, [saveDraft, rid, tmid]);

return {
initialValue: initialValueRef.current,
persistLocal,
flushDraft,
};
};
1 change: 1 addition & 0 deletions apps/meteor/lib/publishFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const subscriptionFields = {
E2EKey: 1,
E2ESuggestedKey: 1,
oldRoomKeys: 1,
draft: 1,
tunread: 1,
tunreadGroup: 1,
tunreadUser: 1,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/meteor/server/settings/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ export const createAccountSettings = () =>
'Open_Livechats',
'On_Hold_Chats',
'Unread',
'Drafts',
'Favorites',
'Teams',
'Discussions',
Expand Down
2 changes: 2 additions & 0 deletions packages/core-typings/src/ISubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface ISubscription extends IRocketChatRecord {

department?: unknown;

draft?: string;

desktopPrefOrigin?: 'subscription' | 'user';
mobilePrefOrigin?: 'subscription' | 'user';
emailPrefOrigin?: 'subscription' | 'user';
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,9 @@
"Download_Snippet": "Download",
"Download_file": "Download file",
"Downloading_file_from_external_URL": "Downloading file from external URL",
"Drafts": "Drafts",
"Drafts_in_sidebar": "Drafts in sidebar",
"Drafts_in_sidebar_description": "Quick access to all your rooms with unfinished messages.",
"Drop_to_upload_file": "Drop to upload file",
"Dry_run": "Dry run",
"Dry_run_description": "Will only send one email, to the same address as in From. The email must belong to a valid user.",
Expand Down
1 change: 1 addition & 0 deletions packages/model-typings/src/models/ISubscriptionsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export interface ISubscriptionsModel extends IBaseModel<ISubscription> {
updateHideUnreadStatusById(_id: string, hideUnreadStatus: boolean): Promise<UpdateResult>;
updateAudioNotificationValueById(_id: string, audioNotificationValue: string): Promise<UpdateResult>;
updateAutoTranslateLanguageById(_id: string, autoTranslateLanguage: string): Promise<UpdateResult>;
updateDraftById(_id: string, draft: string | undefined): Promise<UpdateResult>;

removeByVisitorToken(token: string): Promise<DeleteResult>;
findByToken(token: string, options?: FindOptions): FindCursor<ISubscription>;
Expand Down
8 changes: 8 additions & 0 deletions packages/models/src/models/Subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,14 @@ export class SubscriptionsRaw extends BaseRaw<ISubscription> implements ISubscri
return this.updateOne(query, update);
}

updateDraftById(_id: string, draft: string | undefined): Promise<UpdateResult> {
if (draft) {
return this.updateOne({ _id }, { $set: { draft } });
}

return this.updateOne({ _id }, { $unset: { draft: 1 } });
}

updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise<UpdateResult | Document> {
const query = {
'u._id': userId,
Expand Down
22 changes: 22 additions & 0 deletions packages/rest-typings/src/v1/subscriptionsEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ const SubscriptionsUnreadSchema = {

export const isSubscriptionsUnreadProps = ajv.compile<SubscriptionsUnread>(SubscriptionsUnreadSchema);

type SubscriptionsSaveDraft = { rid: IRoom['_id']; draft: string };

const SubscriptionsSaveDraftSchema = {
type: 'object',
properties: {
rid: {
type: 'string',
},
draft: {
type: 'string',
},
},
required: ['rid', 'draft'],
additionalProperties: false,
};

export const isSubscriptionsSaveDraftProps = ajv.compile<SubscriptionsSaveDraft>(SubscriptionsSaveDraftSchema);

export type SubscriptionsEndpoints = {
'/v1/subscriptions.get': {
GET: (params: SubscriptionsGet) => {
Expand All @@ -127,4 +145,8 @@ export type SubscriptionsEndpoints = {
'/v1/subscriptions.unread': {
POST: (params: SubscriptionsUnread) => void;
};

'/v1/subscriptions.saveDraft': {
POST: (params: SubscriptionsSaveDraft) => void;
};
};
11 changes: 10 additions & 1 deletion packages/ui-client/src/hooks/useFeaturePreviewList.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TranslationKey } from '@rocket.chat/ui-contexts';

export type FeaturesAvailable = 'secondarySidebar' | 'expandableMessageComposer';
export type FeaturesAvailable = 'secondarySidebar' | 'sidebarDrafts';

export type FeaturePreviewProps = {
name: FeaturesAvailable;
Expand Down Expand Up @@ -28,6 +28,15 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [
value: false,
enabled: true,
},
{
name: 'sidebarDrafts',
i18n: 'Drafts_in_sidebar',
description: 'Drafts_in_sidebar_description',
group: 'Navigation',
imageUrl: 'images/featurePreview/sidebar-drafts.png',
value: false,
enabled: true,
},
];

export const enabledDefaultFeatures = defaultFeaturesPreview.filter((feature) => feature.enabled);
Expand Down
Loading