From c56a223c0cf30cea7dde153b2ca5692866de2c1a Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 13 Jul 2026 19:56:55 -0300 Subject: [PATCH 1/9] feat: remove feature preview --- apps/meteor/client/sidebar/hooks/useRoomList.ts | 11 ----------- .../images/featurePreview/sidebar-drafts.png | Bin 3926 -> 0 bytes apps/meteor/server/settings/accounts.ts | 1 - .../ui-client/src/hooks/useFeaturePreviewList.ts | 11 +---------- 4 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 apps/meteor/public/images/featurePreview/sidebar-drafts.png diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index eda1362011b7b..2fb1bfc3366b3 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -1,6 +1,5 @@ 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'; @@ -20,7 +19,6 @@ const order = [ 'Open_Livechats', 'On_Hold_Chats', 'Unread', - 'Drafts', 'Favorites', 'Teams', 'Discussions', @@ -42,7 +40,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); - const sidebarDrafts = useFeaturePreview('sidebarDrafts'); const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); @@ -61,7 +58,6 @@ 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(); @@ -86,10 +82,6 @@ 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); } @@ -130,8 +122,6 @@ 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); @@ -203,7 +193,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) rooms, showOmnichannel, inquiries.enabled, - sidebarDrafts, queue, sidebarShowUnread, favoritesEnabled, diff --git a/apps/meteor/public/images/featurePreview/sidebar-drafts.png b/apps/meteor/public/images/featurePreview/sidebar-drafts.png deleted file mode 100644 index b49f579ab70f52b4d6251e8b0b835b27f49ac07d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3926 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 diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index 0268e8f5c9be2..5e3a7af494ae5 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -750,7 +750,6 @@ export const createAccountSettings = () => 'Open_Livechats', 'On_Hold_Chats', 'Unread', - 'Drafts', 'Favorites', 'Teams', 'Discussions', diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index 8a4ec4af5bce0..8290b319b08aa 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -1,6 +1,6 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; -export type FeaturesAvailable = 'secondarySidebar' | 'sidebarDrafts' | 'aiSearch'; +export type FeaturesAvailable = 'secondarySidebar' | 'aiSearch'; export type FeaturePreviewProps = { name: FeaturesAvailable; @@ -28,15 +28,6 @@ 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, - }, { name: 'aiSearch', i18n: 'Intelligent_Search', From 22599cb47ba8e16aadf276ee5da452e981c8b567 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 13 Jul 2026 20:07:43 -0300 Subject: [PATCH 2/9] feat: add draft indication for main message and threads --- .../lib/utils/getSubscriptionDraft.spec.ts | 22 +++++++++ .../client/lib/utils/getSubscriptionDraft.ts | 9 ++++ .../getMessagePreview.spec.ts | 40 ++++++++++++++++ .../getMessagePreview.ts | 6 +++ apps/meteor/client/sidebar/Item/Condensed.tsx | 3 +- apps/meteor/client/sidebar/Item/Extended.tsx | 3 +- apps/meteor/client/sidebar/Item/Medium.tsx | 3 +- .../RoomList/SidebarItemTemplateWithData.tsx | 14 +++++- .../SidepanelItem/RoomSidePanelItem.tsx | 8 +++- .../sidepanel/SidepanelItem/SidepanelItem.tsx | 3 ++ .../views/room/composer/ComposerMessage.tsx | 1 + .../room/composer/messageBox/MessageBox.tsx | 9 +++- .../composer/messageBox/hooks/useDraft.ts | 12 +++-- .../room/contextualBar/Threads/ThreadList.tsx | 1 + .../Threads/components/ThreadChat.tsx | 1 + .../Threads/components/ThreadListItem.tsx | 4 +- .../Threads/components/ThreadListMessage.tsx | 6 +++ apps/meteor/lib/publishFields.ts | 1 + apps/meteor/server/api/v1/rooms.ts | 8 ++-- apps/meteor/tests/end-to-end/api/rooms.ts | 47 +++++++++++++++++++ packages/core-typings/src/ISubscription.ts | 1 + packages/i18n/src/locales/en.i18n.json | 6 +-- packages/i18n/src/locales/pt-BR.i18n.json | 3 -- packages/i18n/src/locales/sv.i18n.json | 3 -- .../src/models/ISubscriptionsModel.ts | 9 +++- packages/models/src/models/Subscriptions.ts | 6 ++- 26 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts create mode 100644 apps/meteor/client/lib/utils/getSubscriptionDraft.ts diff --git a/apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts b/apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts new file mode 100644 index 0000000000000..3a73ea92d83a1 --- /dev/null +++ b/apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts @@ -0,0 +1,22 @@ +import { getSubscriptionDraft } from './getSubscriptionDraft'; + +it('should return undefined when there is no draft', () => { + expect(getSubscriptionDraft({})).toBeUndefined(); + expect(getSubscriptionDraft({ threadDrafts: {} })).toBeUndefined(); +}); + +it('should return the main composer draft when present', () => { + expect(getSubscriptionDraft({ draft: 'main draft' })).toBe('main draft'); +}); + +it('should prefer the main composer draft over thread drafts', () => { + expect(getSubscriptionDraft({ draft: 'main draft', threadDrafts: { t1: 'thread draft' } })).toBe('main draft'); +}); + +it('should fall back to a thread draft when there is no main draft', () => { + expect(getSubscriptionDraft({ threadDrafts: { t1: 'thread draft' } })).toBe('thread draft'); +}); + +it('should return the first non-empty thread draft', () => { + expect(getSubscriptionDraft({ threadDrafts: { t1: '', t2: 'second' } })).toBe('second'); +}); diff --git a/apps/meteor/client/lib/utils/getSubscriptionDraft.ts b/apps/meteor/client/lib/utils/getSubscriptionDraft.ts new file mode 100644 index 0000000000000..b1e087af5a3c5 --- /dev/null +++ b/apps/meteor/client/lib/utils/getSubscriptionDraft.ts @@ -0,0 +1,9 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; + +export const getSubscriptionDraft = (subscription: Pick): string | undefined => { + if (subscription.draft) { + return subscription.draft; + } + + return subscription.threadDrafts && Object.values(subscription.threadDrafts).find(Boolean); +}; diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.spec.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.spec.ts index 6e6d7454025af..c8210937edb8c 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.spec.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.spec.ts @@ -16,6 +16,46 @@ describe('getMessagePreview', () => { mockNormalizeMessagePreview.mockReturnValue('normalized message'); }); + describe('when room has a draft', () => { + it('should return "Draft: content" taking precedence over the last message', () => { + const room = createFakeSubscription({ draft: 'unfinished text' }); + const message = createFakeMessage(); + + const result = getMessagePreview(room, message, mockT); + + expect(result).toBe('Draft: unfinished text'); + expect(mockT).toHaveBeenCalledWith('Draft'); + expect(mockNormalizeMessagePreview).not.toHaveBeenCalled(); + }); + + it('should escape HTML in the draft content', () => { + const room = createFakeSubscription({ draft: '' }); + + const result = getMessagePreview(room, undefined, mockT); + + expect(result).toBe('Draft: <img src=x onerror=alert(1)>'); + expect(mockNormalizeMessagePreview).not.toHaveBeenCalled(); + }); + + it('should fall back to a thread-composer draft when there is no main draft', () => { + const room = createFakeSubscription({ draft: undefined, threadDrafts: { 'thread-id': 'unfinished thread reply' } }); + const message = createFakeMessage(); + + const result = getMessagePreview(room, message, mockT); + + expect(result).toBe('Draft: unfinished thread reply'); + expect(mockNormalizeMessagePreview).not.toHaveBeenCalled(); + }); + + it('should prefer the main draft over thread-composer drafts', () => { + const room = createFakeSubscription({ draft: 'main draft', threadDrafts: { 'thread-id': 'thread draft' } }); + + const result = getMessagePreview(room, undefined, mockT); + + expect(result).toBe('Draft: main draft'); + }); + }); + describe('when lastMessage is undefined', () => { it('should return translation for No_messages_yet', () => { const room = createFakeSubscription(); diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.ts index 72a49a1ec40bb..74e166cb4c7bb 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/getMessagePreview.ts @@ -5,8 +5,14 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import type { TFunction } from 'i18next'; import { normalizeMessagePreview } from './normalizeMessagePreview'; +import { getSubscriptionDraft } from '../getSubscriptionDraft'; export const getMessagePreview = (room: SubscriptionWithRoom, lastMessage: IMessage | undefined, t: TFunction): string | undefined => { + const draft = getSubscriptionDraft(room); + if (draft) { + return `${t('Draft')}: ${escapeHTML(draft)}`; + } + if (!lastMessage) { return t('No_messages_yet'); } diff --git a/apps/meteor/client/sidebar/Item/Condensed.tsx b/apps/meteor/client/sidebar/Item/Condensed.tsx index 822bb451ab9fb..528006c7373a0 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.tsx @@ -19,7 +19,7 @@ export type CondensedProps = { clickable?: boolean; } & Omit, 'is'>; -const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => { +const Condensed = ({ icon, title, titleIcon, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => { const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount(); return ( @@ -27,6 +27,7 @@ const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...prop {avatar && {avatar}} {icon} {title} + {titleIcon} {badges} {actions} {menu && ( diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index 39555de56f131..170f0f84e2b9a 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -42,7 +42,7 @@ const Extended = ({ menu, menuOptions: _menuOptions, subtitle = '', - titleIcon: _titleIcon, + titleIcon, badges, threadUnread: _threadUnread, unread, @@ -63,6 +63,7 @@ const Extended = ({ {subtitle} + {titleIcon} {badges} {actions} {menu && ( diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index ea26967a01149..8134e94790dc2 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -18,7 +18,7 @@ export type MediumProps = { menuOptions?: any; } & Omit, 'is'>; -const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props }: MediumProps) => { +const Medium = ({ icon, title, titleIcon, avatar, actions, badges, unread, menu, ...props }: MediumProps) => { const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount(); return ( @@ -26,6 +26,7 @@ const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props } {avatar} {icon} {title} + {titleIcon} {badges} {actions} {menu && ( diff --git a/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx index c896399b4edbd..1a8176d986550 100644 --- a/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx @@ -1,5 +1,5 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; -import { SidebarV2Action, SidebarV2Actions, SidebarV2ItemIcon } from '@rocket.chat/fuselage'; +import { Icon, SidebarV2Action, SidebarV2Actions, SidebarV2ItemIcon } from '@rocket.chat/fuselage'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { useLayout } from '@rocket.chat/ui-contexts'; import type { TFunction } from 'i18next'; @@ -9,6 +9,7 @@ import { memo, useMemo } from 'react'; import { RoomIcon } from '../../components/RoomIcon'; import { useUserStatusTooltip } from '../../hooks/useUserStatusTooltip'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { getSubscriptionDraft } from '../../lib/utils/getSubscriptionDraft'; import { getUidDirectMessage } from '../../lib/utils/getUidDirectMessage'; import { isIOsDevice } from '../../lib/utils/isIOsDevice'; import { getMessagePreview } from '../../lib/utils/normalizeMessagePreview/getMessagePreview'; @@ -91,6 +92,10 @@ const SidebarItemTemplateWithData = ({ /> ); + const titleIcon = getSubscriptionDraft(room) ? ( + + ) : undefined; + const actions = useMemo( () => videoConfActions && ( @@ -125,6 +130,7 @@ const SidebarItemTemplateWithData = ({ time={lastMessage?.ts} subtitle={subtitle} icon={icon} + titleIcon={titleIcon} style={style} badges={} avatar={AvatarTemplate && } @@ -194,6 +200,12 @@ export default memo(SidebarItemTemplateWithData, (prevProps, nextProps) => { if (prevProps.room.alert !== nextProps.room.alert) { return false; } + if (prevProps.room.draft !== nextProps.room.draft) { + return false; + } + if (prevProps.room.threadDrafts !== nextProps.room.threadDrafts) { + return false; + } if (isOmnichannelRoom(prevProps.room) && isOmnichannelRoom(nextProps.room) && prevProps.room?.v?.status !== nextProps.room?.v?.status) { return false; } diff --git a/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/RoomSidePanelItem.tsx b/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/RoomSidePanelItem.tsx index 2ff99e3f64c59..020d3ec3f8b8f 100644 --- a/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/RoomSidePanelItem.tsx +++ b/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/RoomSidePanelItem.tsx @@ -1,5 +1,5 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; -import { SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage'; +import { Icon, SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage'; import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useUserId, type SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; @@ -11,6 +11,7 @@ import SidePanelParent from './SidePanelParent'; import SidePanelItem from './SidepanelItem'; import { RoomIcon } from '../../../../components/RoomIcon'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; +import { getSubscriptionDraft } from '../../../../lib/utils/getSubscriptionDraft'; import { isIOsDevice } from '../../../../lib/utils/isIOsDevice'; import { getMessagePreview } from '../../../../lib/utils/normalizeMessagePreview/getMessagePreview'; import { useOmnichannelPriorities } from '../../../omnichannel/hooks/useOmnichannelPriorities'; @@ -39,6 +40,10 @@ const RoomSidePanelItem = ({ room, openedRoom, isRoomFilter, ...props }: RoomSid const parentRoomId = Boolean(room.prid || (room.teamId && !room.teamMain)); + const titleIcon = getSubscriptionDraft(room) ? ( + + ) : undefined; + const menu = !isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) ? ( } icon={} />} unread={highlighted} diff --git a/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/SidepanelItem.tsx b/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/SidepanelItem.tsx index 3053a8a353bd1..fdff918e61449 100644 --- a/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/SidepanelItem.tsx +++ b/apps/meteor/client/views/navigation/sidepanel/SidepanelItem/SidepanelItem.tsx @@ -19,6 +19,7 @@ export type SidePanelItemProps = { href: string; selected: boolean; title: string; + titleIcon?: ReactNode; avatar: ReactNode; icon: ReactNode; unread: boolean; @@ -33,6 +34,7 @@ const SidePanelItem = ({ href, selected, title, + titleIcon, avatar, icon, unread, @@ -72,6 +74,7 @@ const SidePanelItem = ({ {subtitle} {parentRoom} + {titleIcon} {badges} {menu && ( diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index 0085072016bf3..ed7a993a4c253 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -11,6 +11,7 @@ import MessageBox from './messageBox/MessageBox'; export type ComposerMessageProps = { tmid?: IMessage['_id']; + threadExists?: boolean; children?: ReactNode; subscription?: ISubscription; tshow?: boolean; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index ff5bc453c2a68..0f470627c56bc 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -95,6 +95,7 @@ export type MessageBoxProps = { subscription?: ISubscription; showFormattingTips: boolean; isEmbedded?: boolean; + threadExists?: boolean; }; const MessageBox = ({ @@ -107,6 +108,7 @@ const MessageBox = ({ onTyping, tshow, previewUrls, + threadExists, }: MessageBoxProps) => { const chat = useChat(); const room = useRoom(); @@ -130,7 +132,12 @@ const MessageBox = ({ const messageComposerRef = useRef(null); const subscription = useRoomSubscription(); - const { initialValue, persistLocal, flushDraft } = useDraft(room._id, tmid ? undefined : subscription?.draft, tmid); + const { initialValue, persistLocal, flushDraft } = useDraft( + room._id, + tmid ? subscription?.threadDrafts?.[tmid] : subscription?.draft, + tmid, + threadExists, + ); const callbackRef = useCallback( (node: HTMLTextAreaElement) => { diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts index 4b7a080785912..f8aa9469e6217 100644 --- a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts @@ -2,12 +2,13 @@ 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) => { +export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threadExists = true) => { const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`; const [localDraft, setLocalDraft] = useLocalStorage(storageKey, ''); const saveDraft = useEndpoint('POST', '/v1/rooms.saveDraft'); const initialValueRef = useRef(serverDraft || localDraft); const draftRef = useRef(null); + const threadExistsRef = useRef(threadExists); const persistLocal = useCallback( (value: string) => { @@ -18,11 +19,16 @@ export const useDraft = (rid: string, serverDraft?: string, tmid?: string) => { ); const flushDraft = useCallback(() => { - if (draftRef.current === null || tmid) { + if (draftRef.current === null) { return; } - void saveDraft({ rid, draft: draftRef.current }); + if (tmid && !threadExistsRef.current) { + draftRef.current = null; + return; + } + + void saveDraft({ rid, draft: draftRef.current, ...(tmid && { tmid }) }); draftRef.current = null; }, [saveDraft, rid, tmid]); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx index f41b9dd9334d8..44f6a0c0cd18f 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx @@ -177,6 +177,7 @@ const ThreadList = () => { unread={subscription?.tunread ?? []} unreadUser={subscription?.tunreadUser ?? []} unreadGroup={subscription?.tunreadGroup ?? []} + hasDraft={Boolean(subscription?.threadDrafts?.[data._id])} onClick={handleThreadClick} /> )} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 97261cddca637..3b1bc47d446f4 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -113,6 +113,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { void; }; -const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: ThreadListItemProps) => { +const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, hasDraft, onClick }: ThreadListItemProps) => { const uid = useUserId(); const decryptedMsg = useDecryptedMessage(thread); const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg }); @@ -59,6 +60,7 @@ const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: Th mention={unreadUser.includes(thread._id)} all={unreadGroup.includes(thread._id)} following={following} + hasDraft={hasDraft} data-id={thread._id} msg={msg ?? ''} rid={thread.rid} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx index bd0815472c6b0..d49077f194f92 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx @@ -8,11 +8,13 @@ import { MessageTimestamp, MessageBody, MessageContainerFixed, + MessageStatusIndicatorItem, Box, } from '@rocket.chat/fuselage'; import { MessageAvatar } from '@rocket.chat/ui-avatar'; import type { ComponentProps, ReactNode } from 'react'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import ThreadListMetrics from './ThreadListMetrics'; import Emoji from '../../../../../components/Emoji'; @@ -35,6 +37,7 @@ export type ThreadListMessageProps = { all: boolean; tlm: Date; emoji: IMessage['emoji']; + hasDraft?: boolean; } & Omit, 'is'>; const ThreadListMessage = ({ @@ -53,8 +56,10 @@ const ThreadListMessage = ({ tlm, className = [], emoji, + hasDraft, ...props }: ThreadListMessageProps) => { + const { t } = useTranslation(); const formatDate = useTimeAgo(); return ( @@ -67,6 +72,7 @@ const ThreadListMessage = ({ {name} {formatDate(ts)} + {hasDraft && } {msg} diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 5f977c3669030..b187bb95d9677 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -40,6 +40,7 @@ export const subscriptionFields = { E2ESuggestedKey: 1, oldRoomKeys: 1, draft: 1, + threadDrafts: 1, tunread: 1, tunreadGroup: 1, tunreadUser: 1, diff --git a/apps/meteor/server/api/v1/rooms.ts b/apps/meteor/server/api/v1/rooms.ts index ea8f7cbc658bf..2b1e8f7b5d539 100644 --- a/apps/meteor/server/api/v1/rooms.ts +++ b/apps/meteor/server/api/v1/rooms.ts @@ -2,6 +2,7 @@ import { FederationMatrix, MeteorError, Room, Team } from '@rocket.chat/core-ser import { type IRoom, type IRoomAbacRedaction, + type IMessage, type IUpload, type RequiredField, type RoomAdminFieldsType, @@ -419,11 +420,12 @@ const roomsSaveNotificationEndpoint = API.v1.post( }, ); -const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string }>({ +const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string; tmid?: IMessage['_id'] }>({ type: 'object', properties: { rid: { type: 'string', minLength: 1 }, draft: { type: 'string' }, + tmid: { type: 'string', minLength: 1, nullable: true }, }, required: ['rid', 'draft'], additionalProperties: false, @@ -450,13 +452,13 @@ const roomsSaveDraftEndpoint = API.v1.post( }, }, async function action() { - const { rid, draft } = this.bodyParams; + const { rid, draft, tmid } = this.bodyParams; if (draft.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { return API.v1.failure('error-message-size-exceeded'); } - const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined); + const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined, tmid); if (!subscription) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription'); } diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 8e85c20edcf80..428f69c5eadfc 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -181,6 +181,53 @@ describe('[Rooms]', () => { }); }); + it('should save a thread draft keyed by tmid without touching the main draft', async () => { + const tmid = `tmid-${Date.now()}`; + const draft = `thread-draft-${Date.now()}`; + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft, tmid }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.nested.property(`threadDrafts.${tmid}`, draft); + expect(res.body.subscription).to.not.have.property('draft'); + }); + }); + + it('should clear a thread draft when saving an empty draft for the tmid', async () => { + const tmid = `tmid-to-clear-${Date.now()}`; + const draft = `thread-draft-${Date.now()}`; + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft, tmid }).expect(200); + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '', tmid }).expect(200); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.not.have.nested.property(`threadDrafts.${tmid}`); + }); + }); + it('should fail when the user does not have a subscription for the room', async () => { await request .post(api('rooms.saveDraft')) diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index a2d0559bf46e6..a40c50bad0404 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -66,6 +66,7 @@ export interface ISubscription extends IRocketChatRecord { department?: unknown; draft?: string; + threadDrafts?: Record; desktopPrefOrigin?: 'subscription' | 'user'; mobilePrefOrigin?: 'subscription' | 'user'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c3ebda30f6a6c..2e3bf12852067 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1986,9 +1986,7 @@ "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.", + "Draft": "Draft", "Drag_and_drop_license_placeholder": "Drag and drop a file here, or paste your license string", "Drop_to_upload_file": "Drop to upload file", "Dry_run": "Dry run", @@ -5693,6 +5691,8 @@ "Undefined": "Undefined", "Undo_request": "Undo request", "Unfavorite": "Unfavorite", + "Unfinished_message": "Unfinished message", + "Unfinished_thread_message": "Unfinished thread message", "Unfollow_message": "Unfollow message", "Unignore": "Unignore", "Uninstall": "Uninstall", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index 3779e81eae554..49a48d64b689e 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -1917,9 +1917,6 @@ "Download_Snippet": "Baixar", "Download_file": "Baixar arquivo", "Downloading_file_from_external_URL": "Baixar arquivo de URL externo", - "Drafts": "Rascunhos", - "Drafts_in_sidebar": "Rascunhos na barra lateral", - "Drafts_in_sidebar_description": "Acesso rápido a todos os seus sala com mensagens não concluídas.", "Drop_to_upload_file": "Soltar para fazer upload de arquivos", "Dry_run": "Simulação", "Dry_run_description": "Enviará apenas um e-mail, para o mesmo endereço definido em 'De'. O e-mail deve pertencer a um usuário válido.", diff --git a/packages/i18n/src/locales/sv.i18n.json b/packages/i18n/src/locales/sv.i18n.json index fa66233f1dc9a..1b27437e0e91f 100644 --- a/packages/i18n/src/locales/sv.i18n.json +++ b/packages/i18n/src/locales/sv.i18n.json @@ -1908,9 +1908,6 @@ "Download_Snippet": "Ladda ned", "Download_file": "Ladda ner fil", "Downloading_file_from_external_URL": "Laddar ned fil från extern URL", - "Drafts": "Utkast", - "Drafts_in_sidebar": "Utkast i sidofältet", - "Drafts_in_sidebar_description": "Snabb åtkomst till alla dina rum med oavslutade meddelanden.", "Drop_to_upload_file": "Släpp för att ladda upp filen", "Dry_run": "Testkörning", "Dry_run_description": "Kommer endast att skicka ett e-postmeddelande, till samma adress som i Från. E-postadressen måste höra till en giltig användare.", diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index c936f780ab831..c40d60179496f 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -1,4 +1,4 @@ -import type { ISubscription, IRole, IUser, IRoom, SpotlightUser, AtLeast } from '@rocket.chat/core-typings'; +import type { ISubscription, IRole, IUser, IRoom, IMessage, SpotlightUser, AtLeast } from '@rocket.chat/core-typings'; import type { FindOptions, FindCursor, @@ -139,7 +139,12 @@ export interface ISubscriptionsModel extends IBaseModel { updateHideUnreadStatusById(_id: string, hideUnreadStatus: boolean): Promise; updateAudioNotificationValueById(_id: string, audioNotificationValue: string): Promise; updateAutoTranslateLanguageById(_id: string, autoTranslateLanguage: string): Promise; - updateDraftByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], draft: string | undefined): Promise>; + updateDraftByRoomIdAndUserId( + rid: IRoom['_id'], + uid: IUser['_id'], + draft: string | undefined, + tmid?: IMessage['_id'], + ): Promise>; updateMuteGroupMentions(_id: string, muteGroupMentions: boolean): Promise; changeDepartmentByRoomId(rid: string, department: string): Promise; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index fe78d87bc2ae7..6641b69a16d08 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -641,9 +641,11 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne(query, update); } - updateDraftByRoomIdAndUserId(rid: string, uid: string, draft: string | undefined): Promise> { + updateDraftByRoomIdAndUserId(rid: string, uid: string, draft: string | undefined, tmid?: string): Promise> { const query = { rid, 'u._id': uid }; - const update = draft ? { $set: { draft } } : { $unset: { draft: 1 as const } }; + + const field = tmid ? `threadDrafts.${tmid}` : 'draft'; + const update = draft ? { $set: { [field]: draft } } : { $unset: { [field]: 1 as const } }; return this.findOneAndUpdate(query, update, { returnDocument: 'after' }); } From 9a157c7d053379a545042bfb9e2fa6210d74451f Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 14 Jul 2026 15:59:48 -0300 Subject: [PATCH 3/9] chore: changeset --- .changeset/funny-wings-sell.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/funny-wings-sell.md diff --git a/.changeset/funny-wings-sell.md b/.changeset/funny-wings-sell.md new file mode 100644 index 0000000000000..539df3d0e822d --- /dev/null +++ b/.changeset/funny-wings-sell.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Replaces the "Drafts in sidebar" feature preview with an always-on draft indicator. Thread-composer drafts are also persisted per thread and indicated in the thread list From b4a750dc23986530bc0b58fe35e043ef1655bfff Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Thu, 16 Jul 2026 23:18:31 -0300 Subject: [PATCH 4/9] test: add unit tests for useDraft hook Also keep threadExistsRef in sync with the threadExists prop so flushDraft respects thread existence changes after mount. Co-Authored-By: Claude Fable 5 --- .../messageBox/hooks/useDraft.spec.tsx | 333 ++++++++++++++++++ .../composer/messageBox/hooks/useDraft.ts | 6 +- 2 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx new file mode 100644 index 0000000000000..e10b8268e3f3a --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx @@ -0,0 +1,333 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { useDraft } from './useDraft'; + +// `useLocalStorage` from fuselage-hooks namespaces keys and serializes values as JSON +const storageKey = (rid: string, tmid?: string) => `fuselage-localStorage-messagebox_${rid}${tmid ? `-${tmid}` : ''}`; + +const seedLocalDraft = (value: string, rid: string, tmid?: string) => { + localStorage.setItem(storageKey(rid, tmid), JSON.stringify(value)); +}; + +const readLocalDraft = (rid: string, tmid?: string) => { + const item = localStorage.getItem(storageKey(rid, tmid)); + return item === null ? null : JSON.parse(item); +}; + +type RenderUseDraftProps = { + serverDraft?: string; + threadExists?: boolean; +}; + +const renderUseDraft = ({ + rid = 'rid', + serverDraft, + tmid, + threadExists, + endpointHandler = jest.fn(() => null), +}: { + rid?: string; + tmid?: string; + endpointHandler?: jest.Mock; +} & RenderUseDraftProps = {}) => { + const { result, rerender } = renderHook, RenderUseDraftProps>( + ({ serverDraft, threadExists }) => useDraft(rid, serverDraft, tmid, threadExists), + { + initialProps: { serverDraft, threadExists }, + wrapper: mockAppRoot().withEndpoint('POST', '/v1/rooms.saveDraft', endpointHandler).build(), + }, + ); + + return { result, rerender, endpointHandler }; +}; + +afterEach(() => { + localStorage.clear(); +}); + +describe('initialValue', () => { + it('should be an empty string when there is no server draft nor local draft', () => { + const { result } = renderUseDraft(); + + expect(result.current.initialValue).toBe(''); + }); + + it('should restore the local draft when there is no server draft', () => { + seedLocalDraft('local draft', 'rid'); + + const { result } = renderUseDraft(); + + expect(result.current.initialValue).toBe('local draft'); + }); + + it('should restore the server draft when provided', () => { + const { result } = renderUseDraft({ serverDraft: 'server draft' }); + + expect(result.current.initialValue).toBe('server draft'); + }); + + it('should prefer the server draft over the local draft', () => { + seedLocalDraft('local draft', 'rid'); + + const { result } = renderUseDraft({ serverDraft: 'server draft' }); + + expect(result.current.initialValue).toBe('server draft'); + }); + + it('should fall back to the local draft when the server draft is an empty string', () => { + seedLocalDraft('local draft', 'rid'); + + const { result } = renderUseDraft({ serverDraft: '' }); + + expect(result.current.initialValue).toBe('local draft'); + }); + + it('should restore the thread-scoped local draft when a tmid is provided', () => { + seedLocalDraft('room draft', 'rid'); + seedLocalDraft('thread draft', 'rid', 'tmid'); + + const { result } = renderUseDraft({ tmid: 'tmid' }); + + expect(result.current.initialValue).toBe('thread draft'); + }); + + it('should not restore the room draft in a thread composer', () => { + seedLocalDraft('room draft', 'rid'); + + const { result } = renderUseDraft({ tmid: 'tmid' }); + + expect(result.current.initialValue).toBe(''); + }); + + it('should remain stable across rerenders even if the server draft changes', () => { + const { result, rerender } = renderUseDraft({ serverDraft: 'first' }); + + rerender({ serverDraft: 'second' }); + + expect(result.current.initialValue).toBe('first'); + }); +}); + +describe('persistLocal', () => { + it('should write the draft to local storage under the room-scoped key', () => { + const { result } = renderUseDraft(); + + act(() => { + result.current.persistLocal('typed message'); + }); + + expect(readLocalDraft('rid')).toBe('typed message'); + }); + + it('should write the draft to local storage under the thread-scoped key when a tmid is provided', () => { + const { result } = renderUseDraft({ tmid: 'tmid' }); + + act(() => { + result.current.persistLocal('typed in thread'); + }); + + expect(readLocalDraft('rid', 'tmid')).toBe('typed in thread'); + expect(readLocalDraft('rid')).toBe(null); + }); + + it('should overwrite a previously persisted draft', () => { + const { result } = renderUseDraft(); + + act(() => { + result.current.persistLocal('first'); + result.current.persistLocal('second'); + }); + + expect(readLocalDraft('rid')).toBe('second'); + }); + + it('should not call the save draft endpoint', () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal('typed message'); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); +}); + +describe('flushDraft', () => { + it('should not call the save draft endpoint when no draft was persisted', () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + + it('should not flush a draft restored from local storage that was never re-persisted', () => { + seedLocalDraft('restored draft', 'rid'); + + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + + it('should save the persisted draft to the server', async () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal('typed message'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' }); + }); + + it('should include the tmid when flushing a thread draft', async () => { + const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid' }); + + act(() => { + result.current.persistLocal('typed in thread'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in thread', tmid: 'tmid' }); + }); + + it('should flush only the latest persisted draft', async () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal('first'); + result.current.persistLocal('second'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'second' }); + }); + + it('should flush an empty draft so the server draft can be cleared', async () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal(''); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' }); + }); + + it('should not flush the same draft twice', async () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal('typed message'); + result.current.flushDraft(); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + }); + + it('should flush again after a new draft is persisted', async () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal('first'); + result.current.flushDraft(); + result.current.persistLocal('second'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(2)); + expect(endpointHandler).toHaveBeenNthCalledWith(1, { rid: 'rid', draft: 'first' }); + expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'second' }); + }); + + it('should discard the draft instead of saving it when the thread does not exist', () => { + const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false }); + + act(() => { + result.current.persistLocal('typed in deleted thread'); + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + + it('should keep discarding drafts persisted after a flush while the thread does not exist', () => { + const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false }); + + act(() => { + result.current.persistLocal('first'); + result.current.flushDraft(); + result.current.persistLocal('second'); + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + + it('should discard the draft when the thread stops existing after mount', () => { + const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: true }); + + act(() => { + result.current.persistLocal('typed before thread deletion'); + }); + + rerender({ threadExists: false }); + + act(() => { + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + + it('should save the draft when the thread starts existing after mount', async () => { + const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false }); + + rerender({ threadExists: true }); + + act(() => { + result.current.persistLocal('typed in new thread'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in new thread', tmid: 'tmid' }); + }); + + it('should still save the draft when threadExists is false but there is no tmid', async () => { + const { result, endpointHandler } = renderUseDraft({ threadExists: false }); + + act(() => { + result.current.persistLocal('typed message'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1)); + expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' }); + }); +}); + +describe('referential stability', () => { + it('should keep persistLocal and flushDraft identities across rerenders', () => { + const { result, rerender } = renderUseDraft(); + + const { persistLocal, flushDraft } = result.current; + + rerender({}); + + expect(result.current.persistLocal).toBe(persistLocal); + expect(result.current.flushDraft).toBe(flushDraft); + }); +}); diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts index f8aa9469e6217..2df4113dc21d2 100644 --- a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts @@ -1,6 +1,6 @@ import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threadExists = true) => { const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`; @@ -10,6 +10,10 @@ export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threa const draftRef = useRef(null); const threadExistsRef = useRef(threadExists); + useEffect(() => { + threadExistsRef.current = threadExists; + }, [threadExists]); + const persistLocal = useCallback( (value: string) => { draftRef.current = value; From e47caa23d5aa93d868be5104fdd0939ac026097b Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Thu, 16 Jul 2026 23:31:40 -0300 Subject: [PATCH 5/9] Update apps/meteor/server/api/v1/rooms.ts Co-authored-by: Kevin Aleman --- apps/meteor/server/api/v1/rooms.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/server/api/v1/rooms.ts b/apps/meteor/server/api/v1/rooms.ts index 2b1e8f7b5d539..483d6067fb015 100644 --- a/apps/meteor/server/api/v1/rooms.ts +++ b/apps/meteor/server/api/v1/rooms.ts @@ -425,7 +425,7 @@ const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string; tmid properties: { rid: { type: 'string', minLength: 1 }, draft: { type: 'string' }, - tmid: { type: 'string', minLength: 1, nullable: true }, + tmid: { type: 'string', minLength: 1 }, }, required: ['rid', 'draft'], additionalProperties: false, From fc3842a349fd412a88ae814fae532c5282c71487 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Thu, 23 Jul 2026 13:52:57 -0300 Subject: [PATCH 6/9] fix: thread draft not working properly --- .../views/room/composer/ComposerMessage.tsx | 2 +- .../composer/messageBox/hooks/useDraft.ts | 34 ++++++++++++++----- .../Threads/components/ThreadChat.tsx | 4 +-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index ed7a993a4c253..8eb14ca9036df 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -88,7 +88,7 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps) => { return ; } - return ; + return ; }; export default memo(ComposerMessage); diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts index 2df4113dc21d2..02e1f5c67d3dc 100644 --- a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts @@ -1,14 +1,25 @@ -import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback, useEffect, useRef } from 'react'; export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threadExists = true) => { const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`; - const [localDraft, setLocalDraft] = useLocalStorage(storageKey, ''); const saveDraft = useEndpoint('POST', '/v1/rooms.saveDraft'); - const initialValueRef = useRef(serverDraft || localDraft); + + const setLocalDraft = useCallback( + (value?: string) => { + if (value) { + localStorage.setItem(storageKey, value); + } else { + localStorage.removeItem(storageKey); + } + }, + [storageKey], + ); + + const initialValueRef = useRef(serverDraft || localStorage.getItem(storageKey) || ''); const draftRef = useRef(null); const threadExistsRef = useRef(threadExists); + const serverValueRef = useRef(serverDraft ?? ''); useEffect(() => { threadExistsRef.current = threadExists; @@ -27,14 +38,21 @@ export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threa return; } - if (tmid && !threadExistsRef.current) { - draftRef.current = null; + const draft = draftRef.current; + draftRef.current = null; + + if (tmid && !threadExistsRef.current && draft) { return; } - void saveDraft({ rid, draft: draftRef.current, ...(tmid && { tmid }) }); - draftRef.current = null; - }, [saveDraft, rid, tmid]); + if (draft === serverValueRef.current) { + return; + } + + serverValueRef.current = draft; + + void saveDraft({ rid, draft, ...(tmid && { tmid }) }).then(() => setLocalDraft()); + }, [saveDraft, rid, tmid, setLocalDraft]); return { initialValue: initialValueRef.current, diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 3b1bc47d446f4..59b3e8f260301 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -1,5 +1,5 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; -import { isEditedMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isThreadMainMessage } from '@rocket.chat/core-typings'; import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client'; import { useMethod, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; @@ -113,7 +113,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { Date: Thu, 23 Jul 2026 13:53:10 -0300 Subject: [PATCH 7/9] test: update unit test --- .../messageBox/hooks/useDraft.spec.tsx | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx index e10b8268e3f3a..42006d5b500b4 100644 --- a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.spec.tsx @@ -3,17 +3,13 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { useDraft } from './useDraft'; -// `useLocalStorage` from fuselage-hooks namespaces keys and serializes values as JSON -const storageKey = (rid: string, tmid?: string) => `fuselage-localStorage-messagebox_${rid}${tmid ? `-${tmid}` : ''}`; +const storageKey = (rid: string, tmid?: string) => `messagebox_${rid}${tmid ? `-${tmid}` : ''}`; const seedLocalDraft = (value: string, rid: string, tmid?: string) => { - localStorage.setItem(storageKey(rid, tmid), JSON.stringify(value)); + localStorage.setItem(storageKey(rid, tmid), value); }; -const readLocalDraft = (rid: string, tmid?: string) => { - const item = localStorage.getItem(storageKey(rid, tmid)); - return item === null ? null : JSON.parse(item); -}; +const readLocalDraft = (rid: string, tmid?: string) => localStorage.getItem(storageKey(rid, tmid)); type RenderUseDraftProps = { serverDraft?: string; @@ -142,6 +138,18 @@ describe('persistLocal', () => { expect(readLocalDraft('rid')).toBe('second'); }); + it('should remove the local entry instead of storing an empty string', () => { + seedLocalDraft('local draft', 'rid'); + + const { result } = renderUseDraft(); + + act(() => { + result.current.persistLocal(''); + }); + + expect(readLocalDraft('rid')).toBe(null); + }); + it('should not call the save draft endpoint', () => { const { result, endpointHandler } = renderUseDraft(); @@ -188,6 +196,17 @@ describe('flushDraft', () => { expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' }); }); + it('should remove the local copy once the draft is saved to the server', async () => { + const { result } = renderUseDraft(); + + act(() => { + result.current.persistLocal('typed message'); + result.current.flushDraft(); + }); + + await waitFor(() => expect(readLocalDraft('rid')).toBe(null)); + }); + it('should include the tmid when flushing a thread draft', async () => { const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid' }); @@ -213,8 +232,8 @@ describe('flushDraft', () => { expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'second' }); }); - it('should flush an empty draft so the server draft can be cleared', async () => { - const { result, endpointHandler } = renderUseDraft(); + it('should flush an empty draft to clear an existing server draft', async () => { + const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' }); act(() => { result.current.persistLocal(''); @@ -225,6 +244,28 @@ describe('flushDraft', () => { expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' }); }); + it('should not call the endpoint when flushing an empty draft with no existing server draft', () => { + const { result, endpointHandler } = renderUseDraft(); + + act(() => { + result.current.persistLocal(''); + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + + it('should not re-save a restored server draft that was not edited', () => { + const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' }); + + act(() => { + result.current.persistLocal('server draft'); + result.current.flushDraft(); + }); + + expect(endpointHandler).not.toHaveBeenCalled(); + }); + it('should not flush the same draft twice', async () => { const { result, endpointHandler } = renderUseDraft(); @@ -252,7 +293,7 @@ describe('flushDraft', () => { expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'second' }); }); - it('should discard the draft instead of saving it when the thread does not exist', () => { + it('should not save the draft to the server when the thread does not exist', () => { const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false }); act(() => { @@ -263,6 +304,17 @@ describe('flushDraft', () => { expect(endpointHandler).not.toHaveBeenCalled(); }); + it('should keep the draft in local storage when the thread does not exist', () => { + const { result } = renderUseDraft({ tmid: 'tmid', threadExists: false }); + + act(() => { + result.current.persistLocal('typed in deleted thread'); + result.current.flushDraft(); + }); + + expect(readLocalDraft('rid', 'tmid')).toBe('typed in deleted thread'); + }); + it('should keep discarding drafts persisted after a flush while the thread does not exist', () => { const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false }); @@ -276,7 +328,7 @@ describe('flushDraft', () => { expect(endpointHandler).not.toHaveBeenCalled(); }); - it('should discard the draft when the thread stops existing after mount', () => { + it('should not save the draft when the thread stops existing after mount', () => { const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: true }); act(() => { From 4dbf429a5cd0f9dd6f5d96cc592b14ba59571d7d Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Thu, 23 Jul 2026 15:11:49 -0300 Subject: [PATCH 8/9] fix: validate tmid when saving a thread draft --- apps/meteor/server/api/v1/rooms.ts | 7 ++++ apps/meteor/tests/end-to-end/api/rooms.ts | 42 +++++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/meteor/server/api/v1/rooms.ts b/apps/meteor/server/api/v1/rooms.ts index c1ffd13bf6924..629aa9c1d3909 100644 --- a/apps/meteor/server/api/v1/rooms.ts +++ b/apps/meteor/server/api/v1/rooms.ts @@ -458,6 +458,13 @@ const roomsSaveDraftEndpoint = API.v1.post( return API.v1.failure('error-message-size-exceeded'); } + if (tmid && draft) { + const thread = await Messages.findOneByRoomIdAndMessageId(rid, tmid, { projection: { _id: 1 } }); + if (!thread) { + return API.v1.failure('error-invalid-message'); + } + } + const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined, tmid); if (!subscription) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription'); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 086a48097f51b..2738624958d6c 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -107,6 +107,7 @@ describe('[Rooms]', () => { describe('[/rooms.saveDraft]', () => { let testChannel: IRoom; + let threadId: IMessage['_id']; let userWithoutSubscription: TestUser; let userWithoutSubscriptionCredentials: Credentials; @@ -114,6 +115,10 @@ describe('[Rooms]', () => { testChannel = (await createRoom({ type: 'c', name: `rooms.saveDraft.test.${Date.now()}-${Math.random()}` })).body.channel; userWithoutSubscription = await createUser({ joinDefaultChannels: false }); userWithoutSubscriptionCredentials = await login(userWithoutSubscription.username, password); + + const rootMessage = (await sendSimpleMessage({ roomId: testChannel._id, text: 'thread root' })).body.message; + threadId = rootMessage._id; + await sendSimpleMessage({ roomId: testChannel._id, text: 'thread reply', tmid: threadId }); }); after(() => Promise.all([deleteRoom({ type: 'c', roomId: testChannel._id }), deleteUser(userWithoutSubscription)])); @@ -182,13 +187,12 @@ describe('[Rooms]', () => { }); it('should save a thread draft keyed by tmid without touching the main draft', async () => { - const tmid = `tmid-${Date.now()}`; const draft = `thread-draft-${Date.now()}`; await request .post(api('rooms.saveDraft')) .set(credentials) - .send({ rid: testChannel._id, draft, tmid }) + .send({ rid: testChannel._id, draft, tmid: threadId }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { @@ -203,18 +207,42 @@ describe('[Rooms]', () => { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body.subscription).to.have.nested.property(`threadDrafts.${tmid}`, draft); + expect(res.body.subscription).to.have.nested.property(`threadDrafts.${threadId}`, draft); expect(res.body.subscription).to.not.have.property('draft'); }); }); + it('should reject a thread draft when the tmid is not a thread in the room', async () => { + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: 'malicious draft', tmid: `non-existent-thread-${Date.now()}` }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-message'); + }); + }); + + it('should allow clearing a thread draft even when the tmid is not a thread in the room', async () => { + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: '', tmid: `non-existent-thread-${Date.now()}` }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + it('should clear a thread draft when saving an empty draft for the tmid', async () => { - const tmid = `tmid-to-clear-${Date.now()}`; const draft = `thread-draft-${Date.now()}`; - await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft, tmid }).expect(200); + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft, tmid: threadId }).expect(200); - await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '', tmid }).expect(200); + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '', tmid: threadId }).expect(200); await request .get(api('subscriptions.getOne')) @@ -224,7 +252,7 @@ describe('[Rooms]', () => { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body.subscription).to.not.have.nested.property(`threadDrafts.${tmid}`); + expect(res.body.subscription).to.not.have.nested.property(`threadDrafts.${threadId}`); }); }); From c00578444139a3f5da59ee2e6d9f356e93d58e23 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 23 Jul 2026 14:43:15 -0600 Subject: [PATCH 9/9] fix: allow thread drafts for non-alphanumeric message ids (e.g. Slack imports) --- apps/meteor/server/api/v1/rooms.ts | 9 +-- apps/meteor/tests/end-to-end/api/rooms.ts | 76 +++++++++++++++++++++-- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/apps/meteor/server/api/v1/rooms.ts b/apps/meteor/server/api/v1/rooms.ts index 629aa9c1d3909..c12a18653e1df 100644 --- a/apps/meteor/server/api/v1/rooms.ts +++ b/apps/meteor/server/api/v1/rooms.ts @@ -425,7 +425,7 @@ const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string; tmid properties: { rid: { type: 'string', minLength: 1 }, draft: { type: 'string' }, - tmid: { type: 'string', minLength: 1 }, + tmid: { type: 'string', minLength: 1, pattern: '^[^.$]+$' }, }, required: ['rid', 'draft'], additionalProperties: false, @@ -458,13 +458,6 @@ const roomsSaveDraftEndpoint = API.v1.post( return API.v1.failure('error-message-size-exceeded'); } - if (tmid && draft) { - const thread = await Messages.findOneByRoomIdAndMessageId(rid, tmid, { projection: { _id: 1 } }); - if (!thread) { - return API.v1.failure('error-invalid-message'); - } - } - const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined, tmid); if (!subscription) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription'); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 2738624958d6c..363c0b8225f42 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -212,16 +212,65 @@ describe('[Rooms]', () => { }); }); - it('should reject a thread draft when the tmid is not a thread in the room', async () => { + it('should never write a nested key to the subscription when the tmid contains a mongo path separator', async () => { + for (const tmid of ['threadDrafts.polluted', '__proto__.polluted', 'foo.bar.baz', '$set.x']) { + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: 'polluted', tmid }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '', tmid }).expect(400); + } + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect(200) + .expect((res) => { + expect(res.body.subscription).to.not.have.nested.property('threadDrafts.polluted'); + expect(res.body.subscription).to.not.have.nested.property('threadDrafts.__proto__'); + expect(res.body.subscription).to.not.have.nested.property('threadDrafts.foo'); + }); + }); + + it('should accept a thread draft for a message whose _id is non-alphanumeric (e.g. imported threads)', async () => { + const importedRootId = `slack-C1-${Date.now()}-000001`; + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ message: { _id: importedRootId, rid: testChannel._id, msg: 'imported thread root' } }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('_id', importedRootId); + }); + + const draft = `imported-thread-draft-${Date.now()}`; + await request .post(api('rooms.saveDraft')) .set(credentials) - .send({ rid: testChannel._id, draft: 'malicious draft', tmid: `non-existent-thread-${Date.now()}` }) + .send({ rid: testChannel._id, draft, tmid: importedRootId }) .expect('Content-Type', 'application/json') - .expect(400) + .expect(200) .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'error-invalid-message'); + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect(200) + .expect((res) => { + expect(res.body.subscription.threadDrafts).to.have.property(importedRootId, draft); }); }); @@ -229,7 +278,7 @@ describe('[Rooms]', () => { await request .post(api('rooms.saveDraft')) .set(credentials) - .send({ rid: testChannel._id, draft: '', tmid: `non-existent-thread-${Date.now()}` }) + .send({ rid: testChannel._id, draft: '', tmid: `nonExistentThread${Date.now()}` }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { @@ -237,6 +286,21 @@ describe('[Rooms]', () => { }); }); + it('should return the same error for a subscribed and an unsubscribed room regardless of tmid existence', async () => { + for (const tmid of [threadId, `nonExistentThread${Date.now()}`]) { + await request + .post(api('rooms.saveDraft')) + .set(userWithoutSubscriptionCredentials) + .send({ rid: testChannel._id, draft: 'probe', tmid }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-subscription'); + }); + } + }); + it('should clear a thread draft when saving an empty draft for the tmid', async () => { const draft = `thread-draft-${Date.now()}`;