mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
[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
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:
parent
8a8a4ac8b1
commit
d8612e378f
44 changed files with 2337 additions and 28 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ func TestChannelStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore
|
|||
t.Run("SetShared", func(t *testing.T) { testSetShared(t, rctx, ss) })
|
||||
t.Run("GetTeamForChannel", func(t *testing.T) { testGetTeamForChannel(t, rctx, ss) })
|
||||
t.Run("GetChannelsWithUnreadsAndWithMentions", func(t *testing.T) { testGetChannelsWithUnreadsAndWithMentions(t, rctx, ss) })
|
||||
t.Run("GetDirectMessagesWithUnreadAndMentions", func(t *testing.T) { testGetDirectMessagesWithUnreadAndMentions(t, rctx, ss) })
|
||||
t.Run("GetTeamChannelsWithUnreadAndMentions", func(t *testing.T) { testGetTeamChannelsWithUnreadAndMentions(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testChannelStoreSave(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
|
@ -8849,3 +8851,516 @@ func testGetChannelsWithUnreadsAndWithMentions(t *testing.T, rctx request.CTX, s
|
|||
require.Len(t, times, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetDirectMessagesWithUnreadAndMentions(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
setupMembership := func(
|
||||
pushProp string,
|
||||
withUnreads bool,
|
||||
withMentions bool,
|
||||
channelType model.ChannelType,
|
||||
userID string,
|
||||
) (model.Channel, model.ChannelMember) {
|
||||
var o1 *model.Channel
|
||||
var err error
|
||||
|
||||
if channelType == model.ChannelTypeDirect {
|
||||
o1, err = ss.Channel().CreateDirectChannel(rctx, &model.User{Id: userID}, &model.User{Id: model.NewId()}, func(channel *model.Channel) {
|
||||
channel.TotalMsgCount = 25
|
||||
channel.LastPostAt = 12345
|
||||
channel.LastRootPostAt = 12345
|
||||
})
|
||||
require.NoError(t, err)
|
||||
} else if channelType == model.ChannelTypeGroup {
|
||||
// No builtin method to create groups, looks
|
||||
// like a decent amount of logic goes into it too.
|
||||
o1 = &model.Channel{}
|
||||
o1.DisplayName = "GroupChannel1"
|
||||
o1.Name = NewTestID()
|
||||
o1.Type = model.ChannelTypeGroup
|
||||
o1.TotalMsgCount = 25
|
||||
o1.LastPostAt = 12345
|
||||
o1.LastRootPostAt = 12345
|
||||
_, err = ss.Channel().Save(rctx, o1, -1)
|
||||
require.NoError(t, err)
|
||||
|
||||
m1 := model.ChannelMember{}
|
||||
m1.ChannelId = o1.Id
|
||||
m1.UserId = userID
|
||||
m1.NotifyProps = model.GetDefaultChannelNotifyProps()
|
||||
m1.NotifyProps[model.PushNotifyProp] = pushProp
|
||||
if !withUnreads {
|
||||
m1.MsgCount = o1.TotalMsgCount
|
||||
m1.LastViewedAt = o1.LastPostAt
|
||||
}
|
||||
if withMentions {
|
||||
m1.MentionCount = 5
|
||||
}
|
||||
_, err = ss.Channel().SaveMember(rctx, &m1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
m1, err := ss.Channel().GetMember(rctx, o1.Id, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
m1.NotifyProps = model.GetDefaultChannelNotifyProps()
|
||||
m1.NotifyProps[model.PushNotifyProp] = pushProp
|
||||
|
||||
if !withUnreads {
|
||||
m1.MsgCount = o1.TotalMsgCount
|
||||
m1.LastViewedAt = o1.LastPostAt
|
||||
}
|
||||
if withMentions {
|
||||
m1.MentionCount = 5
|
||||
}
|
||||
|
||||
m1, err = ss.Channel().UpdateMember(rctx, m1)
|
||||
require.NoError(t, err)
|
||||
|
||||
return *o1, *m1
|
||||
}
|
||||
|
||||
type TestCase struct {
|
||||
name string
|
||||
pushProp string
|
||||
userNotifyProp string
|
||||
channelType model.ChannelType
|
||||
withUnreads bool
|
||||
withMentions bool
|
||||
}
|
||||
ttcc := []TestCase{}
|
||||
|
||||
channelNotifyProps := []string{model.ChannelNotifyDefault, model.ChannelNotifyAll, model.ChannelNotifyMention, model.ChannelNotifyNone}
|
||||
userNotifyProps := []string{model.UserNotifyAll, model.UserNotifyMention, model.UserNotifyHere, model.UserNotifyNone}
|
||||
channelTypes := []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}
|
||||
boolRange := []bool{true, false}
|
||||
|
||||
nameTemplate := "pushProp: %s, userPushProp: %s, type: %s, unreads: %t, mentions: %t"
|
||||
for _, pushProp := range channelNotifyProps {
|
||||
for _, userNotifyProp := range userNotifyProps {
|
||||
for _, channelType := range channelTypes {
|
||||
for _, withUnreads := range boolRange {
|
||||
ttcc = append(ttcc, TestCase{
|
||||
name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, channelType, withUnreads, false),
|
||||
pushProp: pushProp,
|
||||
userNotifyProp: userNotifyProp,
|
||||
channelType: channelType,
|
||||
withUnreads: withUnreads,
|
||||
withMentions: false,
|
||||
})
|
||||
if withUnreads {
|
||||
ttcc = append(ttcc, TestCase{
|
||||
name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, channelType, withUnreads, true),
|
||||
pushProp: pushProp,
|
||||
userNotifyProp: userNotifyProp,
|
||||
channelType: channelType,
|
||||
withUnreads: withUnreads,
|
||||
withMentions: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range ttcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
userID := model.NewId()
|
||||
o1, m1 := setupMembership(tc.pushProp, tc.withUnreads, tc.withMentions, tc.channelType, userID)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, m1.UserId, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedUnreadsLength := 0
|
||||
if tc.withUnreads {
|
||||
expectedUnreadsLength = 1
|
||||
}
|
||||
require.Len(t, unreads, expectedUnreadsLength)
|
||||
|
||||
propToUse := tc.pushProp
|
||||
if tc.pushProp == model.ChannelNotifyDefault {
|
||||
propToUse = tc.userNotifyProp
|
||||
}
|
||||
|
||||
expectedMentionsLength := 0
|
||||
// Direct messages seem to always have notify on, at least
|
||||
// that is the logic copied from GetChannelsWithUnreadsAndMentions
|
||||
if (tc.channelType == model.ChannelTypeDirect && tc.withUnreads) ||
|
||||
(propToUse == model.UserNotifyAll && tc.withUnreads) ||
|
||||
(propToUse == model.UserNotifyMention && tc.withMentions) {
|
||||
expectedMentionsLength = 1
|
||||
}
|
||||
|
||||
require.Len(t, mentions, expectedMentionsLength)
|
||||
|
||||
if tc.withUnreads {
|
||||
require.Contains(t, times, o1.Id)
|
||||
require.Equal(t, o1.LastPostAt, times[o1.Id])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("multiple directs and groups", func(t *testing.T) {
|
||||
userID := model.NewId()
|
||||
dm1, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeDirect, userID)
|
||||
dm2, _ := setupMembership(model.ChannelNotifyDefault, true, false, model.ChannelTypeDirect, userID)
|
||||
gm1, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeGroup, userID)
|
||||
gm2, _ := setupMembership(model.ChannelNotifyMention, true, false, model.ChannelTypeGroup, userID)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, unreads, 4)
|
||||
require.Contains(t, unreads, dm1.Id)
|
||||
require.Contains(t, unreads, dm2.Id)
|
||||
require.Contains(t, unreads, gm1.Id)
|
||||
require.Contains(t, unreads, gm2.Id)
|
||||
|
||||
require.Len(t, mentions, 3)
|
||||
// Same as above, direct messages seem to always have notify on
|
||||
// but group messages need to have notification policies set.
|
||||
require.Contains(t, mentions, dm1.Id)
|
||||
require.Contains(t, mentions, dm2.Id)
|
||||
require.Contains(t, mentions, gm1.Id)
|
||||
require.NotContains(t, mentions, gm2.Id)
|
||||
|
||||
require.Equal(t, dm1.LastPostAt, times[dm1.Id])
|
||||
require.Equal(t, dm2.LastPostAt, times[dm2.Id])
|
||||
require.Equal(t, gm1.LastPostAt, times[gm1.Id])
|
||||
require.Equal(t, gm2.LastPostAt, times[gm2.Id])
|
||||
})
|
||||
|
||||
t.Run("excludes regular channels", func(t *testing.T) {
|
||||
userID := model.NewId()
|
||||
|
||||
dm, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeDirect, userID)
|
||||
|
||||
regularChannel := model.Channel{}
|
||||
regularChannel.TeamId = model.NewId()
|
||||
regularChannel.DisplayName = "Regular Channel"
|
||||
regularChannel.Name = NewTestID()
|
||||
regularChannel.Type = model.ChannelTypeOpen
|
||||
regularChannel.TotalMsgCount = 25
|
||||
regularChannel.LastPostAt = 12345
|
||||
regularChannel.LastRootPostAt = 12345
|
||||
_, nErr := ss.Channel().Save(rctx, ®ularChannel, -1)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
regularMember := model.ChannelMember{}
|
||||
regularMember.ChannelId = regularChannel.Id
|
||||
regularMember.UserId = userID
|
||||
regularMember.NotifyProps = model.GetDefaultChannelNotifyProps()
|
||||
regularMember.MentionCount = 5
|
||||
_, err := ss.Channel().SaveMember(rctx, ®ularMember)
|
||||
require.NoError(t, err)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only find the DMs and GMs
|
||||
require.Len(t, unreads, 1)
|
||||
require.Contains(t, unreads, dm.Id)
|
||||
require.NotContains(t, unreads, regularChannel.Id)
|
||||
|
||||
require.Len(t, mentions, 1)
|
||||
require.Contains(t, mentions, dm.Id)
|
||||
|
||||
require.Equal(t, dm.LastPostAt, times[dm.Id])
|
||||
require.NotContains(t, times, regularChannel.Id)
|
||||
})
|
||||
|
||||
t.Run("user with no DMs or GMs", func(t *testing.T) {
|
||||
userID := model.NewId()
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, unreads, 0)
|
||||
require.Len(t, mentions, 0)
|
||||
require.Len(t, times, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetTeamChannelsWithUnreadAndMentions(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
setupMembership := func(
|
||||
teamID string,
|
||||
pushProp string,
|
||||
withUnreads bool,
|
||||
withMentions bool,
|
||||
userID string,
|
||||
) (model.Channel, model.ChannelMember) {
|
||||
o1 := model.Channel{}
|
||||
o1.TeamId = teamID
|
||||
o1.DisplayName = "Channel1"
|
||||
o1.Name = NewTestID()
|
||||
o1.Type = model.ChannelTypeOpen
|
||||
o1.TotalMsgCount = 25
|
||||
o1.LastPostAt = 12345
|
||||
o1.LastRootPostAt = 12345
|
||||
_, nErr := ss.Channel().Save(rctx, &o1, -1)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
m1 := model.ChannelMember{}
|
||||
m1.ChannelId = o1.Id
|
||||
m1.UserId = userID
|
||||
m1.NotifyProps = model.GetDefaultChannelNotifyProps()
|
||||
m1.NotifyProps[model.PushNotifyProp] = pushProp
|
||||
if !withUnreads {
|
||||
m1.MsgCount = o1.TotalMsgCount
|
||||
m1.LastViewedAt = o1.LastPostAt
|
||||
}
|
||||
if withMentions {
|
||||
m1.MentionCount = 5
|
||||
}
|
||||
_, err := ss.Channel().SaveMember(rctx, &m1)
|
||||
require.NoError(t, err)
|
||||
|
||||
return o1, m1
|
||||
}
|
||||
|
||||
type TestCase struct {
|
||||
name string
|
||||
pushProp string
|
||||
userNotifyProp string
|
||||
withUnreads bool
|
||||
withMentions bool
|
||||
}
|
||||
ttcc := []TestCase{}
|
||||
|
||||
channelNotifyProps := []string{model.ChannelNotifyDefault, model.ChannelNotifyAll, model.ChannelNotifyMention, model.ChannelNotifyNone}
|
||||
userNotifyProps := []string{model.UserNotifyAll, model.UserNotifyMention, model.UserNotifyHere, model.UserNotifyNone}
|
||||
boolRange := []bool{true, false}
|
||||
|
||||
nameTemplate := "pushProp: %s, userPushProp: %s, unreads: %t, mentions: %t"
|
||||
for _, pushProp := range channelNotifyProps {
|
||||
for _, userNotifyProp := range userNotifyProps {
|
||||
for _, withUnreads := range boolRange {
|
||||
ttcc = append(ttcc, TestCase{
|
||||
name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, withUnreads, false),
|
||||
pushProp: pushProp,
|
||||
userNotifyProp: userNotifyProp,
|
||||
withUnreads: withUnreads,
|
||||
withMentions: false,
|
||||
})
|
||||
if withUnreads {
|
||||
ttcc = append(ttcc, TestCase{
|
||||
name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, withUnreads, true),
|
||||
pushProp: pushProp,
|
||||
userNotifyProp: userNotifyProp,
|
||||
withUnreads: withUnreads,
|
||||
withMentions: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range ttcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
o1, m1 := setupMembership(teamID, tc.pushProp, tc.withUnreads, tc.withMentions, userID)
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, m1.UserId, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedUnreadsLength := 0
|
||||
if tc.withUnreads {
|
||||
expectedUnreadsLength = 1
|
||||
}
|
||||
require.Len(t, unreads, expectedUnreadsLength)
|
||||
|
||||
propToUse := tc.pushProp
|
||||
if tc.pushProp == model.ChannelNotifyDefault {
|
||||
propToUse = tc.userNotifyProp
|
||||
}
|
||||
expectedMentionsLength := 0
|
||||
if (propToUse == model.UserNotifyAll && tc.withUnreads) || (propToUse == model.UserNotifyMention && tc.withMentions) {
|
||||
expectedMentionsLength = 1
|
||||
}
|
||||
|
||||
require.Len(t, mentions, expectedMentionsLength)
|
||||
|
||||
if tc.withUnreads {
|
||||
require.Contains(t, times, o1.Id)
|
||||
require.Equal(t, o1.LastPostAt, times[o1.Id])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("multiple channels on same team", func(t *testing.T) {
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
o1, _ := setupMembership(teamID, model.ChannelNotifyDefault, true, true, userID)
|
||||
o2, _ := setupMembership(teamID, model.ChannelNotifyDefault, true, true, userID)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, unreads, o1.Id)
|
||||
require.Contains(t, unreads, o2.Id)
|
||||
require.Contains(t, mentions, o1.Id)
|
||||
require.Contains(t, mentions, o2.Id)
|
||||
require.Equal(t, o1.LastPostAt, times[o1.Id])
|
||||
require.Equal(t, o2.LastPostAt, times[o2.Id])
|
||||
})
|
||||
|
||||
t.Run("excludes channels from other teams", func(t *testing.T) {
|
||||
teamID1 := model.NewId()
|
||||
teamID2 := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
o1, _ := setupMembership(teamID1, model.ChannelNotifyDefault, true, true, userID)
|
||||
o2, _ := setupMembership(teamID2, model.ChannelNotifyDefault, true, true, userID)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID1, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only include channel from teamID1
|
||||
require.Len(t, unreads, 1)
|
||||
require.Contains(t, unreads, o1.Id)
|
||||
require.NotContains(t, unreads, o2.Id)
|
||||
|
||||
require.Len(t, mentions, 1)
|
||||
require.Contains(t, mentions, o1.Id)
|
||||
require.NotContains(t, mentions, o2.Id)
|
||||
|
||||
require.Equal(t, o1.LastPostAt, times[o1.Id])
|
||||
require.NotContains(t, times, o2.Id)
|
||||
})
|
||||
|
||||
t.Run("non existing team", func(t *testing.T) {
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, "nonexistent-team", "foo", userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, unreads, 0)
|
||||
require.Len(t, mentions, 0)
|
||||
require.Len(t, times, 0)
|
||||
})
|
||||
|
||||
t.Run("user not member of any team channels", func(t *testing.T) {
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
// Create a channel on the team but don't add the user as a member
|
||||
o1 := model.Channel{}
|
||||
o1.TeamId = teamID
|
||||
o1.DisplayName = "Channel1"
|
||||
o1.Name = NewTestID()
|
||||
o1.Type = model.ChannelTypeOpen
|
||||
o1.TotalMsgCount = 25
|
||||
o1.LastPostAt = 12345
|
||||
o1.LastRootPostAt = 12345
|
||||
_, nErr := ss.Channel().Save(rctx, &o1, -1)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, unreads, 0)
|
||||
require.Len(t, mentions, 0)
|
||||
require.Len(t, times, 0)
|
||||
})
|
||||
|
||||
t.Run("LastViewedAt affects readTimes", func(t *testing.T) {
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
o1 := model.Channel{}
|
||||
o1.TeamId = teamID
|
||||
o1.DisplayName = "Channel1"
|
||||
o1.Name = NewTestID()
|
||||
o1.Type = model.ChannelTypeOpen
|
||||
o1.TotalMsgCount = 25
|
||||
o1.LastPostAt = 10000
|
||||
o1.LastRootPostAt = 10000
|
||||
_, nErr := ss.Channel().Save(rctx, &o1, -1)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
m1 := model.ChannelMember{}
|
||||
m1.ChannelId = o1.Id
|
||||
m1.UserId = userID
|
||||
m1.NotifyProps = model.GetDefaultChannelNotifyProps()
|
||||
// Set LastViewedAt to be AFTER LastPostAt (user viewed after last message)
|
||||
m1.MsgCount = o1.TotalMsgCount - 5 // Still has unreads
|
||||
m1.LastViewedAt = 15000 // Newer than LastPostAt
|
||||
_, err := ss.Channel().SaveMember(rctx, &m1)
|
||||
require.NoError(t, err)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyAll
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, unreads, 1)
|
||||
require.Len(t, mentions, 1)
|
||||
|
||||
// Should return the max of LastPostAt and LastViewedAt
|
||||
require.Equal(t, int64(15000), times[o1.Id])
|
||||
})
|
||||
|
||||
t.Run("mixed notification settings on same team", func(t *testing.T) {
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
// Channel with UserNotifyAll behavior
|
||||
o1, _ := setupMembership(teamID, model.ChannelNotifyAll, true, false, userID)
|
||||
|
||||
// Channel with UserNotifyMention behavior (has unreads but no mentions)
|
||||
o2, _ := setupMembership(teamID, model.ChannelNotifyMention, true, false, userID)
|
||||
|
||||
// Channel with UserNotifyMention behavior (has unreads AND mentions)
|
||||
o3, _ := setupMembership(teamID, model.ChannelNotifyMention, true, true, userID)
|
||||
|
||||
// Channel with UserNotifyNone behavior
|
||||
o4, _ := setupMembership(teamID, model.ChannelNotifyNone, true, true, userID)
|
||||
|
||||
userNotifyProps := model.GetDefaultChannelNotifyProps()
|
||||
userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention // User default (not used when channel overrides)
|
||||
|
||||
unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps)
|
||||
require.NoError(t, err)
|
||||
|
||||
// All 4 channels should have unreads
|
||||
require.Len(t, unreads, 4)
|
||||
require.Contains(t, unreads, o1.Id)
|
||||
require.Contains(t, unreads, o2.Id)
|
||||
require.Contains(t, unreads, o3.Id)
|
||||
require.Contains(t, unreads, o4.Id)
|
||||
|
||||
// Only o1 (NotifyAll) and o3 (NotifyMention with mentions) should trigger mentions
|
||||
require.Len(t, mentions, 2)
|
||||
require.Contains(t, mentions, o1.Id)
|
||||
require.NotContains(t, mentions, o2.Id) // NotifyMention but no mentions
|
||||
require.Contains(t, mentions, o3.Id)
|
||||
require.NotContains(t, mentions, o4.Id) // NotifyNone
|
||||
|
||||
require.Equal(t, o1.LastPostAt, times[o1.Id])
|
||||
require.Equal(t, o2.LastPostAt, times[o2.Id])
|
||||
require.Equal(t, o3.LastPostAt, times[o3.Id])
|
||||
require.Equal(t, o4.LastPostAt, times[o4.Id])
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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'/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
webapp/channels/src/components/feature_toast/index.ts
Normal file
9
webapp/channels/src/components/feature_toast/index.ts
Normal 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}`;
|
||||
|
|
@ -10,6 +10,11 @@ import {suitePluginIds} from 'utils/constants';
|
|||
|
||||
describe('components/KeyboardShortcutsModal', () => {
|
||||
const initialState = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
plugins: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
43
webapp/channels/src/components/mark_all_as_read_modal.scss
Normal file
43
webapp/channels/src/components/mark_all_as_read_modal.scss
Normal 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;
|
||||
}
|
||||
120
webapp/channels/src/components/mark_all_as_read_modal.test.tsx
Normal file
120
webapp/channels/src/components/mark_all_as_read_modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
121
webapp/channels/src/components/mark_all_as_read_modal.tsx
Normal file
121
webapp/channels/src/components/mark_all_as_read_modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export * from './shortcut_sequence';
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}`;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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})}`,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue