From 0676d300de530f22d66f5c0e4160051bc45c3a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 26 Sep 2024 16:59:20 +0200 Subject: [PATCH] Fix draft sent to show and rise errors (#28097) * Fix draft sent to show and rise errors * Remove event and set skipCommands * Fix tests * Improve error styles * Fix lint * Fix lint * Fix tests * Various fixes * Fix lint * fix test * Extract onSubmit options type * Address feedback * Fix text --- .../src/actions/notification_actions.jsx | 2 +- webapp/channels/src/actions/post_actions.ts | 8 +- .../src/actions/views/create_comment.test.jsx | 40 +-- .../src/actions/views/create_comment.tsx | 59 ++--- webapp/channels/src/actions/views/rhs.ts | 4 +- .../advanced_text_editor.tsx | 25 +- .../send_button/send_button.tsx | 6 +- .../advanced_text_editor/use_key_handler.tsx | 18 +- .../advanced_text_editor/use_submit.tsx | 151 +++++++----- .../__snapshots__/channel_draft.test.tsx.snap | 101 -------- .../channel_draft/channel_draft.test.tsx | 64 ----- .../drafts/channel_draft/channel_draft.tsx | 159 ------------ .../components/drafts/channel_draft/index.ts | 36 --- .../__snapshots__/action.test.tsx.snap | 4 +- .../__snapshots__/draft_actions.test.tsx.snap | 2 + .../drafts/draft_actions/action.tsx | 20 +- .../draft_actions/draft_actions.test.tsx | 2 + .../drafts/draft_actions/draft_actions.tsx | 70 +++--- .../src/components/drafts/draft_row.tsx | 227 +++++++++++++++--- .../src/components/drafts/panel/panel.scss | 4 + .../components/drafts/panel/panel.test.tsx | 1 + .../src/components/drafts/panel/panel.tsx | 16 +- .../drafts/panel/panel_body.test.tsx | 3 +- .../components/drafts/panel/panel_body.tsx | 1 - .../components/drafts/panel/panel_header.tsx | 30 ++- .../__snapshots__/thread_draft.test.tsx.snap | 111 --------- .../components/drafts/thread_draft/index.ts | 39 --- .../drafts/thread_draft/thread_draft.test.tsx | 68 ------ .../drafts/thread_draft/thread_draft.tsx | 130 ---------- .../file_preview_modal_info.tsx | 2 +- .../components/forward_post_modal/index.tsx | 2 +- .../move_thread_modal/move_thread_modal.tsx | 2 +- .../post_view/post_message_preview/index.ts | 2 +- .../sidebar/sidebar_channel/index.ts | 2 +- .../global_threads/thread_item/index.ts | 2 +- .../thread_pane/thread_pane.tsx | 4 +- .../threading/thread_viewer/index.ts | 2 +- .../create_comment.tsx | 2 +- webapp/channels/src/i18n/en.json | 3 + .../mattermost-redux/src/actions/posts.ts | 6 +- .../src/selectors/entities/channels.test.ts | 6 +- .../src/selectors/entities/channels.ts | 14 +- webapp/channels/src/selectors/rhs.ts | 2 +- 43 files changed, 489 insertions(+), 963 deletions(-) delete mode 100644 webapp/channels/src/components/drafts/channel_draft/__snapshots__/channel_draft.test.tsx.snap delete mode 100644 webapp/channels/src/components/drafts/channel_draft/channel_draft.test.tsx delete mode 100644 webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx delete mode 100644 webapp/channels/src/components/drafts/channel_draft/index.ts delete mode 100644 webapp/channels/src/components/drafts/thread_draft/__snapshots__/thread_draft.test.tsx.snap delete mode 100644 webapp/channels/src/components/drafts/thread_draft/index.ts delete mode 100644 webapp/channels/src/components/drafts/thread_draft/thread_draft.test.tsx delete mode 100644 webapp/channels/src/components/drafts/thread_draft/thread_draft.tsx diff --git a/webapp/channels/src/actions/notification_actions.jsx b/webapp/channels/src/actions/notification_actions.jsx index 2559aa9427..03f1c0d941 100644 --- a/webapp/channels/src/actions/notification_actions.jsx +++ b/webapp/channels/src/actions/notification_actions.jsx @@ -75,7 +75,7 @@ export function sendDesktopNotification(post, msgProps) { const teamId = msgProps.team_id; - let channel = makeGetChannel()(state, {id: post.channel_id}); + let channel = makeGetChannel()(state, post.channel_id); const user = getCurrentUser(state); const userStatus = getStatusForUserId(state, user.id); const member = getMyChannelMember(state, post.channel_id); diff --git a/webapp/channels/src/actions/post_actions.ts b/webapp/channels/src/actions/post_actions.ts index 4ef136b91f..2effbef2fc 100644 --- a/webapp/channels/src/actions/post_actions.ts +++ b/webapp/channels/src/actions/post_actions.ts @@ -106,7 +106,12 @@ export function unflagPost(postId: string): ActionFuncAsync { }; } -export function createPost(post: Post, files: FileInfo[], afterSubmit?: (response: SubmitPostReturnType) => void): ActionFuncAsync { +export function createPost( + post: Post, + files: FileInfo[], + afterSubmit?: (response: SubmitPostReturnType) => void, + afterOptimisticSubmit?: () => void, +): ActionFuncAsync { return async (dispatch) => { // parse message and emit emoji event const emojis = matchEmoticons(post.message); @@ -123,6 +128,7 @@ export function createPost(post: Post, files: FileInfo[], afterSubmit?: (respons dispatch(storeDraft(post.channel_id, null)); } + afterOptimisticSubmit?.(); return result; }; } diff --git a/webapp/channels/src/actions/views/create_comment.test.jsx b/webapp/channels/src/actions/views/create_comment.test.jsx index 24cff670f6..86e2111c6e 100644 --- a/webapp/channels/src/actions/views/create_comment.test.jsx +++ b/webapp/channels/src/actions/views/create_comment.test.jsx @@ -13,12 +13,12 @@ import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage'; import { clearCommentDraftUploads, updateCommentDraft, + onSubmit, submitPost, submitCommand, - makeOnSubmit, makeOnEditLatestPost, } from 'actions/views/create_comment'; -import {removeDraft, setGlobalDraftSource} from 'actions/views/drafts'; +import {setGlobalDraftSource} from 'actions/views/drafts'; import mockStore from 'tests/test_store'; import {StoragePrefixes} from 'utils/constants'; @@ -312,8 +312,7 @@ describe('rhs view actions', () => { }); }); - describe('makeOnSubmit', () => { - const onSubmit = makeOnSubmit(channelId, rootId, latestPostId); + describe('onSubmit', () => { const draft = { message: 'test', fileInfos: [], @@ -323,7 +322,7 @@ describe('rhs view actions', () => { }; test('it adds message into history', () => { - store.dispatch(onSubmit(draft)); + store.dispatch(onSubmit(draft, {})); const testStore = mockStore(initialState); testStore.dispatch(addMessageIntoHistory('test')); @@ -333,39 +332,12 @@ describe('rhs view actions', () => { ); }); - test('it clears comment draft', () => { - store.dispatch(onSubmit(draft)); - - const testStore = mockStore(initialState); - const key = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`; - testStore.dispatch(removeDraft(key, channelId, rootId)); - - expect(store.getActions()).toEqual( - expect.arrayContaining(testStore.getActions()), - ); - }); - - test('it submits a reaction when message is +:smile:', () => { - store.dispatch(onSubmit({ - message: '+:smile:', - fileInfos: [], - uploadsInProgress: [], - })); - - const testStore = mockStore(initialState); - testStore.dispatch(PostActions.submitReaction(latestPostId, '+', 'smile')); - - expect(store.getActions()).toEqual( - expect.arrayContaining(testStore.getActions()), - ); - }); - test('it submits a command when message is /away', () => { store.dispatch(onSubmit({ message: '/away', fileInfos: [], uploadsInProgress: [], - })); + }, {})); const testStore = mockStore(initialState); testStore.dispatch(submitCommand(channelId, rootId, {message: '/away', fileInfos: [], uploadsInProgress: []})); @@ -398,7 +370,7 @@ describe('rhs view actions', () => { message: 'test msg', fileInfos: [], uploadsInProgress: [], - })); + }, {})); const testStore = mockStore(initialState); testStore.dispatch(submitPost(channelId, rootId, {message: 'test msg', fileInfos: [], uploadsInProgress: []})); diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx index 50e12bf16d..58cdd557d3 100644 --- a/webapp/channels/src/actions/views/create_comment.tsx +++ b/webapp/channels/src/actions/views/create_comment.tsx @@ -29,7 +29,7 @@ import {executeCommand} from 'actions/command'; import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks'; import * as PostActions from 'actions/post_actions'; import {actionOnGlobalItemsWithPrefix} from 'actions/storage'; -import {updateDraft, removeDraft} from 'actions/views/drafts'; +import {updateDraft} from 'actions/views/drafts'; import {Constants, StoragePrefixes} from 'utils/constants'; import EmojiMap from 'utils/emoji_map'; @@ -56,7 +56,13 @@ export function updateCommentDraft(rootId: string, draft?: PostDraft, save = fal return updateDraft(key, draft ?? null, rootId, save); } -export function submitPost(channelId: string, rootId: string, draft: PostDraft, afterSubmit?: (response: SubmitPostReturnType) => void): ActionFuncAsync { +export function submitPost( + channelId: string, + rootId: string, + draft: PostDraft, + afterSubmit?: (response: SubmitPostReturnType) => void, + afterOptimisticSubmit?: () => void, +): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); @@ -103,7 +109,7 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft, post = hookResult.data; - return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit)); + return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, afterOptimisticSubmit)); }; } @@ -147,39 +153,18 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf }; } -export function makeOnSubmit(channelId: string, rootId: string, latestPostId: string): (draft: PostDraft, options?: {ignoreSlash?: boolean}) => ActionFuncAsync { - return (draft, options = {}) => async (dispatch, getState) => { - const {message} = draft; - - dispatch(addMessageIntoHistory(message)); - - const key = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`; - dispatch(removeDraft(key, channelId, rootId)); - - const isReaction = Utils.REACTION_PATTERN.exec(message); - - const emojis = getCustomEmojisByName(getState()); - const emojiMap = new EmojiMap(emojis); - - if (isReaction && emojiMap.has(isReaction[2])) { - dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); - } else if (message.indexOf('/') === 0 && !options.ignoreSlash) { - try { - await dispatch(submitCommand(channelId, rootId, draft)); - } catch (err) { - dispatch(updateCommentDraft(rootId, draft, true)); - throw err; - } - } else { - dispatch(submitPost(channelId, rootId, draft)); - } - return {data: true}; - }; -} - export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType; -export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afterSubmit?: (response: SubmitPostReturnType) => void}): ActionFuncAsync { +export type OnSubmitOptions = { + ignoreSlash?: boolean; + afterSubmit?: (response: SubmitPostReturnType) => void; + afterOptimisticSubmit?: () => void; +} + +export function onSubmit( + draft: PostDraft, + options: OnSubmitOptions, +): ActionFuncAsync { return async (dispatch, getState) => { const {message, channelId, rootId} = draft; const state = getState(); @@ -191,19 +176,19 @@ export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afte const emojis = getCustomEmojisByName(state); const emojiMap = new EmojiMap(emojis); - if (isReaction && emojiMap.has(isReaction[2])) { + if (isReaction && emojiMap.has(isReaction[2]) && !options.ignoreSlash) { const latestPostId = getLatestInteractablePostId(state, channelId, rootId); if (latestPostId) { return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); } - return {error: new Error('no post to react to')}; + return {error: new Error('No post to react to')}; } if (message.indexOf('/') === 0 && !options.ignoreSlash) { return dispatch(submitCommand(channelId, rootId, draft)); } - return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit)); + return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, options.afterOptimisticSubmit)); }; } diff --git a/webapp/channels/src/actions/views/rhs.ts b/webapp/channels/src/actions/views/rhs.ts index 2c92b1d69a..6aa582d260 100644 --- a/webapp/channels/src/actions/views/rhs.ts +++ b/webapp/channels/src/actions/views/rhs.ts @@ -539,8 +539,8 @@ export function selectPost(post: Post, previousRhsState?: RhsState) { export function selectPostById(postId: string): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); - const post = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data; - if (post) { + const post: Post | undefined = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data; + if (post && post.state !== 'DELETED' && post.delete_at === 0) { const channel = getChannelSelector(state, post.channel_id); if (!channel) { await dispatch(getChannel(post.channel_id)); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index 76cceda78f..9687265b39 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -108,7 +108,7 @@ const AdvancedTextEditor = ({ const isRHS = Boolean(postId && !isThreadView); const currentUserId = useSelector(getCurrentUserId); - const channelDisplayName = useSelector((state: GlobalState) => getChannelSelector(state, {id: channelId})?.display_name || ''); + const channelDisplayName = useSelector((state: GlobalState) => getChannelSelector(state, channelId)?.display_name || ''); const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId)); const badConnection = useSelector((state: GlobalState) => connectionErrorCount(state) > 1); const maxPostSize = useSelector((state: GlobalState) => parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT); @@ -253,7 +253,21 @@ const AdvancedTextEditor = ({ isValidPersistentNotifications, onSubmitCheck: prioritySubmitCheck, } = usePriority(draft, handleDraftChange, focusTextbox, showPreview); - const [handleSubmit, errorClass] = useSubmit(draft, postError, channelId, postId, serverError, lastBlurAt, focusTextbox, setServerError, setPostError, setShowPreview, handleDraftChange, prioritySubmitCheck, afterSubmit); + const [handleSubmit, errorClass] = useSubmit( + draft, + postError, + channelId, + postId, + serverError, + lastBlurAt, + focusTextbox, + setServerError, + setShowPreview, + handleDraftChange, + prioritySubmitCheck, + undefined, + afterSubmit, + ); const [handleKeyDown, postMsgKeyPress] = useKeyHandler( draft, channelId, @@ -272,6 +286,8 @@ const AdvancedTextEditor = ({ toggleEmojiPicker, ); + const noArgumentHandleSubmit = useCallback(() => handleSubmit(), [handleSubmit]); + const handlePostError = useCallback((err: React.ReactNode) => { setPostError(err); }, []); @@ -388,6 +404,7 @@ const AdvancedTextEditor = ({ // Remove show preview when we switch channels or posts useEffect(() => { setShowPreview(false); + setServerError(null); }, [channelId, postId]); // Remove uploads in progress on mount @@ -532,7 +549,7 @@ const AdvancedTextEditor = ({ id={postId ? undefined : 'create_post'} data-testid={postId ? undefined : 'create-post'} className={(!postId && !fullWidthTextBox) ? 'center' : undefined} - onSubmit={handleSubmit} + onSubmit={noArgumentHandleSubmit} > {canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && ( @@ -657,7 +674,7 @@ const AdvancedTextEditor = ({ )} void; + handleSubmit: () => void; disabled: boolean; } @@ -46,7 +46,7 @@ const SendButton = ({disabled, handleSubmit}: SendButtonProps) => { const sendMessage = (e: React.FormEvent) => { e.stopPropagation(); e.preventDefault(); - handleSubmit(e); + handleSubmit(); }; return ( diff --git a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx index 00b69349f8..d7dea5c0ac 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx @@ -39,7 +39,7 @@ const useKeyHandler = ( focusTextbox: (forceFocus?: boolean) => void, applyMarkdown: (params: ApplyMarkdownOptions) => void, handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, - handleSubmit: (e: React.FormEvent, submittingDraft?: PostDraft) => void, + handleSubmit: (submittingDraft?: PostDraft) => void, emitTypingEvent: () => void, toggleShowPreview: () => void, toggleAdvanceTextEditor: () => void, @@ -124,19 +124,9 @@ const useKeyHandler = ( } if (allowSending && isValidPersistentNotifications) { - e.persist?.(); - - // textboxRef.current?.blur(); - - if (withClosedCodeBlock && message) { - handleSubmit(e, {...draft, message}); - } else { - handleSubmit(e); - } - - // setTimeout(() => { - // focusTextbox(); - // }); + e.preventDefault(); + const updatedDraft = (withClosedCodeBlock && message) ? {...draft, message} : undefined; + handleSubmit(updatedDraft); } emitTypingEvent(); diff --git a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx index 3f4c0e1c8f..a4508579f4 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx @@ -16,7 +16,7 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles' import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; import {scrollPostListToBottom} from 'actions/views/channel'; -import type {SubmitPostReturnType} from 'actions/views/create_comment'; +import type {OnSubmitOptions, SubmitPostReturnType} from 'actions/views/create_comment'; import {onSubmit} from 'actions/views/create_comment'; import {openModal} from 'actions/views/modals'; @@ -57,13 +57,14 @@ const useSubmit = ( lastBlurAt: React.MutableRefObject, focusTextbox: (forceFocust?: boolean) => void, setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void, - setPostError: (err: React.ReactNode) => void, setShowPreview: (showPreview: boolean) => void, handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, prioritySubmitCheck: (onConfirm: () => void) => boolean, + afterOptimisticSubmit?: () => void, afterSubmit?: (response: SubmitPostReturnType) => void, + skipCommands?: boolean, ): [ - (e: React.FormEvent, submittingDraft?: PostDraft) => void, + (submittingDraft?: PostDraft) => Promise, string | null, ] => { const getGroupMentions = useGroups(channelId, draft.message); @@ -117,9 +118,7 @@ const useSubmit = ( })); }, [dispatch]); - const doSubmit = useCallback(async (e?: React.FormEvent, submittingDraft = draft) => { - e?.preventDefault(); - + const doSubmit = useCallback(async (submittingDraft = draft) => { if (submittingDraft.uploadsInProgress.length > 0) { isDraftSubmitting.current = false; return; @@ -135,6 +134,7 @@ const useSubmit = ( } if (submittingDraft.message.trim().length === 0 && submittingDraft.fileInfos.length === 0) { + isDraftSubmitting.current = false; return; } @@ -145,6 +145,7 @@ const useSubmit = ( } if (serverError && !isErrorInvalidSlashCommand(serverError)) { + isDraftSubmitting.current = false; return; } @@ -154,13 +155,15 @@ const useSubmit = ( setServerError(null); - const ignoreSlash = isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message; - const options = {ignoreSlash, afterSubmit}; + const ignoreSlash = skipCommands || (isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message); + const options: OnSubmitOptions = {ignoreSlash, afterSubmit, afterOptimisticSubmit}; try { - await dispatch(onSubmit(submittingDraft, options)); + const res = await dispatch(onSubmit(submittingDraft, options)); + if (res.error) { + throw res.error; + } - setPostError(null); setServerError(null); handleDraftChange({ message: '', @@ -180,6 +183,8 @@ const useSubmit = ( ...err, submittedMessage: submittingDraft.message, }); + } else { + setServerError(err as any); } isDraftSubmitting.current = false; return; @@ -190,7 +195,22 @@ const useSubmit = ( } isDraftSubmitting.current = false; - }, [handleDraftChange, dispatch, draft, focusTextbox, isRootDeleted, postError, serverError, showPostDeletedModal, channelId, postId, lastBlurAt, setPostError, setServerError, afterSubmit]); + }, [draft, + postError, + isRootDeleted, + serverError, + lastBlurAt, + focusTextbox, + setServerError, + skipCommands, + afterSubmit, + afterOptimisticSubmit, + postId, + showPostDeletedModal, + dispatch, + handleDraftChange, + channelId, + ]); const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { dispatch(openModal({ @@ -205,11 +225,15 @@ const useSubmit = ( })); }, [doSubmit, dispatch]); - const handleSubmit = useCallback(async (e: React.FormEvent, submittingDraft = draft) => { + const handleSubmit = useCallback(async (submittingDraft = draft) => { if (!channel) { return; } - e.preventDefault(); + + if (isDraftSubmitting.current) { + return; + } + setShowPreview(false); isDraftSubmitting.current = true; @@ -249,59 +273,61 @@ const useSubmit = ( return; } - const status = getStatusFromSlashCommand(submittingDraft.message); - if (userIsOutOfOffice && status) { - const resetStatusModalData = { - modalId: ModalIdentifiers.RESET_STATUS, - dialogType: ResetStatusModal, - dialogProps: {newStatus: status}, - }; + if (!skipCommands) { + const status = getStatusFromSlashCommand(submittingDraft.message); + if (userIsOutOfOffice && status) { + const resetStatusModalData = { + modalId: ModalIdentifiers.RESET_STATUS, + dialogType: ResetStatusModal, + dialogProps: {newStatus: status}, + }; - dispatch(openModal(resetStatusModalData)); + dispatch(openModal(resetStatusModalData)); - handleDraftChange({ - ...submittingDraft, - message: '', - }); - isDraftSubmitting.current = false; - return; + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } + + if (submittingDraft.message.trimEnd() === '/header') { + const editChannelHeaderModalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, + dialogType: EditChannelHeaderModal, + dialogProps: {channel}, + }; + + dispatch(openModal(editChannelHeaderModalData)); + + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } + + if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') { + const editChannelPurposeModalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, + dialogType: EditChannelPurposeModal, + dialogProps: {channel}, + }; + + dispatch(openModal(editChannelPurposeModalData)); + + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } } - if (submittingDraft.message.trimEnd() === '/header') { - const editChannelHeaderModalData = { - modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, - dialogType: EditChannelHeaderModal, - dialogProps: {channel}, - }; - - dispatch(openModal(editChannelHeaderModalData)); - - handleDraftChange({ - ...submittingDraft, - message: '', - }); - isDraftSubmitting.current = false; - return; - } - - if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') { - const editChannelPurposeModalData = { - modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, - dialogType: EditChannelPurposeModal, - dialogProps: {channel}, - }; - - dispatch(openModal(editChannelPurposeModalData)); - - handleDraftChange({ - ...submittingDraft, - message: '', - }); - isDraftSubmitting.current = false; - return; - } - - await doSubmit(e, submittingDraft); + await doSubmit(submittingDraft); }, [ doSubmit, draft, @@ -311,6 +337,7 @@ const useSubmit = ( channelMembersCount, dispatch, enableConfirmNotificationsToChannel, + skipCommands, handleDraftChange, showNotifyAllModal, useChannelMentions, diff --git a/webapp/channels/src/components/drafts/channel_draft/__snapshots__/channel_draft.test.tsx.snap b/webapp/channels/src/components/drafts/channel_draft/__snapshots__/channel_draft.test.tsx.snap deleted file mode 100644 index 16c62ef143..0000000000 --- a/webapp/channels/src/components/drafts/channel_draft/__snapshots__/channel_draft.test.tsx.snap +++ /dev/null @@ -1,101 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/drafts/drafts_row should match snapshot for channel draft 1`] = ` - - - -`; - -exports[`components/drafts/drafts_row should match snapshot for undefined channel 1`] = ` - - - -`; diff --git a/webapp/channels/src/components/drafts/channel_draft/channel_draft.test.tsx b/webapp/channels/src/components/drafts/channel_draft/channel_draft.test.tsx deleted file mode 100644 index 7aac5599e8..0000000000 --- a/webapp/channels/src/components/drafts/channel_draft/channel_draft.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; -import {Provider} from 'react-redux'; - -import type {Channel} from '@mattermost/types/channels'; -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import mockStore from 'tests/test_store'; - -import type {PostDraft} from 'types/store/draft'; - -import ChannelDraft from './channel_draft'; - -describe('components/drafts/drafts_row', () => { - const baseProps = { - channel: { - id: '', - } as Channel, - channelUrl: '', - displayName: '', - draftId: '', - id: {} as Channel['id'], - status: {} as UserStatus['status'], - type: 'channel' as 'channel' | 'thread', - user: {} as UserProfile, - value: {} as PostDraft, - postPriorityEnabled: false, - isRemote: false, - }; - - it('should match snapshot for channel draft', () => { - const store = mockStore(); - - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot for undefined channel', () => { - const store = mockStore(); - - const props = { - ...baseProps, - channel: null as unknown as Channel, - }; - - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx b/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx deleted file mode 100644 index bc7059dd39..0000000000 --- a/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {memo, useCallback} from 'react'; -import {useDispatch} from 'react-redux'; -import {useHistory} from 'react-router-dom'; - -import type {Channel} from '@mattermost/types/channels'; -import type {Post, PostMetadata} from '@mattermost/types/posts'; -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import {createPost} from 'actions/post_actions'; -import {removeDraft} from 'actions/views/drafts'; -import {openModal} from 'actions/views/modals'; - -import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal'; - -import {ModalIdentifiers} from 'utils/constants'; -import {hasRequestedPersistentNotifications, specialMentionsInText} from 'utils/post_utils'; - -import type {PostDraft} from 'types/store/draft'; - -import DraftActions from '../draft_actions'; -import DraftTitle from '../draft_title'; -import Panel from '../panel/panel'; -import PanelBody from '../panel/panel_body'; -import Header from '../panel/panel_header'; - -type Props = { - channel?: Channel; - channelUrl: string; - displayName: string; - draftId: string; - id: Channel['id']; - postPriorityEnabled: boolean; - status: UserStatus['status']; - type: 'channel' | 'thread'; - user: UserProfile; - value: PostDraft; - isRemote?: boolean; -} - -function ChannelDraft({ - channel, - channelUrl, - displayName, - draftId, - postPriorityEnabled, - status, - type, - user, - value, - isRemote, - id: channelId, -}: Props) { - const dispatch = useDispatch(); - const history = useHistory(); - - const handleOnEdit = useCallback(() => { - history.push(channelUrl); - }, [history, channelUrl]); - - const handleOnDelete = useCallback((id: string) => { - dispatch(removeDraft(id, channelId)); - }, [dispatch, channelId]); - - const doSubmit = useCallback((id: string, post: Post) => { - dispatch(createPost(post, value.fileInfos)); - dispatch(removeDraft(id, channelId)); - history.push(channelUrl); - }, [dispatch, history, value.fileInfos, channelId, channelUrl]); - - const showPersistNotificationModal = useCallback((id: string, post: Post) => { - if (!channel) { - return; - } - - dispatch(openModal({ - modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL, - dialogType: PersistNotificationConfirmModal, - dialogProps: { - message: post.message, - channelType: channel.type, - specialMentions: specialMentionsInText(post.message), - onConfirm: () => doSubmit(id, post), - }, - })); - }, [channel, dispatch, doSubmit]); - - const handleOnSend = useCallback(async (id: string) => { - const post = {} as Post; - post.file_ids = []; - post.message = value.message; - post.props = value.props || {}; - post.user_id = user.id; - post.channel_id = value.channelId; - post.metadata = (value.metadata || {}) as PostMetadata; - - if (post.message.trim().length === 0 && value.fileInfos.length === 0) { - return; - } - - if (postPriorityEnabled && hasRequestedPersistentNotifications(value?.metadata?.priority)) { - showPersistNotificationModal(id, post); - return; - } - doSubmit(id, post); - }, [doSubmit, postPriorityEnabled, value, user.id, showPersistNotificationModal]); - - if (!channel) { - return null; - } - - return ( - - {({hover}) => ( - <> -
- )} - title={( - - )} - timestamp={value.updateAt} - remote={isRemote || false} - /> - - - )} - - ); -} - -export default memo(ChannelDraft); diff --git a/webapp/channels/src/components/drafts/channel_draft/index.ts b/webapp/channels/src/components/drafts/channel_draft/index.ts deleted file mode 100644 index 27211e98b9..0000000000 --- a/webapp/channels/src/components/drafts/channel_draft/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; - -import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; -import {isPostPriorityEnabled} from 'mattermost-redux/selectors/entities/posts'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; - -import {getChannelURL} from 'selectors/urls'; - -import type {GlobalState} from 'types/store'; - -import ChannelDraft from './channel_draft'; - -type OwnProps = { - id: string; -} - -function makeMapStateToProps() { - const getChannel = makeGetChannel(); - - return (state: GlobalState, ownProps: OwnProps) => { - const channel = getChannel(state, ownProps); - - const teamId = getCurrentTeamId(state); - const channelUrl = channel ? getChannelURL(state, channel, teamId) : ''; - - return { - channel, - channelUrl, - postPriorityEnabled: isPostPriorityEnabled(state), - }; - }; -} -export default connect(makeMapStateToProps)(ChannelDraft); diff --git a/webapp/channels/src/components/drafts/draft_actions/__snapshots__/action.test.tsx.snap b/webapp/channels/src/components/drafts/draft_actions/__snapshots__/action.test.tsx.snap index 66b8ce6821..95ab53b53b 100644 --- a/webapp/channels/src/components/drafts/draft_actions/__snapshots__/action.test.tsx.snap +++ b/webapp/channels/src/components/drafts/draft_actions/__snapshots__/action.test.tsx.snap @@ -11,11 +11,11 @@ exports[`components/drafts/draft_actions/action should match snapshot 1`] = ` > diff --git a/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap b/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap index 00f33dd7f5..43b5ba22a5 100644 --- a/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap +++ b/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap @@ -33,6 +33,8 @@ exports[`components/drafts/draft_actions should match snapshot 1`] = ` } > - + diff --git a/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx b/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx index a6a8cdb467..f415f4112f 100644 --- a/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx +++ b/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx @@ -16,6 +16,8 @@ describe('components/drafts/draft_actions', () => { onDelete: jest.fn(), onEdit: jest.fn(), onSend: jest.fn(), + canSend: true, + canEdit: true, }; it('should match snapshot', () => { diff --git a/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx b/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx index ca28c37ec5..d35b8da7f8 100644 --- a/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx +++ b/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx @@ -15,18 +15,20 @@ import SendDraftModal from './send_draft_modal'; type Props = { displayName: string; - draftId: string; - onDelete: (draftId: string) => void; + onDelete: () => void; onEdit: () => void; - onSend: (draftId: string) => void; + onSend: () => void; + canEdit: boolean; + canSend: boolean; } function DraftActions({ displayName, - draftId, onDelete, onEdit, onSend, + canEdit, + canSend, }: Props) { const dispatch = useDispatch(); @@ -36,10 +38,10 @@ function DraftActions({ dialogType: DeleteDraftModal, dialogProps: { displayName, - onConfirm: () => onDelete(draftId), + onConfirm: onDelete, }, })); - }, [displayName]); + }, [dispatch, displayName, onDelete]); const handleSend = useCallback(() => { dispatch(openModal({ @@ -47,10 +49,10 @@ function DraftActions({ dialogType: SendDraftModal, dialogProps: { displayName, - onConfirm: () => onSend(draftId), + onConfirm: onSend, }, })); - }, [displayName]); + }, [dispatch, displayName, onSend]); return ( <> @@ -66,30 +68,34 @@ function DraftActions({ )} onClick={handleDelete} /> - - )} - onClick={onEdit} - /> - - )} - onClick={handleSend} - /> + {canEdit && ( + + )} + onClick={onEdit} + /> + )} + {canSend && ( + + )} + onClick={handleSend} + /> + )} ); } diff --git a/webapp/channels/src/components/drafts/draft_row.tsx b/webapp/channels/src/components/drafts/draft_row.tsx index 48cd47ec3c..fbf7ae80a3 100644 --- a/webapp/channels/src/components/drafts/draft_row.tsx +++ b/webapp/channels/src/components/drafts/draft_row.tsx @@ -1,14 +1,41 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {memo} from 'react'; +import noop from 'lodash/noop'; +import React, {memo, useCallback, useMemo, useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import {useHistory} from 'react-router-dom'; +import type {ServerError} from '@mattermost/types/errors'; import type {UserProfile, UserStatus} from '@mattermost/types/users'; -import type {Draft} from 'selectors/drafts'; +import {getPost as getPostAction} from 'mattermost-redux/actions/posts'; +import {Permissions} from 'mattermost-redux/constants'; +import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getPost} from 'mattermost-redux/selectors/entities/posts'; +import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads'; -import ChannelDraft from './channel_draft'; -import ThreadDraft from './thread_draft'; +import {removeDraft} from 'actions/views/drafts'; +import {selectPostById} from 'actions/views/rhs'; +import type {Draft} from 'selectors/drafts'; +import {getChannelURL} from 'selectors/urls'; + +import usePriority from 'components/advanced_text_editor/use_priority'; +import useSubmit from 'components/advanced_text_editor/use_submit'; + +import Constants, {StoragePrefixes} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + +import DraftActions from './draft_actions'; +import DraftTitle from './draft_title'; +import Panel from './panel/panel'; +import PanelBody from './panel/panel_body'; +import Header from './panel/panel_header'; type Props = { user: UserProfile; @@ -18,34 +45,174 @@ type Props = { isRemote?: boolean; } -function DraftRow({draft, user, status, displayName, isRemote}: Props) { - switch (draft.type) { - case 'channel': - return ( - - ); - case 'thread': - return ( - - ); - default: +const mockLastBlurAt = {current: 0}; + +function DraftRow({ + draft, + user, + status, + displayName, + isRemote, +}: Props) { + const intl = useIntl(); + + const rootId = draft.value.rootId; + const channelId = draft.value.channelId; + + const [serverError, setServerError] = useState<(ServerError & { submittedMessage?: string }) | null>(null); + + const history = useHistory(); + const dispatch = useDispatch(); + + const getChannel = useMemo(() => makeGetChannel(), []); + const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []); + + const rootPostDeleted = useSelector((state: GlobalState) => { + if (!rootId) { + return false; + } + const rootPost = getPost(state, rootId); + return !rootPost || rootPost.delete_at > 0 || rootPost.state === 'DELETED'; + }); + + const tooLong = useSelector((state: GlobalState) => { + const maxPostSize = parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT; + return draft.value.message.length > maxPostSize; + }); + + const readOnly = !useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST) : false; + }); + + let postError = ''; + if (rootPostDeleted) { + postError = intl.formatMessage({id: 'drafts.error.post_not_found', defaultMessage: 'Thread not found'}); + } else if (tooLong) { + postError = intl.formatMessage({id: 'drafts.error.too_long', defaultMessage: 'Message too long'}); + } else if (readOnly) { + postError = intl.formatMessage({id: 'drafts.error.read_only', defaultMessage: 'Channel is read only'}); + } + + const canSend = !postError; + const canEdit = !(rootPostDeleted || readOnly); + + const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); + const channelUrl = useSelector((state: GlobalState) => { + if (!channel) { + return ''; + } + + const teamId = getCurrentTeamId(state); + return getChannelURL(state, channel, teamId); + }); + + const goToMessage = useCallback(async () => { + if (rootId) { + if (rootPostDeleted) { + return; + } + await dispatch(selectPostById(rootId)); + return; + } + history.push(channelUrl); + }, [channelUrl, dispatch, history, rootId, rootPostDeleted]); + + const {onSubmitCheck: prioritySubmitCheck} = usePriority(draft.value, noop, noop, false); + const [handleOnSend] = useSubmit( + draft.value, + postError, + channelId, + rootId, + serverError, + mockLastBlurAt, + noop, + setServerError, + noop, + noop, + prioritySubmitCheck, + goToMessage, + undefined, + true, + ); + + const thread = useSelector((state: GlobalState) => { + if (!rootId) { + return undefined; + } + const post = getPost(state, rootId); + if (!post) { + return undefined; + } + + return getThreadOrSynthetic(state, post); + }); + + const handleOnDelete = useCallback(() => { + let key = `${StoragePrefixes.DRAFT}${channelId}`; + if (rootId) { + key = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`; + } + dispatch(removeDraft(key, channelId, rootId)); + }, [dispatch, channelId, rootId]); + + useEffect(() => { + if (rootId && !thread?.id) { + dispatch(getPostAction(rootId)); + } + }, [thread?.id]); + + if (!channel) { return null; } + + return ( + + {({hover}) => ( + <> +
+ )} + title={( + + )} + timestamp={draft.value.updateAt} + remote={isRemote || false} + error={postError || serverError?.message} + /> + + + )} + + ); } export default memo(DraftRow); diff --git a/webapp/channels/src/components/drafts/panel/panel.scss b/webapp/channels/src/components/drafts/panel/panel.scss index 71f5a30ef4..bd1cd80834 100644 --- a/webapp/channels/src/components/drafts/panel/panel.scss +++ b/webapp/channels/src/components/drafts/panel/panel.scss @@ -18,4 +18,8 @@ box-shadow: var(--elevation-2); transition-duration: 0.15s; } + + &.draftError { + border-color: var(--error-text); + } } diff --git a/webapp/channels/src/components/drafts/panel/panel.test.tsx b/webapp/channels/src/components/drafts/panel/panel.test.tsx index 35197fca0d..09eb028158 100644 --- a/webapp/channels/src/components/drafts/panel/panel.test.tsx +++ b/webapp/channels/src/components/drafts/panel/panel.test.tsx @@ -10,6 +10,7 @@ describe('components/drafts/panel/', () => { const baseProps = { children: jest.fn(), onClick: jest.fn(), + hasError: false, }; it('should match snapshot', () => { diff --git a/webapp/channels/src/components/drafts/panel/panel.tsx b/webapp/channels/src/components/drafts/panel/panel.tsx index e26a7d03dd..939e6c6447 100644 --- a/webapp/channels/src/components/drafts/panel/panel.tsx +++ b/webapp/channels/src/components/drafts/panel/panel.tsx @@ -1,19 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import classNames from 'classnames'; import React, {memo, useState} from 'react'; import {makeIsEligibleForClick} from 'utils/utils'; + import './panel.scss'; type Props = { children: ({hover}: {hover: boolean}) => React.ReactNode; onClick: () => void; + hasError: boolean; }; const isEligibleForClick = makeIsEligibleForClick('.hljs, code'); -function Panel({children, onClick}: Props) { +function Panel({ + children, + onClick, + hasError, +}: Props) { const [hover, setHover] = useState(false); const handleMouseOver = () => { @@ -32,7 +39,12 @@ function Panel({children, onClick}: Props) { return (
{ - const baseProps = { + const baseProps: ComponentProps = { channelId: 'channel_id', displayName: 'display_name', fileInfos: [] as PostDraft['fileInfos'], diff --git a/webapp/channels/src/components/drafts/panel/panel_body.tsx b/webapp/channels/src/components/drafts/panel/panel_body.tsx index 2d50426c9d..1cf50db996 100644 --- a/webapp/channels/src/components/drafts/panel/panel_body.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_body.tsx @@ -55,7 +55,6 @@ function PanelBody({ }, [currentRelativeTeamUrl]); return ( -
{title}
@@ -62,11 +70,21 @@ function PanelHeader({actions, hover, timestamp, remote, title}: Props) { /> )}
- + {!error && ( + + )} + {error && ( + + )}
diff --git a/webapp/channels/src/components/drafts/thread_draft/__snapshots__/thread_draft.test.tsx.snap b/webapp/channels/src/components/drafts/thread_draft/__snapshots__/thread_draft.test.tsx.snap deleted file mode 100644 index b4e4330511..0000000000 --- a/webapp/channels/src/components/drafts/thread_draft/__snapshots__/thread_draft.test.tsx.snap +++ /dev/null @@ -1,111 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/drafts/drafts_row should match snapshot for channel draft 1`] = ` - - - -`; - -exports[`components/drafts/drafts_row should match snapshot for undefined thread 1`] = ` - - - -`; diff --git a/webapp/channels/src/components/drafts/thread_draft/index.ts b/webapp/channels/src/components/drafts/thread_draft/index.ts deleted file mode 100644 index 8ab27f1150..0000000000 --- a/webapp/channels/src/components/drafts/thread_draft/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; - -import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getPost} from 'mattermost-redux/selectors/entities/posts'; -import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads'; - -import type {GlobalState} from 'types/store'; -import type {PostDraft} from 'types/store/draft'; - -import ThreadDraft from './thread_draft'; - -type OwnProps = { - id: string; - value: PostDraft; -} - -function makeMapStatetoProps() { - const getThreadOrSynthetic = makeGetThreadOrSynthetic(); - const getChannel = makeGetChannel(); - return (state: GlobalState, ownProps: OwnProps) => { - const channel = getChannel(state, {id: ownProps.value.channelId}); - const post = getPost(state, ownProps.id); - - let thread; - if (post) { - thread = getThreadOrSynthetic(state, post); - } - - return { - channel, - thread, - }; - }; -} - -export default connect(makeMapStatetoProps)(ThreadDraft); diff --git a/webapp/channels/src/components/drafts/thread_draft/thread_draft.test.tsx b/webapp/channels/src/components/drafts/thread_draft/thread_draft.test.tsx deleted file mode 100644 index fef3117ff5..0000000000 --- a/webapp/channels/src/components/drafts/thread_draft/thread_draft.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; -import {Provider} from 'react-redux'; - -import type {Channel} from '@mattermost/types/channels'; -import type {UserThread, UserThreadSynthetic} from '@mattermost/types/threads'; -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import mockStore from 'tests/test_store'; - -import type {PostDraft} from 'types/store/draft'; - -import ThreadDraft from './thread_draft'; - -describe('components/drafts/drafts_row', () => { - const baseProps = { - channel: { - id: '', - } as Channel, - channelUrl: '', - displayName: '', - draftId: '', - rootId: '' as UserThread['id'] | UserThreadSynthetic['id'], - id: {} as Channel['id'], - status: {} as UserStatus['status'], - thread: { - id: '', - } as UserThread | UserThreadSynthetic, - type: 'thread' as 'channel' | 'thread', - user: {} as UserProfile, - value: {} as PostDraft, - isRemote: false, - }; - - it('should match snapshot for channel draft', () => { - const store = mockStore(); - - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot for undefined thread', () => { - const store = mockStore(); - - const props = { - ...baseProps, - thread: null as unknown as UserThread | UserThreadSynthetic, - }; - - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/webapp/channels/src/components/drafts/thread_draft/thread_draft.tsx b/webapp/channels/src/components/drafts/thread_draft/thread_draft.tsx deleted file mode 100644 index 55a1201c32..0000000000 --- a/webapp/channels/src/components/drafts/thread_draft/thread_draft.tsx +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {memo, useCallback, useMemo, useEffect} from 'react'; -import {useDispatch} from 'react-redux'; - -import type {Channel} from '@mattermost/types/channels'; -import type {Post} from '@mattermost/types/posts'; -import type {UserThread, UserThreadSynthetic} from '@mattermost/types/threads'; -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import {getPost} from 'mattermost-redux/actions/posts'; - -import {makeOnSubmit} from 'actions/views/create_comment'; -import {removeDraft} from 'actions/views/drafts'; -import {selectPost} from 'actions/views/rhs'; - -import type {PostDraft} from 'types/store/draft'; - -import DraftActions from '../draft_actions'; -import DraftTitle from '../draft_title'; -import Panel from '../panel/panel'; -import PanelBody from '../panel/panel_body'; -import Header from '../panel/panel_header'; - -type Props = { - channel?: Channel; - displayName: string; - draftId: string; - rootId: UserThread['id'] | UserThreadSynthetic['id']; - status: UserStatus['status']; - thread?: UserThread | UserThreadSynthetic; - type: 'channel' | 'thread'; - user: UserProfile; - value: PostDraft; - isRemote?: boolean; -} - -function ThreadDraft({ - channel, - displayName, - draftId, - rootId, - status, - thread, - type, - user, - value, - isRemote, -}: Props) { - const dispatch = useDispatch(); - - useEffect(() => { - if (!thread?.id) { - dispatch(getPost(rootId)); - } - }, [thread?.id]); - - const onSubmit = useMemo(() => { - if (thread?.id) { - return makeOnSubmit(value.channelId, thread.id, ''); - } - - return () => Promise.resolve({data: true}); - }, [value.channelId, thread?.id]); - - const handleOnDelete = useCallback((id: string) => { - dispatch(removeDraft(id, value.channelId, rootId)); - }, [value.channelId, rootId, dispatch]); - - const handleOnEdit = useCallback(() => { - dispatch(selectPost({id: rootId, channel_id: value.channelId} as Post)); - }, [value.channelId, dispatch, rootId]); - - const handleOnSend = useCallback(async (id: string) => { - await dispatch(onSubmit(value)); - - handleOnDelete(id); - handleOnEdit(); - }, [value, onSubmit, dispatch, handleOnDelete, handleOnEdit]); - - if (!thread || !channel) { - return null; - } - - return ( - - {({hover}) => ( - <> -
- )} - title={( - - )} - timestamp={value.updateAt} - remote={isRemote || false} - /> - - - )} - - ); -} - -export default memo(ThreadDraft); diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal_info/file_preview_modal_info.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal_info/file_preview_modal_info.tsx index 700620d9c4..9d40fd29b2 100644 --- a/webapp/channels/src/components/file_preview_modal/file_preview_modal_info/file_preview_modal_info.tsx +++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal_info/file_preview_modal_info.tsx @@ -34,7 +34,7 @@ const FilePreviewModalInfo: React.FC = (props: Props) => { const user = useSelector((state: GlobalState) => selectUser(state, props.post?.user_id ?? '')) as UserProfile | undefined; const channel = useSelector((state: GlobalState) => { const getChannel = makeGetChannel(); - return getChannel(state, {id: props.post?.channel_id ?? ''}); + return getChannel(state, props.post?.channel_id ?? ''); }); const name = useSelector((state: GlobalState) => displayNameGetter(state, props.post?.user_id ?? '', true)); diff --git a/webapp/channels/src/components/forward_post_modal/index.tsx b/webapp/channels/src/components/forward_post_modal/index.tsx index 6c306a0637..74e8573c4f 100644 --- a/webapp/channels/src/components/forward_post_modal/index.tsx +++ b/webapp/channels/src/components/forward_post_modal/index.tsx @@ -52,7 +52,7 @@ const ForwardPostModal = ({onExited, post}: Props) => { const getChannel = useMemo(makeGetChannel, []); - const channel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id})); + const channel = useSelector((state: GlobalState) => getChannel(state, post.channel_id)); const currentTeam = useSelector(getCurrentTeam); const relativePermaLink = useSelector((state: GlobalState) => (currentTeam ? getPermalinkURL(state, currentTeam.id, post.id) : '')); diff --git a/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx b/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx index 73d960979c..9b93148f01 100644 --- a/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx +++ b/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx @@ -63,7 +63,7 @@ const preventActionOnPreview = (e: React.MouseEvent) => { const MoveThreadModal = ({onExited, post, actions}: Props) => { const {formatMessage} = useIntl(); - const originalChannel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id})); + const originalChannel = useSelector((state: GlobalState) => getChannel(state, post.channel_id)); const currentTeam = useSelector(getCurrentTeam); const timeoutRef = useRef(null); diff --git a/webapp/channels/src/components/post_view/post_message_preview/index.ts b/webapp/channels/src/components/post_view/post_message_preview/index.ts index 3b6048108e..f01a2b8b9a 100644 --- a/webapp/channels/src/components/post_view/post_message_preview/index.ts +++ b/webapp/channels/src/components/post_view/post_message_preview/index.ts @@ -49,7 +49,7 @@ function makeMapStateToProps() { } if (ownProps.metadata.channel_type === General.DM_CHANNEL) { - channelDisplayName = getChannel(state, {id: ownProps.metadata.channel_id})?.display_name || ''; + channelDisplayName = getChannel(state, ownProps.metadata.channel_id)?.display_name || ''; } return { diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/index.ts b/webapp/channels/src/components/sidebar/sidebar_channel/index.ts index 58e0018a85..0697a2a1db 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/index.ts +++ b/webapp/channels/src/components/sidebar/sidebar_channel/index.ts @@ -32,7 +32,7 @@ function makeMapStateToProps() { const getUnreadCount = makeGetChannelUnreadCount(); return (state: GlobalState, ownProps: OwnProps) => { - const channel = getChannel(state, {id: ownProps.channelId}); + const channel = getChannel(state, ownProps.channelId); const currentTeam = getCurrentTeam(state); const currentChannelId = getCurrentChannelId(state); diff --git a/webapp/channels/src/components/threading/global_threads/thread_item/index.ts b/webapp/channels/src/components/threading/global_threads/thread_item/index.ts index dea43e9c25..8198c285d1 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_item/index.ts +++ b/webapp/channels/src/components/threading/global_threads/thread_item/index.ts @@ -32,7 +32,7 @@ function makeMapStateToProps() { return { post, - channel: getChannel(state, {id: post.channel_id}), + channel: getChannel(state, post.channel_id), currentRelativeTeamUrl: getCurrentRelativeTeamUrl(state), displayName: getDisplayName(state, post.user_id, true), postsInThread: getPostsForThread(state, post.id), diff --git a/webapp/channels/src/components/threading/global_threads/thread_pane/thread_pane.tsx b/webapp/channels/src/components/threading/global_threads/thread_pane/thread_pane.tsx index 6fa24a4b9c..a6f62f4c88 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_pane/thread_pane.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_pane/thread_pane.tsx @@ -54,7 +54,7 @@ const ThreadPane = ({ }, } = thread; - const channel = useSelector((state: GlobalState) => getChannel(state, {id: channelId})); + const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); const post = useSelector((state: GlobalState) => getPost(state, thread.id)); const postsInThread = useSelector((state: GlobalState) => getPostsForThread(state, post.id)); const selectHandler = useCallback(() => select(), []); @@ -72,7 +72,7 @@ const ThreadPane = ({ const followHandler = useCallback(() => { dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing)); - }, [currentUserId, currentTeamId, threadId, isFollowing, setThreadFollow]); + }, [dispatch, currentUserId, currentTeamId, threadId, isFollowing]); return (
(({ if (threadIsLimited) { return null; } - return getChannel(state, {id: rootPost.channel_id}); + return getChannel(state, rootPost.channel_id); }); if (!channel || threadIsLimited) { return null; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 5eac286e36..7512206921 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3493,6 +3493,9 @@ "drafts.draft_title.you": "(you)", "drafts.empty.subtitle": "Any messages you’ve started will show here.", "drafts.empty.title": "No drafts at the moment", + "drafts.error.post_not_found": "Thread not found", + "drafts.error.read_only": "Channel is read only", + "drafts.error.too_long": "Message too long", "drafts.heading": "Drafts", "drafts.info.sync": "Updated from another device", "drafts.sidebarLink": "Drafts", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index fcc8abaa9a..4133024b9a 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -174,7 +174,11 @@ export type CreatePostReturnType = { pending?: string; } -export function createPost(post: Post, files: any[] = [], afterSubmit?: (response: any) => void): ActionFuncAsync { +export function createPost( + post: Post, + files: any[] = [], + afterSubmit?: (response: any) => void, +): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); const currentUserId = state.entities.users.currentUserId; diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.test.ts index c4bb775159..3d33497e68 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.test.ts @@ -481,13 +481,13 @@ describe('makeGetChannel', () => { test('should return non-DM/non-GM channels directly from the store', () => { const getChannel = Selectors.makeGetChannel(); - expect(getChannel(testState, {id: channel1.id})).toBe(channel1); + expect(getChannel(testState, channel1.id)).toBe(channel1); }); test('should return DMs with computed data added', () => { const getChannel = Selectors.makeGetChannel(); - expect(getChannel(testState, {id: channel2.id})).toEqual({ + expect(getChannel(testState, channel2.id)).toEqual({ ...channel2, display_name: user2.username, status: 'offline', @@ -498,7 +498,7 @@ describe('makeGetChannel', () => { test('should return GMs with computed data added', () => { const getChannel = Selectors.makeGetChannel(); - expect(getChannel(testState, {id: channel3.id})).toEqual({ + expect(getChannel(testState, channel3.id)).toEqual({ ...channel3, display_name: [user2.username, user3.username].sort(sortUsernames).join(', '), }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts index 3cea38f758..8997f044b4 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts @@ -147,18 +147,21 @@ export function getChannelMember(state: GlobalState, channelId: string, userId: return getChannelMembersInChannels(state)[channelId]?.[userId]; } +type OldMakeChannelArgument = {id: string}; + // makeGetChannel returns a selector that returns a channel from the store with the following filled in for DM/GM channels: // - The display_name set to the other user(s) names, following the Teammate Name Display setting // - The teammate_id for DM channels // - The status of the other user in a DM channel -export function makeGetChannel(): (state: GlobalState, props: {id: string}) => Channel | undefined { +export function makeGetChannel(): (state: GlobalState, id: string) => Channel | undefined { return createSelector( 'makeGetChannel', getCurrentUserId, (state: GlobalState) => state.entities.users.profiles, (state: GlobalState) => state.entities.users.profilesInChannel, - (state: GlobalState, props: {id: string}) => { - const channel = getChannel(state, props.id); + (state: GlobalState, channelId: string | OldMakeChannelArgument) => { + const id = typeof channelId === 'string' ? channelId : channelId.id; + const channel = getChannel(state, id); if (!channel || !isDirectChannel(channel)) { return ''; } @@ -169,7 +172,10 @@ export function makeGetChannel(): (state: GlobalState, props: {id: string}) => C return teammateStatus || 'offline'; }, - (state: GlobalState, props: {id: string}) => getChannel(state, props.id), + (state: GlobalState, channelId: string | OldMakeChannelArgument) => { + const id = typeof channelId === 'string' ? channelId : channelId.id; + return getChannel(state, id); + }, getTeammateNameDisplaySetting, (currentUserId, profiles, profilesInChannel, teammateStatus, channel, teammateNameDisplay) => { if (channel) { diff --git a/webapp/channels/src/selectors/rhs.ts b/webapp/channels/src/selectors/rhs.ts index 66aa540ea9..9ffca17e23 100644 --- a/webapp/channels/src/selectors/rhs.ts +++ b/webapp/channels/src/selectors/rhs.ts @@ -57,7 +57,7 @@ export const getSelectedChannel = (() => { return (state: GlobalState) => { const channelId = getSelectedChannelId(state); - return getChannel(state, {id: channelId}); + return getChannel(state, channelId); }; })();