From d8612e378f61933ae8fb7e66c0308eb9a13c42e6 Mon Sep 17 00:00:00 2001 From: Joshua D Schoep Date: Wed, 13 May 2026 10:38:30 -0600 Subject: [PATCH] [MM-2541] Shortcut to mark all channels as read for a team (#34012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(webapp): added keyboard shortcut for Mark All As Read (MM-2541) - Added shortcut (within sidebar) for Shift+ESC to mark _all_ messages, teams as read - Desktop only - Added feature toasts for new features and localStorage support - Added feature toast for mark-all-as-read feature - Should decide when/how people want this shown, I just followed designs - Will only show if the user has not clicked 'Got it' before, and is not on mobile - Added confirmation modal for mark all as read shortcut - Contains option to not show again, saved in localStorage - Added English translations for read shortcut - Will need i18n aid on other languages This is a draft version of this feature update that still needs testing and i18n support, along with a11y validation. * feat(webapp): feature flags and fixes for mark all as read shortcut - Added feature flags surrounding rollout of mark-all-as-read shortcut - Added shortcut to list of shortcuts in help section - Extended tests for new components - Updated snapshot for sidebar_list, keyboard_shortcuts_modal - Fixed styling and CSS issues Still in draft, needs documentation and e2e support. * fix(webapp): fixed some issues with new mark-all-read feature - Scoped persistent storage to current user ID so that subsequent new logins also get the notification - Replaced LocalStorage calls with useGlobalState calls, sad that I missed that this updated call was being used. - Fixed an issue that would have caused the new shortcut to show up in the Help menu's shortcuts without being enabled. * Fixed a snapshot test and a missing i18n member * Replaced useGlobalState with backend-ready usePreference. Previous version was just a mistake as we didnt know about the supported API * fix(server): fix lint issue with gofmt * feat(server,webapp): added cleaner and more effective method with which to mark-all-read - Added 2 new routes to the API (need to find docs to update those): - `PUT /api/v4/channels/members//direct/read` will mark a user's non-team DMs and GMs as read - `PUT /api/v4/users//teams//read` will do a similar action as the multi-channel mark_read action, but with a teamId signifier. Because this is using a teamId, it will _not_ handle DMs or GMs. - Updated sidebar_list.tsx to use these new routes for the new shortcut - Added extensive testing, including feature flag assurance. * fix from upstream changes * fix: eslint errors in teams actions * document new API endpoints * fix i18n * fix err id * remove unused localhost methods * use ShortcutKey and ShortcutSequence * feature_enhancements, mark as read toast enchancements * read all modal mount point, use openModal * use handler * fix style * fix: fix refactoring typo * Merge fix: realign branch with upstream changes Upstream MM-67319/MM-67320 (#36037) moved ShortcutKey and WithTooltip into the shared package and rewrote the keyboard shortcuts test to snapshot real DOM instead of a react-test-renderer tree. The merge resolution missed several follow-on consequences; clean them up so the branch builds, type checks, lints, passes i18n-extract-check and runs without throwing at mount. - Port the inline-content variant from the deleted channels-side shortcut_key.scss to the new shared shortcut_key.css. - Refresh the keyboard_shortcuts_sequence snapshot so it matches Testing Library's container output (DOM only, no component nodes, class= not className=). - Repoint mark_all_as_read_modal and mark_all_as_read_toast at components/shortcut_key for ShortcutKeys and use ShortcutKeys.escape; the channels-side with_tooltip is now a thin re-export and the field was renamed in the shared keys map. Without this both consumers threw "Cannot read properties of undefined" at mount. - Switch mark_all_as_read_toast's UserAgent import to @mattermost/shared/utils/user_agent; the channels-local utils/user_agent path no longer resolves. - Drop the orphan mark_all_threads_as_read_modal.cancel string from en.json so formatjs extraction is in sync. * Clean up TestReadAllInTeam Drop four lines left from debugging and replace them with a real assertion: LastViewedAtTimes must contain the test channel with a value at or after the most recent post. Update three client.GetChannel calls to the (ctx, id) signature; the prior etag argument no longer compiles after upstream removed it. * Use SelectBuilder for team channels query GetTeamChannelsWithUnreadAndMentions built a squirrel query and then manually called ToSql before handing the string+args to GetReplica().Select. SelectBuilder accepts the builder directly and removes the intermediate dance, matching the pattern used elsewhere in this store. * Mark all team-channel threads on team read MarkTeamChannelsAndThreadsViewed used Thread().MarkAllAsReadByTeam unconditionally, writing every thread membership in the team for the user even when nothing was stale. Scoping the call to channelsToView (channels with unread channel-level messages) would have closed the perf concern but introduced a regression: in CRT mode a thread reply does not bump the channel's TotalMsgCount, so a channel can be read at the channel level while still having unread thread replies, and those would have been silently skipped. Build the channel-id list from the keys of the times map instead. GetTeamChannelsWithUnreadAndMentions already populates that map for every team channel the user belongs to, so no extra query is needed. MarkAllAsReadByChannels then filters the actual UPDATE through its LastReplyAt > LastViewed clause, keeping writes bounded to genuinely stale rows. Gate the channel-level work (UpdateLastViewedAt, push clearing, the MultipleChannelsViewed event) on channelsToView being non-empty, but always run the thread mark and broadcast ThreadReadChanged for every team channel so CRT clients refresh thread state in channels that had no channel-level change. * Mark mark-read audit records as success The handlers for mark all DM/GM and mark team read created an audit record with status Fail and never updated it on success, so successful calls were always logged as failures. * Mark all DM/GM threads on full read MarkAllDirectAndGroupMessagesViewed early-returned when no channel had unreads, so followed threads in DMs/GMs whose channel-level counters were already current stayed unread under CRT. Mirror MarkTeamChannelsAndThreadsViewed and call MarkAllAsReadByChannels for every DM/GM in times. * Polish DM/GM channels-with-unreads query Use model.ChannelTypeDirect/Group constants instead of bare "D"/"G" literals, and update the error wrap to mention DM/GM channels (it was copied from the team variant). * Fix stale ReadAllMessages godoc * Type last_viewed_at_times as int64 map in OpenAPI The response field was declared as a generic object. Add additionalProperties so generated clients see it as a channelId -> int64 timestamp map. * Gate MarkAllAsReadToast mount on feature flag The toast was mounted unconditionally, so its async chunk loaded even when EnableShiftEscapeToMarkAllRead was off. Gate the mount with the flag so the chunk only loads when the feature is on. * Return data from markAllInTeamAsRead thunk Match the {data: response} shape used by adjacent thunks instead of returning {}, so callers can read the API payload. * Coerce undefined suffix in createStoredKey createStoredKey('foo') returned 'fooundefined' when the suffix arg was omitted. Coerce a missing suffix to ''. * Refactor mark-read websocket events * Polish DM/GM channels-with-unreads query * Fix import order in shortcut_key consumers * Fix CI --------- Co-authored-by: Mattermost Build Co-authored-by: Jesse Hallam Co-authored-by: Caleb Roseland Co-authored-by: Alejandro García Montoro --- api/v4/source/channels.yaml | 46 ++ api/v4/source/users.yaml | 54 ++ server/channels/api4/channel.go | 83 +++ server/channels/api4/channel_test.go | 238 ++++++++ server/channels/app/channel.go | 127 ++++- .../channels/store/sqlstore/channel_store.go | 132 +++++ server/channels/store/store.go | 2 + .../channels/store/storetest/channel_store.go | 515 ++++++++++++++++++ .../store/storetest/mocks/ChannelStore.go | 96 ++++ server/i18n/en.json | 8 + server/public/model/audit_events.go | 2 + server/public/model/client4.go | 19 + server/public/model/feature_flags.go | 4 + .../channel_controller.test.tsx | 1 + .../channel_layout/channel_controller.tsx | 4 + .../feature_toast/feature_toast.scss | 62 +++ .../feature_toast/feature_toast.test.tsx | 132 +++++ .../feature_toast/feature_toast.tsx | 93 ++++ .../features/mark_all_as_read_toast.tsx | 75 +++ .../src/components/feature_toast/index.ts | 9 + .../keyboard_shortcuts_modal.test.tsx | 5 + .../keyboard_shortcuts_modal.tsx | 3 + .../keyboard_shortcuts.ts | 4 + .../keyboard_shortcuts_sequence.tsx | 36 +- .../components/mark_all_as_read_modal.scss | 43 ++ .../mark_all_as_read_modal.test.tsx | 120 ++++ .../src/components/mark_all_as_read_modal.tsx | 121 ++++ .../src/components/shortcut_sequence/index.ts | 4 + .../shortcut_sequence.test.tsx | 71 +++ .../shortcut_sequence/shortcut_sequence.tsx | 50 ++ .../components/sidebar/sidebar_list/index.ts | 30 +- .../sidebar_list/sidebar_list.test.tsx | 7 + .../sidebar/sidebar_list/sidebar_list.tsx | 59 +- webapp/channels/src/i18n/en.json | 10 + .../mattermost-redux/src/actions/channels.ts | 17 + .../mattermost-redux/src/actions/teams.ts | 22 +- .../src/constants/preferences.ts | 6 + webapp/channels/src/stores/hooks.ts | 9 +- webapp/channels/src/utils/constants.tsx | 3 + webapp/platform/client/src/client4.ts | 14 + .../src/components/shortcut_key/keys.ts | 4 + .../components/shortcut_key/shortcut_key.css | 10 + .../components/shortcut_key/shortcut_key.tsx | 2 + .../tooltip/tooltip_shortcut.test.tsx | 13 + 44 files changed, 2337 insertions(+), 28 deletions(-) create mode 100644 webapp/channels/src/components/feature_toast/feature_toast.scss create mode 100644 webapp/channels/src/components/feature_toast/feature_toast.test.tsx create mode 100644 webapp/channels/src/components/feature_toast/feature_toast.tsx create mode 100644 webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx create mode 100644 webapp/channels/src/components/feature_toast/index.ts create mode 100644 webapp/channels/src/components/mark_all_as_read_modal.scss create mode 100644 webapp/channels/src/components/mark_all_as_read_modal.test.tsx create mode 100644 webapp/channels/src/components/mark_all_as_read_modal.tsx create mode 100644 webapp/channels/src/components/shortcut_sequence/index.ts create mode 100644 webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx create mode 100644 webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx diff --git a/api/v4/source/channels.yaml b/api/v4/source/channels.yaml index 9566147659a..bcf74c0fdb1 100644 --- a/api/v4/source/channels.yaml +++ b/api/v4/source/channels.yaml @@ -2120,6 +2120,52 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "/api/v4/channels/members/{user_id}/direct/read": + put: + tags: + - channels + summary: Mark all direct and group messages as read + description: | + Mark all direct and group messages as read for a user. + + ##### Permissions + + Must be logged in as user or have `edit_other_users` permission. + + __Minimum server version__: 11.3 + operationId: MarkAllDirectMessagesRead + parameters: + - in: path + name: user_id + description: User ID to mark messages as read for + required: true + schema: + type: string + responses: + "200": + description: Direct messages marked as read successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: Value should be "OK" if successful + last_viewed_at_times: + type: object + description: A JSON object mapping channel IDs to the last viewed times + additionalProperties: + type: integer + format: int64 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "501": + $ref: "#/components/responses/NotImplemented" "/api/v4/users/{user_id}/teams/{team_id}/channels/members": get: tags: diff --git a/api/v4/source/users.yaml b/api/v4/source/users.yaml index 572d834c6fd..c22441ec34d 100644 --- a/api/v4/source/users.yaml +++ b/api/v4/source/users.yaml @@ -3158,6 +3158,60 @@ $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" + "/api/v4/users/{user_id}/teams/{team_id}/read": + put: + tags: + - channels + summary: Mark all channels and threads in a team as read + description: | + Mark all channels and threads in a team as read for a user. + + ##### Permissions + + Must be logged in as user or have `edit_other_users` permission. Must have `view_team` permission for the team. + + __Minimum server version__: 11.3 + operationId: MarkAllTeamChannelsRead + parameters: + - name: user_id + in: path + description: User ID to mark channels as read for + required: true + schema: + type: string + - name: team_id + in: path + description: Team ID to mark all channels as read in + required: true + schema: + type: string + responses: + "200": + description: Team channels marked as read successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: Value should be "OK" if successful + last_viewed_at_times: + type: object + description: A JSON object mapping channel IDs to the last viewed times + additionalProperties: + type: integer + format: int64 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" "/api/v4/users/{user_id}/teams/{team_id}/threads/read": put: tags: diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index d55b316a81d..426cbec08fc 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -26,6 +26,7 @@ func (api *API) InitChannel() { api.BaseRoutes.Channels.Handle("/group", api.APISessionRequired(createGroupChannel)).Methods(http.MethodPost) api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/view", api.APISessionRequired(viewChannel)).Methods(http.MethodPost) api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/mark_read", api.APISessionRequired(readMultipleChannels)).Methods(http.MethodPost) + api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/direct/read", api.APISessionRequired(readAllMessages)).Methods(http.MethodPut) api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/scheme", api.APISessionRequired(updateChannelScheme)).Methods(http.MethodPut) api.BaseRoutes.Channels.Handle("/stats/member_count", api.APISessionRequired(getChannelsMemberCount)).Methods(http.MethodPost) @@ -37,6 +38,7 @@ func (api *API) InitChannel() { api.BaseRoutes.ChannelsForTeam.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchChannelsForTeam)).Methods(http.MethodPost) api.BaseRoutes.ChannelsForTeam.Handle("/autocomplete", api.APISessionRequired(autocompleteChannelsForTeam)).Methods(http.MethodGet) api.BaseRoutes.ChannelsForTeam.Handle("/search_autocomplete", api.APISessionRequired(autocompleteChannelsForTeamForSearch)).Methods(http.MethodGet) + api.BaseRoutes.User.Handle("/teams/{team_id:[A-Za-z0-9]+}/read", api.APISessionRequired(readAllInTeam)).Methods(http.MethodPut) if api.srv.Config().FeatureFlags.ManagedChannelCategories { api.BaseRoutes.ChannelsForTeam.Handle("/managed_categories", api.APISessionRequired(getManagedCategories)).Methods(http.MethodGet) } @@ -606,6 +608,44 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) { } } +func readAllMessages(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.EnableShiftEscapeToMarkAllRead { + c.Err = model.NewAppError("readAllMessages", "api.mark_all_as_read.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + c.RequireUserId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventMarkMessagesRead, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + times, err := c.App.MarkAllDirectAndGroupMessagesViewed(c.AppContext, c.Params.UserId, c.AppContext.Session().Id, c.App.IsCRTEnabledForUser(c.AppContext, c.Params.UserId)) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + + resp := &model.ChannelViewResponse{ + Status: "OK", + LastViewedAtTimes: times, + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func searchGroupChannels(c *Context, w http.ResponseWriter, r *http.Request) { var props *model.ChannelSearch err := json.NewDecoder(r.Body).Decode(&props) @@ -1866,6 +1906,49 @@ func readMultipleChannels(c *Context, w http.ResponseWriter, r *http.Request) { } } +func readAllInTeam(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.EnableShiftEscapeToMarkAllRead { + c.Err = model.NewAppError("readAllInTeam", "api.mark_all_as_read.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventMarkTeamRead, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) + model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + times, err := c.App.MarkTeamChannelsAndThreadsViewed(c.AppContext, c.Params.TeamId, c.Params.UserId, c.AppContext.Session().Id, c.App.IsCRTEnabledForUser(c.AppContext, c.Params.UserId)) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + + resp := &model.ChannelViewResponse{ + Status: "OK", + LastViewedAtTimes: times, + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func updateChannelMemberRoles(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireChannelId().RequireUserId() if c.Err != nil { diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 4a8db044e2a..d762340a23a 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -4506,6 +4506,244 @@ func TestReadMultipleChannels(t *testing.T) { }) } +func TestReadAllMessages(t *testing.T) { + mainHelper.Parallel(t) + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }).InitBasic(t) + client := th.Client + user := th.BasicUser + + t.Run("Should fail when feature flag is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = false + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }) + + _, resp, err := client.ReadAllMessages(context.Background(), user.Id) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("Should successfully mark all direct messages as read for self", func(t *testing.T) { + dmChannel, _, err := client.CreateDirectChannel(context.Background(), user.Id, th.BasicUser2.Id) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: dmChannel.Id, + Message: "test message", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllMessages(context.Background(), user.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should successfully mark all group messages as read for self", func(t *testing.T) { + gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user.Id, th.BasicUser2.Id, th.TeamAdminUser.Id}) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: gmChannel.Id, + Message: "test group message", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllMessages(context.Background(), user.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should fail marking messages for other user without permission", func(t *testing.T) { + _, _, err := client.ReadAllMessages(context.Background(), th.BasicUser2.Id) + require.Error(t, err) + }) + + t.Run("Admin should succeed in marking messages for other user", func(t *testing.T) { + adminClient := th.SystemAdminClient + + dmChannel, _, err := adminClient.CreateDirectChannel(context.Background(), th.BasicUser2.Id, th.TeamAdminUser.Id) + require.NoError(t, err) + + _, _, err = adminClient.CreatePost(context.Background(), &model.Post{ + ChannelId: dmChannel.Id, + Message: "test message for user2", + }) + require.NoError(t, err) + + channelResponse, _, err := adminClient.ReadAllMessages(context.Background(), th.BasicUser2.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should handle empty direct/group message list gracefully", func(t *testing.T) { + channelResponse, _, err := client.ReadAllMessages(context.Background(), user.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + }) + + t.Run("Should fail with invalid user ID", func(t *testing.T) { + _, _, err := client.ReadAllMessages(context.Background(), "invalid-user-id") + require.Error(t, err) + }) +} + +func TestReadAllInTeam(t *testing.T) { + mainHelper.Parallel(t) + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }).InitBasic(t) + client := th.Client + user := th.BasicUser + team := th.BasicTeam + + t.Run("Should fail when feature flag is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = false + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }) + + _, resp, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("Should successfully mark all channels and threads as read for self in team", func(t *testing.T) { + channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id) + require.NoError(t, err) + channel2, _, err := client.GetChannel(context.Background(), th.BasicChannel2.Id) + require.NoError(t, err) + + post, _, err := client.CreatePost(context.Background(), &model.Post{ + ChannelId: channel.Id, + Message: "test message in channel 1", + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: channel2.Id, + Message: "test message in channel 2", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + require.Contains(t, channelResponse.LastViewedAtTimes, channel.Id) + require.GreaterOrEqual(t, channelResponse.LastViewedAtTimes[channel.Id], post.CreateAt, + "channel last_viewed_at should be at or after the latest post in the channel") + }) + + t.Run("Should fail marking channels for other user without permission", func(t *testing.T) { + _, _, err := client.ReadAllInTeam(context.Background(), th.BasicUser2.Id, team.Id) + require.Error(t, err) + }) + + t.Run("Should fail with invalid team ID", func(t *testing.T) { + _, _, err := client.ReadAllInTeam(context.Background(), user.Id, "invalid-team-id") + require.Error(t, err) + }) + + t.Run("Admin should succeed in marking channels for other user in team", func(t *testing.T) { + adminClient := th.SystemAdminClient + channel, _, err := adminClient.GetChannel(context.Background(), th.BasicChannel.Id) + require.NoError(t, err) + + _, _, err = adminClient.CreatePost(context.Background(), &model.Post{ + ChannelId: channel.Id, + Message: "test message for user2", + }) + require.NoError(t, err) + + channelResponse, _, err := adminClient.ReadAllInTeam(context.Background(), th.BasicUser2.Id, team.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should handle empty channel list gracefully", func(t *testing.T) { + newTeam := th.CreateTeam(t) + th.LinkUserToTeam(t, user, newTeam) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, newTeam.Id) + + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should only mark channels in the specified team", func(t *testing.T) { + team2 := th.CreateTeam(t) + th.LinkUserToTeam(t, user, team2) + + channelTeam1, _, err := client.CreateChannel(context.Background(), &model.Channel{ + TeamId: team.Id, + Name: model.NewId(), + DisplayName: "Team 1 Channel", + Type: model.ChannelTypeOpen, + }) + require.NoError(t, err) + + channelTeam2, _, err := client.CreateChannel(context.Background(), &model.Channel{ + TeamId: team2.Id, + Name: model.NewId(), + DisplayName: "Team 2 Channel", + Type: model.ChannelTypeOpen, + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: channelTeam1.Id, + Message: "message in team 1", + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: channelTeam2.Id, + Message: "message in team 2", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.Contains(t, channelResponse.LastViewedAtTimes, channelTeam1.Id, "team1 channel should be marked as read") + require.NotContains(t, channelResponse.LastViewedAtTimes, channelTeam2.Id, "team2 channel should not be marked as read") + }) + + t.Run("Should handle both public and private channels in team", func(t *testing.T) { + _, _, err := client.CreatePost(context.Background(), &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "public message", + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: th.BasicPrivateChannel.Id, + Message: "private message", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.Contains(t, channelResponse.LastViewedAtTimes, th.BasicChannel.Id, "public channel should be marked as read") + require.Contains(t, channelResponse.LastViewedAtTimes, th.BasicPrivateChannel.Id, "private channel should be marked as read") + }) +} + func TestGetChannelUnread(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index ab02d364872..bd9b3b2b21e 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -3319,7 +3319,130 @@ func (a *App) SearchChannelsUserNotIn(rctx request.CTX, teamID string, userID st return channelList, nil } -func (a *App) MarkChannelsAsViewed(rctx request.CTX, channelIDs []string, userID string, currentSessionId string, collapsedThreadsSupported, isCRTEnabled bool) (map[string]int64, *model.AppError) { +func (a *App) MarkTeamChannelsAndThreadsViewed(rctx request.CTX, teamID string, userID string, currentSessionID string, isCRTEnabled bool) (map[string]int64, *model.AppError) { + user, err := a.Srv().Store().User().Get(rctx.Context(), userID) + if err != nil { + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + channelsToView, channelsToClearPushNotifications, times, err := a.Srv().Store().Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, user.NotifyProps) + if err != nil { + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.channel.get_channels_by_team_with_unreads_and_with_mentions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // times already contains every channel the user belongs to in this team, including + // fully-read ones. We pass the full set to the thread store because a CRT-enabled + // user can have unread thread replies in a channel whose channel-level counters are + // already up to date (thread replies don't bump TotalMsgCount). The thread store's + // `LastReplyAt > LastViewed` clause keeps the actual UPDATE bounded to genuinely + // stale thread memberships. + allChannelIDs := make([]string, 0, len(times)) + for channelID := range times { + allChannelIDs = append(allChannelIDs, channelID) + } + if err = a.Srv().Store().Thread().MarkAllAsReadByChannels(userID, allChannelIDs); err != nil { + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.thread.mark_all_as_read_by_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if len(channelsToView) > 0 { + _, err = a.Srv().Store().Channel().UpdateLastViewedAt(channelsToView, userID) + if err != nil { + var invErr *store.ErrInvalidInput + switch { + case errors.As(err, &invErr): + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusBadRequest).Wrap(err) + default: + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + if *a.Config().ServiceSettings.EnableChannelViewedMessages { + message := model.NewWebSocketEvent(model.WebsocketEventMultipleChannelsViewed, "", "", userID, nil, "") + message.Add("channel_times", times) + a.Publish(message) + } + } + + for _, channelID := range channelsToClearPushNotifications { + a.clearPushNotification(currentSessionID, userID, channelID, "") + } + + if isCRTEnabled { + // Threads can have been marked read across the entire team, so broadcast a + // single team-scoped event. The client routes this to a single + // ALL_TEAM_THREADS_READ Redux action — it does NOT trigger any API calls. + message := model.NewWebSocketEvent(model.WebsocketEventThreadReadChanged, teamID, "", userID, nil, "") + message.Add("timestamp", model.GetMillis()) + a.Publish(message) + } + + return times, nil +} + +func (a *App) MarkAllDirectAndGroupMessagesViewed(rctx request.CTX, userID string, currentSessionID string, isCRTEnabled bool) (map[string]int64, *model.AppError) { + user, err := a.Srv().Store().User().Get(rctx.Context(), userID) + if err != nil { + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + messagesToView, messagesToClearPushNotifications, times, err := a.Srv().Store().Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, user.NotifyProps) + if err != nil { + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.channel.get_channels_by_team_with_unreads_and_with_mentions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // times already contains every DM/GM the user belongs to, including fully-read + // ones. We pass the full set to the thread store because a CRT-enabled user can + // have unread thread replies in a channel whose channel-level counters are + // already up to date (thread replies don't bump TotalMsgCount). The thread + // store's `LastReplyAt > LastViewed` clause keeps the actual UPDATE bounded to + // genuinely stale thread memberships. + allChannelIDs := make([]string, 0, len(times)) + for channelID := range times { + allChannelIDs = append(allChannelIDs, channelID) + } + if err = a.Srv().Store().Thread().MarkAllAsReadByChannels(userID, allChannelIDs); err != nil { + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.thread.mark_all_as_read_by_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if len(messagesToView) > 0 { + _, err = a.Srv().Store().Channel().UpdateLastViewedAt(messagesToView, userID) + if err != nil { + var invErr *store.ErrInvalidInput + switch { + case errors.As(err, &invErr): + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusBadRequest).Wrap(err) + default: + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + if *a.Config().ServiceSettings.EnableChannelViewedMessages { + message := model.NewWebSocketEvent(model.WebsocketEventMultipleChannelsViewed, "", "", userID, nil, "") + message.Add("channel_times", times) + a.Publish(message) + } + } + + for _, channelID := range messagesToClearPushNotifications { + a.clearPushNotification(currentSessionID, userID, channelID, "") + } + + if isCRTEnabled { + // Threads can have been marked read in any DM/GM. There's no team to + // broadcast on, so emit one event per channel. The client routes each to a + // single ALL_THREADS_IN_CHANNEL_READ Redux action — no API calls are made. + timestamp := model.GetMillis() + for _, channelID := range allChannelIDs { + message := model.NewWebSocketEvent(model.WebsocketEventThreadReadChanged, "", channelID, userID, nil, "") + message.Add("timestamp", timestamp) + a.Publish(message) + } + } + + return times, nil +} + +func (a *App) MarkChannelsAsViewed(rctx request.CTX, channelIDs []string, userID string, currentSessionID string, collapsedThreadsSupported, isCRTEnabled bool) (map[string]int64, *model.AppError) { var err error user, err := a.Srv().Store().User().Get(rctx.Context(), userID) @@ -3363,7 +3486,7 @@ func (a *App) MarkChannelsAsViewed(rctx request.CTX, channelIDs []string, userID } for _, channelID := range channelsToClearPushNotifications { - a.clearPushNotification(currentSessionId, userID, channelID, "") + a.clearPushNotification(currentSessionID, userID, channelID, "") } if updateThreads && isCRTEnabled { diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 5e61988f288..6c5bb51bff9 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -2115,6 +2115,138 @@ func (s SqlChannelStore) GetChannelsWithUnreadsAndWithMentions(_ request.CTX, ch return channelsWithUnreads, channelsWithMentions, readTimes, nil } +func (s SqlChannelStore) GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + query := s.getQueryBuilder(). + Select( + "Channels.Id", + "Channels.Type", + "Channels.TotalMsgCount", + "Channels.LastPostAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastViewedAt", + ). + From("ChannelMembers"). + Join("Channels ON ChannelMembers.ChannelId = Channels.Id"). + Where(sq.Eq{ + "Channels.TeamId": teamID, + "ChannelMembers.UserId": userID, + }) + + var channels []struct { + Id string + Type string + TotalMsgCount int + LastPostAt int64 + MsgCount int + MentionCount int + NotifyProps model.StringMap + LastViewedAt int64 + } + + if err := s.GetReplica().SelectBuilder(&channels, query); err != nil { + return nil, nil, nil, errors.Wrap(err, "failed to find team channels with unreads and mentions data") + } + + channelsWithUnreads := make([]string, 0, len(channels)) + channelsWithMentions := make([]string, 0, len(channels)) + readTimes := make(map[string]int64, len(channels)) + + for _, channel := range channels { + hasMentions := (channel.MentionCount > 0) + hasUnreads := (channel.TotalMsgCount-channel.MsgCount > 0) || hasMentions + + if hasUnreads { + channelsWithUnreads = append(channelsWithUnreads, channel.Id) + } + + notify := channel.NotifyProps[model.PushNotifyProp] + if notify == model.ChannelNotifyDefault { + notify = userNotifyProps[model.PushNotifyProp] + } + if notify == model.UserNotifyAll || channel.Type == string(model.ChannelTypeDirect) { + if hasUnreads { + channelsWithMentions = append(channelsWithMentions, channel.Id) + } + } else if notify == model.UserNotifyMention { + if hasMentions { + channelsWithMentions = append(channelsWithMentions, channel.Id) + } + } + + readTimes[channel.Id] = max(channel.LastPostAt, channel.LastViewedAt) + } + + return channelsWithUnreads, channelsWithMentions, readTimes, nil +} + +func (s SqlChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + query := s.getQueryBuilder(). + Select( + "Channels.Id", + "Channels.Type", + "Channels.TotalMsgCount", + "Channels.LastPostAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastViewedAt", + ). + From("ChannelMembers"). + Join("Channels ON ChannelMembers.ChannelId = Channels.Id"). + Where(sq.Eq{ + "ChannelMembers.UserId": userID, + "Type": []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}, + }) + + var channels []struct { + Id string + Type string + TotalMsgCount int + LastPostAt int64 + MsgCount int + MentionCount int + NotifyProps model.StringMap + LastViewedAt int64 + } + + if err := s.GetReplica().SelectBuilder(&channels, query); err != nil { + return nil, nil, nil, errors.Wrap(err, "failed to find direct or group channels with unreads and mentions data") + } + + channelsWithUnreads := make([]string, 0, len(channels)) + channelsWithMentions := make([]string, 0, len(channels)) + readTimes := make(map[string]int64, len(channels)) + + for _, channel := range channels { + hasMentions := (channel.MentionCount > 0) + hasUnreads := (channel.TotalMsgCount-channel.MsgCount > 0) || hasMentions + + if hasUnreads { + channelsWithUnreads = append(channelsWithUnreads, channel.Id) + } + + notify := channel.NotifyProps[model.PushNotifyProp] + if notify == model.ChannelNotifyDefault { + notify = userNotifyProps[model.PushNotifyProp] + } + if notify == model.UserNotifyAll || channel.Type == string(model.ChannelTypeDirect) { + if hasUnreads { + channelsWithMentions = append(channelsWithMentions, channel.Id) + } + } else if notify == model.UserNotifyMention { + if hasMentions { + channelsWithMentions = append(channelsWithMentions, channel.Id) + } + } + + readTimes[channel.Id] = max(channel.LastPostAt, channel.LastViewedAt) + } + + return channelsWithUnreads, channelsWithMentions, readTimes, nil +} + func (s SqlChannelStore) GetMember(rctx request.CTX, channelID string, userID string) (*model.ChannelMember, error) { selectSQL, args, err := s.channelMembersForTeamWithSchemeSelectQuery. Where(sq.Eq{ diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 8a5bed79cc3..97181ddcd96 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -286,6 +286,8 @@ type ChannelStore interface { GetMembersInfoByChannelIds(channelIDs []string) (map[string][]*model.User, error) GetChannelUnread(channelID, userID string) (*model.ChannelUnread, error) GetChannelsWithUnreadsAndWithMentions(rctx request.CTX, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) + GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) + GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) ClearCaches() ClearMembersForUserCache() GetChannelsByScheme(schemeID string, offset int, limit int) (model.ChannelList, error) diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index 7c21dfc6eac..447f7aedabd 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -163,6 +163,8 @@ func TestChannelStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore t.Run("SetShared", func(t *testing.T) { testSetShared(t, rctx, ss) }) t.Run("GetTeamForChannel", func(t *testing.T) { testGetTeamForChannel(t, rctx, ss) }) t.Run("GetChannelsWithUnreadsAndWithMentions", func(t *testing.T) { testGetChannelsWithUnreadsAndWithMentions(t, rctx, ss) }) + t.Run("GetDirectMessagesWithUnreadAndMentions", func(t *testing.T) { testGetDirectMessagesWithUnreadAndMentions(t, rctx, ss) }) + t.Run("GetTeamChannelsWithUnreadAndMentions", func(t *testing.T) { testGetTeamChannelsWithUnreadAndMentions(t, rctx, ss) }) } func testChannelStoreSave(t *testing.T, rctx request.CTX, ss store.Store) { @@ -8849,3 +8851,516 @@ func testGetChannelsWithUnreadsAndWithMentions(t *testing.T, rctx request.CTX, s require.Len(t, times, 0) }) } + +func testGetDirectMessagesWithUnreadAndMentions(t *testing.T, rctx request.CTX, ss store.Store) { + setupMembership := func( + pushProp string, + withUnreads bool, + withMentions bool, + channelType model.ChannelType, + userID string, + ) (model.Channel, model.ChannelMember) { + var o1 *model.Channel + var err error + + if channelType == model.ChannelTypeDirect { + o1, err = ss.Channel().CreateDirectChannel(rctx, &model.User{Id: userID}, &model.User{Id: model.NewId()}, func(channel *model.Channel) { + channel.TotalMsgCount = 25 + channel.LastPostAt = 12345 + channel.LastRootPostAt = 12345 + }) + require.NoError(t, err) + } else if channelType == model.ChannelTypeGroup { + // No builtin method to create groups, looks + // like a decent amount of logic goes into it too. + o1 = &model.Channel{} + o1.DisplayName = "GroupChannel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeGroup + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, err = ss.Channel().Save(rctx, o1, -1) + require.NoError(t, err) + + m1 := model.ChannelMember{} + m1.ChannelId = o1.Id + m1.UserId = userID + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + m1.NotifyProps[model.PushNotifyProp] = pushProp + if !withUnreads { + m1.MsgCount = o1.TotalMsgCount + m1.LastViewedAt = o1.LastPostAt + } + if withMentions { + m1.MentionCount = 5 + } + _, err = ss.Channel().SaveMember(rctx, &m1) + require.NoError(t, err) + } + + m1, err := ss.Channel().GetMember(rctx, o1.Id, userID) + require.NoError(t, err) + + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + m1.NotifyProps[model.PushNotifyProp] = pushProp + + if !withUnreads { + m1.MsgCount = o1.TotalMsgCount + m1.LastViewedAt = o1.LastPostAt + } + if withMentions { + m1.MentionCount = 5 + } + + m1, err = ss.Channel().UpdateMember(rctx, m1) + require.NoError(t, err) + + return *o1, *m1 + } + + type TestCase struct { + name string + pushProp string + userNotifyProp string + channelType model.ChannelType + withUnreads bool + withMentions bool + } + ttcc := []TestCase{} + + channelNotifyProps := []string{model.ChannelNotifyDefault, model.ChannelNotifyAll, model.ChannelNotifyMention, model.ChannelNotifyNone} + userNotifyProps := []string{model.UserNotifyAll, model.UserNotifyMention, model.UserNotifyHere, model.UserNotifyNone} + channelTypes := []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup} + boolRange := []bool{true, false} + + nameTemplate := "pushProp: %s, userPushProp: %s, type: %s, unreads: %t, mentions: %t" + for _, pushProp := range channelNotifyProps { + for _, userNotifyProp := range userNotifyProps { + for _, channelType := range channelTypes { + for _, withUnreads := range boolRange { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, channelType, withUnreads, false), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + channelType: channelType, + withUnreads: withUnreads, + withMentions: false, + }) + if withUnreads { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, channelType, withUnreads, true), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + channelType: channelType, + withUnreads: withUnreads, + withMentions: true, + }) + } + } + } + } + } + + for _, tc := range ttcc { + t.Run(tc.name, func(t *testing.T) { + userID := model.NewId() + o1, m1 := setupMembership(tc.pushProp, tc.withUnreads, tc.withMentions, tc.channelType, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, m1.UserId, userNotifyProps) + require.NoError(t, err) + + expectedUnreadsLength := 0 + if tc.withUnreads { + expectedUnreadsLength = 1 + } + require.Len(t, unreads, expectedUnreadsLength) + + propToUse := tc.pushProp + if tc.pushProp == model.ChannelNotifyDefault { + propToUse = tc.userNotifyProp + } + + expectedMentionsLength := 0 + // Direct messages seem to always have notify on, at least + // that is the logic copied from GetChannelsWithUnreadsAndMentions + if (tc.channelType == model.ChannelTypeDirect && tc.withUnreads) || + (propToUse == model.UserNotifyAll && tc.withUnreads) || + (propToUse == model.UserNotifyMention && tc.withMentions) { + expectedMentionsLength = 1 + } + + require.Len(t, mentions, expectedMentionsLength) + + if tc.withUnreads { + require.Contains(t, times, o1.Id) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + } + }) + } + + t.Run("multiple directs and groups", func(t *testing.T) { + userID := model.NewId() + dm1, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeDirect, userID) + dm2, _ := setupMembership(model.ChannelNotifyDefault, true, false, model.ChannelTypeDirect, userID) + gm1, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeGroup, userID) + gm2, _ := setupMembership(model.ChannelNotifyMention, true, false, model.ChannelTypeGroup, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 4) + require.Contains(t, unreads, dm1.Id) + require.Contains(t, unreads, dm2.Id) + require.Contains(t, unreads, gm1.Id) + require.Contains(t, unreads, gm2.Id) + + require.Len(t, mentions, 3) + // Same as above, direct messages seem to always have notify on + // but group messages need to have notification policies set. + require.Contains(t, mentions, dm1.Id) + require.Contains(t, mentions, dm2.Id) + require.Contains(t, mentions, gm1.Id) + require.NotContains(t, mentions, gm2.Id) + + require.Equal(t, dm1.LastPostAt, times[dm1.Id]) + require.Equal(t, dm2.LastPostAt, times[dm2.Id]) + require.Equal(t, gm1.LastPostAt, times[gm1.Id]) + require.Equal(t, gm2.LastPostAt, times[gm2.Id]) + }) + + t.Run("excludes regular channels", func(t *testing.T) { + userID := model.NewId() + + dm, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeDirect, userID) + + regularChannel := model.Channel{} + regularChannel.TeamId = model.NewId() + regularChannel.DisplayName = "Regular Channel" + regularChannel.Name = NewTestID() + regularChannel.Type = model.ChannelTypeOpen + regularChannel.TotalMsgCount = 25 + regularChannel.LastPostAt = 12345 + regularChannel.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(rctx, ®ularChannel, -1) + require.NoError(t, nErr) + + regularMember := model.ChannelMember{} + regularMember.ChannelId = regularChannel.Id + regularMember.UserId = userID + regularMember.NotifyProps = model.GetDefaultChannelNotifyProps() + regularMember.MentionCount = 5 + _, err := ss.Channel().SaveMember(rctx, ®ularMember) + require.NoError(t, err) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + require.NoError(t, err) + + // Should only find the DMs and GMs + require.Len(t, unreads, 1) + require.Contains(t, unreads, dm.Id) + require.NotContains(t, unreads, regularChannel.Id) + + require.Len(t, mentions, 1) + require.Contains(t, mentions, dm.Id) + + require.Equal(t, dm.LastPostAt, times[dm.Id]) + require.NotContains(t, times, regularChannel.Id) + }) + + t.Run("user with no DMs or GMs", func(t *testing.T) { + userID := model.NewId() + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) +} + +func testGetTeamChannelsWithUnreadAndMentions(t *testing.T, rctx request.CTX, ss store.Store) { + setupMembership := func( + teamID string, + pushProp string, + withUnreads bool, + withMentions bool, + userID string, + ) (model.Channel, model.ChannelMember) { + o1 := model.Channel{} + o1.TeamId = teamID + o1.DisplayName = "Channel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(rctx, &o1, -1) + require.NoError(t, nErr) + + m1 := model.ChannelMember{} + m1.ChannelId = o1.Id + m1.UserId = userID + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + m1.NotifyProps[model.PushNotifyProp] = pushProp + if !withUnreads { + m1.MsgCount = o1.TotalMsgCount + m1.LastViewedAt = o1.LastPostAt + } + if withMentions { + m1.MentionCount = 5 + } + _, err := ss.Channel().SaveMember(rctx, &m1) + require.NoError(t, err) + + return o1, m1 + } + + type TestCase struct { + name string + pushProp string + userNotifyProp string + withUnreads bool + withMentions bool + } + ttcc := []TestCase{} + + channelNotifyProps := []string{model.ChannelNotifyDefault, model.ChannelNotifyAll, model.ChannelNotifyMention, model.ChannelNotifyNone} + userNotifyProps := []string{model.UserNotifyAll, model.UserNotifyMention, model.UserNotifyHere, model.UserNotifyNone} + boolRange := []bool{true, false} + + nameTemplate := "pushProp: %s, userPushProp: %s, unreads: %t, mentions: %t" + for _, pushProp := range channelNotifyProps { + for _, userNotifyProp := range userNotifyProps { + for _, withUnreads := range boolRange { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, withUnreads, false), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + withUnreads: withUnreads, + withMentions: false, + }) + if withUnreads { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, withUnreads, true), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + withUnreads: withUnreads, + withMentions: true, + }) + } + } + } + } + + for _, tc := range ttcc { + t.Run(tc.name, func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + o1, m1 := setupMembership(teamID, tc.pushProp, tc.withUnreads, tc.withMentions, userID) + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, m1.UserId, userNotifyProps) + require.NoError(t, err) + + expectedUnreadsLength := 0 + if tc.withUnreads { + expectedUnreadsLength = 1 + } + require.Len(t, unreads, expectedUnreadsLength) + + propToUse := tc.pushProp + if tc.pushProp == model.ChannelNotifyDefault { + propToUse = tc.userNotifyProp + } + expectedMentionsLength := 0 + if (propToUse == model.UserNotifyAll && tc.withUnreads) || (propToUse == model.UserNotifyMention && tc.withMentions) { + expectedMentionsLength = 1 + } + + require.Len(t, mentions, expectedMentionsLength) + + if tc.withUnreads { + require.Contains(t, times, o1.Id) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + } + }) + } + + t.Run("multiple channels on same team", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + o1, _ := setupMembership(teamID, model.ChannelNotifyDefault, true, true, userID) + o2, _ := setupMembership(teamID, model.ChannelNotifyDefault, true, true, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + require.Contains(t, unreads, o1.Id) + require.Contains(t, unreads, o2.Id) + require.Contains(t, mentions, o1.Id) + require.Contains(t, mentions, o2.Id) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + require.Equal(t, o2.LastPostAt, times[o2.Id]) + }) + + t.Run("excludes channels from other teams", func(t *testing.T) { + teamID1 := model.NewId() + teamID2 := model.NewId() + userID := model.NewId() + + o1, _ := setupMembership(teamID1, model.ChannelNotifyDefault, true, true, userID) + o2, _ := setupMembership(teamID2, model.ChannelNotifyDefault, true, true, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID1, userID, userNotifyProps) + require.NoError(t, err) + + // Should only include channel from teamID1 + require.Len(t, unreads, 1) + require.Contains(t, unreads, o1.Id) + require.NotContains(t, unreads, o2.Id) + + require.Len(t, mentions, 1) + require.Contains(t, mentions, o1.Id) + require.NotContains(t, mentions, o2.Id) + + require.Equal(t, o1.LastPostAt, times[o1.Id]) + require.NotContains(t, times, o2.Id) + }) + + t.Run("non existing team", func(t *testing.T) { + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, "nonexistent-team", "foo", userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) + + t.Run("user not member of any team channels", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + + // Create a channel on the team but don't add the user as a member + o1 := model.Channel{} + o1.TeamId = teamID + o1.DisplayName = "Channel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(rctx, &o1, -1) + require.NoError(t, nErr) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) + + t.Run("LastViewedAt affects readTimes", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + + o1 := model.Channel{} + o1.TeamId = teamID + o1.DisplayName = "Channel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 10000 + o1.LastRootPostAt = 10000 + _, nErr := ss.Channel().Save(rctx, &o1, -1) + require.NoError(t, nErr) + + m1 := model.ChannelMember{} + m1.ChannelId = o1.Id + m1.UserId = userID + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + // Set LastViewedAt to be AFTER LastPostAt (user viewed after last message) + m1.MsgCount = o1.TotalMsgCount - 5 // Still has unreads + m1.LastViewedAt = 15000 // Newer than LastPostAt + _, err := ss.Channel().SaveMember(rctx, &m1) + require.NoError(t, err) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyAll + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 1) + require.Len(t, mentions, 1) + + // Should return the max of LastPostAt and LastViewedAt + require.Equal(t, int64(15000), times[o1.Id]) + }) + + t.Run("mixed notification settings on same team", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + + // Channel with UserNotifyAll behavior + o1, _ := setupMembership(teamID, model.ChannelNotifyAll, true, false, userID) + + // Channel with UserNotifyMention behavior (has unreads but no mentions) + o2, _ := setupMembership(teamID, model.ChannelNotifyMention, true, false, userID) + + // Channel with UserNotifyMention behavior (has unreads AND mentions) + o3, _ := setupMembership(teamID, model.ChannelNotifyMention, true, true, userID) + + // Channel with UserNotifyNone behavior + o4, _ := setupMembership(teamID, model.ChannelNotifyNone, true, true, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention // User default (not used when channel overrides) + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + // All 4 channels should have unreads + require.Len(t, unreads, 4) + require.Contains(t, unreads, o1.Id) + require.Contains(t, unreads, o2.Id) + require.Contains(t, unreads, o3.Id) + require.Contains(t, unreads, o4.Id) + + // Only o1 (NotifyAll) and o3 (NotifyMention with mentions) should trigger mentions + require.Len(t, mentions, 2) + require.Contains(t, mentions, o1.Id) + require.NotContains(t, mentions, o2.Id) // NotifyMention but no mentions + require.Contains(t, mentions, o3.Id) + require.NotContains(t, mentions, o4.Id) // NotifyNone + + require.Equal(t, o1.LastPostAt, times[o1.Id]) + require.Equal(t, o2.LastPostAt, times[o2.Id]) + require.Equal(t, o3.LastPostAt, times[o3.Id]) + require.Equal(t, o4.LastPostAt, times[o4.Id]) + }) +} diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 30ffdb0cf82..cd3749be685 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -1787,6 +1787,54 @@ func (_m *ChannelStore) GetMembersInfoByChannelIds(channelIDs []string) (map[str return r0, r1 } +// GetDirectMessagesWithUnreadAndMentions provides a mock function with given fields: rctx, userID, userNotifyProps +func (_m *ChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + ret := _m.Called(rctx, userID, userNotifyProps) + + if len(ret) == 0 { + panic("no return value specified for GetDirectMessagesWithUnreadAndMentions") + } + + var r0 []string + var r1 []string + var r2 map[string]int64 + var r3 error + if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { + return rf(rctx, userID, userNotifyProps) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) []string); ok { + r0 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, model.StringMap) []string); ok { + r1 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, model.StringMap) map[string]int64); ok { + r2 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(map[string]int64) + } + } + + if rf, ok := ret.Get(3).(func(request.CTX, string, model.StringMap) error); ok { + r3 = rf(rctx, userID, userNotifyProps) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GetMoreChannels provides a mock function with given fields: teamID, userID, offset, limit func (_m *ChannelStore) GetMoreChannels(teamID string, userID string, offset int, limit int) (model.ChannelList, error) { ret := _m.Called(teamID, userID, offset, limit) @@ -2115,6 +2163,54 @@ func (_m *ChannelStore) GetTeamChannels(teamID string) (model.ChannelList, error return r0, r1 } +// GetTeamChannelsWithUnreadAndMentions provides a mock function with given fields: rctx, teamID, userID, userNotifyProps +func (_m *ChannelStore) GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + ret := _m.Called(rctx, teamID, userID, userNotifyProps) + + if len(ret) == 0 { + panic("no return value specified for GetTeamChannelsWithUnreadAndMentions") + } + + var r0 []string + var r1 []string + var r2 map[string]int64 + var r3 error + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { + return rf(rctx, teamID, userID, userNotifyProps) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.StringMap) []string); ok { + r0 = rf(rctx, teamID, userID, userNotifyProps) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string, model.StringMap) []string); ok { + r1 = rf(rctx, teamID, userID, userNotifyProps) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, string, model.StringMap) map[string]int64); ok { + r2 = rf(rctx, teamID, userID, userNotifyProps) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(map[string]int64) + } + } + + if rf, ok := ret.Get(3).(func(request.CTX, string, string, model.StringMap) error); ok { + r3 = rf(rctx, teamID, userID, userNotifyProps) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GetTeamForChannel provides a mock function with given fields: channelID func (_m *ChannelStore) GetTeamForChannel(channelID string) (*model.Team, error) { ret := _m.Called(channelID) diff --git a/server/i18n/en.json b/server/i18n/en.json index b0abeee2be5..aa60e14c2a2 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2636,6 +2636,10 @@ "id": "api.license_error", "translation": "api endpoint requires a license" }, + { + "id": "api.mark_all_as_read.disabled.app_error", + "translation": "Mark all as read feature is not enabled." + }, { "id": "api.marshal_error", "translation": "Failed to marshal." @@ -5502,6 +5506,10 @@ "id": "app.channel.get_channels_by_ids.not_found.app_error", "translation": "No channel found." }, + { + "id": "app.channel.get_channels_by_team_with_unreads_and_with_mentions.app_error", + "translation": "Unable to get channels with unreads and mentions." + }, { "id": "app.channel.get_channels_member_count.existing.app_error", "translation": "Unable to find member count for given channels." diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 6cf11758771..f339724b6e5 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -458,6 +458,8 @@ const ( AuditEventLogin = "login" // user login to system AuditEventLoginWithDesktopToken = "loginWithDesktopToken" // user login to system with desktop token AuditEventLogout = "logout" // user logout from system + AuditEventMarkMessagesRead = "markAllMessagesRead" // user marked all direct and group messages as read + AuditEventMarkTeamRead = "markFullTeamRead" // user marked an entire team as read AuditEventMigrateAuthToLdap = "migrateAuthToLdap" // migrate user authentication method to LDAP AuditEventMigrateAuthToSaml = "migrateAuthToSaml" // migrate user authentication method to SAML AuditEventPatchUser = "patchUser" // update user properties diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 9f31beeecc5..72720b225ae 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -3269,6 +3269,25 @@ func (c *Client4) ReadMultipleChannels(ctx context.Context, userId string, chann return DecodeJSONFromResponse[*ChannelViewResponse](r) } +// ReadAllMessages performs a view action on all direct and group messages for a user +func (c *Client4) ReadAllMessages(ctx context.Context, userId string) (*ChannelViewResponse, *Response, error) { + r, err := c.doAPIPutJSON(ctx, c.channelsRoute().Join("members", userId, "direct", "read"), nil) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*ChannelViewResponse](r) +} + +func (c *Client4) ReadAllInTeam(ctx context.Context, userId string, teamId string) (*ChannelViewResponse, *Response, error) { + r, err := c.doAPIPutJSON(ctx, c.userRoute(userId).Join("teams", teamId, "read"), nil) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*ChannelViewResponse](r) +} + // GetChannelUnread will return a ChannelUnread object that contains the number of // unread messages and mentions for a user. func (c *Client4) GetChannelUnread(ctx context.Context, channelId, userId string) (*ChannelUnread, *Response, error) { diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 44a1601d7f5..ac8d06a3092 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -89,6 +89,9 @@ type FeatureFlags struct { // Mobile clients should use the direct SSO callback flow with srv parameter verification. MobileSSOCodeExchange bool + // Enable the SHIFT+ESC combo to mark _all_ chats, messages, and channels as read + EnableShiftEscapeToMarkAllRead bool + // FEATURE_FLAG_REMOVAL: AutoTranslation - Remove this when MVP is to be released // Enable auto-translation feature for messages in channels AutoTranslation bool @@ -153,6 +156,7 @@ func (f *FeatureFlags) SetDefaults() { // DEPRECATED: Disabled by default - mobile clients use direct SSO callback flow f.MobileSSOCodeExchange = false + f.EnableShiftEscapeToMarkAllRead = false f.AutoTranslation = true diff --git a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx index ad84e9f9079..92c54084cfa 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx @@ -24,6 +24,7 @@ jest.mock('components/channel_layout/center_channel', () => () =>
); jest.mock('components/loading_screen', () => () =>
); jest.mock('components/unreads_status_handler', () => () =>
); jest.mock('components/product_notices_modal', () => () =>
); +jest.mock('components/feature_toast/features/mark_all_as_read_toast', () => () =>
); jest.mock('plugins/pluggable', () => () =>
); jest.mock('actions/status_actions', () => ({ diff --git a/webapp/channels/src/components/channel_layout/channel_controller.tsx b/webapp/channels/src/components/channel_layout/channel_controller.tsx index 71da75b87b2..f3b3d729c14 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.tsx @@ -13,6 +13,7 @@ import {getIsMobileView} from 'selectors/views/browser'; import {makeAsyncComponent} from 'components/async_load'; import CenterChannel from 'components/channel_layout/center_channel'; +import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue'; import LoadingScreen from 'components/loading_screen'; import QueryParamActionController from 'components/query_param_actions/query_param_action_controller'; import Sidebar from 'components/sidebar'; @@ -25,6 +26,7 @@ import {Constants} from 'utils/constants'; const ProductNoticesModal = makeAsyncComponent('ProductNoticesModal', lazy(() => import('components/product_notices_modal'))); const ResetStatusModal = makeAsyncComponent('ResetStatusModal', lazy(() => import('components/reset_status_modal'))); const MobileSidebarRight = makeAsyncComponent('MobileSidebarRight', lazy(() => import('components/mobile_sidebar_right'))); +const MarkAllAsReadToast = makeAsyncComponent('MarkAllAsReadToast', lazy(() => import('components/feature_toast/features/mark_all_as_read_toast'))); const BODY_CLASS_FOR_CHANNEL = ['channel-view']; @@ -35,6 +37,7 @@ type Props = { export default function ChannelController(props: Props) { const isMobileView = useSelector(getIsMobileView); const enabledUserStatuses = useSelector(getIsUserStatusesConfigEnabled); + const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true'; const dispatch = useDispatch(); useEffect(() => { @@ -80,6 +83,7 @@ export default function ChannelController(props: Props) { > + {enableMarkAllReadShortcut && }
{props.shouldRenderCenterChannel ? : } diff --git a/webapp/channels/src/components/feature_toast/feature_toast.scss b/webapp/channels/src/components/feature_toast/feature_toast.scss new file mode 100644 index 00000000000..e558cfbbd13 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/feature_toast.scss @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +@use 'utils/variables'; + +.feature_toast { + position: fixed; + z-index: variables.$z-index-popover; + top: 72px; // Below channel header (56px) + margin + right: 60px; // Clear app bar (44px) + margin + display: flex; + width: 386px; + align-items: flex-start; + padding: 24px; + border: var(--border-default); + border-radius: var(--radius-s); + background-color: var(--center-channel-bg); + gap: 12px; +} + +.feature_toast__actions { + padding: 4px 0; +} + +.feature_toast__header_content { + display: flex; + width: 100%; + align-items: flex-start; + gap: 8px; + + h3 { + flex-grow: 1; + padding-top: 6px; // Align with 32px button + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 20px; + } +} + +.feature_toast__icon { + flex-shrink: 0; + margin-top: 4px; // Align with title text +} + +.feature_toast__main_content { + display: flex; + flex-direction: column; + flex-grow: 1; + align-items: baseline; + gap: 8px; + + p { + margin: 0; + } + + mark { + & + mark { + margin-left: 4px; + } + } +} diff --git a/webapp/channels/src/components/feature_toast/feature_toast.test.tsx b/webapp/channels/src/components/feature_toast/feature_toast.test.tsx new file mode 100644 index 00000000000..3d19b620518 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/feature_toast.test.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import FeatureToast from './feature_toast'; + +describe('components/FeatureToast', () => { + const baseProps = { + show: true, + title: 'New Feature', + message: 'Check out this new feature!', + onDismiss: jest.fn(), + }; + + test('should render when show is true', () => { + renderWithContext(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('New Feature')).toBeInTheDocument(); + expect(screen.getByText('Check out this new feature!')).toBeInTheDocument(); + }); + + test('should not render when show is false', () => { + renderWithContext( + , + ); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + test('should render with JSX element as message', () => { + const jsxMessage = {'JSX Message with '}{'marked text'}; + renderWithContext( + , + ); + + expect(screen.getByText('JSX Message with')).toBeInTheDocument(); + expect(screen.getByText('marked text')).toBeInTheDocument(); + }); + + test('should have correct ARIA attributes', () => { + renderWithContext(); + + const toast = screen.getByRole('status'); + expect(toast).toHaveAttribute('aria-live', 'polite'); + expect(toast).toHaveAttribute('aria-atomic', 'true'); + }); + + test('should have accessible close button', () => { + renderWithContext(); + + const closeButton = screen.getByRole('button', {name: /close/i}); + expect(closeButton).toBeInTheDocument(); + expect(closeButton).toHaveAttribute('aria-label', 'Close'); + }); + + test('should call onDismiss when close button is clicked', async () => { + const onDismiss = jest.fn(); + renderWithContext( + , + ); + + const closeButton = screen.getByRole('button', {name: /close/i}); + await userEvent.click(closeButton); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + test('should render action button when showButton is true', () => { + renderWithContext( + , + ); + + expect(screen.getByRole('button', {name: 'Learn More'})).toBeInTheDocument(); + }); + + test('should not render action button when showButton is false', () => { + renderWithContext( + , + ); + + expect(screen.queryByRole('button', {name: 'Learn More'})).not.toBeInTheDocument(); + }); + + test('should not render action button when showButton is undefined', () => { + renderWithContext(); + + // Should only have the close button + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveAttribute('aria-label', 'Close'); + }); + + test('should call onDismiss when action button is clicked', async () => { + const onDismiss = jest.fn(); + renderWithContext( + , + ); + + const actionButton = screen.getByRole('button', {name: 'Got it'}); + await userEvent.click(actionButton); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webapp/channels/src/components/feature_toast/feature_toast.tsx b/webapp/channels/src/components/feature_toast/feature_toast.tsx new file mode 100644 index 00000000000..1210feabfc7 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/feature_toast.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {FloatingPortal} from '@floating-ui/react'; +import React from 'react'; +import {useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {PlaylistCheckIcon, CloseIcon} from '@mattermost/compass-icons/components'; +import {WithTooltip} from '@mattermost/shared/components/tooltip'; + +import {isAnyModalOpen} from 'selectors/views/modals'; + +import {RootHtmlPortalId} from 'utils/constants'; + +import './feature_toast.scss'; + +type Props = { + show: boolean; + title: string; + message: string | JSX.Element; + showButton?: boolean; + buttonText?: string; + onDismiss: () => void; +}; + +export default function FeatureToast({ + show, + title, + message, + showButton, + buttonText, + onDismiss, +}: Props) { + const {formatMessage} = useIntl(); + const anyModalOpen = useSelector(isAnyModalOpen); + + if (!show || anyModalOpen) { + return null; + } + + const handleDismiss = () => { + onDismiss(); + }; + + return ( + +
+ +
+
+

{title}

+ + + +
+

{message}

+
+ {showButton && ( + + )} +
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx b/webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx new file mode 100644 index 00000000000..9ef5be36608 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {ShortcutKeys} from '@mattermost/shared/components/shortcut_key'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + +import {Preferences} from 'mattermost-redux/constants'; + +import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue'; +import usePreference from 'components/common/hooks/usePreference'; +import {ShortcutSequence, ShortcutKeyVariant} from 'components/shortcut_sequence'; + +import FeatureToast from '../feature_toast'; + +export default function MarkAllAsReadToast() { + const {formatMessage} = useIntl(); + const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true'; + const [userHasSeenMarkAllReadToast, setUserHasSeenMarkAllReadToast] = usePreference( + Preferences.CATEGORY_NEW_FEATURES, + Preferences.HAS_SEEN_MARK_ALL_READ_FEATURE, + ); + const [show, setShow] = useState( + enableMarkAllReadShortcut && + !UserAgent.isMobile() && + !userHasSeenMarkAllReadToast, + ); + + if (!enableMarkAllReadShortcut) { + return null; + } + + const onDismiss = () => { + setShow(false); + setUserHasSeenMarkAllReadToast('true'); + }; + + const titleText = formatMessage({ + id: 'mark_all_as_read_toast.title', + defaultMessage: 'A new shortcut to clear unreads', + }); + + const message = ( + + ), + }} + /> + ); + + const buttonText = formatMessage({ + id: 'mark_all_as_read_toast.button', + defaultMessage: 'Got it', + }); + + return ( + + ); +} diff --git a/webapp/channels/src/components/feature_toast/index.ts b/webapp/channels/src/components/feature_toast/index.ts new file mode 100644 index 00000000000..2091939f6e8 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export class FeaturesToAnnounce { + static MARK_ALL_AS_READ_SHORTCUT = 'mark_all_as_read_shortcut'; +} + +export const createHasSeenFeatureSuffix = (userId: string, featureName: string) => + `${userId}_${featureName}`; diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx index 838593536b8..d4a6b1c082a 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx @@ -10,6 +10,11 @@ import {suitePluginIds} from 'utils/constants'; describe('components/KeyboardShortcutsModal', () => { const initialState = { + entities: { + general: { + config: {}, + }, + }, plugins: { plugins: {}, }, diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx index 432f4c270e4..08c1c606bbf 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx @@ -10,6 +10,7 @@ import * as UserAgent from '@mattermost/shared/utils/user_agent'; import {isCallsEnabled} from 'selectors/calls'; +import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue'; import KeyboardShortcutSequence, { KEYBOARD_SHORTCUTS, } from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; @@ -85,6 +86,7 @@ interface Props { const KeyboardShortcutsModal = ({onExited}: Props): JSX.Element => { const [show, setShow] = useState(true); const contentRef = useRef(null); + const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true'; const {formatMessage} = useIntl(); @@ -163,6 +165,7 @@ const KeyboardShortcutsModal = ({onExited}: Props): JSX.Element => {

{formatMessage(modalMessages.msgHeader)}

+ {enableMarkAllReadShortcut && }

{formatMessage(modalMessages.msgInputHeader)}

diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts index d093716cf0f..ac8e70626de 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts @@ -253,6 +253,10 @@ export const KEYBOARD_SHORTCUTS = { defaultMessage: 'Toggle unread/all channels:\t⌘|Shift|U', }, }), + markAllRead: defineMessage({ + id: 'shortcuts.msgs.mark_all_read', + defaultMessage: 'Mark all messages as read:\tShift|Esc', + }), msgEdit: defineMessage({ id: 'shortcuts.msgs.edit', defaultMessage: 'Edit last message in channel:\tUp', diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx index 518408e7f08..7f975265443 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx @@ -4,12 +4,13 @@ import React, {memo} from 'react'; import {useIntl} from 'react-intl'; -import {ShortcutKeyVariant, ShortcutKey} from '@mattermost/shared/components/shortcut_key'; import {isMac} from '@mattermost/shared/utils/user_agent'; +import {ShortcutSequence, ShortcutKeyVariant, KEY_SEPARATOR} from 'components/shortcut_sequence'; + import {isMessageDescriptor} from 'utils/i18n'; -import type {KeyboardShortcutDescriptor} from './keyboard_shortcuts'; +import {type KeyboardShortcutDescriptor} from './keyboard_shortcuts'; import './keyboard_shortcuts_sequence.scss'; @@ -28,12 +29,11 @@ function normalizeShortcutDescriptor(shortcut: KeyboardShortcutDescriptor) { return isMac() && mac ? mac : standard; } -const KEY_SEPARATOR = '|'; - function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription, isInsideTooltip}: Props) { const {formatMessage} = useIntl(); const shortcutText = formatMessage(normalizeShortcutDescriptor(shortcut)); const splitShortcut = shortcutText.split('\t'); + const variant = isInsideTooltip ? ShortcutKeyVariant.Tooltip : ShortcutKeyVariant.ShortcutModal; let description = ''; let keys = ''; @@ -50,19 +50,13 @@ function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription, } const renderAltKeys = () => { - const shortcutKeys = altKeys.split(KEY_SEPARATOR).map((key) => ( - - {key} - - )); - return ( <> {'\t|\t'} - {shortcutKeys} + ); }; @@ -72,14 +66,12 @@ function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription, {hoistDescription && !hideDescription && description?.replace(/:{1,2}$/, '')}
{!hoistDescription && !hideDescription && description && {description}} - {keys && keys.split(KEY_SEPARATOR).map((key) => ( - - {key} - - ))} + {keys && ( + + )} {altKeys && renderAltKeys()}
diff --git a/webapp/channels/src/components/mark_all_as_read_modal.scss b/webapp/channels/src/components/mark_all_as_read_modal.scss new file mode 100644 index 00000000000..61f1937b8cf --- /dev/null +++ b/webapp/channels/src/components/mark_all_as_read_modal.scss @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.mark_all_as_read_modal { + width: 512px; + + .modal-body { + margin-bottom: 48px; + } + + .modal-header { + min-height: 56px; + padding-bottom: 0; + } +} + +.mark_all_as_read_modal__body { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 48px 32px; + text-align: center; + + h2 { + margin-top: 0; + font-size: 22px; + font-weight: 600; + } + + mark + mark { + margin-left: 4px; + } + + .checkbox { + margin-top: 16px; + } +} + +.mark_all_as_read_modal__footer { + display: flex; + justify-content: center; + gap: 10px; +} diff --git a/webapp/channels/src/components/mark_all_as_read_modal.test.tsx b/webapp/channels/src/components/mark_all_as_read_modal.test.tsx new file mode 100644 index 00000000000..1e41ceccb6c --- /dev/null +++ b/webapp/channels/src/components/mark_all_as_read_modal.test.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import MarkAllAsReadModal from './mark_all_as_read_modal'; + +describe('components/MarkAllAsReadModal', () => { + const baseProps = { + onConfirm: jest.fn(), + onHide: jest.fn(), + }; + + test('should render modal content', () => { + renderWithContext(); + + expect(screen.getByText('Mark all messages as read?')).toBeInTheDocument(); + expect(screen.getByText(/will mark all messages as read/i)).toBeInTheDocument(); + }); + + test('should render checkbox with correct label', () => { + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + + test('should toggle checkbox state when clicked', async () => { + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + expect(checkbox).not.toBeChecked(); + + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + test('should render cancel and confirm buttons', () => { + renderWithContext(); + + expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Mark all read'})).toBeInTheDocument(); + }); + + test('should call onHide when cancel button is clicked', async () => { + const onHide = jest.fn(); + renderWithContext( + , + ); + + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); + await userEvent.click(cancelButton); + + expect(onHide).toHaveBeenCalledTimes(1); + }); + + test('should call onConfirm with false when confirm button is clicked without checkbox', async () => { + const onConfirm = jest.fn(); + renderWithContext( + , + ); + + const confirmButton = screen.getByRole('button', {name: 'Mark all read'}); + await userEvent.click(confirmButton); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + test('should call onConfirm with true when confirm button is clicked with checkbox checked', async () => { + const onConfirm = jest.fn(); + renderWithContext( + , + ); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + await userEvent.click(checkbox); + + const confirmButton = screen.getByRole('button', {name: 'Mark all read'}); + await userEvent.click(confirmButton); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(true); + }); + + test('should reset checkbox state when cancel is clicked', async () => { + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); + await userEvent.click(cancelButton); + + // Re-render to check state after cancel + const {rerender} = renderWithContext(); + rerender(); + + const checkboxAfterCancel = screen.getByRole('checkbox', {name: /Don't ask me again/}); + expect(checkboxAfterCancel).not.toBeChecked(); + }); +}); diff --git a/webapp/channels/src/components/mark_all_as_read_modal.tsx b/webapp/channels/src/components/mark_all_as_read_modal.tsx new file mode 100644 index 00000000000..c9cea8be315 --- /dev/null +++ b/webapp/channels/src/components/mark_all_as_read_modal.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; +import {ShortcutKeys} from '@mattermost/shared/components/shortcut_key'; + +import {ShortcutSequence, ShortcutKeyVariant} from './shortcut_sequence'; + +import './mark_all_as_read_modal.scss'; + +export type Props = { + onConfirm: (dontAskAgain: boolean) => void; + onExited?: () => void; + onHide?: () => void; +} + +export default function MarkAllAsReadModal({ + onConfirm, + onExited, + onHide, +}: Props) { + const [checked, setChecked] = useState(false); + + const title = ( + + ); + + const handleClose = () => { + setChecked(false); + onHide?.(); + }; + + const handleConfirm = () => { + onConfirm(checked); + onHide?.(); + }; + + const message = ( + + ), + }} + /> + ); + + const checkboxText = ( + + ); + + const checkbox = ( +
+ +
+ ); + + const cancelButtonText = ( + + ); + + const confirmButtonText = ( + + ); + + return ( + +
+

{title}

+

{message}

+ {checkbox} +
+
+ + +
+
+ ); +} diff --git a/webapp/channels/src/components/shortcut_sequence/index.ts b/webapp/channels/src/components/shortcut_sequence/index.ts new file mode 100644 index 00000000000..aec6e67e49c --- /dev/null +++ b/webapp/channels/src/components/shortcut_sequence/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export * from './shortcut_sequence'; diff --git a/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx new file mode 100644 index 00000000000..3060e726438 --- /dev/null +++ b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {ShortcutKeyVariant} from '@mattermost/shared/components/shortcut_key'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import {ShortcutSequence, KEY_SEPARATOR} from './shortcut_sequence'; + +describe('ShortcutSequence', () => { + test('should render single key from string', () => { + renderWithContext(); + + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + }); + + test('should render multiple keys from pipe-separated string', () => { + renderWithContext(); + + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + test('should render keys from array of strings', () => { + renderWithContext(); + + expect(screen.getByText('Shift')).toBeInTheDocument(); + expect(screen.getByText('Enter')).toBeInTheDocument(); + }); + + test('should render keys from array with message descriptors', () => { + const keys = [ + /* defineMessage */({ + id: 'test.ctrl', + defaultMessage: 'Ctrl', + }), + 'K', + ]; + + renderWithContext(); + + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + test('should apply tooltip variant class', () => { + renderWithContext( + , + ); + + const keyElement = screen.getByText('K'); + expect(keyElement).toHaveClass('shortcut-key--tooltip'); + }); + + test('should apply contrast variant class', () => { + renderWithContext( + , + ); + + const keyElement = screen.getByText('K'); + expect(keyElement).toHaveClass('shortcut-key--contrast'); + }); +}); diff --git a/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx new file mode 100644 index 00000000000..8ddd7ee4d51 --- /dev/null +++ b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; + +import {ShortcutKey, ShortcutKeyVariant} from '@mattermost/shared/components/shortcut_key'; + +import {isMessageDescriptor} from 'utils/i18n'; + +export const KEY_SEPARATOR = '|'; + +export {ShortcutKeyVariant}; + +export type ShortcutKeyDescriptor = string | MessageDescriptor; + +export type ShortcutSequenceProps = { + keys: string | ShortcutKeyDescriptor[]; + variant?: ShortcutKeyVariant; +}; + +export const ShortcutSequence = ({keys, variant}: ShortcutSequenceProps) => { + const keysArr = typeof keys === 'string' ? keys.split(KEY_SEPARATOR) : keys; + + return ( + <> + {keysArr.map((shortcutKey) => { + let key; + let content; + if (isMessageDescriptor(shortcutKey)) { + key = shortcutKey.id; + content = ; + } else { + key = shortcutKey; + content = shortcutKey; + } + + return ( + + {content} + + ); + })} + + ); +}; diff --git a/webapp/channels/src/components/sidebar/sidebar_list/index.ts b/webapp/channels/src/components/sidebar/sidebar_list/index.ts index afd9a2a0bf8..76990dfb27d 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/index.ts +++ b/webapp/channels/src/components/sidebar/sidebar_list/index.ts @@ -5,11 +5,19 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; +import type {PreferenceType} from '@mattermost/types/preferences'; + import {moveCategory} from 'mattermost-redux/actions/channel_categories'; +import {readAllMessages} from 'mattermost-redux/actions/channels'; +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {markAllInTeamAsRead} from 'mattermost-redux/actions/teams'; +import {Preferences} from 'mattermost-redux/constants'; import {getCurrentChannelId, getUnreadChannelIds} from 'mattermost-redux/selectors/entities/channels'; -import {shouldShowUnreadsCategory, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; +import {shouldShowUnreadsCategory, isCollapsedThreadsEnabled, get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {getThreadCountsInCurrentTeam} from 'mattermost-redux/selectors/entities/threads'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {switchToChannelById} from 'actions/views/channel'; import { @@ -19,6 +27,7 @@ import { clearChannelSelection, } from 'actions/views/channel_sidebar'; import {close, switchToLhsStaticPage} from 'actions/views/lhs'; +import {openModal} from 'actions/views/modals'; import {getCurrentStaticPageId, getVisibleStaticPages} from 'selectors/lhs'; import { getDisplayedChannels, @@ -34,6 +43,8 @@ import SidebarList from './sidebar_list'; function mapStateToProps(state: GlobalState) { const currentTeam = getCurrentTeam(state); const collapsedThreads = isCollapsedThreadsEnabled(state); + const getmarkAllAsReadWithoutConfirm = (state: GlobalState) => + getPreference(state, Preferences.CATEGORY_SHORTCUT_ACTIONS, Preferences.MARK_ALL_READ_WITHOUT_CONFIRM, 'false'); let hasUnreadThreads = false; if (collapsedThreads) { @@ -42,6 +53,7 @@ function mapStateToProps(state: GlobalState) { return { currentTeam, + currentUserId: getCurrentUserId(state), currentChannelId: getCurrentChannelId(state), categories: getCategoriesForCurrentTeam(state), isUnreadFilterEnabled: isUnreadFilterEnabled(state), @@ -53,12 +65,24 @@ function mapStateToProps(state: GlobalState) { showUnreadsCategory: shouldShowUnreadsCategory(state), collapsedThreads, hasUnreadThreads, + markAllAsReadWithoutConfirm: getmarkAllAsReadWithoutConfirm(state) === 'true', + markAllAsReadShortcutEnabled: getFeatureFlagValue(state, 'EnableShiftEscapeToMarkAllRead') === 'true', currentStaticPageId: getCurrentStaticPageId(state), staticPages: getVisibleStaticPages(state), }; } function mapDispatchToProps(dispatch: Dispatch) { + const setMarkAllAsReadWithoutConfirm = (userId: string, value: boolean) => { + const preference: PreferenceType = { + category: Preferences.CATEGORY_SHORTCUT_ACTIONS, + name: Preferences.MARK_ALL_READ_WITHOUT_CONFIRM, + user_id: userId, + value: String(value), + }; + return savePreferences(userId, [preference]); + }; + return { actions: bindActionCreators({ close, @@ -69,6 +93,10 @@ function mapDispatchToProps(dispatch: Dispatch) { stopDragging, clearChannelSelection, switchToLhsStaticPage, + readAllMessages, + markAllInTeamAsRead, + setMarkAllAsReadWithoutConfirm, + openModal, }, dispatch), }; } diff --git a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx index 1cb8828430b..63ded333208 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx @@ -131,6 +131,7 @@ describe('SidebarList', () => { }, ], unreadChannelIds: ['channel_id_2'], + currentUserId: 'current_user_id', displayedChannels: [currentChannel, unreadChannel], newCategoryIds: [], multiSelectedChannelIds: [], @@ -143,6 +144,8 @@ describe('SidebarList', () => { showUnreadsCategory: false, collapsedThreads: true, hasUnreadThreads: false, + markAllAsReadWithoutConfirm: false, + markAllAsReadShortcutEnabled: false, currentStaticPageId: '', staticPages: [], actions: { @@ -156,6 +159,10 @@ describe('SidebarList', () => { stopDragging: jest.fn(), clearChannelSelection: jest.fn(), multiSelectChannelAdd: jest.fn(), + readAllMessages: jest.fn(), + markAllInTeamAsRead: jest.fn(), + setMarkAllAsReadWithoutConfirm: jest.fn(), + openModal: jest.fn(), }, }; diff --git a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx index a76e5850b9e..0d7dfcefc0a 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx @@ -19,13 +19,16 @@ import {CategoryTypes} from 'mattermost-redux/constants/channel_categories'; import {makeAsyncComponent} from 'components/async_load'; import Scrollbars from 'components/common/scrollbars'; +import MarkAllAsReadModal from 'components/mark_all_as_read_modal'; +import type {Props as MarkAllAsReadModalProps} from 'components/mark_all_as_read_modal'; import SidebarCategory from 'components/sidebar/sidebar_category'; import {findNextUnreadChannelId} from 'utils/channel_utils'; -import {Constants, DraggingStates, DraggingStateTypes} from 'utils/constants'; +import {Constants, DraggingStates, DraggingStateTypes, ModalIdentifiers} from 'utils/constants'; import {isKeyPressed, cmdOrCtrlPressed} from 'utils/keyboard'; import {mod} from 'utils/utils'; +import type {ModalData} from 'types/actions'; import type {DraggingState} from 'types/store'; import type {StaticPage} from 'types/store/lhs'; @@ -37,6 +40,7 @@ const UnreadChannels = makeAsyncComponent('UnreadChannels', lazy(() => import('. type Props = WrappedComponentProps & { currentTeam?: Team; + currentUserId: string; currentChannelId: string; categories: ChannelCategory[]; unreadChannelIds: string[]; @@ -54,6 +58,8 @@ type Props = WrappedComponentProps & { handleOpenMoreDirectChannelsModal: (e: Event) => void; onDragStart: (initial: DragStart) => void; onDragEnd: (result: DropResult) => void; + markAllAsReadWithoutConfirm: boolean; + markAllAsReadShortcutEnabled: boolean; actions: { moveChannelsInSidebar: (categoryId: string, targetIndex: number, draggableChannelId: string) => void; @@ -64,6 +70,10 @@ type Props = WrappedComponentProps & { setDraggingState: (data: DraggingState) => void; stopDragging: () => void; clearChannelSelection: () => void; + readAllMessages: (userId: string) => void; + markAllInTeamAsRead: (userId: string, teamId: string) => void; + setMarkAllAsReadWithoutConfirm: (userId: string, value: boolean) => void; + openModal:

(modalData: ModalData

) => void; }; }; @@ -107,11 +117,17 @@ export class SidebarList extends React.PureComponent { componentDidMount() { document.addEventListener('keydown', this.navigateChannelShortcut); document.addEventListener('keydown', this.navigateUnreadChannelShortcut); + if (this.props.markAllAsReadShortcutEnabled) { + document.addEventListener('keydown', this.markAllChannelsAsReadShortcut); + } } componentWillUnmount() { document.removeEventListener('keydown', this.navigateChannelShortcut); document.removeEventListener('keydown', this.navigateUnreadChannelShortcut); + if (this.props.markAllAsReadShortcutEnabled) { + document.removeEventListener('keydown', this.markAllChannelsAsReadShortcut); + } } componentDidUpdate(prevProps: Props) { @@ -340,6 +356,23 @@ export class SidebarList extends React.PureComponent { } }; + markAllChannelsAsReadShortcut = (e: KeyboardEvent) => { + if (!e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && isKeyPressed(e, Constants.KeyCodes.ESCAPE)) { + e.preventDefault(); + if (this.props.markAllAsReadWithoutConfirm) { + this.markAllAsRead(); + } else { + this.props.actions.openModal({ + modalId: ModalIdentifiers.MARK_ALL_AS_READ, + dialogType: MarkAllAsReadModal, + dialogProps: { + onConfirm: this.onMarkAllAsReadConfirm, + }, + }); + } + } + }; + renderCategory = (category: ChannelCategory, index: number) => { return ( { this.props.actions.stopDragging(); }; + hasAnyUnreads = () => { + return this.props.unreadChannelIds.length > 0 || this.props.hasUnreadThreads; + }; + + markAllAsRead = () => { + if (this.hasAnyUnreads()) { + // I'm not sure if a user can ever _not_ be in a team, but this just + // feels safe in case that functionality is ever introduced, so the + // hotkey still marks all DMs as read. + if (this.props.currentTeam?.id) { + this.props.actions.markAllInTeamAsRead(this.props.currentUserId, this.props.currentTeam.id); + } + this.props.actions.readAllMessages(this.props.currentUserId); + } + }; + + onMarkAllAsReadConfirm = (dontShowAgain: boolean) => { + this.markAllAsRead(); + this.props.actions.setMarkAllAsReadWithoutConfirm( + this.props.currentUserId, + dontShowAgain, + ); + }; + render() { const {categories} = this.props; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 99da925ca60..f2f6c7e3251 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4691,6 +4691,7 @@ "feature_restricted_modal.agreement": "By selecting Try free for {trialLength} days, I agree to the Mattermost Software Evaluation Agreement, Privacy Policy, and receiving product emails.", "feature_restricted_modal.button.notify": "Notify admin", "feature_restricted_modal.button.plans": "View plans", + "feature_toast.tooltipCloseBtn": "Close", "feedback.cancelButton.text": "Cancel", "feedback.downgradeWorkspace.downgrade": "Downgrade", "feedback.downgradeWorkspace.exploringOptions": "Exploring other solutions", @@ -5374,6 +5375,14 @@ "manage_team_groups_modal.search_placeholder": "Search groups", "managed_category.label": "Managed category (optional)", "managed_category.placeholder": "Choose a managed category (optional)", + "mark_all_as_read_modal.cancel": "Cancel", + "mark_all_as_read_modal.checkbox": "Don't ask me again", + "mark_all_as_read_modal.confirm": "Mark all read", + "mark_all_as_read_modal.message": "{shortcut} will mark all messages as read in channels, threads, and Direct Messages for this team. Are you sure?", + "mark_all_as_read_modal.title": "Mark all messages as read?", + "mark_all_as_read_toast.button": "Got it", + "mark_all_as_read_toast.message": "Now you can use {shortcut} to mark all of your messages for this team as read. Don't worry, you'll be asked to confirm.", + "mark_all_as_read_toast.title": "A new shortcut to clear unreads", "mark_all_threads_as_read_modal.confirm": "Mark all as read", "mark_all_threads_as_read_modal.description": "All your threads will be marked as read, with unread and mention badges cleared. Do you want to continue?", "mark_all_threads_as_read_modal.title": "Mark all your threads as read", @@ -6224,6 +6233,7 @@ "shortcuts.msgs.formatting_bar.post_priority": "Message priority", "shortcuts.msgs.header": "Messages", "shortcuts.msgs.input.header": "Works inside an empty input field", + "shortcuts.msgs.mark_all_read": "Mark all messages as read:\tShift|Esc", "shortcuts.msgs.markdown.bold": "Bold:\tCtrl|B", "shortcuts.msgs.markdown.bold.mac": "Bold:\t⌘|B", "shortcuts.msgs.markdown.code": "Code:\tCtrl|Alt|C", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index 2c4ae1d4664..6c442b82df1 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -739,6 +739,23 @@ export function unsetActiveChannelOnServer(): ActionFuncAsync { }; } +export function readAllMessages(userId: string): ActionFuncAsync { + return async (dispatch, getState) => { + let response; + try { + response = await Client4.markAllMessagesAsRead(userId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch(markMultipleChannelsAsRead(response.last_viewed_at_times)); + + return {data: true}; + }; +} + export function readMultipleChannels(channelIds: string[]): ActionFuncAsync { return async (dispatch, getState) => { let response; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts index 13a81caf94f..74bbd1cb88e 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts @@ -9,7 +9,7 @@ import type {Team, TeamMembership, TeamMemberWithError, GetTeamMembersOpts, Team import type {UserProfile} from '@mattermost/types/users'; import {ChannelTypes, TeamTypes, UserTypes} from 'mattermost-redux/action_types'; -import {selectChannel} from 'mattermost-redux/actions/channels'; +import {markMultipleChannelsAsRead, selectChannel} from 'mattermost-redux/actions/channels'; import {logError} from 'mattermost-redux/actions/errors'; import {bindClientFunc, forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; @@ -22,6 +22,8 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionResult, DispatchFunc, GetStateFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import {handleAllMarkedRead} from './threads'; + async function getProfilesAndStatusesForMembers(userIds: string[], dispatch: DispatchFunc, getState: GetStateFunc) { const state = getState(); const { @@ -730,3 +732,21 @@ export function updateNoticesAsViewed(noticeIds: string[]) { ], }); } + +export function markAllInTeamAsRead(userId: string, teamId: string): ActionFuncAsync { + return async (dispatch, getState) => { + let response; + try { + response = await Client4.markAllInTeamAsRead(userId, teamId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch(markMultipleChannelsAsRead(response.last_viewed_at_times)); + dispatch(handleAllMarkedRead(teamId)); + + return {data: response}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts index 60ea89f7954..84667e683a8 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts @@ -55,6 +55,12 @@ const Preferences = { CATEGORY_WHATS_NEW_MODAL: 'whats_new_modal', HAS_SEEN_SIDEBAR_WHATS_NEW_MODAL: 'has_seen_sidebar_whats_new_modal', + CATEGORY_SHORTCUT_ACTIONS: 'shortcut_actions', + MARK_ALL_READ_WITHOUT_CONFIRM: 'mark_all_read_without_confirm', + + CATEGORY_NEW_FEATURES: 'new_features', + HAS_SEEN_MARK_ALL_READ_FEATURE: 'mark_all_read_seen', + CATEGORY_PERFORMANCE_DEBUGGING: 'performance_debugging', NAME_DISABLE_CLIENT_PLUGINS: 'disable_client_plugins', NAME_DISABLE_TYPING_MESSAGES: 'disable_typing_messages', diff --git a/webapp/channels/src/stores/hooks.ts b/webapp/channels/src/stores/hooks.ts index f8a339c25ab..84221b4db36 100644 --- a/webapp/channels/src/stores/hooks.ts +++ b/webapp/channels/src/stores/hooks.ts @@ -35,7 +35,7 @@ export function useGlobalState( const dispatch = useDispatch(); const defaultSuffix = useSelector(currentUserAndTeamSuffix); const suffixToUse = suffix || defaultSuffix; - const storedKey = `${name}${suffixToUse}`; + const storedKey = createStoredKey(name, suffixToUse); const value = useSelector(makeGetGlobalItem(storedKey, initialValue), shallowEqual); const setValue = useCallback((newValue: TVal) => dispatch(setGlobalItem(storedKey, newValue)), [storedKey]); @@ -45,3 +45,10 @@ export function useGlobalState( setValue, ]; } + +/** + * This seems verbose, but it is or use with + * existing class components.They can't use hooks, + * but will still want to have the same format as the hook. + */ +export const createStoredKey = (name: string, suffixToUse?: string) => `${name}${suffixToUse ?? ''}`; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 92956f208a4..d1cc00213f9 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -415,6 +415,7 @@ export const ModalIdentifiers = { POST_DELETED_MODAL: 'post_deleted_modal', FILE_PREVIEW_MODAL: 'file_preview_modal', LEAVE_PRIVATE_CHANNEL_MODAL: 'leave_private_channel_modal', + MARK_ALL_AS_READ: 'mark_all_as_read', GET_PUBLIC_LINK_MODAL: 'get_public_link_modal', KEYBOARD_SHORTCUTS_MODAL: 'keyboar_shortcuts_modal', USERS_TO_BE_REMOVED: 'users_to_be_removed', @@ -813,6 +814,8 @@ export const StoragePrefixes = { DELINQUENCY: 'delinquency_', HIDE_JOINED_CHANNELS: 'hideJoinedChannels', HIDE_NOTIFICATION_PERMISSION_REQUEST_BANNER: 'hideNotificationPermissionRequestBanner', + MARK_ALL_READ_WITHOUT_CONFIRM: 'mark_all_as_read_without_confirm', + HAS_SEEN_FEATURE_TOAST: 'has_seen_feature_toast', }; export const LandingPreferenceTypes = { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 0b3a36ae540..9d8124e1e29 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -2014,6 +2014,20 @@ export default class Client4 { ); }; + markAllInTeamAsRead = (userId: string, teamId: string) => { + return this.doFetch( + `${this.getUserRoute(userId)}/teams/${teamId}/read`, + {method: 'put'}, + ); + }; + + markAllMessagesAsRead = (userId: string) => { + return this.doFetch( + `${this.getChannelsRoute()}/members/${userId}/direct/read`, + {method: 'put'}, + ); + }; + autocompleteChannels = (teamId: string, name: string) => { return this.doFetch( `${this.getTeamRoute(teamId)}/channels/autocomplete${buildQueryString({name})}`, diff --git a/webapp/platform/shared/src/components/shortcut_key/keys.ts b/webapp/platform/shared/src/components/shortcut_key/keys.ts index dffaefd2b21..9accb3f0286 100644 --- a/webapp/platform/shared/src/components/shortcut_key/keys.ts +++ b/webapp/platform/shared/src/components/shortcut_key/keys.ts @@ -17,6 +17,10 @@ export const ShortcutKeys = { id: 'shortcuts.generic.enter', defaultMessage: 'Enter', }), + escape: defineMessage({ + id: 'general_button.esc', + defaultMessage: 'Esc', + }), option: '⌥', shift: defineMessage({ id: 'shortcuts.generic.shift', diff --git a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css index d021374ffb5..d91050a9c33 100644 --- a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css +++ b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css @@ -39,4 +39,14 @@ font-weight: 600; line-height: 16px; } + + &.shortcut-key--inline-content { + padding: 2px 5px; + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.75); + font-family: inherit; + font-size: 12px; + font-weight: 600; + line-height: 16px; + } } diff --git a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx index 792215cc9d2..5234d0ba974 100644 --- a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx +++ b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx @@ -11,6 +11,7 @@ export enum ShortcutKeyVariant { Tooltip = 'tooltip', TutorialTip = 'tutorialTip', ShortcutModal = 'shortcut', + InlineContent = 'inline-content', } export interface ShortcutKeyProps { @@ -26,6 +27,7 @@ export function ShortcutKey({children, variant}: ShortcutKeyProps) { 'shortcut-key--tooltip': variant === ShortcutKeyVariant.Tooltip, 'shortcut-key--tutorial-tip': variant === ShortcutKeyVariant.TutorialTip, 'shortcut-key--shortcut-modal': variant === ShortcutKeyVariant.ShortcutModal, + 'shortcut-key--inline-content': variant === ShortcutKeyVariant.InlineContent, })} > {children} diff --git a/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx b/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx index 4588c263015..e1ad4a45667 100644 --- a/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx +++ b/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx @@ -69,4 +69,17 @@ describe('TooltipShortcut', () => { expect(screen.getByText('Enter')).toBeInTheDocument(); }); + + test('should render with tooltip variant styling', () => { + const shortcut = { + default: ['K'], + }; + + renderWithContext( + , + ); + + const keyElement = screen.getByText('K'); + expect(keyElement).toHaveClass('shortcut-key--tooltip'); + }); });