From 1f5425e8a5a49ff04b686769557ec5e8224dc4d4 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 27 Feb 2026 17:38:35 -0300 Subject: [PATCH 1/7] chore: add sidebarDrafts feature preview --- .../images/featurePreview/sidebar-drafts.png | Bin 0 -> 3926 bytes packages/i18n/src/locales/en.i18n.json | 2 ++ .../ui-client/src/hooks/useFeaturePreviewList.ts | 11 ++++++++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/public/images/featurePreview/sidebar-drafts.png diff --git a/apps/meteor/public/images/featurePreview/sidebar-drafts.png b/apps/meteor/public/images/featurePreview/sidebar-drafts.png new file mode 100644 index 0000000000000000000000000000000000000000..b49f579ab70f52b4d6251e8b0b835b27f49ac07d GIT binary patch literal 3926 zcmZu!c{r49+rRIb!3;C@Wn{}zLNWGzA6s?`i5g^ih{hIWyAfk)EG^=x2$7oXd$t?J zgCdm(Ws1}zWmGcuZ+gD>dyn_{j`upQ<2--ob)CQSytea?E7Qrr3dgyR69535jWy8) z0FW>Xx1o_NDV85y2mm0_$?lj1%iP%5T4!#qZ~X3idwZLk+Z!9(>&&fxV-{ewm4775 zQ$@p&1@(+sq2_)>02g=mGFi-YUrAS>LI;@&$IH1D3`C*Hg_L8 z;d`r$M$k0UF|ZB@iPAB&j7>~8vvT_WW6jjk(bUSRt$Q$;l6x*HK~>B2YEhZDZwPZ^ z>lbr#dwVDUT2a8+C|jo!z5Ro+3F)LWA=Y-sV&hWYFE1^ATAH4nYwI4Upf#N($K0pY zjoqKAeApbDNYOX3rnKCPkth%dv{yWqIyOF0?9y_(IP$NZDsz)+`y-vr_&XGbwNeV4{IBhll-O5drGQ1j8)B) zG!Eufc3(`m#Agy^uju;&a7<*KBX5+8gDbFqZl#2GjFdMA5m+FPLiw)5x`TQfqUo{N z>E*XwVKW3l6Q7N_AjnL_>yU}7={NBdI|+jkYpIdav z8oV~XP^tH3`Sr|ks`Kn59Qd&1%iGGOlgpVRbv|8HqoKbvN69w6qusYp_l)d=q(>~G zqpW(6WO+Y_f|%JJ$a_$~FcC~GxVc0>i4z?AuwI_9=Ie|k{LSF+ssRg|Y9FkuP1bKn zB8t0@)9jmRAijAXx34b5J2Zr(*BQHaBlJlvVZW_tl5*$9z3K=1pcAdw*xiy8$FZa& zPLg}LHe=^zywon5!WsH0H`nHAC(mG2!OKF)4uVAq^7PU;@`(Irk_&ueC4Gt;xpwy}wy+D^AZb^0If> z4hN5AR*n2cX}Y`gqjinAJ??jDKyd6OX$bJ!EFybFIp1O)dLvZf5s>S2VW-HC}h8UOqZ4 zM&tsbU_qH{$=UJyu=iqfE@pCAa37_Hu&AuP+3Vy^L0oaA zNciWh&&slNns&asz_|&iTpWeax+4*NqdB!y-T_8o5iJx5QwZT?Nr;1_esm#(nHpt! zurOj&A^Nv!St|bRVE-Qugx}*@m;cS9zYkbXaz@#~sIz!bfNTVgEl`wdr0Gb+E|h{$ z<^=@^pxCbo0bPWEZUbQ~Q~dI)_I>2GmgL}K)7uxX38!`bK16*5CLiNiKa_ z!BwHT!81C?=fypj(Pcz{qW@|AMdiUeHKd2jcOdg)C4uEFlMfoxcRDMFv*al1)L9c3 zj`X+$JI&O$`s`hwV%M@GXf;78zfAd3?G6wrHwowp!>e~SSuhS-)V+S=Od8t;D4yy`bY{24LAuR z)gd4ie%T|XyF24ec@tg;y(fVEK8W;w6qaIv+&#P1m65@|{q9!+KOc@+ZwE^w-pSH= z!4*Do2DFaWA;NJeaT&THf}4*a2~yCpxsW-k(+ZYuX^2B@2hQajKg&^i2y5JbG3^@7 zS|KAVMOcesGP>0>(uL`lyDIb4?&F!-9=`K?_0G?orr|ML2zTJW$R36B*js?uQRv*w z?LC)H#*xDreB}q>bbk5_B68?3-zWZ)Y4Vo#zsap^TBJ%VvJa*z)TYp2@=s20V(1Z{ zHq?|8BL3RaU{N>1(kJGyT~2LZd%$8v{sEnfW1{5;eupYAT6wGTiHkJ$`7`9H8lL7i z9k3ODlUndQuVcGCxeQqYb@%~&p4Le~@la>Zdf`8`Z2GemHu3_O7`@mQ7YJuGucnRj zlgu#;3xb3LTfu9!FWfpW97n*ZHBCw$n@2*AF*0^4$$A}HEe==Cdul}DxSY0)+CF&i zex83{5T>rQDLKhyMJH>9)G1a6+&l`EOd9|CMrwB6pEJ)?T999T(T`Q{!w&^J@w-LP zo*!AL3V!6&)T|o%Vsv{a!Niit%X8W?%7ooO7VA+w4$a4`IqqaIoVqbF7CX`%|bQ^~?>l&VnNr>tSR)AX*68atz@K zNbYYonV>p767gC)Sc*QgIG5S;y?x9|vv;Sr1uZwd@ z$MFmlE2}JMY-nU)V6=vGpdQV`&v3OhyBX8(-3QOL=cRJ#A~ZMA2{G%N2i(~2PE%3K z!;eAh^|A7r?DMlX^!iU2+)P#w#4?F+bj_bs?>9|u66)yKbF%}2CVzPOyFtOT2OYn8 zU!`NQg?L6Z!eMw=ccSXx6K7+(U3Ev7V2i|U@EThb#v|=-JVBKn!qyqAP1QgSa|WGN zp%1sYg(Xi{CyL(DuhOgwfHg44?l3rQyoTE5m$>9o9S7={>uX*)*`^SwJ*M$T0xVuT@_qUIkmib z9Z2GJg&zNT%YsB41Y2v|Td%Ke!^uD^aH~#6^$oiL-|NwDNO3jQFPqX)U(nHc!)@&rSZmg+5t zr~KqhZ|Fh>PxXn08A(9W4ArKu2_{C787%uR$#i-lOtb$onp#vu?rRRjxk!8|oLij_*c7SBYrC?E{|AdV>niFokvcE%V0s zqwFDlQSh4s4wC9h-1y#Gu1LzC;VlrQxkD?1;q@B)h#Xzul0s0jUxSCFnF!QAbCBcP z!KxMLfS|g?;;E;vW6IbhMWh<2+k4R8L~gzO@|Ud5{ahFtQ6vo=%%mQL%{~i}{-8z< z%IQsb4kBW+{Q9(jIgU$;Z+Xg&i*e~?MA%abRlqU7*=tI9mjj(SjF7GgnSeWxgwLIu z1ZA!Dy&qhOeC38<7uu&JK`PY|MxG{5a>H-9VMJXW>aa_W#D|f3Usr@3AAJrH&sg|s zDpYqu@_R$OOXNrZ<{=Q69bq`Gaj8rv`iGBLU4%d^8zCpGlJf?1e4{%oawtrIlm)DO zB67>>klMNXiB>=eJmBdK6lfu?I*gx5$rAFkr$H4H8^?};sULugS|CYuSB7!qanqEV zPdea=idBKALhWLeoRsLaXHJC(f;eZ0kjvBIa2bzo48Dm!+y!Z1Q*_$~u71;Jh_1wj z{qbbkNWzRoG=`GZX|HZQQ)4>kD-2eNmtKhz0w>j+b^oNc5}ZXG7Vd2FgS!|~;TfMo zYvxt8{8T|zjaR*qrT4pu(B2caoqAHEc~BN>4TWL%&7lO=i&jA_JIArl-cTAL{lzN1 zGQx*mN%e`j=4m;cpX& zIk5|ELClZz0b=0bvybMT^8Op-=`la}`VhC$ui7L|G zr`LONQ0{=$;C#6oBqFZljxCnIllIaJalVK4y`7jW$Zp~ZXmqGbAJ&$EUr8+|jVR}I zJ^&fntNoPum}-E=Yn~YYhbr#vWB$6h915-ZN?-c`!=4eSD}6-%ahNQ^Zs+3UC~CqX zHrl}-NvHf`GDrG3Nmp&3sbwC(N;M|n?Rsv0IubjK)(8Z1A e>yct{bLXx=1o(tg`8C$Z4zRIsAl8_XQvL_fFCmQp literal 0 HcmV?d00001 diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c2cc8d1e6cbf6..a6c0c2d1b45ab 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1857,6 +1857,8 @@ "Download_Snippet": "Download", "Download_file": "Download file", "Downloading_file_from_external_URL": "Downloading file from external URL", + "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.", diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index 73d6c91e40b0a..78a32e0effb8a 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' | 'expandableMessageComposer'; +export type FeaturesAvailable = 'secondarySidebar' | 'sidebarDrafts'; export type FeaturePreviewProps = { name: FeaturesAvailable; @@ -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); From e3bc7b84e22b0caa8835823400261b9ecd0965b4 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 27 Feb 2026 17:39:37 -0300 Subject: [PATCH 2/7] feat: implement drafts sidebar group --- .../client/sidebar/hooks/useDraftRoomIds.ts | 82 +++++++++++++++++++ .../client/sidebar/hooks/useRoomList.ts | 16 +++- apps/meteor/server/settings/accounts.ts | 1 + 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts diff --git a/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts b/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts new file mode 100644 index 0000000000000..572142a37ad55 --- /dev/null +++ b/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts @@ -0,0 +1,82 @@ +import { useSyncExternalStore, useCallback, useRef } from 'react'; + +const MESSAGEBOX_PREFIX = 'messagebox_'; + +const areSetsEqual = (a: Set, b: Set): boolean => { + if (a.size !== b.size) return false; + for (const item of a) { + if (!b.has(item)) return false; + } + return true; +}; + +const emptySet = new Set(); + +export const useDraftRoomIds = (enabled = true): Set => { + const cacheRef = useRef>(new Set()); + + const getSnapshot = useCallback(() => { + if (!enabled) { + return emptySet; + } + + if (typeof window === 'undefined' || !window.localStorage) { + return cacheRef.current; + } + + const draftRoomIds = new Set(); + + try { + const keys = Object.keys(localStorage); + for (const key of keys) { + if (!key.startsWith(MESSAGEBOX_PREFIX)) { + continue; + } + + const content = localStorage.getItem(key); + if (!content?.trim()) { + continue; + } + + const roomId = key.substring(MESSAGEBOX_PREFIX.length); + draftRoomIds.add(roomId); + } + } catch (error) { + console.error('Error reading drafts from localStorage:', error); + } + + if (!areSetsEqual(cacheRef.current, draftRoomIds)) { + cacheRef.current = draftRoomIds; + } + + return cacheRef.current; + }, [enabled]); + + const subscribe = useCallback((callback: () => void) => { + if (!enabled) { + return () => {}; + } + + const handleStorageChange = (e: StorageEvent) => { + if (e.key?.startsWith(MESSAGEBOX_PREFIX) || e.key === null) { + callback(); + } + }; + + window.addEventListener('storage', handleStorageChange); + + const handleLocalUpdate = (e: Event) => { + if (e instanceof CustomEvent && e.detail?.key?.startsWith(MESSAGEBOX_PREFIX)) { + callback(); + } + }; + window.addEventListener('localStorageUpdated', handleLocalUpdate); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('localStorageUpdated', handleLocalUpdate); + }; + }, [enabled]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index 2fb1bfc3366b3..25ed590b183a1 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -3,8 +3,10 @@ import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; 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'; +import { useFeaturePreview } from '@rocket.chat/ui-client'; import { useMemo } from 'react'; +import { useDraftRoomIds } from './useDraftRoomIds'; import { useSortQueryOptions } from '../../hooks/useSortQueryOptions'; import { useOmnichannelEnabled } from '../../views/omnichannel/hooks/useOmnichannelEnabled'; import { useQueuedInquiries } from '../../views/omnichannel/hooks/useQueuedInquiries'; @@ -19,6 +21,7 @@ const order = [ 'Open_Livechats', 'On_Hold_Chats', 'Unread', + 'Drafts', 'Favorites', 'Teams', 'Discussions', @@ -40,7 +43,8 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); - const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; + const sidebarDrafts = useFeaturePreview('sidebarDrafts'); + const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); @@ -52,12 +56,15 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const incomingCalls = useVideoConfIncomingCalls(); + const draftRoomIds = useDraftRoomIds(sidebarDrafts); + const queue = inquiries.enabled ? inquiries.queue : emptyQueue; const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useDebouncedValue( useMemo(() => { const isCollapsed = (groupTitle: string) => collapsedGroups?.includes(groupTitle); + const drafts = new Set(); const incomingCall = new Set(); const favorite = new Set(); const team = new Set(); @@ -82,6 +89,10 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) return unread.add(room); } + if (sidebarDrafts && draftRoomIds.has(room.rid)) { + return drafts.add(room); + } + if (favoritesEnabled && room.f) { return favorite.add(room); } @@ -122,6 +133,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); @@ -193,6 +206,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) rooms, showOmnichannel, inquiries.enabled, + draftRoomIds, queue, sidebarShowUnread, favoritesEnabled, diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index 9860fb3b26455..df4cf51dbd46a 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -749,6 +749,7 @@ export const createAccountSettings = () => 'Open_Livechats', 'On_Hold_Chats', 'Unread', + 'Drafts', 'Favorites', 'Teams', 'Discussions', From 4f1855b024535f702b538b7e257dfd08ef3c020e Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 27 Feb 2026 20:08:20 -0300 Subject: [PATCH 3/7] fix: lint --- .../client/sidebar/hooks/useDraftRoomIds.ts | 37 ++++++++----------- .../client/sidebar/hooks/useRoomList.ts | 4 +- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts b/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts index 572142a37ad55..9c2f90bf7a252 100644 --- a/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts +++ b/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts @@ -52,31 +52,26 @@ export const useDraftRoomIds = (enabled = true): Set => { return cacheRef.current; }, [enabled]); - const subscribe = useCallback((callback: () => void) => { - if (!enabled) { - return () => {}; - } - - const handleStorageChange = (e: StorageEvent) => { - if (e.key?.startsWith(MESSAGEBOX_PREFIX) || e.key === null) { - callback(); + const subscribe = useCallback( + (callback: () => void) => { + if (!enabled) { + return () => undefined; } - }; - window.addEventListener('storage', handleStorageChange); + const handleStorageChange = (e: StorageEvent) => { + if (e.key?.startsWith(MESSAGEBOX_PREFIX) || e.key === null) { + callback(); + } + }; - const handleLocalUpdate = (e: Event) => { - if (e instanceof CustomEvent && e.detail?.key?.startsWith(MESSAGEBOX_PREFIX)) { - callback(); - } - }; - window.addEventListener('localStorageUpdated', handleLocalUpdate); + window.addEventListener('storage', handleStorageChange); - return () => { - window.removeEventListener('storage', handleStorageChange); - window.removeEventListener('localStorageUpdated', handleLocalUpdate); - }; - }, [enabled]); + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, + [enabled], + ); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); }; diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index 25ed590b183a1..f091381949492 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -1,9 +1,9 @@ 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'; -import { useFeaturePreview } from '@rocket.chat/ui-client'; import { useMemo } from 'react'; import { useDraftRoomIds } from './useDraftRoomIds'; @@ -44,7 +44,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); const sidebarDrafts = useFeaturePreview('sidebarDrafts'); - const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; + const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); From 3e83767201fd031cec291eec76faea42c5d785e4 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 3 Mar 2026 10:07:45 -0300 Subject: [PATCH 4/7] chore: changeset --- .changeset/red-maps-wink.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/red-maps-wink.md diff --git a/.changeset/red-maps-wink.md b/.changeset/red-maps-wink.md new file mode 100644 index 0000000000000..c6f130650b185 --- /dev/null +++ b/.changeset/red-maps-wink.md @@ -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. \ No newline at end of file From d261ca1661943e88748dd6892aee3ab1f4279a1e Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 3 Mar 2026 10:15:30 -0300 Subject: [PATCH 5/7] i18n: add Drafts key --- packages/i18n/src/locales/en.i18n.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index a6c0c2d1b45ab..28357ae8779c4 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1857,6 +1857,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.", "Drop_to_upload_file": "Drop to upload file", From fb59dfe904a33bd7ec43c0c9ed648f2ef574f88a Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 6 Apr 2026 19:10:51 -0300 Subject: [PATCH 6/7] feat: add saveDraft endpoint and update subscription models for draft support --- .../meteor/app/api/server/v1/subscriptions.ts | 26 +++++++++++++++++++ apps/meteor/lib/publishFields.ts | 1 + packages/core-typings/src/ISubscription.ts | 2 ++ .../src/models/ISubscriptionsModel.ts | 1 + packages/models/src/models/Subscriptions.ts | 8 ++++++ .../src/v1/subscriptionsEndpoints.ts | 22 ++++++++++++++++ 6 files changed, 60 insertions(+) diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index b6e406bbfc969..bcb573e4fed23 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -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'; @@ -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(); + }, + }, +); diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 4ef4b6fe95197..5f977c3669030 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -39,6 +39,7 @@ export const subscriptionFields = { E2EKey: 1, E2ESuggestedKey: 1, oldRoomKeys: 1, + draft: 1, tunread: 1, tunreadGroup: 1, tunreadUser: 1, diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 65751dd46638b..eac7b652d8bfc 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -65,6 +65,8 @@ export interface ISubscription extends IRocketChatRecord { department?: unknown; + draft?: string; + desktopPrefOrigin?: 'subscription' | 'user'; mobilePrefOrigin?: 'subscription' | 'user'; emailPrefOrigin?: 'subscription' | 'user'; diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 27a8e2dadc709..1cd4428dadc65 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -151,6 +151,7 @@ export interface ISubscriptionsModel extends IBaseModel { updateHideUnreadStatusById(_id: string, hideUnreadStatus: boolean): Promise; updateAudioNotificationValueById(_id: string, audioNotificationValue: string): Promise; updateAutoTranslateLanguageById(_id: string, autoTranslateLanguage: string): Promise; + updateDraftById(_id: string, draft: string | undefined): Promise; removeByVisitorToken(token: string): Promise; findByToken(token: string, options?: FindOptions): FindCursor; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index f85013fd4bce4..9c8f6b2468042 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -716,6 +716,14 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne(query, update); } + updateDraftById(_id: string, draft: string | undefined): Promise { + if (draft) { + return this.updateOne({ _id }, { $set: { draft } }); + } + + return this.updateOne({ _id }, { $unset: { draft: 1 } }); + } + updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise { const query = { 'u._id': userId, diff --git a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts index ce820565673c7..f68f45c1d84c3 100644 --- a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts +++ b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts @@ -106,6 +106,24 @@ const SubscriptionsUnreadSchema = { export const isSubscriptionsUnreadProps = ajv.compile(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(SubscriptionsSaveDraftSchema); + export type SubscriptionsEndpoints = { '/v1/subscriptions.get': { GET: (params: SubscriptionsGet) => { @@ -127,4 +145,8 @@ export type SubscriptionsEndpoints = { '/v1/subscriptions.unread': { POST: (params: SubscriptionsUnread) => void; }; + + '/v1/subscriptions.saveDraft': { + POST: (params: SubscriptionsSaveDraft) => void; + }; }; From dd3e4a6697bb2a3273e6bf4ec0bc5d2d7f515b7c Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 6 Apr 2026 19:11:26 -0300 Subject: [PATCH 7/7] feat: refactor draft handling in message box and sidebar components --- .../client/messageBox/createComposerAPI.ts | 17 ++-- .../client/sidebar/hooks/useDraftRoomIds.ts | 77 ------------------- .../client/sidebar/hooks/useRoomList.ts | 7 +- .../room/composer/messageBox/MessageBox.tsx | 11 ++- .../composer/messageBox/hooks/useDraft.ts | 34 ++++++++ 5 files changed, 52 insertions(+), 94 deletions(-) delete mode 100644 apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts create mode 100644 apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index ed282ce3c75aa..291e6355dfce7 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -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'; @@ -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, ): ComposerAPI => { @@ -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 => { @@ -269,10 +268,12 @@ export const createComposerAPI = ( const insertNewLine = (): void => insertText('\n'); - setText(Accounts.storageLocation.getItem(storageID) ?? '', { + setText(initialDraft, { skipFocus: true, }); + ready = true; + // 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; diff --git a/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts b/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts deleted file mode 100644 index 9c2f90bf7a252..0000000000000 --- a/apps/meteor/client/sidebar/hooks/useDraftRoomIds.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useSyncExternalStore, useCallback, useRef } from 'react'; - -const MESSAGEBOX_PREFIX = 'messagebox_'; - -const areSetsEqual = (a: Set, b: Set): boolean => { - if (a.size !== b.size) return false; - for (const item of a) { - if (!b.has(item)) return false; - } - return true; -}; - -const emptySet = new Set(); - -export const useDraftRoomIds = (enabled = true): Set => { - const cacheRef = useRef>(new Set()); - - const getSnapshot = useCallback(() => { - if (!enabled) { - return emptySet; - } - - if (typeof window === 'undefined' || !window.localStorage) { - return cacheRef.current; - } - - const draftRoomIds = new Set(); - - try { - const keys = Object.keys(localStorage); - for (const key of keys) { - if (!key.startsWith(MESSAGEBOX_PREFIX)) { - continue; - } - - const content = localStorage.getItem(key); - if (!content?.trim()) { - continue; - } - - const roomId = key.substring(MESSAGEBOX_PREFIX.length); - draftRoomIds.add(roomId); - } - } catch (error) { - console.error('Error reading drafts from localStorage:', error); - } - - if (!areSetsEqual(cacheRef.current, draftRoomIds)) { - cacheRef.current = draftRoomIds; - } - - return cacheRef.current; - }, [enabled]); - - const subscribe = useCallback( - (callback: () => void) => { - if (!enabled) { - return () => undefined; - } - - const handleStorageChange = (e: StorageEvent) => { - if (e.key?.startsWith(MESSAGEBOX_PREFIX) || e.key === null) { - callback(); - } - }; - - window.addEventListener('storage', handleStorageChange); - - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, - [enabled], - ); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -}; diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index f091381949492..eda1362011b7b 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -6,7 +6,6 @@ import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.cha import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf'; import { useMemo } from 'react'; -import { useDraftRoomIds } from './useDraftRoomIds'; import { useSortQueryOptions } from '../../hooks/useSortQueryOptions'; import { useOmnichannelEnabled } from '../../views/omnichannel/hooks/useOmnichannelEnabled'; import { useQueuedInquiries } from '../../views/omnichannel/hooks/useQueuedInquiries'; @@ -56,8 +55,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const incomingCalls = useVideoConfIncomingCalls(); - const draftRoomIds = useDraftRoomIds(sidebarDrafts); - const queue = inquiries.enabled ? inquiries.queue : emptyQueue; const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useDebouncedValue( @@ -89,7 +86,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) return unread.add(room); } - if (sidebarDrafts && draftRoomIds.has(room.rid)) { + if (sidebarDrafts && room.draft) { return drafts.add(room); } @@ -206,7 +203,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) rooms, showOmnichannel, inquiries.enabled, - draftRoomIds, + sidebarDrafts, queue, sidebarShowUnread, favoritesEnabled, diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 8ad2997ef917a..c6e9cc69e2f82 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -33,7 +33,7 @@ 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'; @@ -41,6 +41,7 @@ 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'; @@ -126,20 +127,22 @@ const MessageBox = ({ const textareaRef = useRef(null); const messageComposerRef = useRef(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); diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts new file mode 100644 index 0000000000000..df203790708ad --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useDraft.ts @@ -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(storageKey, ''); + const saveDraft = useEndpoint('POST', '/v1/subscriptions.saveDraft'); + const initialValueRef = useRef(serverDraft || localDraft); + const draftRef = useRef(null); + + 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, + }; +};