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 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/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/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index 2fb1bfc3366b3..eda1362011b7b 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -1,5 +1,6 @@ import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useFeaturePreview } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts'; import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts'; import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf'; @@ -19,6 +20,7 @@ const order = [ 'Open_Livechats', 'On_Hold_Chats', 'Unread', + 'Drafts', 'Favorites', 'Teams', 'Discussions', @@ -40,6 +42,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); + const sidebarDrafts = useFeaturePreview('sidebarDrafts'); const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); @@ -58,6 +61,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) useMemo(() => { const isCollapsed = (groupTitle: string) => collapsedGroups?.includes(groupTitle); + const drafts = new Set(); const incomingCall = new Set(); const favorite = new Set(); const team = new Set(); @@ -82,6 +86,10 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) return unread.add(room); } + if (sidebarDrafts && room.draft) { + return drafts.add(room); + } + if (favoritesEnabled && room.f) { return favorite.add(room); } @@ -122,6 +130,8 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) sidebarShowUnread && unread.size && groups.set('Unread', unread); + sidebarDrafts && drafts.size && groups.set('Drafts', drafts); + favoritesEnabled && favorite.size && groups.set('Favorites', favorite); sidebarGroupByType && team.size && groups.set('Teams', team); @@ -193,6 +203,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) rooms, showOmnichannel, inquiries.enabled, + 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, + }; +}; 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/apps/meteor/public/images/featurePreview/sidebar-drafts.png b/apps/meteor/public/images/featurePreview/sidebar-drafts.png new file mode 100644 index 0000000000000..b49f579ab70f5 Binary files /dev/null and b/apps/meteor/public/images/featurePreview/sidebar-drafts.png differ 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', 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/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c2cc8d1e6cbf6..28357ae8779c4 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1857,6 +1857,9 @@ "Download_Snippet": "Download", "Download_file": "Download file", "Downloading_file_from_external_URL": "Downloading file from external URL", + "Drafts": "Drafts", + "Drafts_in_sidebar": "Drafts in sidebar", + "Drafts_in_sidebar_description": "Quick access to all your rooms with unfinished messages.", "Drop_to_upload_file": "Drop to upload file", "Dry_run": "Dry run", "Dry_run_description": "Will only send one email, to the same address as in From. The email must belong to a valid user.", 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; + }; }; 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);