[MM-2541] Shortcut to mark all channels as read for a team (#34012)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / Check go fix (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres (shard 0) (push) Blocked by required conditions
Server CI / Postgres (shard 1) (push) Blocked by required conditions
Server CI / Postgres (shard 2) (push) Blocked by required conditions
Server CI / Postgres (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres Test Results (push) Blocked by required conditions
Server CI / Elasticsearch v8 Compatibility (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 0) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 1) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 2) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres FIPS Test Results (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Tools CI / check-style (mattermost-govet) (push) Waiting to run
Tools CI / Test (mattermost-govet) (push) Waiting to run
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
YAML Lint / yamllint (push) Waiting to run

* 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/<userId>/direct/read` will mark a user's non-team DMs and GMs as read
    - `PUT /api/v4/users/<userId>/teams/<teamId>/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 <build@mattermost.com>
Co-authored-by: Jesse Hallam <jesse@mattermost.com>
Co-authored-by: Caleb Roseland <caleb@calebroseland.com>
Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
This commit is contained in:
Joshua D Schoep 2026-05-13 10:38:30 -06:00 committed by GitHub
parent 8a8a4ac8b1
commit d8612e378f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2337 additions and 28 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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{

View file

@ -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)

View file

@ -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, &regularChannel, -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, &regularMember)
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])
})
}

View file

@ -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)

View file

@ -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."

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -24,6 +24,7 @@ jest.mock('components/channel_layout/center_channel', () => () => <div/>);
jest.mock('components/loading_screen', () => () => <div/>);
jest.mock('components/unreads_status_handler', () => () => <div/>);
jest.mock('components/product_notices_modal', () => () => <div/>);
jest.mock('components/feature_toast/features/mark_all_as_read_toast', () => () => <div/>);
jest.mock('plugins/pluggable', () => () => <div/>);
jest.mock('actions/status_actions', () => ({

View file

@ -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) {
>
<UnreadsStatusHandler/>
<ProductNoticesModal/>
{enableMarkAllReadShortcut && <MarkAllAsReadToast/>}
<div className={classNames('container-fluid channel-view-inner')}>
{props.shouldRenderCenterChannel ? <CenterChannel/> : <LoadingScreen centered={true}/>}
<Pluggable pluggableName='Root'/>

View file

@ -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;
}
}
}

View file

@ -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(<FeatureToast {...baseProps}/>);
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(
<FeatureToast
{...baseProps}
show={false}
/>,
);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
test('should render with JSX element as message', () => {
const jsxMessage = <span>{'JSX Message with '}<mark>{'marked text'}</mark></span>;
renderWithContext(
<FeatureToast
{...baseProps}
message={jsxMessage}
/>,
);
expect(screen.getByText('JSX Message with')).toBeInTheDocument();
expect(screen.getByText('marked text')).toBeInTheDocument();
});
test('should have correct ARIA attributes', () => {
renderWithContext(<FeatureToast {...baseProps}/>);
const toast = screen.getByRole('status');
expect(toast).toHaveAttribute('aria-live', 'polite');
expect(toast).toHaveAttribute('aria-atomic', 'true');
});
test('should have accessible close button', () => {
renderWithContext(<FeatureToast {...baseProps}/>);
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(
<FeatureToast
{...baseProps}
onDismiss={onDismiss}
/>,
);
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(
<FeatureToast
{...baseProps}
showButton={true}
buttonText='Learn More'
/>,
);
expect(screen.getByRole('button', {name: 'Learn More'})).toBeInTheDocument();
});
test('should not render action button when showButton is false', () => {
renderWithContext(
<FeatureToast
{...baseProps}
showButton={false}
buttonText='Learn More'
/>,
);
expect(screen.queryByRole('button', {name: 'Learn More'})).not.toBeInTheDocument();
});
test('should not render action button when showButton is undefined', () => {
renderWithContext(<FeatureToast {...baseProps}/>);
// 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(
<FeatureToast
{...baseProps}
onDismiss={onDismiss}
showButton={true}
buttonText='Got it'
/>,
);
const actionButton = screen.getByRole('button', {name: 'Got it'});
await userEvent.click(actionButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View file

@ -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 (
<FloatingPortal id={RootHtmlPortalId}>
<div
role='status'
aria-live='polite'
aria-atomic='true'
className='feature_toast'
>
<PlaylistCheckIcon
size={24}
color={'blue'}
className='feature_toast__icon'
/>
<div
className='feature_toast__main_content'
>
<div
className='feature_toast__header_content'
>
<h3>{title}</h3>
<WithTooltip
title={formatMessage({id: 'feature_toast.tooltipCloseBtn', defaultMessage: 'Close'})}
>
<button
className='btn btn-icon btn-sm'
onClick={handleDismiss}
aria-label={formatMessage({id: 'feature_toast.tooltipCloseBtn', defaultMessage: 'Close'})}
>
<CloseIcon size={18}/>
</button>
</WithTooltip>
</div>
<p>{message}</p>
<div className='feature_toast__actions'>
{showButton && (
<button
className='btn btn-primary'
onClick={handleDismiss}
>
{buttonText}
</button>
)}
</div>
</div>
</div>
</FloatingPortal>
);
}

View file

@ -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 = (
<FormattedMessage
id='mark_all_as_read_toast.message'
defaultMessage="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."
values={{
shortcut: (
<ShortcutSequence
keys={[ShortcutKeys.shift, ShortcutKeys.escape]}
variant={ShortcutKeyVariant.InlineContent}
/>
),
}}
/>
);
const buttonText = formatMessage({
id: 'mark_all_as_read_toast.button',
defaultMessage: 'Got it',
});
return (
<FeatureToast
show={show}
showButton={true}
title={titleText}
message={message}
buttonText={buttonText}
onDismiss={onDismiss}
/>
);
}

View file

@ -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}`;

View file

@ -10,6 +10,11 @@ import {suitePluginIds} from 'utils/constants';
describe('components/KeyboardShortcutsModal', () => {
const initialState = {
entities: {
general: {
config: {},
},
},
plugins: {
plugins: {},
},

View file

@ -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<HTMLDivElement>(null);
const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true';
const {formatMessage} = useIntl();
@ -163,6 +165,7 @@ const KeyboardShortcutsModal = ({onExited}: Props): JSX.Element => {
<div className='section'>
<div>
<h3 className='section-title'><strong>{formatMessage(modalMessages.msgHeader)}</strong></h3>
{enableMarkAllReadShortcut && <KeyboardShortcutSequence shortcut={KEYBOARD_SHORTCUTS.markAllRead}/>}
<div className='subsection'>
<h4 className='subsection-title'>{formatMessage(modalMessages.msgInputHeader)}</h4>
<KeyboardShortcutSequence shortcut={KEYBOARD_SHORTCUTS.msgEdit}/>

View file

@ -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',

View file

@ -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) => (
<ShortcutKey
key={key}
variant={isInsideTooltip ? ShortcutKeyVariant.Tooltip : ShortcutKeyVariant.ShortcutModal}
>
{key}
</ShortcutKey>
));
return (
<>
<span>{'\t|\t'}</span>
{shortcutKeys}
<ShortcutSequence
keys={altKeys}
variant={variant}
/>
</>
);
};
@ -72,14 +66,12 @@ function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription,
{hoistDescription && !hideDescription && description?.replace(/:{1,2}$/, '')}
<div className='shortcut-line'>
{!hoistDescription && !hideDescription && description && <span>{description}</span>}
{keys && keys.split(KEY_SEPARATOR).map((key) => (
<ShortcutKey
key={key}
variant={isInsideTooltip ? ShortcutKeyVariant.Tooltip : ShortcutKeyVariant.ShortcutModal}
>
{key}
</ShortcutKey>
))}
{keys && (
<ShortcutSequence
keys={keys}
variant={variant}
/>
)}
{altKeys && renderAltKeys()}
</div>

View file

@ -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;
}

View file

@ -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(<MarkAllAsReadModal {...baseProps}/>);
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(<MarkAllAsReadModal {...baseProps}/>);
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(<MarkAllAsReadModal {...baseProps}/>);
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(<MarkAllAsReadModal {...baseProps}/>);
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(
<MarkAllAsReadModal
{...baseProps}
onHide={onHide}
/>,
);
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(
<MarkAllAsReadModal
{...baseProps}
onConfirm={onConfirm}
/>,
);
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(
<MarkAllAsReadModal
{...baseProps}
onConfirm={onConfirm}
/>,
);
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(<MarkAllAsReadModal {...baseProps}/>);
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(<MarkAllAsReadModal {...baseProps}/>);
rerender(<MarkAllAsReadModal {...baseProps}/>);
const checkboxAfterCancel = screen.getByRole('checkbox', {name: /Don't ask me again/});
expect(checkboxAfterCancel).not.toBeChecked();
});
});

View file

@ -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 = (
<FormattedMessage
id='mark_all_as_read_modal.title'
defaultMessage='Mark all messages as read?'
/>
);
const handleClose = () => {
setChecked(false);
onHide?.();
};
const handleConfirm = () => {
onConfirm(checked);
onHide?.();
};
const message = (
<FormattedMessage
id='mark_all_as_read_modal.message'
defaultMessage='{shortcut} will mark all messages as read in channels, threads, and Direct Messages for this team. Are you sure?'
values={{
shortcut: (
<ShortcutSequence
keys={[ShortcutKeys.shift, ShortcutKeys.escape]}
variant={ShortcutKeyVariant.InlineContent}
/>
),
}}
/>
);
const checkboxText = (
<FormattedMessage
id='mark_all_as_read_modal.checkbox'
defaultMessage="Don't ask me again"
/>
);
const checkbox = (
<div className='checkbox text-center mb-0'>
<label>
<input
type='checkbox'
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked)}
checked={checked}
/>
{checkboxText}
</label>
</div>
);
const cancelButtonText = (
<FormattedMessage
id='mark_all_as_read_modal.cancel'
defaultMessage='Cancel'
/>
);
const confirmButtonText = (
<FormattedMessage
id='mark_all_as_read_modal.confirm'
defaultMessage='Mark all read'
/>
);
return (
<GenericModal
className='mark_all_as_read_modal a11y__modal'
onHide={handleClose}
onExited={onExited}
ariaLabelledby='markAllReadModalLabel'
>
<div className='mark_all_as_read_modal__body'>
<h2 id='markAllReadModalLabel'>{title}</h2>
<p>{message}</p>
{checkbox}
</div>
<div className='mark_all_as_read_modal__footer'>
<button
className='btn btn-tertiary'
onClick={handleClose}
>
{cancelButtonText}
</button>
<button
className='btn btn-danger'
onClick={handleConfirm}
>
{confirmButtonText}
</button>
</div>
</GenericModal>
);
}

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export * from './shortcut_sequence';

View file

@ -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(<ShortcutSequence keys='Ctrl'/>);
expect(screen.getByText('Ctrl')).toBeInTheDocument();
});
test('should render multiple keys from pipe-separated string', () => {
renderWithContext(<ShortcutSequence keys={`Ctrl${KEY_SEPARATOR}K`}/>);
expect(screen.getByText('Ctrl')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
});
test('should render keys from array of strings', () => {
renderWithContext(<ShortcutSequence keys={['Shift', 'Enter']}/>);
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(<ShortcutSequence keys={keys}/>);
expect(screen.getByText('Ctrl')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
});
test('should apply tooltip variant class', () => {
renderWithContext(
<ShortcutSequence
keys='K'
variant={ShortcutKeyVariant.Tooltip}
/>,
);
const keyElement = screen.getByText('K');
expect(keyElement).toHaveClass('shortcut-key--tooltip');
});
test('should apply contrast variant class', () => {
renderWithContext(
<ShortcutSequence
keys='K'
variant={ShortcutKeyVariant.Contrast}
/>,
);
const keyElement = screen.getByText('K');
expect(keyElement).toHaveClass('shortcut-key--contrast');
});
});

View file

@ -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 = <FormattedMessage {...shortcutKey}/>;
} else {
key = shortcutKey;
content = shortcutKey;
}
return (
<ShortcutKey
key={key}
variant={variant}
>
{content}
</ShortcutKey>
);
})}
</>
);
};

View file

@ -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),
};
}

View file

@ -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(),
},
};

View file

@ -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: <P>(modalData: ModalData<P>) => void;
};
};
@ -107,11 +117,17 @@ export class SidebarList extends React.PureComponent<Props, State> {
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<Props, State> {
}
};
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<MarkAllAsReadModalProps>({
modalId: ModalIdentifiers.MARK_ALL_AS_READ,
dialogType: MarkAllAsReadModal,
dialogProps: {
onConfirm: this.onMarkAllAsReadConfirm,
},
});
}
}
};
renderCategory = (category: ChannelCategory, index: number) => {
return (
<SidebarCategory
@ -422,6 +455,30 @@ export class SidebarList extends React.PureComponent<Props, State> {
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;

View file

@ -4691,6 +4691,7 @@
"feature_restricted_modal.agreement": "By selecting <highlight>Try free for {trialLength} days</highlight>, I agree to the <linkEvaluation>Mattermost Software Evaluation Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy>, 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",

View file

@ -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;

View file

@ -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};
};
}

View file

@ -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',

View file

@ -35,7 +35,7 @@ export function useGlobalState<TVal>(
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<TVal>(
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 ?? ''}`;

View file

@ -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 = {

View file

@ -2014,6 +2014,20 @@ export default class Client4 {
);
};
markAllInTeamAsRead = (userId: string, teamId: string) => {
return this.doFetch<ChannelViewResponse>(
`${this.getUserRoute(userId)}/teams/${teamId}/read`,
{method: 'put'},
);
};
markAllMessagesAsRead = (userId: string) => {
return this.doFetch<ChannelViewResponse>(
`${this.getChannelsRoute()}/members/${userId}/direct/read`,
{method: 'put'},
);
};
autocompleteChannels = (teamId: string, name: string) => {
return this.doFetch<Channel[]>(
`${this.getTeamRoute(teamId)}/channels/autocomplete${buildQueryString({name})}`,

View file

@ -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',

View file

@ -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;
}
}

View file

@ -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}

View file

@ -69,4 +69,17 @@ describe('TooltipShortcut', () => {
expect(screen.getByText('Enter')).toBeInTheDocument();
});
test('should render with tooltip variant styling', () => {
const shortcut = {
default: ['K'],
};
renderWithContext(
<TooltipShortcut shortcut={shortcut}/>,
);
const keyElement = screen.getByText('K');
expect(keyElement).toHaveClass('shortcut-key--tooltip');
});
});