From 20ed58906adcc1405cb13690b2a02bf27241d273 Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Thu, 26 Sep 2024 13:52:59 +0200 Subject: [PATCH 1/6] [MM-60307] Check if session is nil before calling UpdateLastActivityAtIfNeeded (#28254) --- server/channels/app/platform/session.go | 9 +++++---- server/channels/app/platform/web_conn.go | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/server/channels/app/platform/session.go b/server/channels/app/platform/session.go index 27d5f0d315ef7..e12f22bb96001 100644 --- a/server/channels/app/platform/session.go +++ b/server/channels/app/platform/session.go @@ -17,11 +17,12 @@ import ( func (ps *PlatformService) ReturnSessionToPool(session *model.Session) { if session != nil { session.Id = "" - // Once the session is retrieved from the pool, all existing prop fields are cleared. - // To avoid a race between clearing the props and accessing it, clear the props maps before returning it to the pool. + // All existing prop fields are cleared once the session is retrieved from the pool. + // To speed up that process, clear the props here to avoid doing that in the hot path. + // + // If the request handler spawns a goroutine that uses the session, it might race with this code. + // In that case, the handler should copy the session and use the copy in the goroutine. clear(session.Props) - // Also clear the team members slice to avoid a similar race condition. - clear(session.TeamMembers) ps.sessionPool.Put(session) } } diff --git a/server/channels/app/platform/web_conn.go b/server/channels/app/platform/web_conn.go index caa27dd663ab5..af2d41b51b3d3 100644 --- a/server/channels/app/platform/web_conn.go +++ b/server/channels/app/platform/web_conn.go @@ -253,7 +253,10 @@ func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, runn // Create a goroutine to avoid blocking the creation of the websocket connection. ps.Go(func() { ps.SetStatusOnline(userID, false) - ps.UpdateLastActivityAtIfNeeded(*wc.GetSession()) + session := wc.GetSession() + if session != nil { + ps.UpdateLastActivityAtIfNeeded(*session) + } }) } From 3428cd15b64fd864091085c05f64a867bda3341c Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Thu, 26 Sep 2024 13:57:48 +0200 Subject: [PATCH 2/6] [MM-60648] Don't start server until all routes are registered (#28291) --- server/channels/api4/api.go | 6 ++++++ server/channels/manualtesting/manual_testing.go | 8 +------- server/cmd/mattermost/commands/server.go | 9 ++------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 5bc47c3791a7d..2fb9a7b5b4227 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/v8/channels/app" + "github.com/mattermost/mattermost/server/v8/channels/manualtesting" "github.com/mattermost/mattermost/server/v8/channels/web" ) @@ -337,6 +338,11 @@ func Init(srv *app.Server) (*API, error) { api.InitOutgoingOAuthConnection() api.InitClientPerformanceMetrics() + // If we allow testing then listen for manual testing URL hits + if *srv.Config().ServiceSettings.EnableTesting { + api.BaseRoutes.Root.Handle("/manualtest", api.APIHandler(manualtesting.ManualTest)).Methods(http.MethodGet) + } + srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) InitLocal(srv) diff --git a/server/channels/manualtesting/manual_testing.go b/server/channels/manualtesting/manual_testing.go index e0d0f4f825d3a..2e49066d9fbf3 100644 --- a/server/channels/manualtesting/manual_testing.go +++ b/server/channels/manualtesting/manual_testing.go @@ -15,7 +15,6 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" - "github.com/mattermost/mattermost/server/v8/channels/api4" "github.com/mattermost/mattermost/server/v8/channels/app" "github.com/mattermost/mattermost/server/v8/channels/app/slashcommands" "github.com/mattermost/mattermost/server/v8/channels/store" @@ -34,12 +33,7 @@ type TestEnvironment struct { Request *http.Request } -// Init adds manualtest endpoint to the API. -func Init(api4 *api4.API) { - api4.BaseRoutes.Root.Handle("/manualtest", api4.APIHandler(manualTest)).Methods(http.MethodGet) -} - -func manualTest(c *web.Context, w http.ResponseWriter, r *http.Request) { +func ManualTest(c *web.Context, w http.ResponseWriter, r *http.Request) { // Let the world know c.Logger.Info("Setting up for manual test...") diff --git a/server/cmd/mattermost/commands/server.go b/server/cmd/mattermost/commands/server.go index 5f73ba0501247..642bd95b786d8 100644 --- a/server/cmd/mattermost/commands/server.go +++ b/server/cmd/mattermost/commands/server.go @@ -18,7 +18,6 @@ import ( "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/api4" "github.com/mattermost/mattermost/server/v8/channels/app" - "github.com/mattermost/mattermost/server/v8/channels/manualtesting" "github.com/mattermost/mattermost/server/v8/channels/utils" "github.com/mattermost/mattermost/server/v8/channels/web" "github.com/mattermost/mattermost/server/v8/channels/wsapi" @@ -93,11 +92,12 @@ func runServer(configStore *config.Store, interruptChan chan os.Signal) error { } }() - api, err := api4.Init(server) + _, err = api4.Init(server) if err != nil { mlog.Error(err.Error()) return err } + wsapi.Init(server) web.New(server) @@ -107,11 +107,6 @@ func runServer(configStore *config.Store, interruptChan chan os.Signal) error { return err } - // If we allow testing then listen for manual testing URL hits - if *server.Config().ServiceSettings.EnableTesting { - manualtesting.Init(api) - } - notifyReady() // wait for kill signal before attempting to gracefully shutdown From d58b04896586fd0e006872ef943f5a18672fdf4b Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Thu, 26 Sep 2024 09:02:11 -0400 Subject: [PATCH 3/6] [MM-60603] Don't follow threads when marking them as read on focus (#28263) * [MM-60603] Don't follow threads when marking them as read on focus * Fix tests * Fix lint * Fix the original bug in the API call --- server/channels/api4/user.go | 8 ++++ server/channels/api4/user_test.go | 38 +++++++++-------- server/channels/app/user.go | 11 ++--- server/channels/app/user_test.go | 44 ++------------------ webapp/channels/src/actions/views/threads.ts | 4 +- 5 files changed, 39 insertions(+), 66 deletions(-) diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index 60f7faf9252d2..c9eef925ad030 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -3344,6 +3344,14 @@ func setUnreadThreadByPostId(c *Context, w http.ResponseWriter, r *http.Request) return } + // We want to make sure the thread is followed when marking as unread + // https://mattermost.atlassian.net/browse/MM-36430 + err := c.App.UpdateThreadFollowForUser(c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, true) + if err != nil { + c.Err = err + return + } + thread, err := c.App.UpdateThreadReadForUserByPost(c.AppContext, c.AppContext.Session().Id, c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, c.Params.PostId) if err != nil { c.Err = err diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 16220e4cdf17d..75a8ee0ac25a8 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -6855,58 +6855,60 @@ func TestThreadSocketEvents(t *testing.T) { require.Truef(t, caught, "User should have received %s event", model.WebsocketEventThreadUpdated) }) - resp, err = th.Client.UpdateThreadFollowForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, rpost.Id, false) + _, resp, err = th.Client.UpdateThreadReadForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, rpost.Id, replyPost.CreateAt+1) require.NoError(t, err) CheckOKStatus(t, resp) - t.Run("Listed for follow event", func(t *testing.T) { + t.Run("Listed for read event", func(t *testing.T) { var caught bool func() { for { select { case ev := <-userWSClient.EventChannel: - if ev.EventType() == model.WebsocketEventThreadFollowChanged { + if ev.EventType() == model.WebsocketEventThreadReadChanged { caught = true - require.Equal(t, ev.GetData()["state"], false) - require.Equal(t, ev.GetData()["reply_count"], float64(1)) + + data := ev.GetData() + require.EqualValues(t, replyPost.CreateAt+1, data["timestamp"]) + require.EqualValues(t, float64(1), data["previous_unread_replies"]) + require.EqualValues(t, float64(1), data["previous_unread_mentions"]) + require.EqualValues(t, float64(0), data["unread_replies"]) + require.EqualValues(t, float64(0), data["unread_mentions"]) } case <-time.After(2 * time.Second): return } } }() - require.Truef(t, caught, "User should have received %s event", model.WebsocketEventThreadFollowChanged) + + require.Truef(t, caught, "User should have received %s event", model.WebsocketEventThreadReadChanged) }) - _, resp, err = th.Client.UpdateThreadReadForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, rpost.Id, replyPost.CreateAt+1) + resp, err = th.Client.UpdateThreadFollowForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, rpost.Id, false) require.NoError(t, err) CheckOKStatus(t, resp) - t.Run("Listed for read event", func(t *testing.T) { + t.Run("Listed for follow event", func(t *testing.T) { var caught bool func() { for { select { case ev := <-userWSClient.EventChannel: - if ev.EventType() == model.WebsocketEventThreadReadChanged { + if ev.EventType() == model.WebsocketEventThreadFollowChanged { caught = true - - data := ev.GetData() - require.EqualValues(t, replyPost.CreateAt+1, data["timestamp"]) - require.EqualValues(t, float64(1), data["previous_unread_replies"]) - require.EqualValues(t, float64(1), data["previous_unread_mentions"]) - require.EqualValues(t, float64(0), data["unread_replies"]) - require.EqualValues(t, float64(0), data["unread_mentions"]) + require.Equal(t, ev.GetData()["state"], false) + require.Equal(t, ev.GetData()["reply_count"], float64(1)) } case <-time.After(2 * time.Second): return } } }() - - require.Truef(t, caught, "User should have received %s event", model.WebsocketEventThreadReadChanged) + require.Truef(t, caught, "User should have received %s event", model.WebsocketEventThreadFollowChanged) }) + _, err = th.Client.UpdateThreadFollowForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, rpost.Id, true) + require.NoError(t, err) _, resp, err = th.Client.SetThreadUnreadByPostId(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, rpost.Id, rpost.Id) require.NoError(t, err) CheckOKStatus(t, resp) diff --git a/server/channels/app/user.go b/server/channels/app/user.go index d6a70a38e6235..62f9d0004e0ad 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -2831,13 +2831,10 @@ func (a *App) UpdateThreadReadForUser(c request.CTX, currentSessionId, userID, t return nil, err } - opts := store.ThreadMembershipOpts{ - Following: true, - UpdateFollowing: true, - } - membership, storeErr := a.Srv().Store().Thread().MaintainMembership(userID, threadID, opts) - if storeErr != nil { - return nil, model.NewAppError("UpdateThreadReadForUser", "app.user.update_thread_read_for_user.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + // If the thread doesn't have a membership, we shouldn't try to mark it as unread + membership, err := a.GetThreadMembershipForUser(userID, threadID) + if err != nil { + return nil, err } previousUnreadMentions := membership.UnreadMentions diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 4c8c49304d58f..bb9691cf70fb4 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -8,7 +8,6 @@ import ( "context" "encoding/json" "errors" - "net/http" "path/filepath" "strings" "testing" @@ -1930,7 +1929,7 @@ func TestPatchUser(t *testing.T) { } func TestUpdateThreadReadForUser(t *testing.T) { - t.Run("Ensure thread membership is created and followed", func(t *testing.T) { + t.Run("Ensure thread membership exists before updating read", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { @@ -1947,48 +1946,13 @@ func TestUpdateThreadReadForUser(t *testing.T) { require.Zero(t, threads.Total) _, appErr = th.App.UpdateThreadReadForUser(th.Context, "currentSessionId", th.BasicUser.Id, th.BasicChannel.TeamId, rootPost.Id, replyPost.CreateAt) - require.Nil(t, appErr) - - threads, appErr = th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) - require.Nil(t, appErr) - assert.NotZero(t, threads.Total) - - threadMembership, appErr := th.App.GetThreadMembershipForUser(th.BasicUser.Id, rootPost.Id) - require.Nil(t, appErr) - require.NotNil(t, threadMembership) - assert.True(t, threadMembership.Following) - - _, appErr = th.App.GetThreadMembershipForUser(th.BasicUser.Id, "notfound") require.NotNil(t, appErr) - assert.Equal(t, http.StatusNotFound, appErr.StatusCode) - }) - - t.Run("Ensure no panic on error", func(t *testing.T) { - th := SetupWithStoreMock(t) - defer th.TearDown() - mockStore := th.App.Srv().Store().(*storemocks.Store) - mockUserStore := storemocks.UserStore{} - mockUserStore.On("Count", mock.Anything).Return(int64(10), nil) - mockUserStore.On("Get", mock.Anything, "user1").Return(&model.User{Id: "user1"}, nil) - - mockThreadStore := storemocks.ThreadStore{} - mockThreadStore.On("MaintainMembership", "user1", "postid", mock.Anything).Return(nil, errors.New("error")) - - var err error - th.App.ch.srv.userService, err = users.New(users.ServiceConfig{ - UserStore: &mockUserStore, - SessionStore: &storemocks.SessionStore{}, - OAuthStore: &storemocks.OAuthStore{}, - ConfigFn: th.App.ch.srv.platform.Config, - LicenseFn: th.App.ch.srv.License, - }) + _, err := th.App.Srv().Store().Thread().MaintainMembership(th.BasicUser.Id, rootPost.Id, store.ThreadMembershipOpts{Following: true, UpdateFollowing: true}) require.NoError(t, err) - mockStore.On("User").Return(&mockUserStore) - mockStore.On("Thread").Return(&mockThreadStore) - _, err = th.App.UpdateThreadReadForUser(th.Context, "currentSessionId", "user1", "team1", "postid", 100) - require.Error(t, err) + _, appErr = th.App.UpdateThreadReadForUser(th.Context, "currentSessionId", th.BasicUser.Id, th.BasicChannel.TeamId, rootPost.Id, replyPost.CreateAt) + require.Nil(t, appErr) }) } diff --git a/webapp/channels/src/actions/views/threads.ts b/webapp/channels/src/actions/views/threads.ts index 1200f3da013bb..006e980ced6e5 100644 --- a/webapp/channels/src/actions/views/threads.ts +++ b/webapp/channels/src/actions/views/threads.ts @@ -5,6 +5,7 @@ import {batchActions} from 'redux-batched-actions'; import {updateThreadRead} from 'mattermost-redux/actions/threads'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getThread} from 'mattermost-redux/selectors/entities/threads'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ThunkActionFunc} from 'mattermost-redux/types/actions'; @@ -56,8 +57,9 @@ export function markThreadAsRead(threadId: string): ThunkActionFunc Date: Thu, 26 Sep 2024 16:59:20 +0200 Subject: [PATCH 4/6] 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 | 57 ++--- 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 | 139 ++++++----- .../__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 | 225 +++++++++++++++--- .../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, 481 insertions(+), 955 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 2559aa942767b..03f1c0d941b99 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 4ef136b91f96f..2effbef2fc365 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 24cff670f6dcf..86e2111c6e012 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 50e12bf16de88..58cdd557d3280 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); +export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType; - 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 OnSubmitOptions = { + ignoreSlash?: boolean; + afterSubmit?: (response: SubmitPostReturnType) => void; + afterOptimisticSubmit?: () => void; } -export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType; - -export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afterSubmit?: (response: SubmitPostReturnType) => void}): ActionFuncAsync { +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 2c92b1d69a69c..6aa582d2602c3 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 76cceda78f354..9687265b397bd 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 00b69349f8d33..d7dea5c0ac10f 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 3f4c0e1c8fee5..a4508579f4c22 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}, - }; + if (submittingDraft.message.trimEnd() === '/header') { + const editChannelHeaderModalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, + dialogType: EditChannelHeaderModal, + dialogProps: {channel}, + }; - dispatch(openModal(editChannelHeaderModalData)); + dispatch(openModal(editChannelHeaderModalData)); - handleDraftChange({ - ...submittingDraft, - message: '', - }); - isDraftSubmitting.current = false; - return; - } + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } - if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') { - const editChannelPurposeModalData = { - modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, - dialogType: EditChannelPurposeModal, - dialogProps: {channel}, - }; + if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') { + const editChannelPurposeModalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, + dialogType: EditChannelPurposeModal, + dialogProps: {channel}, + }; - dispatch(openModal(editChannelPurposeModalData)); + dispatch(openModal(editChannelPurposeModalData)); - handleDraftChange({ - ...submittingDraft, - message: '', - }); - isDraftSubmitting.current = false; - return; + 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 16c62ef143a08..0000000000000 --- 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 7aac5599e8e12..0000000000000 --- 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 bc7059dd3952d..0000000000000 --- 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 27211e98b9a9a..0000000000000 --- 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 66b8ce6821fce..95ab53b53b3f3 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 00f33dd7f5228..43b5ba22a5ce1 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 a6a8cdb467624..f415f4112f2d5 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 ca28c37ec58eb..d35b8da7f8469 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 48cd47ec3c945..fbf7ae80a39e5 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 {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 {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 ChannelDraft from './channel_draft'; -import ThreadDraft from './thread_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 = { 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 71f5a30ef4d2b..bd1cd8083491e 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 35197fca0df77..09eb02815810e 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 e26a7d03dd881..939e6c64477f1 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 2d50426c9d6a8..1cf50db996748 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 b4e43305111cb..0000000000000 --- 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 8ab27f115032b..0000000000000 --- 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 fef3117ff509a..0000000000000 --- 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 55a1201c328c4..0000000000000 --- 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 700620d9c4b84..9d40fd29b2720 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 6c306a0637303..74e8573c4ff94 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 73d960979c0be..9b93148f017e8 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 3b6048108e082..f01a2b8b9ae5d 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 58e0018a8579e..0697a2a1dba5f 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 dea43e9c25808..8198c285d1ed1 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 6fa24a4b9c60c..a6f62f4c88083 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 5eac286e362c5..7512206921210 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 fcc8abaa9a6fd..4133024b9a21f 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 c4bb775159b1b..3d33497e68bcb 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 3cea38f7588a3..8997f044b4c93 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 66aa540ea9f1d..9ffca17e233dd 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); }; })(); From bd61f1484b1f7b9f1ed68955bd3318e662319a85 Mon Sep 17 00:00:00 2001 From: Matthew Birtch Date: Thu, 26 Sep 2024 20:24:07 -0400 Subject: [PATCH 5/6] MM-60604 Hide pinned icon in channel header when no pinned posts (#28276) --- .../channel_header.test.tsx.snap | 180 ------------------ .../channel_header/channel_header.tsx | 20 +- 2 files changed, 13 insertions(+), 187 deletions(-) diff --git a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap index 232993bb2e891..1e9d230769499 100644 --- a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap +++ b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap @@ -71,18 +71,6 @@ exports[`components/ChannelHeader should match snapshot with last active display
-