From 09eb7c0243091afbae3f3cbd44babd5aa6925233 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 17 Aug 2026 12:58:33 +0300 Subject: [PATCH] Migrate mute and unmute user endpoints to v2 Adopt the generated OwnUserResponse for both v1 and v2 payloads, and add bulk mute/unmute user APIs. --- CHANGELOG.md | 3 + .../DemoChatChannelListRouter.swift | 44 +++++ Scripts/openapi_generate.sh | 85 +++++++-- Sources/StreamChat/.openapi.sourcery.yml | 2 + .../EndpointPath+OfflineRequest.swift | 3 +- .../Endpoints/ModerationEndpoints.swift | 26 --- .../Payloads/CurrentUserPayloads.swift | 122 ------------ .../Payloads/FlagMessagePayload.swift | 6 +- .../Endpoints/Payloads/FlagUserPayload.swift | 6 +- .../Payloads/GuestUserTokenPayload.swift | 6 +- .../Endpoints/Payloads/UserPayloads.swift | 10 +- .../CurrentUserController.swift | 38 ++++ .../Database/DTOs/CurrentUserDTO.swift | 44 ++++- .../StreamChat/Database/DTOs/UserDTO.swift | 58 +++--- .../StreamChat/Database/DatabaseSession.swift | 7 +- .../StreamChatModel.xcdatamodel/contents | 1 + .../OpenAPI/APIs/DefaultEndpoints.swift | 36 +++- .../OpenAPI/models/BlockedUserResponse.swift | 2 + .../OpenAPI/models/ChannelDetailPayload.swift | 2 + .../OpenAPI/models/ChannelMemberRequest.swift | 1 + .../DeliveryReceiptsPrivacySettings.swift | 17 ++ .../OpenAPI/models/MemberPayload.swift | 1 + .../models/MessageReactionPayload.swift | 1 + .../OpenAPI/models/MuteRequest.swift | 25 +++ .../OpenAPI/models/MuteResponse.swift | 33 ++++ .../OpenAPI/models/MutedChannelPayload.swift | 2 + .../OpenAPI/models/MutedUserPayload.swift | 37 ++++ .../OpenAPI/models/OwnUserResponse.swift | 175 ++++++++++++++++++ .../OpenAPI/models/PollPayload.swift | 1 + .../OpenAPI/models/PollVotePayload.swift | 1 + .../models/ReadReceiptsPrivacySettings.swift | 17 ++ .../TypingIndicatorPrivacySettings.swift | 17 ++ .../OpenAPI/models/UnmuteRequest.swift | 18 ++ .../OpenAPI/models/UnmuteUsersResponse.swift | 18 ++ .../models/UpdateLiveLocationRequest.swift | 8 +- .../OpenAPI/models/UserPayload.swift | 4 +- .../OpenAPI/models/UserPrivacySettings.swift | 27 +++ Sources/StreamChat/Models/CurrentUser.swift | 5 + .../StreamChat/Models/MuteUsersResponse.swift | 49 +++++ .../MuteResponse+asModel.swift | 29 +++ Sources/StreamChat/Models/UserInfo.swift | 47 ----- .../Models/UserPayload+Extensions.swift | 30 ++- .../UserPrivacySettings+Extensions.swift | 21 +++ .../StreamChat/StateLayer/ConnectedUser.swift | 30 ++- .../WebSocketClient/Events/EventPayload.swift | 6 +- .../Events/NotificationEvents.swift | 4 +- .../WebSocketConnectPayload.swift | 51 +---- .../StreamChat/Workers/ChannelUpdater.swift | 2 +- .../Workers/CurrentUserUpdater.swift | 60 +++++- .../StreamChat/Workers/MessageUpdater.swift | 4 +- Sources/StreamChat/Workers/UserUpdater.swift | 15 +- .../Extensions/EndpoinPath+Equatable.swift | 1 - .../Database/DatabaseSession_Mock.swift | 5 + .../Workers/CurrentUserUpdater_Mock.swift | 38 ++++ .../TestData/DecodableEntity.swift | 1 + .../TestData/DummyData/MutedUserPayload.swift | 8 +- ...load.swift => OwnUserResponse+Dummy.swift} | 66 ++++++- .../TestData/DummyData/UserPayload.swift | 2 +- .../TestData/DummyData/XCTestCase+Dummy.swift | 20 +- .../Endpoints/EndpointPath_Tests.swift | 1 - .../Endpoints/ModerationEndpoints_Tests.swift | 38 ---- .../Payloads/CurrentUserPayloads_Tests.swift | 22 +-- .../ChannelController_Tests.swift | 12 +- .../CurrentUserController_Tests.swift | 87 +++++++++ .../Database/DTOs/ChannelDTO_Tests.swift | 6 +- .../Database/DTOs/ChannelMuteDTO_Tests.swift | 8 +- .../Database/DTOs/ChannelReadDTO_Tests.swift | 6 +- .../Database/DTOs/CurrentUserDTO_Tests.swift | 85 +++++++-- .../Database/DTOs/DeviceDTO_Tests.swift | 4 +- .../Database/DTOs/MessageDTO_Tests.swift | 8 +- .../StateLayer/ConnectedUser_Tests.swift | 31 +++- .../ChannelReadUpdaterMiddleware_Tests.swift | 8 +- .../Events/NotificationEvents_Tests.swift | 4 +- .../Workers/ChannelUpdater_Tests.swift | 6 +- .../Workers/CurrentUserUpdater_Tests.swift | 166 ++++++++++++++++- .../Workers/UserUpdater_Tests.swift | 81 +++++++- 76 files changed, 1528 insertions(+), 443 deletions(-) delete mode 100644 Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/DeliveryReceiptsPrivacySettings.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/MuteRequest.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/MuteResponse.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/MutedUserPayload.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/OwnUserResponse.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/ReadReceiptsPrivacySettings.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/TypingIndicatorPrivacySettings.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/UnmuteRequest.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/UnmuteUsersResponse.swift create mode 100644 Sources/StreamChat/Generated/OpenAPI/models/UserPrivacySettings.swift create mode 100644 Sources/StreamChat/Models/MuteUsersResponse.swift create mode 100644 Sources/StreamChat/Models/Payload+asModel/MuteResponse+asModel.swift create mode 100644 Sources/StreamChat/Models/UserPrivacySettings+Extensions.swift rename TestTools/StreamChatTestTools/TestData/DummyData/{CurrentUserPayload.swift => OwnUserResponse+Dummy.swift} (58%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d7d0f93bc..9b2197a2468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `MemberSearch` for debounced channel member search [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) - Add `ChatChannel.truncatedBy`, `ChatChannel.isAutoTranslationEnabled` and `ChatChannel.autoTranslationLanguages` [#4197](https://github.com/GetStream/stream-chat-swift/pull/4197) - Add `ChatChannelMember.memberStatus` and `ChatChannelMember.memberDeletedAt` [#4197](https://github.com/GetStream/stream-chat-swift/pull/4197) +- Add `CurrentChatUserController.muteUsers(_:expiration:completion:)` and `ConnectedUser.muteUsers(_:expiration:)` for muting multiple users at once with an optional expiration +- Add `CurrentChatUserController.unmuteUsers(_:completion:)` and `ConnectedUser.unmuteUsers(_:)` for unmuting multiple users at once +- Add `CurrentChatUser.totalUnreadCountByTeam` for accessing the unread message count per team ### 🐞 Fixed - Fix unread count not clearing immediately when marking a channel as read [#4214](https://github.com/GetStream/stream-chat-swift/pull/4214) - Fix mention suggestions not showing members in channels with 100+ members in the `MentionSuggestionsProvider` [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 51f06c892d2..a2c4f28b583 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -473,6 +473,50 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), + .init(title: "Mute All Channel Members", handler: { [unowned self] _ in + let memberIds = Set(channelController.channel?.lastActiveMembers.map(\.id) ?? []) + .subtracting([client.currentUserId ?? ""]) + guard !memberIds.isEmpty else { + self.rootViewController.presentAlert(title: "Channel \(cid) has no other members") + return + } + client.currentUserController().muteUsers(memberIds) { [unowned self] result in + switch result { + case .success(let mutedUsers): + self.rootViewController.presentAlert( + title: "Muted \(mutedUsers.mutes?.count ?? 0) of \(memberIds.count) members", + message: mutedUsers.nonExistingUsers.map { "Not found: \($0.joined(separator: ", "))" } + ) + case .failure(let error): + self.rootViewController.presentAlert( + title: "Couldn't mute the members of channel \(cid)", + message: "\(error)" + ) + } + } + }), + .init(title: "Unmute All Channel Members", handler: { [unowned self] _ in + let memberIds = Set(channelController.channel?.lastActiveMembers.map(\.id) ?? []) + .subtracting([client.currentUserId ?? ""]) + guard !memberIds.isEmpty else { + self.rootViewController.presentAlert(title: "Channel \(cid) has no other members") + return + } + client.currentUserController().unmuteUsers(memberIds) { [unowned self] result in + switch result { + case .success(let response): + self.rootViewController.presentAlert( + title: "Unmuted \(memberIds.count) members", + message: response.nonExistingUsers.map { "Not found: \($0.joined(separator: ", "))" } + ) + case .failure(let error): + self.rootViewController.presentAlert( + title: "Couldn't unmute the members of channel \(cid)", + message: "\(error)" + ) + } + } + }), .init(title: "Mark channel unread with timestamp", isEnabled: true, handler: { [unowned self] _ in self.rootViewController.presentAlert(title: "Mark messages as unread with timestamp", message: "Marks messages as unread from the last number of days", textFieldPlaceholder: "Days") { offsetInDaysString in let calendar = Calendar.current diff --git a/Scripts/openapi_generate.sh b/Scripts/openapi_generate.sh index 7bab6566916..db4459bdaf3 100755 --- a/Scripts/openapi_generate.sh +++ b/Scripts/openapi_generate.sh @@ -36,6 +36,7 @@ allowed_endpoints=( listDevices listUserGroups markDelivered + mute muteChannel queryMembers queryPollVotes @@ -45,6 +46,7 @@ allowed_endpoints=( searchUserGroups stopWatchingChannel unblockUsers + unmute unmuteChannel unreadCounts updateLiveLocation @@ -76,6 +78,7 @@ allowed_models=( CreateUserGroupRequest DeleteChannelResponse DeliveredMessagePayload + DeliveryReceiptsResponse DeviceResponse Field FileUploadConfig @@ -95,6 +98,9 @@ allowed_models=( MembersResponse MuteChannelRequest MuteChannelResponse + MuteRequest + MuteResponse + OwnUserResponse PollOptionInput PollOptionResponse PollOptionResponseData @@ -103,21 +109,26 @@ allowed_models=( PollVoteResponse PollVoteResponseData PollVotesResponse + PrivacySettingsResponse PushPreferenceInput PushPreferencesResponse QueryMembersPayload QueryPollVotesRequest QueryReactionsRequest ReactionResponse + ReadReceiptsResponse RemoveUserGroupMembersRequest Role SearchRolesResponse SharedLocationResponseData SharedLocationsResponse SortParamRequest + TypingIndicatorsResponse UnblockUsersRequest UnblockUsersResponse UnmuteChannelRequest + UnmuteRequest + UnmuteResponse UnreadCountsChannel UnreadCountsChannelType UnreadCountsThread @@ -132,6 +143,7 @@ allowed_models=( UpsertPushPreferencesResponse UserGroupMember UserGroupResponse + UserMuteResponse UserResponse VoteData WrappedUnreadCountsResponse @@ -397,10 +409,15 @@ rename_generated ChannelMemberResponse MemberPayload rename_generated ChannelMute MutedChannelPayload rename_generated ChannelResponse ChannelDetailPayload rename_generated MuteChannelResponse MutedChannelPayloadResponse +rename_generated UnmuteResponse UnmuteUsersResponse +rename_generated UserMuteResponse MutedUserPayload +rename_generated DeliveryReceiptsResponse DeliveryReceiptsPrivacySettings +rename_generated PrivacySettingsResponse UserPrivacySettings +rename_generated ReadReceiptsResponse ReadReceiptsPrivacySettings +rename_generated TypingIndicatorsResponse TypingIndicatorPrivacySettings rename_generated_type MarkDeliveredResponse EmptyResponse rename_generated_type Response EmptyResponse -rename_generated_type UnmuteResponse EmptyResponse # Remove a generated property (declaration, doc comment, init param, assignment, # CodingKeys case). Runs before publicize, so there are no access modifiers to @@ -434,6 +451,17 @@ optionalize_property MemberPayload channelRole optionalize_property MemberPayload notificationsMuted optionalize_property MemberPayload shadowBanned +optionalize_property OwnUserResponse banned +optionalize_property OwnUserResponse channelMutes +optionalize_property OwnUserResponse devices +optionalize_property OwnUserResponse invisible +optionalize_property OwnUserResponse language +optionalize_property OwnUserResponse mutes +optionalize_property OwnUserResponse teams +optionalize_property OwnUserResponse totalUnreadCount +optionalize_property OwnUserResponse unreadChannels +optionalize_property OwnUserResponse unreadThreads + # Remove a generated property (declaration, doc comment, init param, assignment, # CodingKeys case). Runs before publicize, so there are no access modifiers to # handle. Assumes the single-line init the generator emits (step 7 re-wraps). @@ -474,6 +502,8 @@ remove_property DeleteChannelResponse duration remove_property MutedChannelPayloadResponse channelMutes remove_property MutedChannelPayloadResponse duration remove_property MutedChannelPayloadResponse ownUser +remove_property OwnUserResponse unreadCount +remove_property UnmuteUsersResponse duration retype_property ChannelDetailPayload cid String ChannelId retype_property ChannelDetailPayload config ChannelConfigWithInfo ChannelConfig @@ -492,7 +522,23 @@ remove_nested_enum() { remove_nested_enum PushPreferenceInput PushPreferenceInputCallLevel remove_nested_enum PushPreferenceInput PushPreferenceInputFeedsLevel -# 4c. Expose selected generated models as public API. The class and its stored +# Give a generated model mutable stored properties, so it can replace a hand-written +# public type whose properties were var. Mutable state rules out checked Sendable, +# hence the relaxed conformance. Runs before publicize_model, which anchors on the +# resulting var lines. +make_model_mutable() { + local file="$OUTPUT_DIR_CHAT/models/$1.swift" + sed -i '' -E \ + -e 's/^(final class [A-Za-z0-9_]+): Sendable,/\1: @unchecked Sendable,/' \ + -e 's/^ let / var /' \ + "$file" +} +make_model_mutable DeliveryReceiptsPrivacySettings +make_model_mutable ReadReceiptsPrivacySettings +make_model_mutable TypingIndicatorPrivacySettings +make_model_mutable UserPrivacySettings + +# 4c. Expose selected generated models as public API. The type and its stored # properties become public, along with the generated Hashable conformance # (== and hash(into:)); the memberwise init and CodingKeys stay internal. publicize_model() { @@ -500,32 +546,51 @@ publicize_model() { sed -i '' -E \ -e 's/^final class /public final class /' \ -e 's/^ let / public let /' \ + -e 's/^ var / public var /' \ -e 's/^ static func == / public static func == /' \ -e 's/^ func hash\(into / public func hash(into /' \ "$file" } publicize_model AppSettings publicize_model CurrentUserUnreads +publicize_model DeliveryReceiptsPrivacySettings publicize_model Device publicize_model PushPreference +publicize_model ReadReceiptsPrivacySettings publicize_model Role publicize_model SharedLocation +publicize_model TypingIndicatorPrivacySettings +publicize_model UnmuteUsersResponse publicize_model UnreadChannel publicize_model UnreadChannelByType publicize_model UnreadThread publicize_model UploadConfig publicize_model UserGroup publicize_model UserGroupMember +publicize_model UserPrivacySettings -# Drop `final` from a generated model so hand-written payloads can subclass it. -unfinalize_model() { +# Expose a generated model's memberwise init, for models whose hand-written public +# counterpart had a public init. +publicize_init() { local file="$OUTPUT_DIR_CHAT/models/$1.swift" - sed -i '' -E \ - -e 's/^final class /class /' \ - -e 's/^(class [A-Za-z0-9_]+): Sendable,/\1: @unchecked Sendable,/' \ - "$file" + sed -i '' -E 's/^ init\(/ public init(/' "$file" +} +publicize_init DeliveryReceiptsPrivacySettings +publicize_init ReadReceiptsPrivacySettings +publicize_init TypingIndicatorPrivacySettings + +# Give a generated memberwise init parameter a default value, restoring one the +# hand-written public init had. +default_init_parameter() { + local file="$OUTPUT_DIR_CHAT/models/$1.swift" + P="$2" D="$3" perl -0777 -pi -e ' + my ($p, $d) = ($ENV{P}, $ENV{D}); + s/([(,]\s*)\Q$p\E: ([^,)\n=]+)(?=[,)])/${1}$p: $2 = $d/; + ' "$file" } -unfinalize_model UserPayload +default_init_parameter DeliveryReceiptsPrivacySettings enabled true +default_init_parameter ReadReceiptsPrivacySettings enabled true +default_init_parameter TypingIndicatorPrivacySettings enabled true # 4d. Strip the generated Hashable conformance from every model not in # allowed_hashable_models. The Hashable extension is always the last block in @@ -609,7 +674,6 @@ inject_v1_endpoint_paths() { case banMember case flagUser(Bool) case flagMessage(Bool) - case muteUser(Bool) case callToken(String) case createCall(String) @@ -667,7 +731,6 @@ EOF case .banMember: return "moderation/ban" case let .flagUser(flag): return "moderation/\(flag ? "flag" : "unflag")" case let .flagMessage(flag): return "moderation/\(flag ? "flag" : "unflag")" - case let .muteUser(mute): return "moderation/\(mute ? "mute" : "unmute")" case let .callToken(callId): return "calls/\(callId)" case let .createCall(queryString): return "channels/\(queryString)/call" diff --git a/Sources/StreamChat/.openapi.sourcery.yml b/Sources/StreamChat/.openapi.sourcery.yml index ba2b6f1edd3..464975b5cf9 100644 --- a/Sources/StreamChat/.openapi.sourcery.yml +++ b/Sources/StreamChat/.openapi.sourcery.yml @@ -1,5 +1,6 @@ sources: - ./Generated/OpenAPI/models/ChannelDetailPayload.swift + - ./Generated/OpenAPI/models/OwnUserResponse.swift - ./Generated/OpenAPI/models/MemberPayload.swift - ./Generated/OpenAPI/models/MessageReactionPayload.swift - ./Generated/OpenAPI/models/UserPayload.swift @@ -11,4 +12,5 @@ args: # v1 endpoints return the user's custom data flattened next to a wider set of keys than v2 # declares, so those have to be excluded too when rebuilding the extra data. v1CodingKeys: + OwnUserResponse: UserPayloadsCodingKeys UserPayload: UserPayloadsCodingKeys diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index a0ff2693d3f..53648e71295 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -62,8 +62,8 @@ extension EndpointPath { .markThreadUnread, .message, .messageAction, + .mute, .muteChannel, - .muteUser, .pinnedMessages, .queryMembers, .queryPollVotes, @@ -83,6 +83,7 @@ extension EndpointPath { .translateMessage, .truncateChannel, .unblockUsers, + .unmute, .unmuteChannel, .unreadCounts, .updateChannel, diff --git a/Sources/StreamChat/APIClient/Endpoints/ModerationEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ModerationEndpoints.swift index 781c70991c0..c043c5aa4ca 100644 --- a/Sources/StreamChat/APIClient/Endpoints/ModerationEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/ModerationEndpoints.swift @@ -4,18 +4,6 @@ import Foundation -// MARK: - User muting - -extension Endpoint { - static func muteUser(_ userId: UserId) -> Endpoint { - muteUser(true, with: userId) - } - - static func unmuteUser(_ userId: UserId) -> Endpoint { - muteUser(false, with: userId) - } -} - // MARK: - User banning extension Endpoint { @@ -99,17 +87,3 @@ extension Endpoint { ) } } - -// MARK: - Private - -private extension Endpoint { - static func muteUser(_ mute: Bool, with userId: UserId) -> Endpoint { - .init( - path: .muteUser(mute), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: ["target_id": userId] - ) - } -} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift deleted file mode 100644 index aae3c0050d0..00000000000 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// An object describing the incoming current user JSON payload. -final class CurrentUserPayload: UserPayload, @unchecked Sendable { - /// A list of devices. - let devices: [Device] - /// Muted users. - let mutedUsers: [MutedUserPayload] - /// Muted channels. - let mutedChannels: [MutedChannelPayload] - /// Unread channel and message counts - let unreadCount: UnreadCountPayload? - /// The current privacy settings of the user. - let privacySettings: UserPrivacySettingsPayload? - /// Blocked user ids. - let blockedUserIds: Set - /// Whether the user is invisible to others. Only the own user carries it. - let isInvisible: Bool - /// Push preference for the user. - let pushPreference: PushPreference? - - init( - id: String, - name: String?, - imageURL: URL?, - role: UserRole, - teamsRole: [String: UserRole]?, - createdAt: Date, - updatedAt: Date, - deactivatedAt: Date?, - lastActiveAt: Date?, - isOnline: Bool, - isInvisible: Bool, - isBanned: Bool, - teams: [TeamId] = [], - language: String?, - extraData: [String: RawJSON], - devices: [Device] = [], - mutedUsers: [MutedUserPayload] = [], - mutedChannels: [MutedChannelPayload] = [], - unreadCount: UnreadCountPayload? = nil, - privacySettings: UserPrivacySettingsPayload? = nil, - blockedUserIds: Set = [], - pushPreference: PushPreference? - ) { - self.devices = devices - self.mutedUsers = mutedUsers - self.mutedChannels = mutedChannels - self.unreadCount = unreadCount - self.privacySettings = privacySettings - self.blockedUserIds = blockedUserIds - self.isInvisible = isInvisible - self.pushPreference = pushPreference - - super.init( - banned: isBanned, - createdAt: createdAt, - custom: extraData, - deactivatedAt: deactivatedAt, - id: id, - image: imageURL?.absoluteString, - language: language, - lastActive: lastActiveAt, - name: name, - online: isOnline, - role: role.rawValue, - teams: teams, - teamsRole: teamsRole?.mapValues(\.rawValue), - updatedAt: updatedAt - ) - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: UserPayloadsCodingKeys.self) - devices = try container.decodeIfPresent([Device].self, forKey: .devices) ?? [] - mutedUsers = try container.decodeIfPresent([MutedUserPayload].self, forKey: .mutedUsers) ?? [] - mutedChannels = try container.decodeIfPresent([MutedChannelPayload].self, forKey: .mutedChannels) ?? [] - unreadCount = try? UnreadCountPayload(from: decoder) - privacySettings = try container.decodeIfPresent(UserPrivacySettingsPayload.self, forKey: .privacySettings) - blockedUserIds = try container.decodeIfPresent(Set.self, forKey: .blockedUserIds) ?? [] - isInvisible = try container.decodeIfPresent(Bool.self, forKey: .isInvisible) ?? false - pushPreference = try container.decodeIfPresent(PushPreference.self, forKey: .pushPreference) - - try super.init(from: decoder) - } -} - -/// An object describing the incoming muted-user JSON payload. -struct MutedUserPayload: Decodable { - private enum CodingKeys: String, CodingKey { - case mutedUser = "target" - case created = "created_at" - case updated = "updated_at" - } - - let mutedUser: UserPayload - let created: Date - let updated: Date -} - -extension MutedUserPayload: Equatable { - static func == (lhs: MutedUserPayload, rhs: MutedUserPayload) -> Bool { - lhs.mutedUser.id == rhs.mutedUser.id && lhs.created == rhs.created - } -} - -/// A muted users response. -struct MutedUsersResponse: Decodable { - private enum CodingKeys: String, CodingKey { - case mutedUser = "mute" - case currentUser = "own_user" - } - - /// A muted user. - public let mutedUser: MutedUserPayload - /// The current user. - public let currentUser: CurrentUserPayload -} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagMessagePayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagMessagePayload.swift index 6a4e81c991c..62fbd49c92a 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagMessagePayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagMessagePayload.swift @@ -13,7 +13,7 @@ struct FlagMessagePayload: Decodable { } /// The payload of the current user who performed flag/unflag action. - let currentUser: CurrentUserPayload + let currentUser: OwnUserResponse /// The `id` of the message which was flagged or unflagged. let flaggedMessageId: MessageId @@ -23,13 +23,13 @@ struct FlagMessagePayload: Decodable { .nestedContainer(keyedBy: CodingKeys.self, forKey: .flag) self.init( - currentUser: try nestedContainer.decode(CurrentUserPayload.self, forKey: .currentUser), + currentUser: try nestedContainer.decode(OwnUserResponse.self, forKey: .currentUser), flaggedMessageId: try nestedContainer.decode(MessageId.self, forKey: .flaggedMessageId) ) } init( - currentUser: CurrentUserPayload, + currentUser: OwnUserResponse, flaggedMessageId: MessageId ) { self.currentUser = currentUser diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagUserPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagUserPayload.swift index b4268581854..0a3830eb960 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagUserPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/FlagUserPayload.swift @@ -13,7 +13,7 @@ struct FlagUserPayload: Decodable { } /// The payload of a current user who performed flag/unflag action. - let currentUser: CurrentUserPayload + let currentUser: OwnUserResponse /// The payload of a user who was flagged or unflagged. let flaggedUser: UserPayload @@ -23,13 +23,13 @@ struct FlagUserPayload: Decodable { .nestedContainer(keyedBy: CodingKeys.self, forKey: .flag) self.init( - currentUser: try nestedContainer.decode(CurrentUserPayload.self, forKey: .currentUser), + currentUser: try nestedContainer.decode(OwnUserResponse.self, forKey: .currentUser), flaggedUser: try nestedContainer.decode(UserPayload.self, forKey: .flaggedUser) ) } init( - currentUser: CurrentUserPayload, + currentUser: OwnUserResponse, flaggedUser: UserPayload ) { self.currentUser = currentUser diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/GuestUserTokenPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/GuestUserTokenPayload.swift index 4a880ed3c6e..5598846af7f 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/GuestUserTokenPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/GuestUserTokenPayload.swift @@ -10,12 +10,12 @@ struct GuestUserTokenPayload: Decodable { case token = "access_token" } - let user: CurrentUserPayload + let user: OwnUserResponse let token: Token init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let user = try container.decode(CurrentUserPayload.self, forKey: .user) + let user = try container.decode(OwnUserResponse.self, forKey: .user) let token = try container.decode(Token.self, forKey: .token) guard user.id == token.userId else { @@ -26,7 +26,7 @@ struct GuestUserTokenPayload: Decodable { } init( - user: CurrentUserPayload, + user: OwnUserResponse, token: Token ) { self.user = user diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/UserPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/UserPayloads.swift index 53fefed27cc..a20baed8f60 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/UserPayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/UserPayloads.swift @@ -62,7 +62,7 @@ final class UserRequestBody: Encodable, Sendable { /// An object describing the incoming user JSON payload. struct CurrentUserUpdateResponse: Decodable { - let user: CurrentUserPayload + let user: OwnUserResponse enum CodingKeys: String, CodingKey { case users @@ -70,7 +70,7 @@ struct CurrentUserUpdateResponse: Decodable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let users = try container.decode([String: CurrentUserPayload].self, forKey: .users) + let users = try container.decode([String: OwnUserResponse].self, forKey: .users) guard let user = users.first?.value else { throw DecodingError.dataCorrupted( .init(codingPath: [CodingKeys.users], debugDescription: "Missing updated user.") @@ -79,7 +79,7 @@ struct CurrentUserUpdateResponse: Decodable { self.user = user } - init(user: CurrentUserPayload) { + init(user: OwnUserResponse) { self.user = user } } @@ -88,7 +88,7 @@ struct CurrentUserUpdateResponse: Decodable { struct UserUpdateRequestBody: Encodable { let name: String? let imageURL: URL? - let privacySettings: UserPrivacySettingsPayload? + let privacySettings: UserPrivacySettings? let role: UserRole? let extraData: [String: RawJSON]? let teamsRole: [TeamId: UserRole]? @@ -96,7 +96,7 @@ struct UserUpdateRequestBody: Encodable { init( name: String?, imageURL: URL?, - privacySettings: UserPrivacySettingsPayload?, + privacySettings: UserPrivacySettings?, role: UserRole?, teamsRole: [TeamId: UserRole]?, extraData: [String: RawJSON]? diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index d73249ba44d..a38d8427f53 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -468,6 +468,44 @@ public extension CurrentChatUserController { } } + /// Mutes the users with the given identifiers. + /// + /// - Note: Messages from muted users are not delivered via push notifications. + /// + /// - Parameters: + /// - userIds: The identifiers of the users to mute. + /// - expirationInMinutes: The duration of the mute in minutes. When `nil`, the mute does not expire. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + /// + func muteUsers( + _ userIds: Set, + expiration expirationInMinutes: Int? = nil, + completion: @escaping @MainActor (Result) -> Void + ) { + currentUserUpdater.muteUsers(userIds, expiration: expirationInMinutes) { result in + self.callback { + completion(result) + } + } + } + + /// Unmutes the users with the given identifiers. + /// + /// - Parameters: + /// - userIds: The identifiers of the users to unmute. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + /// + func unmuteUsers( + _ userIds: Set, + completion: @escaping @MainActor (Result) -> Void + ) { + currentUserUpdater.unmuteUsers(userIds) { result in + self.callback { + completion(result) + } + } + } + /// Set global push preferences that apply to all channels for the current user. /// - Parameters: /// - level: The scope level of the push notifications. diff --git a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift index 9d2abc989e1..d9eb32d73fc 100644 --- a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift +++ b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift @@ -12,6 +12,7 @@ class CurrentUserDTO: NSManagedObject { @NSManaged var unreadChannelsCount: Int64 @NSManaged var unreadMessagesCount: Int64 @NSManaged var unreadThreadsCount: Int64 + @NSManaged var totalUnreadCountByTeam: [String: Int]? /// Contains the timestamp when last sync process was finished. /// The date later serves as reference date for the last event synced using `/sync` endpoint @@ -89,12 +90,14 @@ extension CurrentUserDTO { } extension NSManagedObjectContext: CurrentUserDatabaseSession { - func saveCurrentUser(payload: CurrentUserPayload) throws -> CurrentUserDTO { + func saveCurrentUser(payload: OwnUserResponse) throws -> CurrentUserDTO { invalidateCurrentUserCache() let dto = CurrentUserDTO.loadOrCreate(context: self) - dto.user = try saveUser(payload: payload) - dto.isInvisible = payload.isInvisible + let userDTO = UserDTO.loadOrCreate(id: payload.id, context: self, cache: nil) + userDTO.saveCommonUserFields(from: payload) + dto.user = userDTO + dto.isInvisible = payload.invisible ?? false // If not privacy setting is provided by the backend then we treat as enabled by default. // This is a bit different than the rest of the backend responses, but it was done like this @@ -104,26 +107,34 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { dto.isDeliveryReceiptsEnabled = payload.privacySettings?.deliveryReceipts?.enabled ?? true // Save push preference - if let pushPreference = payload.pushPreference { + if let pushPreference = payload.pushPreferences { dto.pushPreference = try savePushPreference(id: payload.id, payload: pushPreference) } - let mutedUsers = try payload.mutedUsers.map { try saveUser(payload: $0.mutedUser) } + let mutedUsers = try (payload.mutes ?? []).compactMap { $0.target }.map { try saveUser(payload: $0) } dto.mutedUsers = Set(mutedUsers) - dto.blockedUserIds = payload.blockedUserIds + dto.blockedUserIds = Set(payload.blockedUserIds ?? []) let channelMutes = Set( - try payload.mutedChannels.map { try saveChannelMute(payload: $0) } + try (payload.channelMutes ?? []).map { try saveChannelMute(payload: $0) } ) dto.channelMutes.subtracting(channelMutes).forEach { delete($0) } dto.channelMutes = channelMutes - if let unreadCount = payload.unreadCount { - try saveCurrentUserUnreadCount(count: unreadCount) + if let unreadChannels = payload.unreadChannels, let totalUnreadCount = payload.totalUnreadCount { + try saveCurrentUserUnreadCount( + count: UnreadCountPayload( + channels: unreadChannels, + messages: totalUnreadCount, + threads: payload.unreadThreads + ) + ) } - _ = try saveCurrentUserDevices(payload.devices, clearExisting: true) + dto.totalUnreadCountByTeam = payload.totalUnreadCountByTeam + + _ = try saveCurrentUserDevices(payload.devices ?? [], clearExisting: true) return dto } @@ -146,6 +157,18 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { } } + func saveCurrentUserMutedUsers(_ mutedUsers: [MutedUserPayload]) throws -> [UserDTO] { + invalidateCurrentUserCache() + + guard let dto = currentUser else { + throw ClientError.CurrentUserDoesNotExist() + } + + let userDTOs = try mutedUsers.compactMap { $0.target }.map { try saveUser(payload: $0) } + dto.mutedUsers.formUnion(userDTOs) + return userDTOs + } + /// Merges per-group unread channel counts into `CurrentUserDTO.unreadChannelCountsByGroup`. /// Called from `queryGroupedChannels` responses and from WS events carrying /// `grouped_unread_channels`; both paths use merge semantics, so keys absent from the input @@ -328,6 +351,7 @@ extension CurrentChatUser { threads: Int(dto.unreadThreadsCount) ), unreadChannelCountsByGroup: dto.unreadChannelCountsByGroup, + totalUnreadCountByTeam: dto.totalUnreadCountByTeam, mutedChannels: mutedChannels, privacySettings: .init( typingIndicators: .init(enabled: dto.isTypingIndicatorsEnabled), diff --git a/Sources/StreamChat/Database/DTOs/UserDTO.swift b/Sources/StreamChat/Database/DTOs/UserDTO.swift index 34883ef5fe5..75f2b6af50f 100644 --- a/Sources/StreamChat/Database/DTOs/UserDTO.swift +++ b/Sources/StreamChat/Database/DTOs/UserDTO.swift @@ -145,33 +145,7 @@ extension NSManagedObjectContext: UserDatabaseSession { cache: PreWarmedCache? ) throws -> UserDTO { let dto = UserDTO.loadOrCreate(id: payload.id, context: self, cache: cache) - - dto.name = payload.name - dto.imageURL = payload.imageURL - dto.isBanned = payload.isBanned - dto.isOnline = payload.isOnline - dto.lastActivityAt = payload.lastActiveAt?.bridgeDate - dto.userCreatedAt = payload.createdAt.bridgeDate - dto.userRoleRaw = payload.role - dto.userUpdatedAt = payload.updatedAt.bridgeDate - dto.userDeactivatedAt = payload.deactivatedAt?.bridgeDate - dto.language = payload.language.flatMap { $0.isEmpty ? nil : $0 } - dto.teamsRole = payload.teamsRole - if let avgResponseTime = payload.avgResponseTime { - dto.avgResponseTime = .init(integerLiteral: avgResponseTime) - } - - do { - dto.extraData = try JSONEncoder.default.encode(payload.extraData) - } catch { - log.error( - "Failed to decode extra payload for User with id: <\(payload.id)>, using default value instead. " - + "Error: \(error)" - ) - dto.extraData = Data() - } - - dto.teams = payload.teams ?? [] + dto.saveCommonUserFields(from: payload) // payloadHash doesn't cover the query if let query = query, let queryDTO = try saveQuery(query: query) { @@ -190,6 +164,36 @@ extension NSManagedObjectContext: UserDatabaseSession { } extension UserDTO { + /// Assigns the fields shared by `UserPayload` and `OwnUserResponse`. + func saveCommonUserFields(from payload: some UserPayloadFields) { + name = payload.name + imageURL = payload.imageURL + isBanned = payload.isBanned + isOnline = payload.isOnline + lastActivityAt = payload.lastActiveAt?.bridgeDate + userCreatedAt = payload.createdAt.bridgeDate + userRoleRaw = payload.role + userUpdatedAt = payload.updatedAt.bridgeDate + userDeactivatedAt = payload.deactivatedAt?.bridgeDate + language = payload.language.flatMap { $0.isEmpty ? nil : $0 } + teamsRole = payload.teamsRole + if let avgResponseTime = payload.avgResponseTime { + self.avgResponseTime = .init(integerLiteral: avgResponseTime) + } + + do { + extraData = try JSONEncoder.default.encode(payload.extraData) + } catch { + log.error( + "Failed to decode extra payload for User with id: <\(payload.id)>, using default value instead. " + + "Error: \(error)" + ) + extraData = Data() + } + + teams = payload.teams ?? [] + } + /// Snapshots the current state of `UserDTO` and returns an immutable model object from it. func asModel() throws -> ChatUser { try .create(fromDTO: self) } diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index be27c85578e..ed2425ded38 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -52,7 +52,7 @@ protocol CurrentUserDatabaseSession { /// Saves the provided payload to the DB. Return's a `CurrentUserDTO` if the save was successful. Throws an error /// if the save fails. @discardableResult - func saveCurrentUser(payload: CurrentUserPayload) throws -> CurrentUserDTO + func saveCurrentUser(payload: OwnUserResponse) throws -> CurrentUserDTO /// Updates the `CurrentUserDTO` with the provided unread. /// If there is no current user, the error will be thrown. @@ -65,6 +65,11 @@ protocol CurrentUserDatabaseSession { /// Adjusts `CurrentUserDTO.unreadChannelCountsByGroup[groupKey]` by `delta`, flooring at 0. func adjustUnreadChannelCount(forGroup groupKey: String, by delta: Int) + /// Adds the provided muted users to `CurrentUserDTO.mutedUsers`. + /// If there's no current user set, an error will be thrown. + @discardableResult + func saveCurrentUserMutedUsers(_ mutedUsers: [MutedUserPayload]) throws -> [UserDTO] + /// Updates the `CurrentUserDTO.devices` with the provided devices. /// If there's no current user set, an error will be thrown. @discardableResult diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index e6086a9831f..0be0b61a78b 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -180,6 +180,7 @@ + diff --git a/Sources/StreamChat/Generated/OpenAPI/APIs/DefaultEndpoints.swift b/Sources/StreamChat/Generated/OpenAPI/APIs/DefaultEndpoints.swift index dfee0996895..0dad6d01b83 100644 --- a/Sources/StreamChat/Generated/OpenAPI/APIs/DefaultEndpoints.swift +++ b/Sources/StreamChat/Generated/OpenAPI/APIs/DefaultEndpoints.swift @@ -59,7 +59,6 @@ enum EndpointPath: Codable { case banMember case flagUser(Bool) case flagMessage(Bool) - case muteUser(Bool) case callToken(String) case createCall(String) @@ -105,6 +104,7 @@ enum EndpointPath: Codable { case listDevices case listUserGroups case markDelivered + case mute case muteChannel case queryMembers case queryPollVotes(pollId: String) @@ -117,6 +117,7 @@ enum EndpointPath: Codable { id: String ) case unblockUsers + case unmute case unmuteChannel case unreadCounts case updateLiveLocation @@ -196,7 +197,6 @@ enum EndpointPath: Codable { case .banMember: return "moderation/ban" case let .flagUser(flag): return "moderation/\(flag ? "flag" : "unflag")" case let .flagMessage(flag): return "moderation/\(flag ? "flag" : "unflag")" - case let .muteUser(mute): return "moderation/\(mute ? "mute" : "unmute")" case let .callToken(callId): return "calls/\(callId)" case let .createCall(queryString): return "channels/\(queryString)/call" @@ -266,6 +266,8 @@ enum EndpointPath: Codable { return "/api/v2/usergroups" case .markDelivered: return "/api/v2/chat/channels/delivered" + case .mute: + return "/api/v2/moderation/mute" case .muteChannel: return "/api/v2/chat/moderation/mute/channel" case .queryMembers: @@ -287,6 +289,8 @@ enum EndpointPath: Codable { return "/api/v2/chat/channels/\(APIHelper.escapedPathItem(type))/\(APIHelper.escapedPathItem(id))/stop-watching" case .unblockUsers: return "/api/v2/users/unblock" + case .unmute: + return "/api/v2/moderation/unmute" case .unmuteChannel: return "/api/v2/chat/moderation/unmute/channel" case .unreadCounts: @@ -808,6 +812,19 @@ extension Endpoint { ) } + static func mute( + muteRequest: MuteRequest, + requiresConnectionId: Bool = false + ) -> Endpoint { + return .init( + path: .mute, + method: .post, + queryItems: nil, + requiresConnectionId: requiresConnectionId, + body: muteRequest + ) + } + static func muteChannel( muteChannelRequest: MuteChannelRequest, requiresConnectionId: Bool = false @@ -957,10 +974,23 @@ extension Endpoint { ) } + static func unmute( + unmuteRequest: UnmuteRequest, + requiresConnectionId: Bool = false + ) -> Endpoint { + return .init( + path: .unmute, + method: .post, + queryItems: nil, + requiresConnectionId: requiresConnectionId, + body: unmuteRequest + ) + } + static func unmuteChannel( unmuteChannelRequest: UnmuteChannelRequest, requiresConnectionId: Bool = false - ) -> Endpoint { + ) -> Endpoint { return .init( path: .unmuteChannel, method: .post, diff --git a/Sources/StreamChat/Generated/OpenAPI/models/BlockedUserResponse.swift b/Sources/StreamChat/Generated/OpenAPI/models/BlockedUserResponse.swift index fe6d2f98c3e..faa5d368485 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/BlockedUserResponse.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/BlockedUserResponse.swift @@ -5,10 +5,12 @@ import Foundation final class BlockedUserResponse: Sendable, Codable, JSONEncodable { + /// User response object let blockedUser: UserPayload /// ID of the user who got blocked let blockedUserId: String let createdAt: Date + /// User response object let user: UserPayload /// ID of the user who blocked another user let userId: String diff --git a/Sources/StreamChat/Generated/OpenAPI/models/ChannelDetailPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/ChannelDetailPayload.swift index 2af391e999f..20d74ad3eab 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/ChannelDetailPayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/ChannelDetailPayload.swift @@ -18,6 +18,7 @@ final class ChannelDetailPayload: Sendable, Codable, JSONEncodable { let cooldown: Int? /// Date/time of creation let createdAt: Date + /// User response object let createdBy: UserPayload? /// Custom data for this object let custom: [String: RawJSON] @@ -52,6 +53,7 @@ final class ChannelDetailPayload: Sendable, Codable, JSONEncodable { let team: String? /// Date of the latest truncation of the channel let truncatedAt: Date? + /// User response object let truncatedBy: UserPayload? /// Type of the channel let type: String diff --git a/Sources/StreamChat/Generated/OpenAPI/models/ChannelMemberRequest.swift b/Sources/StreamChat/Generated/OpenAPI/models/ChannelMemberRequest.swift index 393cb1b6215..2217d49302a 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/ChannelMemberRequest.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/ChannelMemberRequest.swift @@ -8,6 +8,7 @@ final class ChannelMemberRequest: Sendable, Codable, JSONEncodable { /// Role of the member in the channel let channelRole: String? let custom: [String: RawJSON]? + /// User response object let user: UserPayload? let userId: String diff --git a/Sources/StreamChat/Generated/OpenAPI/models/DeliveryReceiptsPrivacySettings.swift b/Sources/StreamChat/Generated/OpenAPI/models/DeliveryReceiptsPrivacySettings.swift new file mode 100644 index 00000000000..82411301a45 --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/DeliveryReceiptsPrivacySettings.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public final class DeliveryReceiptsPrivacySettings: @unchecked Sendable, Codable, JSONEncodable { + public var enabled: Bool + + public init(enabled: Bool = true) { + self.enabled = enabled + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case enabled + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/MemberPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/MemberPayload.swift index 61b17a520eb..4fbc1877ff6 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/MemberPayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/MemberPayload.swift @@ -38,6 +38,7 @@ final class MemberPayload: Sendable, Codable, JSONEncodable { let status: String? /// Date/time of the last update let updatedAt: Date + /// User response object let user: UserPayload? let userId: String? diff --git a/Sources/StreamChat/Generated/OpenAPI/models/MessageReactionPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/MessageReactionPayload.swift index d76141fe19b..a833ea40846 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/MessageReactionPayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/MessageReactionPayload.swift @@ -17,6 +17,7 @@ final class MessageReactionPayload: Sendable, Codable, JSONEncodable { let type: MessageReactionType /// Date/time of the last update let updatedAt: Date + /// User response object let user: UserPayload /// User ID let userId: String diff --git a/Sources/StreamChat/Generated/OpenAPI/models/MuteRequest.swift b/Sources/StreamChat/Generated/OpenAPI/models/MuteRequest.swift new file mode 100644 index 00000000000..04a15e465f8 --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/MuteRequest.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +final class MuteRequest: Sendable, Codable, JSONEncodable { + /// User IDs to mute (if multiple users) + let targetIds: [String] + /// Duration of mute in minutes + let timeout: Int? + + init( + targetIds: [String], + timeout: Int? = nil + ) { + self.targetIds = targetIds + self.timeout = timeout + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case targetIds = "target_ids" + case timeout + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/MuteResponse.swift b/Sources/StreamChat/Generated/OpenAPI/models/MuteResponse.swift new file mode 100644 index 00000000000..d45c800de92 --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/MuteResponse.swift @@ -0,0 +1,33 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +final class MuteResponse: Sendable, Codable, JSONEncodable { + let duration: String + /// Object with mutes (if multiple users were muted) + let mutes: [MutedUserPayload]? + /// A list of users that can't be found. Common cause for this is deleted users + let nonExistingUsers: [String]? + let ownUser: OwnUserResponse? + + init( + duration: String, + mutes: [MutedUserPayload]? = nil, + nonExistingUsers: [String]? = nil, + ownUser: OwnUserResponse? = nil + ) { + self.duration = duration + self.mutes = mutes + self.nonExistingUsers = nonExistingUsers + self.ownUser = ownUser + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case duration + case mutes + case nonExistingUsers = "non_existing_users" + case ownUser = "own_user" + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/MutedChannelPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/MutedChannelPayload.swift index 93732db4688..d1b3847e989 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/MutedChannelPayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/MutedChannelPayload.swift @@ -5,6 +5,7 @@ import Foundation final class MutedChannelPayload: Sendable, Codable, JSONEncodable { + /// Represents channel in chat let channel: ChannelDetailPayload? /// Date/time of creation let createdAt: Date @@ -12,6 +13,7 @@ final class MutedChannelPayload: Sendable, Codable, JSONEncodable { let expires: Date? /// Date/time of the last update let updatedAt: Date + /// User response object let user: UserPayload? init( diff --git a/Sources/StreamChat/Generated/OpenAPI/models/MutedUserPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/MutedUserPayload.swift new file mode 100644 index 00000000000..571d5a663e7 --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/MutedUserPayload.swift @@ -0,0 +1,37 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +final class MutedUserPayload: Sendable, Codable, JSONEncodable { + let createdAt: Date + let expires: Date? + /// User response object + let target: UserPayload? + let updatedAt: Date + /// User response object + let user: UserPayload? + + init( + createdAt: Date, + expires: Date? = nil, + target: UserPayload? = nil, + updatedAt: Date, + user: UserPayload? = nil + ) { + self.createdAt = createdAt + self.expires = expires + self.target = target + self.updatedAt = updatedAt + self.user = user + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case createdAt = "created_at" + case expires + case target + case updatedAt = "updated_at" + case user + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/OwnUserResponse.swift b/Sources/StreamChat/Generated/OpenAPI/models/OwnUserResponse.swift new file mode 100644 index 00000000000..133e3428caa --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/OwnUserResponse.swift @@ -0,0 +1,175 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +final class OwnUserResponse: Sendable, Codable, JSONEncodable { + let avgResponseTime: Int? + let banned: Bool? + let blockedUserIds: [String]? + let channelMutes: [MutedChannelPayload]? + let createdAt: Date + let custom: [String: RawJSON] + let deactivatedAt: Date? + let deletedAt: Date? + let devices: [Device]? + let id: String + let image: String? + let invisible: Bool? + let language: String? + let lastActive: Date? + let latestHiddenChannels: [String]? + let mutes: [MutedUserPayload]? + let name: String? + let online: Bool + let privacySettings: UserPrivacySettings? + let pushPreferences: PushPreference? + let revokeTokensIssuedBefore: Date? + let role: String + let teams: [String]? + let teamsRole: [String: String]? + let totalUnreadCount: Int? + let totalUnreadCountByTeam: [String: Int]? + let unreadChannels: Int? + let unreadThreads: Int? + let updatedAt: Date + + init( + avgResponseTime: Int? = nil, + banned: Bool? = nil, + blockedUserIds: [String]? = nil, + channelMutes: [MutedChannelPayload]? = nil, + createdAt: Date, + custom: [String: RawJSON], + deactivatedAt: Date? = nil, + deletedAt: Date? = nil, + devices: [Device]? = nil, + id: String, + image: String? = nil, + invisible: Bool? = nil, + language: String? = nil, + lastActive: Date? = nil, + latestHiddenChannels: [String]? = nil, + mutes: [MutedUserPayload]? = nil, + name: String? = nil, + online: Bool, + privacySettings: UserPrivacySettings? = nil, + pushPreferences: PushPreference? = nil, + revokeTokensIssuedBefore: Date? = nil, + role: String, + teams: [String]? = nil, + teamsRole: [String: String]? = nil, + totalUnreadCount: Int? = nil, + totalUnreadCountByTeam: [String: Int]? = nil, + unreadChannels: Int? = nil, + unreadThreads: Int? = nil, + updatedAt: Date + ) { + self.avgResponseTime = avgResponseTime + self.banned = banned + self.blockedUserIds = blockedUserIds + self.channelMutes = channelMutes + self.createdAt = createdAt + self.custom = custom + self.deactivatedAt = deactivatedAt + self.deletedAt = deletedAt + self.devices = devices + self.id = id + self.image = image + self.invisible = invisible + self.language = language + self.lastActive = lastActive + self.latestHiddenChannels = latestHiddenChannels + self.mutes = mutes + self.name = name + self.online = online + self.privacySettings = privacySettings + self.pushPreferences = pushPreferences + self.revokeTokensIssuedBefore = revokeTokensIssuedBefore + self.role = role + self.teams = teams + self.teamsRole = teamsRole + self.totalUnreadCount = totalUnreadCount + self.totalUnreadCountByTeam = totalUnreadCountByTeam + self.unreadChannels = unreadChannels + self.unreadThreads = unreadThreads + self.updatedAt = updatedAt + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case avgResponseTime = "avg_response_time" + case banned + case blockedUserIds = "blocked_user_ids" + case channelMutes = "channel_mutes" + case createdAt = "created_at" + case custom + case deactivatedAt = "deactivated_at" + case deletedAt = "deleted_at" + case devices + case id + case image + case invisible + case language + case lastActive = "last_active" + case latestHiddenChannels = "latest_hidden_channels" + case mutes + case name + case online + case privacySettings = "privacy_settings" + case pushPreferences = "push_preferences" + case revokeTokensIssuedBefore = "revoke_tokens_issued_before" + case role + case teams + case teamsRole = "teams_role" + case totalUnreadCount = "total_unread_count" + case totalUnreadCountByTeam = "total_unread_count_by_team" + case unreadChannels = "unread_channels" + case unreadThreads = "unread_threads" + case updatedAt = "updated_at" + } + + class var customExcludedKeys: Set { + Set(CodingKeys.allCases.map(\.rawValue)) + .union(UserPayloadsCodingKeys.allCases.map(\.rawValue)) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + avgResponseTime = try container.decodeIfPresent(Int.self, forKey: .avgResponseTime) + banned = try container.decodeIfPresent(Bool.self, forKey: .banned) + blockedUserIds = try container.decodeIfPresent([String].self, forKey: .blockedUserIds) + channelMutes = try container.decodeIfPresent([MutedChannelPayload].self, forKey: .channelMutes) + createdAt = try container.decode(Date.self, forKey: .createdAt) + if let decoded = try container.decodeIfPresent([String: RawJSON].self, forKey: .custom) { + custom = decoded + } else { + var flattened = try [String: RawJSON](from: decoder) + flattened.removeValues(forKeys: Array(Self.customExcludedKeys)) + custom = flattened + } + deactivatedAt = try container.decodeIfPresent(Date.self, forKey: .deactivatedAt) + deletedAt = try container.decodeIfPresent(Date.self, forKey: .deletedAt) + devices = try container.decodeIfPresent([Device].self, forKey: .devices) + id = try container.decode(String.self, forKey: .id) + image = try container.decodeIfPresent(String.self, forKey: .image) + invisible = try container.decodeIfPresent(Bool.self, forKey: .invisible) + language = try container.decodeIfPresent(String.self, forKey: .language) + lastActive = try container.decodeIfPresent(Date.self, forKey: .lastActive) + latestHiddenChannels = try container.decodeIfPresent([String].self, forKey: .latestHiddenChannels) + mutes = try container.decodeIfPresent([MutedUserPayload].self, forKey: .mutes) + name = try container.decodeIfPresent(String.self, forKey: .name) + online = try container.decode(Bool.self, forKey: .online) + privacySettings = try container.decodeIfPresent(UserPrivacySettings.self, forKey: .privacySettings) + pushPreferences = try container.decodeIfPresent(PushPreference.self, forKey: .pushPreferences) + revokeTokensIssuedBefore = try container.decodeIfPresent(Date.self, forKey: .revokeTokensIssuedBefore) + role = try container.decode(String.self, forKey: .role) + teams = try container.decodeIfPresent([String].self, forKey: .teams) + teamsRole = try container.decodeIfPresent([String: String].self, forKey: .teamsRole) + totalUnreadCount = try container.decodeIfPresent(Int.self, forKey: .totalUnreadCount) + totalUnreadCountByTeam = try container.decodeIfPresent([String: Int].self, forKey: .totalUnreadCountByTeam) + unreadChannels = try container.decodeIfPresent(Int.self, forKey: .unreadChannels) + unreadThreads = try container.decodeIfPresent(Int.self, forKey: .unreadThreads) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/PollPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/PollPayload.swift index 728658ebe73..b154e40292b 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/PollPayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/PollPayload.swift @@ -9,6 +9,7 @@ final class PollPayload: Sendable, Codable, JSONEncodable { let allowUserSuggestedOptions: Bool let answersCount: Int let createdAt: Date + /// User response object let createdBy: UserPayload? let createdById: String let custom: [String: RawJSON]? diff --git a/Sources/StreamChat/Generated/OpenAPI/models/PollVotePayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/PollVotePayload.swift index 890d2030a74..c8d3298a88c 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/PollVotePayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/PollVotePayload.swift @@ -12,6 +12,7 @@ final class PollVotePayload: Sendable, Codable, JSONEncodable { let optionId: String? let pollId: String let updatedAt: Date + /// User response object let user: UserPayload? let userId: String? diff --git a/Sources/StreamChat/Generated/OpenAPI/models/ReadReceiptsPrivacySettings.swift b/Sources/StreamChat/Generated/OpenAPI/models/ReadReceiptsPrivacySettings.swift new file mode 100644 index 00000000000..a012491329e --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/ReadReceiptsPrivacySettings.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public final class ReadReceiptsPrivacySettings: @unchecked Sendable, Codable, JSONEncodable { + public var enabled: Bool + + public init(enabled: Bool = true) { + self.enabled = enabled + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case enabled + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/TypingIndicatorPrivacySettings.swift b/Sources/StreamChat/Generated/OpenAPI/models/TypingIndicatorPrivacySettings.swift new file mode 100644 index 00000000000..f7af8c21f2d --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/TypingIndicatorPrivacySettings.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public final class TypingIndicatorPrivacySettings: @unchecked Sendable, Codable, JSONEncodable { + public var enabled: Bool + + public init(enabled: Bool = true) { + self.enabled = enabled + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case enabled + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/UnmuteRequest.swift b/Sources/StreamChat/Generated/OpenAPI/models/UnmuteRequest.swift new file mode 100644 index 00000000000..8661f829009 --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/UnmuteRequest.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +final class UnmuteRequest: Sendable, Codable, JSONEncodable { + /// User IDs to unmute + let targetIds: [String] + + init(targetIds: [String]) { + self.targetIds = targetIds + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case targetIds = "target_ids" + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/UnmuteUsersResponse.swift b/Sources/StreamChat/Generated/OpenAPI/models/UnmuteUsersResponse.swift new file mode 100644 index 00000000000..b3ab21bedf4 --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/UnmuteUsersResponse.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public final class UnmuteUsersResponse: Sendable, Codable, JSONEncodable { + /// A list of users that can't be found. Common cause for this is deleted users + public let nonExistingUsers: [String]? + + init(nonExistingUsers: [String]? = nil) { + self.nonExistingUsers = nonExistingUsers + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case nonExistingUsers = "non_existing_users" + } +} diff --git a/Sources/StreamChat/Generated/OpenAPI/models/UpdateLiveLocationRequest.swift b/Sources/StreamChat/Generated/OpenAPI/models/UpdateLiveLocationRequest.swift index 48a1ee53bf9..0a34d346bd4 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/UpdateLiveLocationRequest.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/UpdateLiveLocationRequest.swift @@ -8,16 +8,16 @@ final class UpdateLiveLocationRequest: Sendable, Codable, JSONEncodable { /// Time when the live location expires let endAt: Date? /// Latitude coordinate - let latitude: Float? + let latitude: Double? /// Longitude coordinate - let longitude: Float? + let longitude: Double? /// Live location ID let messageId: String init( endAt: Date? = nil, - latitude: Float? = nil, - longitude: Float? = nil, + latitude: Double? = nil, + longitude: Double? = nil, messageId: String ) { self.endAt = endAt diff --git a/Sources/StreamChat/Generated/OpenAPI/models/UserPayload.swift b/Sources/StreamChat/Generated/OpenAPI/models/UserPayload.swift index f60edcb8876..9b952e235b4 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/UserPayload.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/UserPayload.swift @@ -4,7 +4,7 @@ import Foundation -class UserPayload: @unchecked Sendable, Codable, JSONEncodable { +final class UserPayload: Sendable, Codable, JSONEncodable { let avgResponseTime: Int? /// Whether a user is banned or not let banned: Bool? @@ -100,7 +100,7 @@ class UserPayload: @unchecked Sendable, Codable, JSONEncodable { .union(UserPayloadsCodingKeys.allCases.map(\.rawValue)) } - required init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) avgResponseTime = try container.decodeIfPresent(Int.self, forKey: .avgResponseTime) banned = try container.decodeIfPresent(Bool.self, forKey: .banned) diff --git a/Sources/StreamChat/Generated/OpenAPI/models/UserPrivacySettings.swift b/Sources/StreamChat/Generated/OpenAPI/models/UserPrivacySettings.swift new file mode 100644 index 00000000000..8f8f076f44a --- /dev/null +++ b/Sources/StreamChat/Generated/OpenAPI/models/UserPrivacySettings.swift @@ -0,0 +1,27 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public final class UserPrivacySettings: @unchecked Sendable, Codable, JSONEncodable { + public var deliveryReceipts: DeliveryReceiptsPrivacySettings? + public var readReceipts: ReadReceiptsPrivacySettings? + public var typingIndicators: TypingIndicatorPrivacySettings? + + init( + deliveryReceipts: DeliveryReceiptsPrivacySettings? = nil, + readReceipts: ReadReceiptsPrivacySettings? = nil, + typingIndicators: TypingIndicatorPrivacySettings? = nil + ) { + self.deliveryReceipts = deliveryReceipts + self.readReceipts = readReceipts + self.typingIndicators = typingIndicators + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case deliveryReceipts = "delivery_receipts" + case readReceipts = "read_receipts" + case typingIndicators = "typing_indicators" + } +} diff --git a/Sources/StreamChat/Models/CurrentUser.swift b/Sources/StreamChat/Models/CurrentUser.swift index 7e327e12356..7a1be758d98 100644 --- a/Sources/StreamChat/Models/CurrentUser.swift +++ b/Sources/StreamChat/Models/CurrentUser.swift @@ -58,6 +58,9 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable { /// Unread channel counts keyed by the backend-provided group identifier. public let unreadChannelCountsByGroup: [String: Int]? + /// Total unread message counts keyed by team id. + public let totalUnreadCountByTeam: [TeamId: Int]? + /// A Boolean value indicating if the user has opted to hide their online status. public let isInvisible: Bool @@ -91,6 +94,7 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable { flaggedMessageIDs: Set, unreadCount: UnreadCount, unreadChannelCountsByGroup: [String: Int]? = nil, + totalUnreadCountByTeam: [TeamId: Int]? = nil, mutedChannels: Set, privacySettings: UserPrivacySettings, avgResponseTime: Int?, @@ -104,6 +108,7 @@ public class CurrentChatUser: ChatUser, @unchecked Sendable { self.flaggedMessageIDs = flaggedMessageIDs self.unreadCount = unreadCount self.unreadChannelCountsByGroup = unreadChannelCountsByGroup + self.totalUnreadCountByTeam = totalUnreadCountByTeam self.isInvisible = isInvisible self.privacySettings = privacySettings self.mutedChannels = mutedChannels diff --git a/Sources/StreamChat/Models/MuteUsersResponse.swift b/Sources/StreamChat/Models/MuteUsersResponse.swift new file mode 100644 index 00000000000..819da4e351f --- /dev/null +++ b/Sources/StreamChat/Models/MuteUsersResponse.swift @@ -0,0 +1,49 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A type representing the result of muting users. +public struct MuteUsersResponse: Sendable { + /// The mutes which were created. + public let mutes: [MutedUserDetails]? + + /// The ids of the users which could not be found. + public let nonExistingUsers: [String]? + + init( + mutes: [MutedUserDetails]?, + nonExistingUsers: [String]? + ) { + self.mutes = mutes + self.nonExistingUsers = nonExistingUsers + } +} + +/// A type representing a muted user. +public struct MutedUserDetails: Sendable { + /// The date the mute was created. + public let createdAt: Date + + /// The date the mute expires, if it has an expiration. + public let expires: Date? + + /// The muted user. + public let user: ChatUser? + + /// The date the mute was last updated. + public let updatedAt: Date + + init( + createdAt: Date, + expires: Date?, + user: ChatUser?, + updatedAt: Date + ) { + self.createdAt = createdAt + self.expires = expires + self.user = user + self.updatedAt = updatedAt + } +} diff --git a/Sources/StreamChat/Models/Payload+asModel/MuteResponse+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MuteResponse+asModel.swift new file mode 100644 index 00000000000..dd63c49dd87 --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/MuteResponse+asModel.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension MuteResponse { + /// Converts the MuteResponse to a MuteUsersResponse model. + /// - Returns: A MuteUsersResponse instance. + func asModel() -> MuteUsersResponse { + MuteUsersResponse( + mutes: mutes?.map { $0.asModel() }, + nonExistingUsers: nonExistingUsers + ) + } +} + +extension MutedUserPayload { + /// Converts the MutedUserPayload to a MutedUserDetails model. + /// - Returns: A MutedUserDetails instance. + func asModel() -> MutedUserDetails { + MutedUserDetails( + createdAt: createdAt, + expires: expires, + user: target?.asModel(), + updatedAt: updatedAt + ) + } +} diff --git a/Sources/StreamChat/Models/UserInfo.swift b/Sources/StreamChat/Models/UserInfo.swift index d0f75867a50..598f6033104 100644 --- a/Sources/StreamChat/Models/UserInfo.swift +++ b/Sources/StreamChat/Models/UserInfo.swift @@ -43,50 +43,3 @@ public struct UserInfo: Identifiable, Sendable { self.extraData = extraData } } - -/// The privacy settings of the user. -public struct UserPrivacySettings: Sendable { - /// The settings for typing indicator events. - public var typingIndicators: TypingIndicatorPrivacySettings? - /// The settings for the read receipt events. - public var readReceipts: ReadReceiptsPrivacySettings? - /// The settings for the delivery receipt events. - public var deliveryReceipts: DeliveryReceiptsPrivacySettings? - - public init( - typingIndicators: TypingIndicatorPrivacySettings? = nil, - readReceipts: ReadReceiptsPrivacySettings? = nil, - deliveryReceipts: DeliveryReceiptsPrivacySettings? = nil - ) { - self.typingIndicators = typingIndicators - self.readReceipts = readReceipts - self.deliveryReceipts = deliveryReceipts - } -} - -/// The settings for typing indicator events. -public struct TypingIndicatorPrivacySettings: Sendable { - public var enabled: Bool - - public init(enabled: Bool = true) { - self.enabled = enabled - } -} - -/// The settings for the read receipt events. -public struct ReadReceiptsPrivacySettings: Sendable { - public var enabled: Bool - - public init(enabled: Bool = true) { - self.enabled = enabled - } -} - -/// The settings for the delivery receipt events. -public struct DeliveryReceiptsPrivacySettings: Sendable { - public var enabled: Bool - - public init(enabled: Bool = true) { - self.enabled = enabled - } -} diff --git a/Sources/StreamChat/Models/UserPayload+Extensions.swift b/Sources/StreamChat/Models/UserPayload+Extensions.swift index 2aee6049fd8..bed82f8a6e4 100644 --- a/Sources/StreamChat/Models/UserPayload+Extensions.swift +++ b/Sources/StreamChat/Models/UserPayload+Extensions.swift @@ -4,10 +4,36 @@ import Foundation +// The fields shared by the generated UserPayload and OwnUserResponse, which do +// not have a common base class. +protocol UserPayloadFields: Sendable { + var avgResponseTime: Int? { get } + var banned: Bool? { get } + var createdAt: Date { get } + var custom: [String: RawJSON] { get } + var deactivatedAt: Date? { get } + var deletedAt: Date? { get } + var id: String { get } + var image: String? { get } + var language: String? { get } + var lastActive: Date? { get } + var name: String? { get } + var online: Bool { get } + var revokeTokensIssuedBefore: Date? { get } + var role: String { get } + var teams: [String]? { get } + var teamsRole: [String: String]? { get } + var updatedAt: Date { get } +} + +extension OwnUserResponse: UserPayloadFields {} + +extension UserPayload: UserPayloadFields {} + // Extra glue for reducing the git diff: the generated model's properties are // slightly different from the previously hand-written UserPayload and the // convenience init is widely used. Keep for now and clean up gradually. -extension UserPayload { +extension UserPayloadFields { var extraData: [String: RawJSON] { custom } var imageURL: URL? { image.flatMap(URL.init(string:)) } @@ -17,7 +43,9 @@ extension UserPayload { var isOnline: Bool { online } var lastActiveAt: Date? { lastActive } +} +extension UserPayload { convenience init( id: String, name: String?, diff --git a/Sources/StreamChat/Models/UserPrivacySettings+Extensions.swift b/Sources/StreamChat/Models/UserPrivacySettings+Extensions.swift new file mode 100644 index 00000000000..59b38444dbd --- /dev/null +++ b/Sources/StreamChat/Models/UserPrivacySettings+Extensions.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +// The generated memberwise init orders its parameters alphabetically. Keep the +// previously hand-written order as the public one. +public extension UserPrivacySettings { + convenience init( + typingIndicators: TypingIndicatorPrivacySettings? = nil, + readReceipts: ReadReceiptsPrivacySettings? = nil, + deliveryReceipts: DeliveryReceiptsPrivacySettings? = nil + ) { + self.init( + deliveryReceipts: deliveryReceipts, + readReceipts: readReceipts, + typingIndicators: typingIndicators + ) + } +} diff --git a/Sources/StreamChat/StateLayer/ConnectedUser.swift b/Sources/StreamChat/StateLayer/ConnectedUser.swift index af6c075716d..c747dd594e1 100644 --- a/Sources/StreamChat/StateLayer/ConnectedUser.swift +++ b/Sources/StreamChat/StateLayer/ConnectedUser.swift @@ -128,7 +128,23 @@ public final class ConnectedUser: Sendable { public func muteUser(_ userId: UserId) async throws { try await userUpdater.muteUser(userId) } - + + /// Mutes the users in all the channels. + /// + /// - Note: Messages from muted users are not delivered via push notifications. + /// + /// - Parameters: + /// - userIds: The ids of the users to mute. + /// - expirationInMinutes: The duration of the mute in minutes. When `nil`, the mute does not expire. + /// + /// - Throws: An error while communicating with the Stream API. + /// + /// - Returns: The created mutes and the ids of the users which could not be found. + @discardableResult + public func muteUsers(_ userIds: Set, expiration expirationInMinutes: Int? = nil) async throws -> MuteUsersResponse { + try await currentUserUpdater.muteUsers(userIds, expiration: expirationInMinutes) + } + /// Unmutes the user in all the channels. /// /// - Parameter userId: The id of the user to unmute. @@ -137,6 +153,18 @@ public final class ConnectedUser: Sendable { public func unmuteUser(_ userId: UserId) async throws { try await userUpdater.unmuteUser(userId) } + + /// Unmutes the users in all the channels. + /// + /// - Parameter userIds: The ids of the users to unmute. + /// + /// - Throws: An error while communicating with the Stream API. + /// + /// - Returns: The ids of the users which could not be found. + @discardableResult + public func unmuteUsers(_ userIds: Set) async throws -> UnmuteUsersResponse { + try await currentUserUpdater.unmuteUsers(userIds) + } /// Blocks the user in all the channels. /// diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 7b8d4580719..c3508a7599d 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -54,7 +54,7 @@ final class EventPayload: Decodable, Sendable { let connectionId: String? let connectionError: APIError? let cid: ChannelId? - let currentUser: CurrentUserPayload? + let currentUser: OwnUserResponse? let user: UserPayload? let createdBy: UserPayload? let memberContainer: MemberContainerPayload? @@ -98,7 +98,7 @@ final class EventPayload: Decodable, Sendable { connectionId: String? = nil, connectionError: APIError? = nil, cid: ChannelId? = nil, - currentUser: CurrentUserPayload? = nil, + currentUser: OwnUserResponse? = nil, user: UserPayload? = nil, createdBy: UserPayload? = nil, memberContainer: MemberContainerPayload? = nil, @@ -183,7 +183,7 @@ final class EventPayload: Decodable, Sendable { // In healthCheck event we can receive invalid id containing "*". // We don't need to throw error in that case and can treat it like missing cid. cid = try? container.decodeIfPresent(ChannelId.self, forKey: .cid) - currentUser = try container.decodeIfPresent(CurrentUserPayload.self, forKey: .currentUser) + currentUser = try container.decodeIfPresent(OwnUserResponse.self, forKey: .currentUser) user = try container.decodeIfPresent(UserPayload.self, forKey: .user) createdBy = try container.decodeIfPresent(UserPayload.self, forKey: .createdBy) memberContainer = try container.decodeIfPresent(MemberContainerPayload.self, forKey: .memberContainer) diff --git a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift index fa52a65af59..5abea152664 100644 --- a/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift @@ -291,7 +291,7 @@ public final class NotificationMutesUpdatedEvent: Event { } final class NotificationMutesUpdatedEventDTO: EventDTO { - let currentUser: CurrentUserPayload + let currentUser: OwnUserResponse let createdAt: Date let payload: EventPayload @@ -443,7 +443,7 @@ public final class NotificationChannelMutesUpdatedEvent: Event { } final class NotificationChannelMutesUpdatedEventDTO: EventDTO { - let currentUser: CurrentUserPayload + let currentUser: OwnUserResponse let createdAt: Date let payload: EventPayload diff --git a/Sources/StreamChat/WebSocketClient/WebSocketConnectPayload.swift b/Sources/StreamChat/WebSocketClient/WebSocketConnectPayload.swift index a86d4e8ba82..1029c5d5c62 100644 --- a/Sources/StreamChat/WebSocketClient/WebSocketConnectPayload.swift +++ b/Sources/StreamChat/WebSocketClient/WebSocketConnectPayload.swift @@ -38,7 +38,7 @@ struct UserWebSocketPayload: Encodable { let imageURL: URL? let isInvisible: Bool? let language: String? - let privacySettings: UserPrivacySettingsPayload? + let privacySettings: UserPrivacySettings? let blockedUserIds: [UserId]? let extraData: [String: RawJSON] @@ -48,7 +48,7 @@ struct UserWebSocketPayload: Encodable { imageURL = userInfo.imageURL isInvisible = userInfo.isInvisible language = userInfo.language?.languageCode - privacySettings = userInfo.privacySettings.map { .init(settings: $0) } + privacySettings = userInfo.privacySettings blockedUserIds = userInfo.blockedUserIds extraData = userInfo.extraData } @@ -65,50 +65,3 @@ struct UserWebSocketPayload: Encodable { try extraData.encode(to: encoder) } } - -struct UserPrivacySettingsPayload: Codable { - enum CodingKeys: String, CodingKey { - case typingIndicators = "typing_indicators" - case readReceipts = "read_receipts" - case deliveryReceipts = "delivery_receipts" - } - - let typingIndicators: TypingIndicatorPrivacySettingsPayload? - let readReceipts: ReadReceiptsPrivacySettingsPayload? - let deliveryReceipts: DeliveryReceiptsPrivacySettingsPayload? - - init( - typingIndicators: TypingIndicatorPrivacySettingsPayload? = nil, - readReceipts: ReadReceiptsPrivacySettingsPayload? = nil, - deliveryReceipts: DeliveryReceiptsPrivacySettingsPayload? = nil - ) { - self.typingIndicators = typingIndicators - self.readReceipts = readReceipts - self.deliveryReceipts = deliveryReceipts - } - - init(settings: UserPrivacySettings) { - typingIndicators = settings.typingIndicators.map { .init(enabled: $0.enabled) } - readReceipts = settings.readReceipts.map { .init(enabled: $0.enabled) } - deliveryReceipts = settings.deliveryReceipts.map { .init(enabled: $0.enabled) } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(typingIndicators, forKey: .typingIndicators) - try container.encodeIfPresent(readReceipts, forKey: .readReceipts) - try container.encodeIfPresent(deliveryReceipts, forKey: .deliveryReceipts) - } -} - -struct TypingIndicatorPrivacySettingsPayload: Codable { - var enabled: Bool -} - -struct ReadReceiptsPrivacySettingsPayload: Codable { - var enabled: Bool -} - -struct DeliveryReceiptsPrivacySettingsPayload: Codable { - var enabled: Bool -} diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index c313aac32df..35ce062a1e7 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -249,7 +249,7 @@ class ChannelUpdater: Worker, @unchecked Sendable { endpoint: .unmuteChannel( unmuteChannelRequest: .init(channelCids: [cid.rawValue]) ) - ) { [weak self] (result: Result) in + ) { [weak self] (result: Result) in switch result { case .success: self?.database.write({ session in diff --git a/Sources/StreamChat/Workers/CurrentUserUpdater.swift b/Sources/StreamChat/Workers/CurrentUserUpdater.swift index 228badd3e62..e3166073dfd 100644 --- a/Sources/StreamChat/Workers/CurrentUserUpdater.swift +++ b/Sources/StreamChat/Workers/CurrentUserUpdater.swift @@ -40,7 +40,7 @@ class CurrentUserUpdater: Worker, @unchecked Sendable { let payload = UserUpdateRequestBody( name: name, imageURL: imageURL, - privacySettings: privacySettings.map { UserPrivacySettingsPayload(settings: $0) }, + privacySettings: privacySettings, role: role, teamsRole: teamsRole, extraData: userExtraData @@ -260,6 +260,48 @@ class CurrentUserUpdater: Worker, @unchecked Sendable { } } + /// Mutes the users with the provided identifiers. + /// + /// - Parameters: + /// - userIds: The identifiers of the users to mute. + /// - expirationInMinutes: The duration of the mute in minutes. When `nil`, the mute does not expire. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + /// + func muteUsers( + _ userIds: Set, + expiration expirationInMinutes: Int? = nil, + completion: @escaping @Sendable (Result) -> Void + ) { + apiClient.request(endpoint: .mute(muteRequest: .init(targetIds: userIds.sorted(), timeout: expirationInMinutes))) { result in + switch result { + case let .success(payload): + self.database.write(converting: { session in + try session.saveCurrentUserMutedUsers(payload.ownUser?.mutes ?? payload.mutes ?? []) + return payload.asModel() + }, completion: { databaseResult in + completion(databaseResult) + }) + case let .failure(error): + completion(.failure(error)) + } + } + } + + /// Unmutes the users with the provided identifiers. + /// + /// - Parameters: + /// - userIds: The identifiers of the users to unmute. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + /// + func unmuteUsers( + _ userIds: Set, + completion: @escaping @Sendable (Result) -> Void + ) { + apiClient.request(endpoint: .unmute(unmuteRequest: .init(targetIds: userIds.sorted()))) { result in + completion(result) + } + } + func loadActiveLiveLocations(completion: @escaping @Sendable (Result<[SharedLocation], Error>) -> Void) { apiClient.request(endpoint: .getUserLiveLocations()) { result in switch result { @@ -350,6 +392,22 @@ extension CurrentUserUpdater { } } + func muteUsers(_ userIds: Set, expiration expirationInMinutes: Int? = nil) async throws -> MuteUsersResponse { + try await withCheckedThrowingContinuation { continuation in + muteUsers(userIds, expiration: expirationInMinutes) { result in + continuation.resume(with: result) + } + } + } + + func unmuteUsers(_ userIds: Set) async throws -> UnmuteUsersResponse { + try await withCheckedThrowingContinuation { continuation in + unmuteUsers(userIds) { result in + continuation.resume(with: result) + } + } + } + func updateUserData( currentUserId: UserId, name: String?, diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 3d6f2828385..3fb24435607 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -366,8 +366,8 @@ class MessageUpdater: Worker, @unchecked Sendable { } let request = UpdateLiveLocationRequest( - latitude: Float(locationInfo.latitude), - longitude: Float(locationInfo.longitude), + latitude: locationInfo.latitude, + longitude: locationInfo.longitude, messageId: messageId ) diff --git a/Sources/StreamChat/Workers/UserUpdater.swift b/Sources/StreamChat/Workers/UserUpdater.swift index 8e4ce88bc65..59f56173eb5 100644 --- a/Sources/StreamChat/Workers/UserUpdater.swift +++ b/Sources/StreamChat/Workers/UserUpdater.swift @@ -13,8 +13,17 @@ class UserUpdater: Worker, @unchecked Sendable { /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. /// func muteUser(_ userId: UserId, completion: (@Sendable (Error?) -> Void)? = nil) { - apiClient.request(endpoint: .muteUser(userId)) { - completion?($0.error) + apiClient.request(endpoint: .mute(muteRequest: .init(targetIds: [userId]))) { result in + switch result { + case .success(let payload): + self.database.write({ session in + try session.saveCurrentUserMutedUsers(payload.ownUser?.mutes ?? payload.mutes ?? []) + }, completion: { + completion?($0) + }) + case .failure(let error): + completion?(error) + } } } @@ -24,7 +33,7 @@ class UserUpdater: Worker, @unchecked Sendable { /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. /// func unmuteUser(_ userId: UserId, completion: (@Sendable (Error?) -> Void)? = nil) { - apiClient.request(endpoint: .unmuteUser(userId)) { + apiClient.request(endpoint: .unmute(unmuteRequest: .init(targetIds: [userId]))) { completion?($0.error) } } diff --git a/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift b/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift index 0adaa7c5b95..e1f9f930596 100644 --- a/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift +++ b/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift @@ -47,7 +47,6 @@ extension EndpointPath: @retroactive Equatable { case (.banMember, .banMember): return true case let (.flagUser(bool1), .flagUser(bool2)): return bool1 == bool2 case let (.flagMessage(bool1), .flagMessage(bool2)): return bool1 == bool2 - case let (.muteUser(bool1), .muteUser(bool2)): return bool1 == bool2 default: return false } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index c40347f0abf..6aaa9b84613 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -49,6 +49,11 @@ class DatabaseSession_Mock: DatabaseSession { return try underlyingSession.saveCurrentDevice(deviceId) } + func saveCurrentUserMutedUsers(_ mutedUsers: [MutedUserPayload]) throws -> [UserDTO] { + try throwErrorIfNeeded() + return try underlyingSession.saveCurrentUserMutedUsers(mutedUsers) + } + func saveCurrentUserDevices(_ devices: [Device], clearExisting: Bool) throws -> [DeviceDTO] { try throwErrorIfNeeded() return try underlyingSession.saveCurrentUserDevices(devices, clearExisting: clearExisting) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/CurrentUserUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/CurrentUserUpdater_Mock.swift index c71e0174394..51f63e3bb72 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/CurrentUserUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/CurrentUserUpdater_Mock.swift @@ -41,6 +41,15 @@ final class CurrentUserUpdater_Mock: CurrentUserUpdater, @unchecked Sendable { @Atomic var setPushPreference_completion: ((Result) -> Void)? @Atomic var setPushPreference_completion_result: Result? + @Atomic var muteUsers_userIds: Set? + @Atomic var muteUsers_expiration: Int? + @Atomic var muteUsers_completion: ((Result) -> Void)? + @Atomic var muteUsers_completion_result: Result? + + @Atomic var unmuteUsers_userIds: Set? + @Atomic var unmuteUsers_completion: ((Result) -> Void)? + @Atomic var unmuteUsers_completion_result: Result? + @Atomic var markChannelsDelivered_deliveredMessages: [MessageDeliveryInfo]? @Atomic var markChannelsDelivered_callCount = 0 @Atomic var markChannelsDelivered_completion: ((Error?) -> Void)? @@ -114,6 +123,26 @@ final class CurrentUserUpdater_Mock: CurrentUserUpdater, @unchecked Sendable { setPushPreference_completion_result?.invoke(with: completion) } + override func muteUsers( + _ userIds: Set, + expiration expirationInMinutes: Int? = nil, + completion: @escaping (Result) -> Void + ) { + muteUsers_userIds = userIds + muteUsers_expiration = expirationInMinutes + muteUsers_completion = completion + muteUsers_completion_result?.invoke(with: completion) + } + + override func unmuteUsers( + _ userIds: Set, + completion: @escaping (Result) -> Void + ) { + unmuteUsers_userIds = userIds + unmuteUsers_completion = completion + unmuteUsers_completion_result?.invoke(with: completion) + } + override func markMessagesAsDelivered( _ messages: [MessageDeliveryInfo], completion: ((Error?) -> Void)? = nil @@ -160,6 +189,15 @@ final class CurrentUserUpdater_Mock: CurrentUserUpdater, @unchecked Sendable { setPushPreference_completion = nil setPushPreference_completion_result = nil + muteUsers_userIds = nil + muteUsers_expiration = nil + muteUsers_completion = nil + muteUsers_completion_result = nil + + unmuteUsers_userIds = nil + unmuteUsers_completion = nil + unmuteUsers_completion_result = nil + markChannelsDelivered_deliveredMessages = nil markChannelsDelivered_completion = nil } diff --git a/TestTools/StreamChatTestTools/TestData/DecodableEntity.swift b/TestTools/StreamChatTestTools/TestData/DecodableEntity.swift index 2deea7d0dac..5a2213f887e 100644 --- a/TestTools/StreamChatTestTools/TestData/DecodableEntity.swift +++ b/TestTools/StreamChatTestTools/TestData/DecodableEntity.swift @@ -13,3 +13,4 @@ extension MessagePayload: DecodableEntity {} extension MessageReactionPayload: DecodableEntity {} extension UserPayload: DecodableEntity {} extension ChannelDetailPayload: DecodableEntity {} +extension CurrentUserPayload: DecodableEntity {} diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MutedUserPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MutedUserPayload.swift index 5e52a7224a4..32550feb4b2 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MutedUserPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MutedUserPayload.swift @@ -10,9 +10,10 @@ extension MutedUserPayload { static func dummy( userId: UserId, extraData: [String: RawJSON] = [:] - ) -> Self { + ) -> MutedUserPayload { .init( - mutedUser: .init( + createdAt: .unique, + target: .init( id: userId, name: .unique, imageURL: .unique(), @@ -28,8 +29,7 @@ extension MutedUserPayload { language: nil, extraData: extraData ), - created: .unique, - updated: .unique + updatedAt: .unique ) } } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/OwnUserResponse+Dummy.swift similarity index 58% rename from TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift rename to TestTools/StreamChatTestTools/TestData/DummyData/OwnUserResponse+Dummy.swift index d0a2e27ba2e..a010beec482 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/OwnUserResponse+Dummy.swift @@ -5,7 +5,63 @@ import Foundation @testable import StreamChat -extension CurrentUserPayload { +typealias CurrentUserPayload = OwnUserResponse + +extension OwnUserResponse { + convenience init( + id: String, + name: String?, + imageURL: URL?, + role: UserRole, + teamsRole: [String: UserRole]?, + createdAt: Date, + updatedAt: Date, + deactivatedAt: Date?, + lastActiveAt: Date?, + isOnline: Bool, + isInvisible: Bool, + isBanned: Bool, + teams: [TeamId] = [], + language: String?, + extraData: [String: RawJSON], + devices: [Device] = [], + mutedUsers: [MutedUserPayload] = [], + mutedChannels: [MutedChannelPayload] = [], + unreadCount: UnreadCountPayload? = nil, + totalUnreadCountByTeam: [TeamId: Int]? = nil, + privacySettings: UserPrivacySettings? = nil, + blockedUserIds: Set = [], + pushPreference: PushPreference? + ) { + self.init( + banned: isBanned, + blockedUserIds: Array(blockedUserIds), + channelMutes: mutedChannels, + createdAt: createdAt, + custom: extraData, + deactivatedAt: deactivatedAt, + devices: devices, + id: id, + image: imageURL?.absoluteString, + invisible: isInvisible, + language: language, + lastActive: lastActiveAt, + mutes: mutedUsers, + name: name, + online: isOnline, + privacySettings: privacySettings, + pushPreferences: pushPreference, + role: role.rawValue, + teams: teams, + teamsRole: teamsRole?.mapValues(\.rawValue), + totalUnreadCount: unreadCount?.messages, + totalUnreadCountByTeam: totalUnreadCountByTeam, + unreadChannels: unreadCount?.channels, + unreadThreads: unreadCount?.threads, + updatedAt: updatedAt + ) + } + /// Returns a dummy current user payload with the given UserId and extra data static func dummy( userId: UserId, @@ -28,7 +84,7 @@ extension CurrentUserPayload { language: String? = nil, mutedChannels: [MutedChannelPayload] = [], pushPreference: PushPreference? = nil - ) -> CurrentUserPayload { + ) -> OwnUserResponse { .init( id: userId, name: name, @@ -60,9 +116,10 @@ extension CurrentUserPayload { devices: [Device] = [], mutedUsers: [MutedUserPayload] = [], mutedChannels: [MutedChannelPayload] = [], - privacySettings: UserPrivacySettingsPayload? = nil, + totalUnreadCountByTeam: [TeamId: Int]? = nil, + privacySettings: UserPrivacySettings? = nil, pushPreference: PushPreference? = nil - ) -> CurrentUserPayload { + ) -> OwnUserResponse { .init( id: userPayload.id, name: userPayload.name, @@ -83,6 +140,7 @@ extension CurrentUserPayload { mutedUsers: mutedUsers, mutedChannels: mutedChannels, unreadCount: unreadCount, + totalUnreadCountByTeam: totalUnreadCountByTeam, privacySettings: privacySettings, pushPreference: pushPreference ) diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/UserPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/UserPayload.swift index 05f5c8f2a8e..cd171629f36 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/UserPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/UserPayload.swift @@ -53,7 +53,7 @@ extension CurrentUserPayload { isBanned: Bool = false, updatedAt: Date = .unique, deactivatedAt: Date? = nil, - privacySettings: UserPrivacySettingsPayload? = nil + privacySettings: UserPrivacySettings? = nil ) -> CurrentUserPayload { .init( id: userId, diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift index 34f19d77cda..037442dad39 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift @@ -11,7 +11,25 @@ extension XCTestCase { // MARK: - Dummy data with extra data - var dummyCurrentUser: CurrentUserPayload { + var dummyCurrentUser: UserPayload { + UserPayload( + id: "dummyCurrentUser", + name: .unique, + imageURL: nil, + role: .user, + teamsRole: nil, + createdAt: .unique, + updatedAt: .unique, + deactivatedAt: nil, + lastActiveAt: .unique, + isOnline: true, + isBanned: false, + language: nil, + extraData: [:] + ) + } + + var dummyCurrentUserPayload: CurrentUserPayload { CurrentUserPayload( id: "dummyCurrentUser", name: .unique, diff --git a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift index 89cd2072c9c..2951c4fdb3c 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift @@ -250,7 +250,6 @@ final class EndpointPathTests: XCTestCase { assertResultEncodingAndDecoding(.banMember) assertResultEncodingAndDecoding(.flagUser(false)) assertResultEncodingAndDecoding(.flagMessage(false)) - assertResultEncodingAndDecoding(.muteUser(false)) assertResultEncodingAndDecoding(.blockUsers) assertResultEncodingAndDecoding(.unblockUsers) assertResultEncodingAndDecoding(.getBlockedUsers) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ModerationEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ModerationEndpoints_Tests.swift index 181bb27e78c..c0e2c1ac7f2 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/ModerationEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/ModerationEndpoints_Tests.swift @@ -7,44 +7,6 @@ import XCTest final class ModerationEndpoints_Tests: XCTestCase { - func test_muteUser_buildsCorrectly() { - let userId: UserId = .unique - - let expectedEndpoint = Endpoint( - path: .muteUser(true), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: ["target_id": userId] - ) - - // Build endpoint - let endpoint: Endpoint = .muteUser(userId) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("moderation/mute", endpoint.path.value) - } - - func test_unmuteUser_buildsCorrectly() { - let userId: UserId = .unique - - let expectedEndpoint = Endpoint( - path: .muteUser(false), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: ["target_id": userId] - ) - - // Build endpoint - let endpoint: Endpoint = .unmuteUser(userId) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("moderation/unmute", endpoint.path.value) - } - func test_banMember_buildsCorrectly() { let userId: UserId = .unique let cid: ChannelId = .unique diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CurrentUserPayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CurrentUserPayloads_Tests.swift index 2bd44dd9429..a7f637d4cf8 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CurrentUserPayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CurrentUserPayloads_Tests.swift @@ -31,20 +31,20 @@ final class CurrentUserPayload_Tests: XCTestCase { ) XCTAssertEqual(payload.role, "user") XCTAssertEqual(payload.isOnline, true) - XCTAssertEqual(payload.devices.map(\.id), [ + XCTAssertEqual(payload.devices?.map(\.id), [ "cjqZTUHaQIykfH-706Xefw:APA91bF0Ig0gi4ro6w3iPfmE8", "e25wfsxcnyA:APA91bFgZR_hfd6GvR42OqCUgIhvpBajjxw7" ]) - XCTAssertEqual(payload.mutedUsers.map(\.mutedUser.id), ["dawn-grass-7"]) + XCTAssertEqual(payload.mutes?.compactMap(\.target?.id), ["dawn-grass-7"]) XCTAssertEqual(payload.extraData, ["secret_note": .string("Anaking is Vader!")]) - XCTAssertEqual(payload.mutedChannels.count, 1) - XCTAssertEqual(payload.mutedChannels[0].user?.id, "broken-waterfall-5") - XCTAssertEqual(payload.mutedChannels[0].channel?.cid.rawValue, "messaging:B1DFF9C5-E6A6-4BFA-9375-DC5E8C6852FF") - XCTAssertEqual(payload.mutedChannels[0].createdAt, "2021-03-22T10:23:52.516225Z".toDate()) - XCTAssertEqual(payload.mutedChannels[0].updatedAt, "2021-04-22T10:23:52.516225Z".toDate()) - XCTAssertEqual(payload.isInvisible, true) - XCTAssertNotNil(payload.pushPreference) - XCTAssertEqual(payload.pushPreference?.level, "mentions") - XCTAssertEqual(payload.pushPreference?.disabledUntil, "2024-12-31T23:59:59.999Z".toDate()) + XCTAssertEqual(payload.channelMutes?.count, 1) + XCTAssertEqual(payload.channelMutes?[0].user?.id, "broken-waterfall-5") + XCTAssertEqual(payload.channelMutes?[0].channel?.cid.rawValue, "messaging:B1DFF9C5-E6A6-4BFA-9375-DC5E8C6852FF") + XCTAssertEqual(payload.channelMutes?[0].createdAt, "2021-03-22T10:23:52.516225Z".toDate()) + XCTAssertEqual(payload.channelMutes?[0].updatedAt, "2021-04-22T10:23:52.516225Z".toDate()) + XCTAssertEqual(payload.invisible, true) + XCTAssertNotNil(payload.pushPreferences) + XCTAssertEqual(payload.pushPreferences?.level, "mentions") + XCTAssertEqual(payload.pushPreferences?.disabledUntil, "2024-12-31T23:59:59.999Z".toDate()) } } diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 67808f9bbdf..d5ed4d3826f 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -3964,7 +3964,7 @@ final class ChannelController_Tests: XCTestCase { cid: channelId ) - let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let currentUser: UserPayload = .dummy(userId: .unique, role: .user) let channel: ChannelPayload = .dummy( channel: .dummy( @@ -3985,7 +3985,7 @@ final class ChannelController_Tests: XCTestCase { ) try client.databaseContainer.writeSynchronously { session in - try session.saveCurrentUser(payload: currentUser) + try session.saveCurrentUser(payload: .dummy(userPayload: currentUser)) try session.saveChannel(payload: channel) } @@ -4081,7 +4081,7 @@ final class ChannelController_Tests: XCTestCase { cid: channelId ) - let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let currentUser: UserPayload = .dummy(userId: .unique, role: .user) let channel: ChannelPayload = .dummy( channel: .dummy(cid: channelId, lastMessageAt: lastMessage.createdAt, ownCapabilities: [ChannelCapability.readEvents.rawValue]), @@ -4097,7 +4097,7 @@ final class ChannelController_Tests: XCTestCase { ) try client.databaseContainer.writeSynchronously { session in - try session.saveCurrentUser(payload: currentUser) + try session.saveCurrentUser(payload: .dummy(userPayload: currentUser)) try session.saveChannel(payload: channel) } @@ -4211,7 +4211,7 @@ final class ChannelController_Tests: XCTestCase { cid: channelId ) - let currentUser: CurrentUserPayload = .dummy(userId: userId, role: .user) + let currentUser: UserPayload = .dummy(userId: userId, role: .user) let channel: ChannelPayload = .dummy( channel: .dummy(cid: channelId, lastMessageAt: lastMessage.createdAt, ownCapabilities: [ChannelCapability.readEvents.rawValue]), @@ -4227,7 +4227,7 @@ final class ChannelController_Tests: XCTestCase { ) try client.databaseContainer.writeSynchronously { session in - try session.saveCurrentUser(payload: currentUser) + try session.saveCurrentUser(payload: .dummy(userPayload: currentUser)) try session.saveChannel(payload: channel) } diff --git a/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController_Tests.swift b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController_Tests.swift index 2381899385f..c4c202b1a10 100644 --- a/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController_Tests.swift @@ -906,6 +906,93 @@ final class CurrentUserController_Tests: XCTestCase { AssertAsync.willBeEqual(completionResult?.error as? TestError, expectedError) } + // MARK: - muteUsers + + func test_muteUsers_callsUpdaterWithCorrectParameters() { + // GIVEN + let userIds: Set = [.unique, .unique] + + // WHEN + controller.muteUsers(userIds, expiration: 30) { _ in } + + // THEN + XCTAssertEqual(env.currentUserUpdater?.muteUsers_userIds, userIds) + XCTAssertEqual(env.currentUserUpdater?.muteUsers_expiration, 30) + } + + func test_muteUsers_propagatesSuccess() throws { + // GIVEN + let mutedUserId: UserId = .unique + let expectedMutedUsers = MuteUsersResponse( + mutes: [.init(createdAt: .unique, expires: nil, user: .mock(id: mutedUserId), updatedAt: .unique)], + nonExistingUsers: nil + ) + + // WHEN + let result: Result = try waitFor { done in + controller.muteUsers([mutedUserId], completion: done) + env.currentUserUpdater?.muteUsers_completion?(.success(expectedMutedUsers)) + } + + // THEN + XCTAssertEqual(result.value?.mutes?.map { $0.user?.id }, [mutedUserId]) + } + + func test_muteUsers_propagatesError() throws { + // GIVEN + let expectedError = TestError() + + // WHEN + let result: Result = try waitFor { done in + controller.muteUsers([.unique], completion: done) + env.currentUserUpdater?.muteUsers_completion?(.failure(expectedError)) + } + + // THEN + XCTAssertEqual(result.error as? TestError, expectedError) + } + + // MARK: - unmuteUsers + + func test_unmuteUsers_callsUpdaterWithCorrectParameters() { + // GIVEN + let userIds: Set = [.unique, .unique] + + // WHEN + controller.unmuteUsers(userIds) { _ in } + + // THEN + XCTAssertEqual(env.currentUserUpdater?.unmuteUsers_userIds, userIds) + } + + func test_unmuteUsers_propagatesSuccess() throws { + // GIVEN + let nonExistingUserId: UserId = .unique + + // WHEN + let result: Result = try waitFor { done in + controller.unmuteUsers([.unique], completion: done) + env.currentUserUpdater?.unmuteUsers_completion?(.success(.init(nonExistingUsers: [nonExistingUserId]))) + } + + // THEN + XCTAssertEqual(result.value?.nonExistingUsers, [nonExistingUserId]) + } + + func test_unmuteUsers_propagatesError() throws { + // GIVEN + let expectedError = TestError() + + // WHEN + let result: Result = try waitFor { done in + controller.unmuteUsers([.unique], completion: done) + env.currentUserUpdater?.unmuteUsers_completion?(.failure(expectedError)) + } + + // THEN + XCTAssertEqual(result.error as? TestError, expectedError) + } + // MARK: - snoozePushNotifications func test_snoozePushNotifications_callsUpdaterWithCorrectParameters() { diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift index 32d9484dcd2..6d260631273 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -134,7 +134,7 @@ final class ChannelDTO_Tests: XCTestCase { func test_saveChannel_channelReadsAreSavedBeforeMessages() throws { // GIVEN let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) - let currentUserMember: MemberPayload = .dummy(user: currentUser) + let currentUserMember: MemberPayload = .dummy(user: .dummy(userId: currentUser.id)) let anotherMember: MemberPayload = .dummy(user: .dummy(userId: .unique)) let anotherMemberRead: ChannelReadPayload = .init( @@ -1171,7 +1171,7 @@ final class ChannelDTO_Tests: XCTestCase { func test_channelUnreadCount_calculatedCorrectly() throws { // GIVEN - let currentUserPayload: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let currentUserPayload: UserPayload = .dummy(userId: .unique, role: .user) let currentUserChannelReadPayload: ChannelReadPayload = .init( user: currentUserPayload, @@ -1206,7 +1206,7 @@ final class ChannelDTO_Tests: XCTestCase { let unreadMessages = 5 try database.writeSynchronously { session in - try session.saveCurrentUser(payload: currentUserPayload) + try session.saveCurrentUser(payload: .dummy(userPayload: currentUserPayload)) try session.saveChannel(payload: channelPayload) let read = try XCTUnwrap( diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelMuteDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelMuteDTO_Tests.swift index dcd1d2f6c6f..25520d1a328 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelMuteDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelMuteDTO_Tests.swift @@ -30,7 +30,7 @@ final class ChannelMuteDTO_Tests: XCTestCase { let mutedChannel: ChannelDetailPayload = .dummy(cid: .unique) let mutePayload: MutedChannelPayload = .init( mutedChannel: mutedChannel, - user: currentUserPayload, + user: .dummy(userId: currentUserPayload.id), createdAt: .unique, updatedAt: .unique, expiresAt: .unique @@ -73,7 +73,7 @@ final class ChannelMuteDTO_Tests: XCTestCase { let channel: ChannelDetailPayload = .dummy(cid: .unique) let mute: MutedChannelPayload = .init( mutedChannel: channel, - user: currentUser, + user: .dummy(userId: currentUser.id), createdAt: .unique, updatedAt: .unique, expiresAt: .unique @@ -105,7 +105,7 @@ final class ChannelMuteDTO_Tests: XCTestCase { let channel: ChannelDetailPayload = .dummy(cid: .unique) let initialMute: MutedChannelPayload = .init( mutedChannel: channel, - user: currentUser, + user: .dummy(userId: currentUser.id), createdAt: .unique, updatedAt: .unique, expiresAt: .unique @@ -119,7 +119,7 @@ final class ChannelMuteDTO_Tests: XCTestCase { // WHEN let updatedMute: MutedChannelPayload = .init( mutedChannel: channel, - user: currentUser, + user: .dummy(userId: currentUser.id), createdAt: .unique, updatedAt: .unique, expiresAt: .unique diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift index a3ebf8b334e..66e85c2e319 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift @@ -337,7 +337,7 @@ final class ChannelReadDTO_Tests: XCTestCase { ) let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) - let currentUserMember: MemberPayload = .dummy(user: currentUser) + let currentUserMember: MemberPayload = .dummy(user: .dummy(userId: currentUser.id)) let ownMessageReadByAnotherUser: MessagePayload = .dummy( messageId: .unique, authorUserId: currentUser.id, @@ -387,7 +387,7 @@ final class ChannelReadDTO_Tests: XCTestCase { let anotherUserMember: MemberPayload = .dummy(user: anotherUser) let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) - let currentUserMember: MemberPayload = .dummy(user: currentUser) + let currentUserMember: MemberPayload = .dummy(user: .dummy(userId: currentUser.id)) let messageFromAnotherUser: MessagePayload = .dummy( messageId: .unique, @@ -439,7 +439,7 @@ final class ChannelReadDTO_Tests: XCTestCase { let anotherUserMember: MemberPayload = .dummy(user: anotherUser) let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) - let currentUserMember: MemberPayload = .dummy(user: currentUser) + let currentUserMember: MemberPayload = .dummy(user: .dummy(userId: currentUser.id)) let messageFromAnotherUser: MessagePayload = .dummy( messageId: .unique, diff --git a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift index b54c6d30104..c64ae1bdc38 100644 --- a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift @@ -66,19 +66,19 @@ final class CurrentUserModelDTO_Tests: XCTestCase { updatedAt: .unique ) ], - privacySettings: .init(settings: .init( + privacySettings: .init( typingIndicators: .init(enabled: false), readReceipts: .init(enabled: false), deliveryReceipts: .init(enabled: false) - )), + ), pushPreference: .init( level: "mentions", disabledUntil: Date().addingTimeInterval(3600) ) ) - let mutedUserIDs = Set(payload.mutedUsers.map(\.mutedUser.id)) - let mutedChannelIDs = Set(payload.mutedChannels.compactMap(\.channel?.cid)) + let mutedUserIDs = Set(payload.mutes?.compactMap(\.target?.id) ?? []) + let mutedChannelIDs = Set(payload.channelMutes?.compactMap(\.channel?.cid) ?? []) // Asynchronously save the payload to the db try database.writeSynchronously { session in @@ -92,25 +92,27 @@ final class CurrentUserModelDTO_Tests: XCTestCase { XCTAssertEqual(payload.id, loadedCurrentUser.id) XCTAssertEqual(payload.isOnline, loadedCurrentUser.isOnline) - XCTAssertEqual(payload.isInvisible, loadedCurrentUser.isInvisible) + XCTAssertEqual(payload.invisible, loadedCurrentUser.isInvisible) XCTAssertEqual(payload.isBanned, loadedCurrentUser.isBanned) XCTAssertEqual(payload.role, loadedCurrentUser.userRole.rawValue) XCTAssertEqual(payload.createdAt, loadedCurrentUser.userCreatedAt) XCTAssertEqual(payload.updatedAt, loadedCurrentUser.userUpdatedAt) XCTAssertEqual(payload.lastActiveAt, loadedCurrentUser.lastActiveAt) - XCTAssert(loadedCurrentUser.unreadCount.isEqual(toPayload: payload.unreadCount) == true) + XCTAssertEqual(loadedCurrentUser.unreadCount.channels, payload.unreadChannels) + XCTAssertEqual(loadedCurrentUser.unreadCount.messages, payload.totalUnreadCount) + XCTAssertEqual(loadedCurrentUser.unreadCount.threads, payload.unreadThreads) XCTAssertEqual(payload.extraData, loadedCurrentUser.extraData) XCTAssertEqual(mutedUserIDs, Set(loadedCurrentUser.mutedUsers.map(\.id))) - XCTAssertEqual(payload.devices.count, loadedCurrentUser.devices.count) - XCTAssertEqual(payload.devices.first?.id, loadedCurrentUser.devices.first?.id) + XCTAssertEqual(payload.devices?.count, loadedCurrentUser.devices.count) + XCTAssertEqual(payload.devices?.first?.id, loadedCurrentUser.devices.first?.id) XCTAssertEqual(Set(payload.teams ?? []), loadedCurrentUser.teams) XCTAssertEqual(mutedChannelIDs, Set(loadedCurrentUser.mutedChannels.map(\.cid))) XCTAssertEqual(payload.language, loadedCurrentUser.language?.languageCode) XCTAssertEqual(false, loadedCurrentUser.privacySettings.readReceipts?.enabled) XCTAssertEqual(false, loadedCurrentUser.privacySettings.typingIndicators?.enabled) XCTAssertEqual(false, loadedCurrentUser.privacySettings.deliveryReceipts?.enabled) - XCTAssertEqual(payload.pushPreference?.level, loadedCurrentUser.pushPreference?.level) - XCTAssertNearlySameDate(payload.pushPreference?.disabledUntil, loadedCurrentUser.pushPreference?.disabledUntil) + XCTAssertEqual(payload.pushPreferences?.level, loadedCurrentUser.pushPreference?.level) + XCTAssertNearlySameDate(payload.pushPreferences?.disabledUntil, loadedCurrentUser.pushPreference?.disabledUntil) } func test_savingCurrentUser_removesCurrentDevice() throws { @@ -214,6 +216,67 @@ final class CurrentUserModelDTO_Tests: XCTestCase { ) } + func test_saveCurrentUserMutedUsers_whenThereIsNoCurrentUser_throws() throws { + XCTAssertThrowsError( + try database.viewContext.saveCurrentUserMutedUsers([.dummy(userId: .unique)]) + ) { error in + XCTAssertTrue(error is ClientError.CurrentUserDoesNotExist) + } + } + + func test_saveCurrentUserMutedUsers_addsToExistingMutedUsers() throws { + let existingMutedUserId: UserId = .unique + let addedMutedUserId: UserId = .unique + + try database.writeSynchronously { session in + try session.saveCurrentUser( + payload: .dummy( + userPayload: .dummy(userId: .unique, role: .user), + mutedUsers: [.dummy(userId: existingMutedUserId)] + ) + ) + try session.saveCurrentUserMutedUsers([.dummy(userId: addedMutedUserId)]) + } + + let loadedCurrentUser = try database.readSynchronously { try XCTUnwrap($0.currentUser?.asModel()) } + XCTAssertEqual( + Set(loadedCurrentUser.mutedUsers.map(\.id)), + [existingMutedUserId, addedMutedUserId] + ) + } + + func test_totalUnreadCountByTeam_storesAndLoadsFromDB() throws { + let totalUnreadCountByTeam: [TeamId: Int] = [ + "red": 3, + "blue": 7 + ] + let payload = CurrentUserPayload.dummy( + userPayload: .dummy(userId: .unique, role: .admin), + totalUnreadCountByTeam: totalUnreadCountByTeam + ) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: payload) + } + + let loadedCurrentUser = try database.readSynchronously { try XCTUnwrap($0.currentUser?.asModel()) } + XCTAssertEqual(loadedCurrentUser.totalUnreadCountByTeam, totalUnreadCountByTeam) + } + + func test_totalUnreadCountByTeam_whenPayloadDoesNotContainIt_clearsTheStoredValue() throws { + let userPayload: UserPayload = .dummy(userId: .unique, role: .admin) + + try database.writeSynchronously { session in + try session.saveCurrentUser( + payload: .dummy(userPayload: userPayload, totalUnreadCountByTeam: ["red": 3]) + ) + try session.saveCurrentUser(payload: .dummy(userPayload: userPayload)) + } + + let loadedCurrentUser = try database.readSynchronously { try XCTUnwrap($0.currentUser?.asModel()) } + XCTAssertNil(loadedCurrentUser.totalUnreadCountByTeam) + } + func test_saveCurrentUser_removesChannelMutesNotInPayload() throws { // GIVEN let userPayload: UserPayload = .dummy(userId: .unique) @@ -261,7 +324,7 @@ final class CurrentUserModelDTO_Tests: XCTestCase { XCTAssertEqual(try! database.viewContext.count(for: allMutesRequest), 2) XCTAssertEqual( Set(database.viewContext.currentUser?.channelMutes.map(\.channel.cid) ?? []), - Set(payloadWithUpdatedMutes.mutedChannels.compactMap(\.channel?.cid.rawValue)) + Set(payloadWithUpdatedMutes.channelMutes?.compactMap(\.channel?.cid.rawValue) ?? []) ) } diff --git a/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift index cf555407264..fb78c5d16d3 100644 --- a/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift @@ -25,7 +25,7 @@ final class DeviceDTO_Tests: XCTestCase { try database.writeSynchronously { (session) in // Save a current user to db for testing - try session.saveCurrentUser(payload: self.dummyCurrentUser) + try session.saveCurrentUser(payload: self.dummyCurrentUserPayload) // Save dummy devices try session.saveCurrentUserDevices(dummyDevices.devices) @@ -56,7 +56,7 @@ final class DeviceDTO_Tests: XCTestCase { ) try database.writeSynchronously { (session) in - try session.saveCurrentUser(payload: self.dummyCurrentUser) + try session.saveCurrentUser(payload: self.dummyCurrentUserPayload) try session.saveCurrentUserDevices([device]) } diff --git a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift index f22cf235530..4316ac66262 100644 --- a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift @@ -40,9 +40,9 @@ final class MessageDTO_Tests: XCTestCase { ) let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) - let currentUserMember: MemberPayload = .dummy(user: currentUser) + let currentUserMember: MemberPayload = .dummy(user: .dummy(userId: currentUser.id)) let currentUserRead: ChannelReadPayload = .init( - user: currentUser, + user: .dummy(userId: currentUser.id), lastReadAt: anotherUserMessage.createdAt.addingTimeInterval(10), lastReadMessageId: .unique, unreadMessagesCount: 0 @@ -89,14 +89,14 @@ final class MessageDTO_Tests: XCTestCase { func test_saveMessage_messageSentByCurrentUser_hasReadsFromOtherMembers() throws { // GIVEN let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) - let currentUserMember: MemberPayload = .dummy(user: currentUser) + let currentUserMember: MemberPayload = .dummy(user: .dummy(userId: currentUser.id)) let currentUserMessage: MessagePayload = .dummy( messageId: .unique, authorUserId: currentUser.id, createdAt: .init() ) let currentUserRead: ChannelReadPayload = .init( - user: currentUser, + user: .dummy(userId: currentUser.id), lastReadAt: currentUserMessage.createdAt, lastReadMessageId: .unique, unreadMessagesCount: 0 diff --git a/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift b/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift index 6a9f1627bce..391c3c4274f 100644 --- a/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift @@ -111,6 +111,22 @@ final class ConnectedUser_Tests: XCTestCase { XCTAssertEqual(id, env.userUpdaterMock.muteUser_userId) } + func test_muteUsers_whenUpdatedSucceeds_thenMuteUsersSucceeds() async throws { + try await setUpConnectedUser(usesMockedUpdaters: true) + + let mutedUserId = UserId.unique + env.currentUserUpdaterMock.muteUsers_completion_result = .success( + MuteUsersResponse( + mutes: [.init(createdAt: .unique, expires: nil, user: .mock(id: mutedUserId), updatedAt: .unique)], + nonExistingUsers: nil + ) + ) + let mutedUsers = try await connectedUser.muteUsers([mutedUserId], expiration: 30) + XCTAssertEqual([mutedUserId], env.currentUserUpdaterMock.muteUsers_userIds.map(Array.init)) + XCTAssertEqual(30, env.currentUserUpdaterMock.muteUsers_expiration) + XCTAssertEqual([mutedUserId], mutedUsers.mutes?.map { $0.user?.id }) + } + func test_unmuteUser_whenUpdatedSucceeds_thenUnmuteUserSucceeds() async throws { try await setUpConnectedUser(usesMockedUpdaters: true) @@ -119,7 +135,20 @@ final class ConnectedUser_Tests: XCTestCase { try await connectedUser.unmuteUser(id) XCTAssertEqual(id, env.userUpdaterMock.unmuteUser_userId) } - + + func test_unmuteUsers_whenUpdatedSucceeds_thenUnmuteUsersSucceeds() async throws { + try await setUpConnectedUser(usesMockedUpdaters: true) + + let nonExistingUserId = UserId.unique + env.currentUserUpdaterMock.unmuteUsers_completion_result = .success( + UnmuteUsersResponse(nonExistingUsers: [nonExistingUserId]) + ) + let unmutedUserId = UserId.unique + let response = try await connectedUser.unmuteUsers([unmutedUserId]) + XCTAssertEqual([unmutedUserId], env.currentUserUpdaterMock.unmuteUsers_userIds.map(Array.init)) + XCTAssertEqual([nonExistingUserId], response.nonExistingUsers) + } + func test_flagUser_whenUpdatedSucceeds_thenFlagUserSucceeds() async throws { try await setUpConnectedUser(usesMockedUpdaters: true) diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift index 69d2acda1bb..425babbe841 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift @@ -12,7 +12,7 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase { fileprivate var database: DatabaseContainer_Spy! var channelPayload: ChannelPayload! - var currentUserPayload: CurrentUserPayload! + var currentUserPayload: UserPayload! var currentUserReadPayload: ChannelReadPayload! var anotherUserPayload: UserPayload! @@ -60,7 +60,7 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase { ) try! database.writeSynchronously { session in - try! session.saveCurrentUser(payload: self.currentUserPayload) + try! session.saveCurrentUser(payload: .dummy(userPayload: self.currentUserPayload)) try! session.saveChannel(payload: self.channelPayload) } } @@ -913,7 +913,7 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase { // Save dummy payload to database try database.writeSynchronously { - try $0.saveCurrentUser(payload: self.dummyCurrentUser) + try $0.saveCurrentUser(payload: self.dummyCurrentUserPayload) try $0.saveChannel(payload: payload) } @@ -997,7 +997,7 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase { // Save dummy payload to database try database.writeSynchronously { - try $0.saveCurrentUser(payload: self.dummyCurrentUser) + try $0.saveCurrentUser(payload: self.dummyCurrentUserPayload) try $0.saveChannel(payload: payload) } diff --git a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift index 7734a3f03ab..3d4b47a71f0 100644 --- a/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift @@ -139,14 +139,14 @@ final class NotificationsEvents_Tests: XCTestCase { let json = XCTestCase.mockData(fromJSONFile: "NotificationChannelMutesUpdatedWithSomeMutedChannels") let event = try eventDecoder.decode(from: json) as? NotificationChannelMutesUpdatedEventDTO XCTAssertEqual(event?.currentUser.id, "luke_skywalker") - XCTAssertEqual(event?.payload.currentUser?.mutedChannels.isEmpty, false) + XCTAssertEqual(event?.payload.currentUser?.channelMutes?.isEmpty, false) } func test_channelNoMutedChannels() throws { let json = XCTestCase.mockData(fromJSONFile: "NotificationChannelMutesUpdatedWithNoMutedChannels") let event = try eventDecoder.decode(from: json) as? NotificationChannelMutesUpdatedEventDTO XCTAssertEqual(event?.currentUser.id, "luke_skywalker") - XCTAssertEqual(event?.payload.currentUser?.mutedChannels.isEmpty, true) + XCTAssertEqual(event?.payload.currentUser?.channelMutes?.isEmpty, true) } func test_addToChannel() throws { diff --git a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift index 10cb5a9f179..fb647d3906f 100644 --- a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift @@ -1157,7 +1157,7 @@ final class ChannelUpdater_Tests: XCTestCase { channelUpdater.unmuteChannel(cid: channelID) // Assert correct endpoint is called - let referenceEndpoint: Endpoint = .unmuteChannel( + let referenceEndpoint: Endpoint = .unmuteChannel( unmuteChannelRequest: .init(channelCids: [channelID.rawValue]) ) XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) @@ -1192,7 +1192,7 @@ final class ChannelUpdater_Tests: XCTestCase { XCTAssertFalse(completionCalled) // Simulate API response with success - apiClient.test_simulateResponse(Result.success(.init())) + apiClient.test_simulateResponse(Result.success(.init())) // Assert completion is called AssertAsync.willBeTrue(completionCalled) @@ -1210,7 +1210,7 @@ final class ChannelUpdater_Tests: XCTestCase { // Simulate API response with failure let error = TestError() - apiClient.test_simulateResponse(Result.failure(error)) + apiClient.test_simulateResponse(Result.failure(error)) // Assert the completion is called with the error AssertAsync.willBeEqual(completionCalledError as? TestError, error) diff --git a/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift b/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift index bc6fc03eff7..e577e62d97d 100644 --- a/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift @@ -496,7 +496,7 @@ final class CurrentUserUpdater_Tests: XCTestCase { func test_removeDevice_successfulResponse_isSavedToDB() throws { let userPayload: CurrentUserPayload = .dummy(userId: .unique, role: .user, devices: [.dummy()]) - let deviceId = userPayload.devices.first!.id + let deviceId = userPayload.devices!.first!.id // Save user to the db try database.writeSynchronously { @@ -1121,4 +1121,168 @@ final class CurrentUserUpdater_Tests: XCTestCase { // Assert the completion is called with the error XCTAssertEqual(completionCalledError as? TestError, error) } + + // MARK: - Mute Users + + func test_muteUsers_makesCorrectAPICall() { + let userIds: Set = ["b-user", "a-user"] + + // Simulate `muteUsers` call + currentUserUpdater.muteUsers(userIds, expiration: 15) { _ in } + + // Assert correct endpoint is called with sorted target ids + XCTAssertEqual( + apiClient.request_endpoint, + AnyEndpoint(Endpoint.mute(muteRequest: .init(targetIds: ["a-user", "b-user"], timeout: 15))) + ) + } + + func test_muteUsers_propagatesSuccessfulResponse() throws { + // Simulate current user already set + let currentUserId: UserId = .unique + try database.writeSynchronously { + try $0.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .user)) + } + + // Mock the API response before the call so the completion is invoked + let mutedUserId: UserId = .unique + let nonExistingUserId: UserId = .unique + let mute = MutedUserPayload.dummy(userId: mutedUserId) + let payload = MuteResponse( + duration: "", + mutes: [mute], + nonExistingUsers: [nonExistingUserId] + ) + apiClient.test_mockResponseResult(Result.success(payload)) + let expectation = XCTestExpectation() + + // Simulate `muteUsers` call + nonisolated(unsafe) var result: MuteUsersResponse? + currentUserUpdater.muteUsers([mutedUserId]) { + result = try? $0.get() + expectation.fulfill() + } + + wait(for: [expectation], timeout: defaultTimeout) + + // Assert the returned details map the generated response, taking the user from `target` + XCTAssertEqual(result?.mutes?.map { $0.user?.id }, [mutedUserId]) + XCTAssertEqual(result?.mutes?.first?.createdAt, mute.createdAt) + XCTAssertEqual(result?.mutes?.first?.updatedAt, mute.updatedAt) + XCTAssertEqual(result?.nonExistingUsers, [nonExistingUserId]) + } + + func test_muteUsers_savesMutedUsersToDatabase() throws { + // Simulate current user already set + let currentUserId: UserId = .unique + try database.writeSynchronously { + try $0.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .user)) + } + + // Mock the API response before the call so the completion is invoked + let mutedUserId: UserId = .unique + apiClient.test_mockResponseResult( + Result.success( + .init(duration: "", mutes: [.dummy(userId: mutedUserId)]) + ) + ) + let expectation = XCTestExpectation() + + // Simulate `muteUsers` call + currentUserUpdater.muteUsers([mutedUserId]) { _ in expectation.fulfill() } + + wait(for: [expectation], timeout: defaultTimeout) + + // Assert only the target is saved to the database + let mutedUserIds = try database.readSynchronously { $0.currentUser?.mutedUsers.map(\.id) } + XCTAssertEqual(mutedUserIds, [mutedUserId]) + } + + func test_muteUsers_whenResponseContainsOwnUserOnly_savesMutedUsersToDatabase() throws { + // Simulate current user already set + let currentUserId: UserId = .unique + try database.writeSynchronously { + try $0.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .user)) + } + + // Mock the API response where the mute already existed, therefore only `own_user` carries it + let mutedUserId: UserId = .unique + apiClient.test_mockResponseResult( + Result.success( + .init( + duration: "", + mutes: nil, + ownUser: .dummy( + userId: currentUserId, + role: .user, + mutedUsers: [.dummy(userId: mutedUserId)] + ) + ) + ) + ) + let expectation = XCTestExpectation() + + // Simulate `muteUsers` call + currentUserUpdater.muteUsers([mutedUserId]) { _ in expectation.fulfill() } + + wait(for: [expectation], timeout: defaultTimeout) + + // Assert the muted user from `own_user` is saved to the database + let mutedUserIds = try database.readSynchronously { $0.currentUser?.mutedUsers.map(\.id) } + XCTAssertEqual(mutedUserIds, [mutedUserId]) + } + + func test_unmuteUsers_makesCorrectAPICall() { + let userIds: Set = [.unique, .unique] + + // Simulate `unmuteUsers` call + currentUserUpdater.unmuteUsers(userIds) { _ in } + + // Assert correct endpoint is called + XCTAssertEqual( + apiClient.request_endpoint, + AnyEndpoint(.unmute(unmuteRequest: .init(targetIds: userIds.sorted()))) + ) + } + + func test_unmuteUsers_propagatesSuccessfulResponse() throws { + let nonExistingUserId: UserId = .unique + + // Mock the API response before the call so the completion is invoked + apiClient.test_mockResponseResult( + Result.success(.init(nonExistingUsers: [nonExistingUserId])) + ) + + // Simulate `unmuteUsers` call + let result: Result = try waitFor { currentUserUpdater.unmuteUsers([.unique], completion: $0) } + + // Assert the returned details map the generated response + XCTAssertEqual(result.value?.nonExistingUsers, [nonExistingUserId]) + } + + func test_unmuteUsers_propagatesError() throws { + let error = TestError() + + // Mock the API failure before the call so the completion is invoked + apiClient.test_mockResponseResult(Result.failure(error)) + + // Simulate `unmuteUsers` call + let result: Result = try waitFor { currentUserUpdater.unmuteUsers([.unique], completion: $0) } + + // Assert the completion is called with the error + XCTAssertEqual(result.error as? TestError, error) + } + + func test_muteUsers_propagatesError() { + // Mock the API failure before the call so the completion is invoked synchronously + let error = TestError() + apiClient.test_mockResponseResult(Result.failure(error)) + + // Simulate `muteUsers` call + nonisolated(unsafe) var completionCalledError: Error? + currentUserUpdater.muteUsers([.unique]) { completionCalledError = $0.error } + + // Assert the completion is called with the error + XCTAssertEqual(completionCalledError as? TestError, error) + } } diff --git a/Tests/StreamChatTests/Workers/UserUpdater_Tests.swift b/Tests/StreamChatTests/Workers/UserUpdater_Tests.swift index 006991ddd29..1c900078e1e 100644 --- a/Tests/StreamChatTests/Workers/UserUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/UserUpdater_Tests.swift @@ -47,10 +47,12 @@ final class UserUpdater_Tests: XCTestCase { userUpdater.muteUser(userId) // Assert correct endpoint is called - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(.muteUser(userId))) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(.mute(muteRequest: .init(targetIds: [userId])))) } - func test_muteUser_propagatesSuccessfulResponse() { + func test_muteUser_propagatesSuccessfulResponse() throws { + try database.createCurrentUser(id: .unique) + // Simulate `muteUser` call nonisolated(unsafe) var completionCalled = false userUpdater.muteUser(.unique) { error in @@ -62,7 +64,7 @@ final class UserUpdater_Tests: XCTestCase { XCTAssertFalse(completionCalled) // Simulate API response with success - apiClient.test_simulateResponse(Result.success(.init())) + apiClient.test_simulateResponse(Result.success(.init(duration: .unique))) // Assert completion is called AssertAsync.willBeTrue(completionCalled) @@ -77,12 +79,77 @@ final class UserUpdater_Tests: XCTestCase { // Simulate API response with failure let error = TestError() - apiClient.test_simulateResponse(Result.failure(error)) + apiClient.test_simulateResponse(Result.failure(error)) // Assert the completion is called with the error AssertAsync.willBeEqual(completionCalledError as? TestError, error) } + func test_muteUser_savesMutedUsersToDatabase() throws { + let currentUserId: UserId = .unique + let mutedUserId: UserId = .unique + try database.createCurrentUser(id: currentUserId) + + // Mock the API response with the created mute + apiClient.test_mockResponseResult( + Result.success( + .init(duration: .unique, mutes: [.dummy(userId: mutedUserId)]) + ) + ) + + // Simulate `muteUser` call + let error = try waitFor { userUpdater.muteUser(mutedUserId, completion: $0) } + XCTAssertNil(error) + + // Assert the muted user is saved to the database + let mutedUserIds = try database.readSynchronously { $0.currentUser?.mutedUsers.map(\.id) } + XCTAssertEqual(mutedUserIds, [mutedUserId]) + } + + func test_muteUser_whenResponseContainsOwnUserOnly_savesMutedUsersToDatabase() throws { + let currentUserId: UserId = .unique + let mutedUserId: UserId = .unique + try database.createCurrentUser(id: currentUserId) + + // Mock the API response where the mute already existed, therefore only `own_user` carries it + apiClient.test_mockResponseResult( + Result.success( + .init( + duration: .unique, + mutes: nil, + ownUser: .dummy( + userId: currentUserId, + role: .user, + mutedUsers: [.dummy(userId: mutedUserId)] + ) + ) + ) + ) + + // Simulate `muteUser` call + let error = try waitFor { userUpdater.muteUser(mutedUserId, completion: $0) } + XCTAssertNil(error) + + // Assert the muted user is saved to the database + let mutedUserIds = try database.readSynchronously { $0.currentUser?.mutedUsers.map(\.id) } + XCTAssertEqual(mutedUserIds, [mutedUserId]) + } + + func test_muteUser_whenDatabaseWriteFails_propagatesError() throws { + // Mock the API response with the created mute + apiClient.test_mockResponseResult( + Result.success( + .init(duration: .unique, mutes: [.dummy(userId: .unique)]) + ) + ) + + // Simulate `muteUser` call without a current user in the database + let error = try waitFor { userUpdater.muteUser(.unique, completion: $0) } + + // Assert the database error is propagated + XCTAssertTrue(error is ClientError.CurrentUserDoesNotExist) + } + // MARK: - Unmute user func test_unmuteUser_makesCorrectAPICall() { @@ -92,7 +159,7 @@ final class UserUpdater_Tests: XCTestCase { userUpdater.unmuteUser(userId) // Assert correct endpoint is called - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(.unmuteUser(userId))) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(.unmute(unmuteRequest: .init(targetIds: [userId])))) } func test_unmuteUser_propagatesSuccessfulResponse() { @@ -107,7 +174,7 @@ final class UserUpdater_Tests: XCTestCase { XCTAssertFalse(completionCalled) // Simulate API response with success - apiClient.test_simulateResponse(Result.success(.init())) + apiClient.test_simulateResponse(Result.success(.init())) // Assert completion is called AssertAsync.willBeTrue(completionCalled) @@ -122,7 +189,7 @@ final class UserUpdater_Tests: XCTestCase { // Simulate API response with failure let error = TestError() - apiClient.test_simulateResponse(Result.failure(error)) + apiClient.test_simulateResponse(Result.failure(error)) // Assert the completion is called with the error AssertAsync.willBeEqual(completionCalledError as? TestError, error)