From 6e7b9ecb27cb9bf0aba9a6ad0143b95b72a305de Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Oct 2024 17:50:20 +0100 Subject: [PATCH 01/22] Add premium icon to DemoApp's User Cell --- .../Create Chat/NameGroupViewController.swift | 47 +++++++++---------- DemoApp/Screens/MembersViewController.swift | 7 +++ 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/DemoApp/Screens/Create Chat/NameGroupViewController.swift b/DemoApp/Screens/Create Chat/NameGroupViewController.swift index 99b78ccccbb..4eaac7d529a 100644 --- a/DemoApp/Screens/Create Chat/NameGroupViewController.swift +++ b/DemoApp/Screens/Create Chat/NameGroupViewController.swift @@ -5,6 +5,7 @@ import Foundation import Nuke import StreamChat +import StreamChatUI import UIKit class NameGroupViewController: UIViewController { @@ -14,6 +15,7 @@ class NameGroupViewController: UIViewController { let avatarView = AvatarView() let nameLabel = UILabel() let removeButton = UIButton() + let premiumImageView = UIImageView(image: .init(systemName: "crown.fill")!) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -26,31 +28,27 @@ class NameGroupViewController: UIViewController { } func setupUI() { - [avatarView, nameLabel, removeButton].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview($0) - } - - removeButton.tintColor = .black - removeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + removeButton.tintColor = Appearance.default.colorPalette.text + removeButton.setImage(UIImage(systemName: "xmark")!, for: .normal) removeButton.imageView?.contentMode = .scaleAspectFit - - NSLayoutConstraint.activate([ - // AvatarView - avatarView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 0), - avatarView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: contentView.layoutMargins.top), - avatarView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -contentView.layoutMargins.bottom), - avatarView.heightAnchor.constraint(equalToConstant: 40), - avatarView.widthAnchor.constraint(equalTo: avatarView.heightAnchor), - // NameLabel - nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: contentView.layoutMargins.left), - nameLabel.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), - // removeButton - removeButton.leftAnchor.constraint(equalTo: nameLabel.rightAnchor, constant: contentView.layoutMargins.left), - removeButton.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -contentView.layoutMargins.right), - removeButton.widthAnchor.constraint(equalToConstant: 10), - removeButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor) - ]) + removeButton.isUserInteractionEnabled = true + premiumImageView.contentMode = .scaleAspectFill + premiumImageView.tintColor = .systemBlue + premiumImageView.isHidden = true + + HContainer(spacing: 8, alignment: .center) { + avatarView + .width(30) + .height(30) + nameLabel + Spacer() + premiumImageView + .width(20) + .height(20) + removeButton + .width(30) + .height(30) + }.embedToMargins(in: contentView) } } @@ -154,6 +152,7 @@ extension NameGroupViewController: UITableViewDataSource { } cell.nameLabel.text = user.name + cell.premiumImageView.isHidden = true cell.removeButton.addAction(.init(handler: { [weak self] _ in guard let self = self else { return } if let index = self.selectedUsers.firstIndex(of: user) { diff --git a/DemoApp/Screens/MembersViewController.swift b/DemoApp/Screens/MembersViewController.swift index 40bcbf8456d..ad589cc24d0 100644 --- a/DemoApp/Screens/MembersViewController.swift +++ b/DemoApp/Screens/MembersViewController.swift @@ -56,6 +56,7 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl } cell.nameLabel.text = member.name ?? member.id cell.removeButton.isHidden = true + cell.premiumImageView.isHidden = member.isPremium == false return cell } @@ -69,3 +70,9 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl updateData() } } + +extension ChatChannelMember { + var isPremium: Bool { + extraData["is_premium"]?.boolValue == true + } +} From 568577471ee0dab22df39ebc45de637b0eff49a0 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Oct 2024 18:03:31 +0100 Subject: [PATCH 02/22] Add `ChatChannelMemberController.partialUpdate()` to partially update a channel member including extra data --- .../EndpointPath+OfflineRequest.swift | 7 ++-- .../APIClient/Endpoints/EndpointPath.swift | 9 +++-- .../APIClient/Endpoints/MemberEndpoints.swift | 31 +++++++++++++++++ .../MemberController/MemberController.swift | 24 ++++++++++++++ .../Workers/ChannelMemberUpdater.swift | 33 +++++++++++++++++++ 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index 4df30d5929e..419ae4c4448 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -9,12 +9,13 @@ extension EndpointPath { switch self { case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction: return true - case .createChannel, .connect, .sync, .users, .guest, .members, .search, .devices, .channels, .updateChannel, + case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel, .deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread, .markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadAttachment, .message, .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, - .callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, .polls, .pollsQuery, - .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote, .unread, .blockUser, .unblockUser: + .callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, + .polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote, + .unread, .blockUser, .unblockUser: return false } } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index d8f2dbd47d2..d356745f316 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -9,12 +9,14 @@ enum EndpointPath: Codable { case sync case users case guest - case members case search case devices case og case unread + case members + case partialMemberUpdate(userId: UserId, cid: ChannelId) + case threads case thread(messageId: MessageId) case markThreadRead(cid: ChannelId) @@ -79,12 +81,15 @@ enum EndpointPath: Codable { case .sync: return "sync" case .users: return "users" case .guest: return "guest" - case .members: return "members" case .search: return "search" case .devices: return "devices" case .og: return "og" case .unread: return "unread" + case .members: return "members" + case let .partialMemberUpdate(userId, cid): + return "channels/\(cid.apiPath)/member/\(userId)" + case .threads: return "threads" case let .thread(threadId): diff --git a/Sources/StreamChat/APIClient/Endpoints/MemberEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MemberEndpoints.swift index 14af27e64c5..885e757d32a 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MemberEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MemberEndpoints.swift @@ -16,4 +16,35 @@ extension Endpoint { body: ["payload": query] ) } + + static func partialMemberUpdate( + userId: UserId, + cid: ChannelId, + extraData: [String: RawJSON]?, + unset: [String]? + ) -> Endpoint { + var body: [String: AnyEncodable] = [:] + if let extraData { + body["set"] = AnyEncodable(extraData) + } + if let unset { + body["unset"] = AnyEncodable(unset) + } + + return .init( + path: .partialMemberUpdate(userId: userId, cid: cid), + method: .patch, + queryItems: nil, + requiresConnectionId: false, + body: body + ) + } +} + +struct PartialMemberUpdateResponse: Decodable { + var channelMember: MemberPayload + + enum CodingKeys: String, CodingKey { + case channelMember = "channel_member" + } } diff --git a/Sources/StreamChat/Controllers/MemberController/MemberController.swift b/Sources/StreamChat/Controllers/MemberController/MemberController.swift index 849189c4962..7c167081d82 100644 --- a/Sources/StreamChat/Controllers/MemberController/MemberController.swift +++ b/Sources/StreamChat/Controllers/MemberController/MemberController.swift @@ -152,6 +152,30 @@ public class ChatChannelMemberController: DataController, DelegateCallable, Data // MARK: - Actions public extension ChatChannelMemberController { + /// Updates the channel member with additional information. + /// + /// **Note:** The data is assigned to the member in this channel only, and not the user object + /// across multiple channels. + /// - Parameters: + /// - extraData: The additional data to populate the member. + /// - unsetProperties: Properties from the member to be cleared/unset. + func partialUpdate( + extraData: [String: RawJSON]?, + unsetProperties: [String]?, + completion: ((Result) -> Void)? = nil + ) { + memberUpdater.partialUpdate( + userId: userId, + in: cid, + extraData: extraData, + unset: unsetProperties + ) { result in + self.callback { + completion?(result) + } + } + } + /// Bans the channel member. /// - Parameters: /// - timeoutInMinutes: The # of minutes the user should be banned for. diff --git a/Sources/StreamChat/Workers/ChannelMemberUpdater.swift b/Sources/StreamChat/Workers/ChannelMemberUpdater.swift index 98e36a2293f..d4da6e3774b 100644 --- a/Sources/StreamChat/Workers/ChannelMemberUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelMemberUpdater.swift @@ -6,6 +6,39 @@ import Foundation /// Makes channel member related calls to the backend. class ChannelMemberUpdater: Worker { + /// Updates the channel member with additional information. + /// - Parameters: + /// - userId: The user id of the member. + /// - cid: The channel which the member should be updated. + /// - extraData: The additional information. + /// - unset: The properties to be unset/cleared. + func partialUpdate( + userId: UserId, + in cid: ChannelId, + extraData: [String: RawJSON]?, + unset: [String]?, + completion: @escaping ((Result) -> Void) + ) { + apiClient.request( + endpoint: .partialMemberUpdate( + userId: userId, + cid: cid, + extraData: extraData, + unset: unset + ) + ) { result in + switch result { + case .success(let response): + self.database.write { session in + let member = try session.saveMember(payload: response.channelMember, channelId: cid).asModel() + completion(.success(member)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + /// Bans the user in the channel. /// - Parameters: /// - userId: The user identifier to ban. From e1abec4eb047948d6aff84588ea86fc989ff27ac Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Oct 2024 18:04:41 +0100 Subject: [PATCH 03/22] Change `ChatChannelController.addMembers()` to support additional member info --- .../Endpoints/ChannelEndpoints.swift | 20 ++++++++++-- .../ChannelController/ChannelController.swift | 31 ++++++++++++++++--- Sources/StreamChat/Models/Member.swift | 11 +++++++ Sources/StreamChat/StateLayer/Chat.swift | 31 ++++++++++++++++--- .../StreamChat/Workers/ChannelUpdater.swift | 10 +++--- 5 files changed, 88 insertions(+), 15 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift index 6894a13c5f9..37406f750d6 100644 --- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift @@ -142,12 +142,12 @@ extension Endpoint { static func addMembers( cid: ChannelId, - userIds: Set, + members: [MemberInfoRequest], hideHistory: Bool, messagePayload: MessageRequestBody? = nil ) -> Endpoint { var body: [String: AnyEncodable] = [ - "add_members": AnyEncodable(userIds), + "add_members": AnyEncodable(members), "hide_history": AnyEncodable(hideHistory) ] if let messagePayload = messagePayload { @@ -363,3 +363,19 @@ extension Endpoint { ) } } + +struct MemberInfoRequest: Encodable { + let userId: UserId + let extraData: [String: RawJSON]? + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case extraData + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(userId, forKey: .userId) + try extraData?.encode(to: encoder) + } +} diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 32eb439c60f..0305a918877 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -831,17 +831,17 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } - /// Add users to the channel as members. + /// Add users to the channel as members with additional data. /// /// - Parameters: - /// - userIds: User ids that will be added to a channel. + /// - members: An array of `AddMemberInput` objects, each representing a member to be added to the channel. /// - hideHistory: Hide the history of the channel to the added member. By default, it is false. /// - message: Optional system message sent when adding members. /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. /// If request fails, the completion will be called with an error. /// public func addMembers( - userIds: Set, + _ members: [MemberInfo], hideHistory: Bool = false, message: String? = nil, completion: ((Error?) -> Void)? = nil @@ -855,7 +855,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP updater.addMembers( currentUserId: client.currentUserId, cid: cid, - userIds: userIds, + members: members, message: message, hideHistory: hideHistory ) { error in @@ -865,6 +865,29 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } + /// Add users to the channel as members. + /// + /// - Parameters: + /// - userIds: User ids that will be added to a channel. + /// - hideHistory: Hide the history of the channel to the added member. By default, it is false. + /// - message: Optional system message sent when adding members. + /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. + /// If request fails, the completion will be called with an error. + @available(*, deprecated, message: "A new addMembers function is now available that supports adding MemberInfo instead of only the user id.") + public func addMembers( + userIds: Set, + hideHistory: Bool = false, + message: String? = nil, + completion: ((Error?) -> Void)? = nil + ) { + addMembers( + userIds.map { .init(userId: $0, extraData: nil) }, + hideHistory: hideHistory, + message: message, + completion: completion + ) + } + /// Remove users to the channel as members. /// /// - Parameters: diff --git a/Sources/StreamChat/Models/Member.swift b/Sources/StreamChat/Models/Member.swift index a10c29e62c9..3068fb7860a 100644 --- a/Sources/StreamChat/Models/Member.swift +++ b/Sources/StreamChat/Models/Member.swift @@ -145,3 +145,14 @@ public extension MemberRole { } } } + +/// The member information when adding a member to a channel. +public struct MemberInfo { + public var userId: UserId + public var extraData: [String: RawJSON]? + + public init(userId: UserId, extraData: [String: RawJSON]?) { + self.userId = userId + self.extraData = extraData + } +} diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 042787ce99e..46d85d6bac0 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -189,19 +189,19 @@ public class Chat { } // MARK: - Members - + /// Adds given users as members. /// /// - Note: You can only add up to 100 members at once. /// /// - Parameters: - /// - members: An array of user ids that will be added to the channel. + /// - members: An array of member data that will be added to the channel. /// - systemMessage: A system message to be added after adding members. /// - hideHistory: If true, the previous history is available for added members, otherwise they do not see the history. The default value is false. /// /// - Throws: An error while communicating with the Stream API. public func addMembers( - _ members: [UserId], + _ members: [MemberInfo], systemMessage: String? = nil, hideHistory: Bool = false ) async throws { @@ -209,11 +209,34 @@ public class Chat { try await channelUpdater.addMembers( currentUserId: currentUserId, cid: cid, - userIds: Set(members), + members: members, message: systemMessage, hideHistory: hideHistory ) } + + /// Adds given users as members. + /// + /// - Note: You can only add up to 100 members at once. + /// + /// - Parameters: + /// - members: An array of user ids that will be added to the channel. + /// - systemMessage: A system message to be added after adding members. + /// - hideHistory: If true, the previous history is available for added members, otherwise they do not see the history. The default value is false. + /// + /// - Throws: An error while communicating with the Stream API. + @available(*, deprecated, message: "A new addMembers function is now available that supports adding MemberInfo instead of only the user id.") + public func addMembers( + _ members: [UserId], + systemMessage: String? = nil, + hideHistory: Bool = false + ) async throws { + try await addMembers( + members.map { .init(userId: $0, extraData: nil) }, + systemMessage: systemMessage, + hideHistory: hideHistory + ) + } /// Removes given users from the channel. /// diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index e8876f7f110..99d5683ea33 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -352,14 +352,14 @@ class ChannelUpdater: Worker { /// - Parameters: /// - currentUserId: the id of the current user. /// - cid: The Id of the channel where you want to add the users. - /// - userIds: User ids to add to the channel. + /// - members: The members input data to be added. /// - message: Optional system message sent when adding a member. /// - hideHistory: Hide the history of the channel to the added member. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. func addMembers( currentUserId: UserId? = nil, cid: ChannelId, - userIds: Set, + members: [MemberInfo], message: String? = nil, hideHistory: Bool, completion: ((Error?) -> Void)? = nil @@ -368,7 +368,7 @@ class ChannelUpdater: Worker { apiClient.request( endpoint: .addMembers( cid: cid, - userIds: userIds, + members: members.map { MemberInfoRequest(userId: $0.userId, extraData: $0.extraData) }, hideHistory: hideHistory, messagePayload: messagePayload ) @@ -700,7 +700,7 @@ extension ChannelUpdater { func addMembers( currentUserId: UserId? = nil, cid: ChannelId, - userIds: Set, + members: [MemberInfo], message: String? = nil, hideHistory: Bool ) async throws { @@ -708,7 +708,7 @@ extension ChannelUpdater { addMembers( currentUserId: currentUserId, cid: cid, - userIds: userIds, + members: members, message: message, hideHistory: hideHistory ) { error in From 86d48a50e5485f0791262087012a88e43f5fc6f1 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Oct 2024 18:05:09 +0100 Subject: [PATCH 04/22] Add channel member premium feature to the Demo App --- .../DemoChatChannelListRouter.swift | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index e91d805373d..d947f9f9c40 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -165,7 +165,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { return } channelController.addMembers( - userIds: [id], + [MemberInfo(userId: id, extraData: nil)], message: "Members added to the channel" ) { error in if let error = error { @@ -184,7 +184,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { return } channelController.addMembers( - userIds: [id], + [MemberInfo(userId: id, extraData: nil)], hideHistory: true, message: "Members added to the channel" ) { error in @@ -197,6 +197,45 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), + .init(title: "Add premium member", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + self.rootViewController.presentAlert(title: "Enter user id", textFieldPlaceholder: "User ID") { id in + guard let id = id, !id.isEmpty else { + self.rootViewController.presentAlert(title: "User ID is not valid") + return + } + channelController.addMembers( + [MemberInfo(userId: id, extraData: ["is_premium": true])], + message: "Premium member added to the channel" + ) { error in + if let error = error { + self.rootViewController.presentAlert( + title: "Couldn't add user \(id) to channel \(cid)", + message: "\(error)" + ) + } + } + } + }), + .init(title: "Set member as premium", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + let actions = channelController.channel?.lastActiveMembers.map { member in + UIAlertAction(title: member.id, style: .default) { _ in + channelController.client.memberController(userId: member.id, in: cid) + .partialUpdate(extraData: ["is_premium": true], unsetProperties: nil) { [unowned self] result in + do { + let data = try result.get() + print("Member updated. Premium: ", data.isPremium) + self.rootNavigationController?.popViewController(animated: true) + } catch { + self.rootViewController.presentAlert( + title: "Couldn't set user \(member.id) as premium.", + message: "\(error)" + ) + } + } + } + } ?? [] + self.rootViewController.presentAlert(title: "Select a member", actions: actions) + }), .init(title: "Remove a member", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in let actions = channelController.channel?.lastActiveMembers.map { member in UIAlertAction(title: member.id, style: .default) { _ in From 2a2ee6bc150c239d88715a4e7d7019f361ebe1bf Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 12 Nov 2024 13:35:08 +0000 Subject: [PATCH 05/22] Add memberExtraData to ChatMember --- DemoApp/Screens/MembersViewController.swift | 2 +- .../Endpoints/Payloads/MemberPayload.swift | 17 +++++++++++++++-- .../Database/DTOs/MemberModelDTO.swift | 18 +++++++++++++++++- .../StreamChatModel.xcdatamodel/contents | 3 ++- Sources/StreamChat/Models/Member.swift | 7 ++++++- Sources/StreamChat/Models/User.swift | 1 + 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/DemoApp/Screens/MembersViewController.swift b/DemoApp/Screens/MembersViewController.swift index ad589cc24d0..2a888d79530 100644 --- a/DemoApp/Screens/MembersViewController.swift +++ b/DemoApp/Screens/MembersViewController.swift @@ -73,6 +73,6 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl extension ChatChannelMember { var isPremium: Bool { - extraData["is_premium"]?.boolValue == true + memberExtraData["is_premium"]?.boolValue == true } } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MemberPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MemberPayload.swift index 45612ee4ebf..5be6d6f85cf 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MemberPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MemberPayload.swift @@ -27,7 +27,7 @@ struct MemberContainerPayload: Decodable { } struct MemberPayload: Decodable { - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, CaseIterable { case user case userId = "user_id" case role = "channel_role" @@ -67,6 +67,9 @@ struct MemberPayload: Decodable { /// A boolean value that returns whether the user has muted the channel or not. let notificationsMuted: Bool + /// Extra data associated with the member. + let extraData: [String: RawJSON]? + init( user: UserPayload?, userId: String, @@ -79,7 +82,8 @@ struct MemberPayload: Decodable { isInvited: Bool? = nil, inviteAcceptedAt: Date? = nil, inviteRejectedAt: Date? = nil, - notificationsMuted: Bool = false + notificationsMuted: Bool = false, + extraData: [String: RawJSON]? = nil ) { self.user = user self.userId = userId @@ -93,6 +97,7 @@ struct MemberPayload: Decodable { self.inviteAcceptedAt = inviteAcceptedAt self.inviteRejectedAt = inviteRejectedAt self.notificationsMuted = notificationsMuted + self.extraData = extraData } init(from decoder: Decoder) throws { @@ -114,6 +119,14 @@ struct MemberPayload: Decodable { } else { userId = try container.decode(String.self, forKey: .userId) } + + do { + var payload = try [String: RawJSON](from: decoder) + payload.removeValues(forKeys: CodingKeys.allCases.map(\.rawValue)) + extraData = payload + } catch { + extraData = [:] + } } } diff --git a/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift b/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift index 6e7a7550452..0a643eeaeeb 100644 --- a/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift @@ -24,6 +24,8 @@ class MemberDTO: NSManagedObject { @NSManaged var inviteRejectedAt: DBDate? @NSManaged var isInvited: Bool + @NSManaged var extraData: Data? + // MARK: - Relationships @NSManaged var user: UserDTO @@ -128,6 +130,10 @@ extension NSManagedObjectContext { dto.inviteRejectedAt = payload.inviteRejectedAt?.bridgeDate dto.notificationsMuted = payload.notificationsMuted + if let extraData = payload.extraData { + dto.extraData = try? JSONEncoder.default.encode(payload.extraData) + } + if let query = query { let queryDTO = try saveQuery(query) queryDTO.members.insert(dto) @@ -177,6 +183,15 @@ extension ChatChannelMember { extraData = [:] } + var memberExtraData: [String: RawJSON] = [:] + if let dtoMemberExtraData = dto.extraData { + do { + memberExtraData = try JSONDecoder.default.decode([String: RawJSON].self, from: dtoMemberExtraData) + } catch { + memberExtraData = [:] + } + } + let role = dto.channelRoleRaw.flatMap { MemberRole(rawValue: $0) } ?? .member let language: TranslationLanguage? = dto.user.language.map(TranslationLanguage.init) @@ -204,7 +219,8 @@ extension ChatChannelMember { isBannedFromChannel: dto.isBanned, banExpiresAt: dto.banExpiresAt?.bridgeDate, isShadowBannedFromChannel: dto.isShadowBanned, - notificationsMuted: dto.notificationsMuted + notificationsMuted: dto.notificationsMuted, + memberExtraData: memberExtraData ) } } diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index f9e9e39810a..b82f5a946f4 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -174,6 +174,7 @@ + diff --git a/Sources/StreamChat/Models/Member.swift b/Sources/StreamChat/Models/Member.swift index 3068fb7860a..2265bccdedf 100644 --- a/Sources/StreamChat/Models/Member.swift +++ b/Sources/StreamChat/Models/Member.swift @@ -45,6 +45,9 @@ public class ChatChannelMember: ChatUser { /// A boolean value that returns whether the user has muted the channel or not. public let notificationsMuted: Bool + /// Any additional custom data associated with the member of the channel. + public let memberExtraData: [String: RawJSON] + init( id: String, name: String?, @@ -69,7 +72,8 @@ public class ChatChannelMember: ChatUser { isBannedFromChannel: Bool, banExpiresAt: Date?, isShadowBannedFromChannel: Bool, - notificationsMuted: Bool + notificationsMuted: Bool, + memberExtraData: [String: RawJSON] ) { self.memberRole = memberRole self.memberCreatedAt = memberCreatedAt @@ -81,6 +85,7 @@ public class ChatChannelMember: ChatUser { self.isShadowBannedFromChannel = isShadowBannedFromChannel self.banExpiresAt = banExpiresAt self.notificationsMuted = notificationsMuted + self.memberExtraData = memberExtraData super.init( id: id, diff --git a/Sources/StreamChat/Models/User.swift b/Sources/StreamChat/Models/User.swift index 67313328bf2..54f4b206faa 100644 --- a/Sources/StreamChat/Models/User.swift +++ b/Sources/StreamChat/Models/User.swift @@ -63,6 +63,7 @@ public class ChatUser { /// The language code of the user. public let language: TranslationLanguage? + /// Any additional custom data associated with the user. public let extraData: [String: RawJSON] init( From f5f10c03c4e750f2ce6edff8729ce7a8d6d3b18a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 12 Nov 2024 15:07:39 +0000 Subject: [PATCH 06/22] Add a way to query members with extra data --- .../Components/DemoChatChannelListRouter.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index d947f9f9c40..55135868439 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -457,6 +457,15 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { membersController: client.memberListController(query: .init(cid: cid, pageSize: 105)) ), animated: true) }), + .init(title: "Show Channel Premium Members", handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + self.rootViewController.present(MembersViewController( + membersController: client.memberListController( + query: .init(cid: cid, filter: .equal("is_premium", to: true), pageSize: 105) + ) + ), animated: true) + }), .init(title: "Show Channel Moderators", handler: { [unowned self] _ in guard let cid = channelController.channel?.cid else { return } let client = channelController.client From 491de96b6be1cc77c4b62ef5fa14c8c300bb2b85 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 12 Nov 2024 16:23:25 +0000 Subject: [PATCH 07/22] Fix test compilation --- .../ChatChannelMember_Mock.swift | 6 ++-- .../Workers/ChannelUpdater_Mock.swift | 26 ++++++++++++++-- .../DummyData/ChatChannelMember.swift | 3 +- .../Endpoints/ChannelEndpoints_Tests.swift | 9 ++++-- .../Workers/ChannelUpdater_Tests.swift | 31 ++++++++++++++----- 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannelMember_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannelMember_Mock.swift index 67e4adcc291..fa4e48ac61f 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannelMember_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannelMember_Mock.swift @@ -32,7 +32,8 @@ public extension ChatChannelMember { isBannedFromChannel: Bool = false, banExpiresAt: Date? = nil, isShadowBannedFromChannel: Bool = false, - notificationsMuted: Bool = false + notificationsMuted: Bool = false, + memberExtraData: [String: RawJSON] = [:] ) -> ChatChannelMember { .init( id: id, @@ -58,7 +59,8 @@ public extension ChatChannelMember { isBannedFromChannel: isBannedFromChannel, banExpiresAt: banExpiresAt, isShadowBannedFromChannel: isShadowBannedFromChannel, - notificationsMuted: notificationsMuted + notificationsMuted: notificationsMuted, + memberExtraData: memberExtraData ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift index 5e589373f84..3d8d6792fd3 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift @@ -48,6 +48,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { @Atomic var addMembers_currentUserId: UserId? @Atomic var addMembers_cid: ChannelId? @Atomic var addMembers_userIds: Set? + @Atomic var addMembers_memberInfos: [MemberInfo]? @Atomic var addMembers_message: String? @Atomic var addMembers_hideHistory: Bool? @Atomic var addMembers_completion: ((Error?) -> Void)? @@ -376,20 +377,39 @@ final class ChannelUpdater_Mock: ChannelUpdater { override func addMembers( currentUserId: UserId?, cid: ChannelId, - userIds: Set, + members: [MemberInfo], message: String?, hideHistory: Bool, completion: ((Error?) -> Void)? = nil ) { addMembers_currentUserId = currentUserId addMembers_cid = cid - addMembers_userIds = userIds + addMembers_userIds = Set(members.map(\.userId)) + addMembers_memberInfos = members addMembers_message = message addMembers_hideHistory = hideHistory addMembers_completion = completion addMembers_completion_result?.invoke(with: completion) } - + + func addMembers( + currentUserId: UserId?, + cid: ChannelId, + userIds: Set, + message: String?, + hideHistory: Bool, + completion: ((Error?) -> Void)? = nil + ) { + self.addMembers( + currentUserId: currentUserId, + cid: cid, + members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, + message: message, + hideHistory: hideHistory, + completion: completion + ) + } + override func inviteMembers( cid: ChannelId, userIds: Set, diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ChatChannelMember.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ChatChannelMember.swift index a251477ce7e..e499bdd2680 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/ChatChannelMember.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/ChatChannelMember.swift @@ -31,7 +31,8 @@ extension ChatChannelMember { isBannedFromChannel: true, banExpiresAt: .unique, isShadowBannedFromChannel: true, - notificationsMuted: false + notificationsMuted: false, + memberExtraData: [:] ) } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift index 422d5c74c0a..edf4ee930d4 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift @@ -299,17 +299,22 @@ final class ChannelEndpoints_Tests: XCTestCase { func test_addMembers_buildsCorrectly() { let cid = ChannelId.unique let userIds: Set = Set([UserId.unique]) + let members = userIds.map { MemberInfoRequest(userId: $0, extraData: nil) } let expectedEndpoint = Endpoint( path: .channelUpdate(cid.apiPath), method: .post, queryItems: nil, requiresConnectionId: false, - body: ["add_members": AnyEncodable(userIds), "hide_history": AnyEncodable(true)] + body: ["add_members": AnyEncodable(members), "hide_history": AnyEncodable(true)] ) // Build endpoint - let endpoint: Endpoint = .addMembers(cid: cid, userIds: userIds, hideHistory: true) + let endpoint: Endpoint = .addMembers( + cid: cid, + members: members, + hideHistory: true + ) // Assert endpoint is built correctly XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) diff --git a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift index 81e74df1b0f..0905cd654e0 100644 --- a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift @@ -1201,10 +1201,18 @@ final class ChannelUpdater_Tests: XCTestCase { let userIds: Set = Set([UserId.unique]) // Simulate `addMembers(cid:, mute:, userIds:)` call - channelUpdater.addMembers(cid: channelID, userIds: userIds, hideHistory: false) + channelUpdater.addMembers( + cid: channelID, + members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, + hideHistory: false + ) // Assert correct endpoint is called - let referenceEndpoint: Endpoint = .addMembers(cid: channelID, userIds: userIds, hideHistory: false) + let referenceEndpoint: Endpoint = .addMembers( + cid: channelID, + members: userIds.map { MemberInfoRequest(userId: $0, extraData: nil) }, + hideHistory: false + ) XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) } @@ -1218,7 +1226,7 @@ final class ChannelUpdater_Tests: XCTestCase { channelUpdater.addMembers( currentUserId: senderId, cid: channelID, - userIds: userIds, + members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, message: message, hideHistory: false ) @@ -1236,7 +1244,7 @@ final class ChannelUpdater_Tests: XCTestCase { ) let referenceEndpoint: Endpoint = .addMembers( cid: channelID, - userIds: userIds, + members: userIds.map { MemberInfoRequest(userId: $0, extraData: nil) }, hideHistory: false, messagePayload: messageRequestBody ) @@ -1249,7 +1257,11 @@ final class ChannelUpdater_Tests: XCTestCase { // Simulate `addMembers(cid:, mute:, userIds:)` call var completionCalled = false - channelUpdater.addMembers(cid: channelID, userIds: userIds, hideHistory: false) { error in + channelUpdater.addMembers( + cid: channelID, + members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, + hideHistory: false + ) { error in XCTAssertNil(error) completionCalled = true } @@ -1268,9 +1280,14 @@ final class ChannelUpdater_Tests: XCTestCase { let channelID = ChannelId.unique let userIds: Set = Set([UserId.unique]) - // Simulate `addMembers(cid:, userIds:, hideHistory:)` call var completionCalledError: Error? - channelUpdater.addMembers(cid: channelID, userIds: userIds, hideHistory: false) { completionCalledError = $0 } + channelUpdater.addMembers( + cid: channelID, + members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, + hideHistory: false + ) { + completionCalledError = $0 + } // Simulate API response with failure let error = TestError() From acb8f5f1be42e0817ba2af6a90b6f4ac93cb8c87 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 12 Nov 2024 17:27:25 +0000 Subject: [PATCH 08/22] Add test coverage --- .../Fixtures/JSONs/Member.json | 1 + .../Workers/ChannelMemberUpdater_Mock.swift | 29 +++++++ .../Endpoints/ChannelEndpoints_Tests.swift | 2 +- .../Endpoints/EndpointPath_Tests.swift | 11 +++ .../Endpoints/MemberEndpoints_Tests.swift | 30 +++++++ .../Payloads/MemberPayload_Tests.swift | 1 + .../ChannelController_Tests.swift | 16 ++-- .../MemberController_Tests.swift | 61 ++++++++++++++ .../Database/DTOs/MemberModelDTO_Tests.swift | 4 +- .../StateLayer/Chat_Tests.swift | 6 +- .../Workers/ChannelMemberUpdater_Tests.swift | 79 +++++++++++++++++++ 11 files changed, 227 insertions(+), 13 deletions(-) diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Member.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Member.json index 3e1d0b6cdad..6bcd7af38b1 100644 --- a/TestTools/StreamChatTestTools/Fixtures/JSONs/Member.json +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Member.json @@ -7,6 +7,7 @@ "ban_expires" : "2021-03-08T15:42:31.355923Z", "user_id" : "broken-waterfall-5", "channel_role" : "owner", + "is_premium": true, "user" : { "id" : "broken-waterfall-5", "banned" : false, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberUpdater_Mock.swift index e0b7a67311e..24ee5c13813 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberUpdater_Mock.swift @@ -20,6 +20,13 @@ final class ChannelMemberUpdater_Mock: ChannelMemberUpdater { @Atomic var unbanMember_completion: ((Error?) -> Void)? @Atomic var unbanMember_completion_result: Result? + @Atomic var partialUpdate_userId: UserId? + @Atomic var partialUpdate_cid: ChannelId? + @Atomic var partialUpdate_extraData: [String: RawJSON]? + @Atomic var partialUpdate_unset: [String]? + @Atomic var partialUpdate_completion: ((Result) -> Void)? + @Atomic var partialUpdate_completion_result: Result? + func cleanUp() { banMember_userId = nil banMember_cid = nil @@ -33,6 +40,13 @@ final class ChannelMemberUpdater_Mock: ChannelMemberUpdater { unbanMember_cid = nil unbanMember_completion = nil unbanMember_completion_result = nil + + partialUpdate_userId = nil + partialUpdate_cid = nil + partialUpdate_extraData = nil + partialUpdate_unset = nil + partialUpdate_completion = nil + partialUpdate_completion_result = nil } override func banMember( @@ -61,4 +75,19 @@ final class ChannelMemberUpdater_Mock: ChannelMemberUpdater { unbanMember_completion = completion unbanMember_completion_result?.invoke(with: completion) } + + override func partialUpdate( + userId: UserId, + in cid: ChannelId, + extraData: [String: RawJSON]?, + unset: [String]?, + completion: @escaping ((Result) -> Void) + ) { + partialUpdate_userId = userId + partialUpdate_cid = cid + partialUpdate_extraData = extraData + partialUpdate_unset = unset + partialUpdate_completion = completion + partialUpdate_completion_result?.invoke(with: completion) + } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift index edf4ee930d4..b9a84400fa7 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift @@ -299,7 +299,7 @@ final class ChannelEndpoints_Tests: XCTestCase { func test_addMembers_buildsCorrectly() { let cid = ChannelId.unique let userIds: Set = Set([UserId.unique]) - let members = userIds.map { MemberInfoRequest(userId: $0, extraData: nil) } + let members = userIds.map { MemberInfoRequest(userId: $0, extraData: ["is_premium": true]) } let expectedEndpoint = Endpoint( path: .channelUpdate(cid.apiPath), diff --git a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift index c6e55ed6c04..4cd58e00010 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift @@ -70,6 +70,16 @@ final class EndpointPathTests: XCTestCase { XCTAssertFalse(EndpointPath.unread.shouldBeQueuedOffline) } + func test_partialMemberUpdate_shouldNOTBeQueuedOffline() { + XCTAssertFalse(EndpointPath.partialMemberUpdate(userId: "1", cid: .unique).shouldBeQueuedOffline) + } + + func test_partialMemberUpdate_value() { + let cid = ChannelId.unique + let path = EndpointPath.partialMemberUpdate(userId: "1", cid: cid).value + XCTAssertEqual(path, "channels/\(cid.apiPath)/member/1") + } + // MARK: - Codable func test_isProperlyEncodedAndDecoded() throws { @@ -78,6 +88,7 @@ final class EndpointPathTests: XCTestCase { assertResultEncodingAndDecoding(.users) assertResultEncodingAndDecoding(.guest) assertResultEncodingAndDecoding(.members) + assertResultEncodingAndDecoding(.partialMemberUpdate(userId: "1", cid: .init(type: .messaging, id: "2"))) assertResultEncodingAndDecoding(.search) assertResultEncodingAndDecoding(.devices) assertResultEncodingAndDecoding(.threads) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MemberEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MemberEndpoints_Tests.swift index f6a0585d63a..bc8f5abe5ec 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MemberEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MemberEndpoints_Tests.swift @@ -29,4 +29,34 @@ final class MemberEndpoints_Tests: XCTestCase { XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) XCTAssertEqual("members", endpoint.path.value) } + + func test_partialMemberUpdate_buildsCorrectly() { + let userId: UserId = "test-user" + let cid: ChannelId = .unique + let extraData: [String: RawJSON] = ["is_premium": .bool(true)] + let unset: [String] = ["is_cool"] + + let body: [String: AnyEncodable] = [ + "set": AnyEncodable(["is_premium": true]), + "unset": AnyEncodable(["is_cool"]) + ] + let expectedEndpoint = Endpoint( + path: .partialMemberUpdate(userId: userId, cid: cid), + method: .patch, + queryItems: nil, + requiresConnectionId: false, + body: body + ) + + // Build endpoint. + let endpoint: Endpoint = .partialMemberUpdate( + userId: userId, + cid: cid, + extraData: extraData, + unset: unset + ) + + // Assert endpoint is built correctly. + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MemberPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MemberPayload_Tests.swift index d9a2a49ba84..c3d5838b513 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MemberPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MemberPayload_Tests.swift @@ -20,6 +20,7 @@ final class MemberPayload_Tests: XCTestCase { XCTAssertEqual(payload.isBanned, true) XCTAssertEqual(payload.isShadowBanned, true) XCTAssertEqual(payload.notificationsMuted, true) + XCTAssertEqual(payload.extraData?["is_premium"], true) XCTAssertNotNil(payload.user) XCTAssertEqual(payload.user!.id, "broken-waterfall-5") diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 492e6d16fab..03edaeb3051 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -3473,11 +3473,11 @@ final class ChannelController_Tests: XCTestCase { // Create `ChannelController` for new channel let query = ChannelQuery(channelPayload: .unique) setupControllerForNewChannel(query: query) - let members: Set = [.unique] + let members: [MemberInfo] = [.init(userId: .unique, extraData: nil)] // Simulate `addMembers` call and assert error is returned var error: Error? = try waitFor { [callbackQueueID] completion in - controller.addMembers(userIds: members) { error in + controller.addMembers(members) { error in AssertTestQueue(withId: callbackQueueID) completion(error) } @@ -3489,7 +3489,7 @@ final class ChannelController_Tests: XCTestCase { // Simulate `addMembers` call and assert no error is returned error = try waitFor { [callbackQueueID] completion in - controller.addMembers(userIds: members) { error in + controller.addMembers(members) { error in AssertTestQueue(withId: callbackQueueID) completion(error) } @@ -3500,11 +3500,11 @@ final class ChannelController_Tests: XCTestCase { } func test_addMembers_callsChannelUpdater() { - let members: Set = [.unique] + let members: [MemberInfo] = [.init(userId: .unique, extraData: ["is_premium": true])] // Simulate `addMembers` call and catch the completion var completionCalled = false - controller.addMembers(userIds: members) { [callbackQueueID] error in + controller.addMembers(members) { [callbackQueueID] error in AssertTestQueue(withId: callbackQueueID) XCTAssertNil(error) completionCalled = true @@ -3519,7 +3519,7 @@ final class ChannelController_Tests: XCTestCase { // Assert cid and members state are passed to `channelUpdater`, completion is not called yet XCTAssertEqual(env.channelUpdater!.addMembers_cid, channelId) - XCTAssertEqual(env.channelUpdater!.addMembers_userIds, members) + XCTAssertEqual(env.channelUpdater!.addMembers_memberInfos?.map(\.userId), members.map(\.userId)) XCTAssertFalse(completionCalled) // Simulate successful update @@ -3534,11 +3534,11 @@ final class ChannelController_Tests: XCTestCase { } func test_addMembers_propagatesErrorFromUpdater() { - let members: Set = [.unique] + let members: [MemberInfo] = [.init(userId: .unique, extraData: nil)] // Simulate `addMembers` call and catch the completion var completionCalledError: Error? - controller.addMembers(userIds: members) { [callbackQueueID] in + controller.addMembers(members) { [callbackQueueID] in AssertTestQueue(withId: callbackQueueID) completionCalledError = $0 } diff --git a/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift b/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift index 2aea5d0c54a..d3ef0d8d692 100644 --- a/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift @@ -483,6 +483,67 @@ final class MemberController_Tests: XCTestCase { XCTAssertEqual(env.memberUpdater!.unbanMember_userId, controller.userId) XCTAssertEqual(env.memberUpdater!.unbanMember_cid, controller.cid) } + + // MARK: - Partial Update + + func test_partialUpdate_propagatesError() { + let expectedError = TestError() + + // Simulate `partialUpdate` call and catch the completion + var receivedResult: Result? + controller.partialUpdate(extraData: ["key": .string("value")], unsetProperties: ["field"]) { [callbackQueueID] result in + AssertTestQueue(withId: callbackQueueID) + receivedResult = result + } + + // Simulate network response with error + env.memberUpdater!.partialUpdate_completion?(.failure(expectedError)) + + // Assert error is propagated + AssertAsync.willBeEqual(receivedResult?.error as? TestError, expectedError) + } + + func test_partialUpdate_propagatesSuccess() { + let expectedMember: ChatChannelMember = .mock(id: .unique) + + // Simulate `partialUpdate` call and catch the completion + var receivedResult: Result? + controller.partialUpdate(extraData: ["key": .string("value")], unsetProperties: ["field"]) { [callbackQueueID] result in + AssertTestQueue(withId: callbackQueueID) + receivedResult = result + } + + // Keep a weak ref so we can check if it's actually deallocated + weak var weakController = controller + + // (Try to) deallocate the controller + // by not keeping any references to it + controller = nil + + // Simulate successful network response + env.memberUpdater!.partialUpdate_completion?(.success(expectedMember)) + // Release reference of completion so we can deallocate stuff + env.memberUpdater!.partialUpdate_completion = nil + + // Assert success is propagated + AssertAsync.willBeEqual(receivedResult?.value?.id, expectedMember.id) + // `weakController` should be deallocated too + AssertAsync.canBeReleased(&weakController) + } + + func test_partialUpdate_callsMemberUpdater_withCorrectValues() { + let extraData: [String: RawJSON] = ["key": .string("value")] + let unsetProperties = ["field1", "field2"] + + // Simulate `partialUpdate` call + controller.partialUpdate(extraData: extraData, unsetProperties: unsetProperties) + + // Assert updater is called with correct values + XCTAssertEqual(env.memberUpdater!.partialUpdate_userId, controller.userId) + XCTAssertEqual(env.memberUpdater!.partialUpdate_cid, controller.cid) + XCTAssertEqual(env.memberUpdater!.partialUpdate_extraData, extraData) + XCTAssertEqual(env.memberUpdater!.partialUpdate_unset, unsetProperties) + } } private class TestEnvironment { diff --git a/Tests/StreamChatTests/Database/DTOs/MemberModelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MemberModelDTO_Tests.swift index d737c8671c9..69952adc0cc 100644 --- a/Tests/StreamChatTests/Database/DTOs/MemberModelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/MemberModelDTO_Tests.swift @@ -50,7 +50,8 @@ final class MemberModelDTO_Tests: XCTestCase { banExpiresAt: .unique, isBanned: true, isShadowBanned: true, - notificationsMuted: true + notificationsMuted: true, + extraData: ["is_premium": .bool(true)] ) // Asynchronously save the payload to the db @@ -82,6 +83,7 @@ final class MemberModelDTO_Tests: XCTestCase { Assert.willBeEqual(payload.user!.extraData, loadedMember?.extraData) Assert.willBeEqual(Set(payload.user!.teams), loadedMember?.teams) Assert.willBeEqual(payload.user!.language!, loadedMember?.language?.languageCode) + Assert.willBeEqual(true, loadedMember?.memberExtraData["is_premium"]?.boolValue) } } diff --git a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift index 768511f8ede..498fc3b91ba 100644 --- a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift @@ -209,10 +209,10 @@ final class Chat_Tests: XCTestCase { func test_addMembers_whenChannelUpdaterSucceeds_thenAddMembersSucceeds() async throws { for hideHistory in [true, false] { env.channelUpdaterMock.addMembers_completion_result = .success(()) - let memberIds: [UserId] = [.unique, .unique] - try await chat.addMembers(memberIds, systemMessage: "My system message", hideHistory: hideHistory) + let members: [MemberInfo] = [.init(userId: .unique, extraData: nil), .init(userId: .unique, extraData: nil)] + try await chat.addMembers(members, systemMessage: "My system message", hideHistory: hideHistory) XCTAssertEqual(channelId, env.channelUpdaterMock.addMembers_cid) - XCTAssertEqual(memberIds.sorted(), env.channelUpdaterMock.addMembers_userIds?.sorted()) + XCTAssertEqual(members.map(\.userId).sorted(), env.channelUpdaterMock.addMembers_userIds?.sorted()) XCTAssertEqual("My system message", env.channelUpdaterMock.addMembers_message) XCTAssertEqual(hideHistory, env.channelUpdaterMock.addMembers_hideHistory) XCTAssertEqual(currentUserId, env.channelUpdaterMock.addMembers_currentUserId) diff --git a/Tests/StreamChatTests/Workers/ChannelMemberUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelMemberUpdater_Tests.swift index e47d624aa8e..f26dfb5fbf0 100644 --- a/Tests/StreamChatTests/Workers/ChannelMemberUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelMemberUpdater_Tests.swift @@ -135,4 +135,83 @@ final class ChannelMemberUpdater_Tests: XCTestCase { // Assert the completion is called with the error AssertAsync.willBeEqual(completionCalledError as? TestError, error) } + + // MARK: - Partial Update + + func test_partialUpdate_makesCorrectAPICall() { + let userId: UserId = .unique + let cid: ChannelId = .unique + let extraData: [String: RawJSON] = ["key": .string("value")] + let unset: [String] = ["field1"] + + // Simulate `partialUpdate` call + updater.partialUpdate( + userId: userId, + in: cid, + extraData: extraData, + unset: unset, + completion: { _ in } + ) + + // Assert correct endpoint is called + XCTAssertEqual( + apiClient.request_endpoint, + AnyEndpoint( + .partialMemberUpdate( + userId: userId, + cid: cid, + extraData: extraData, + unset: unset + ) + ) + ) + } + + func test_partialUpdate_propagatesSuccessfulResponse() { + let cid: ChannelId = .unique + let memberPayload: MemberPayload = .dummy() + + // Simulate `partialUpdate` call + var completionResult: Result? + updater.partialUpdate( + userId: .unique, + in: cid, + extraData: nil, + unset: nil + ) { result in + completionResult = result + } + + // Simulate API response with success + let response = PartialMemberUpdateResponse(channelMember: memberPayload) + apiClient.test_simulateResponse(Result.success(response)) + + // Assert completion is called with the member + AssertAsync { + Assert.willBeTrue(completionResult?.value?.id == memberPayload.userId) + } + } + + func test_partialUpdate_propagatesError() { + // Simulate `partialUpdate` call + var completionResult: Result? + updater.partialUpdate( + userId: .unique, + in: .unique, + extraData: nil, + unset: nil + ) { result in + completionResult = result + } + + // Simulate API response with failure + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + // Assert the completion is called with the error + AssertAsync { + Assert.willBeTrue(completionResult?.isError == true) + Assert.willBeEqual(completionResult?.error as? TestError, error) + } + } } From b90bee308194314317baa9b3937bc230ab190bab Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 12 Nov 2024 19:26:05 +0000 Subject: [PATCH 09/22] Update CHANGELOG.md --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b1636776a..e13dd64bea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Add support for channel member extra data [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) +- Add `ChatChannelMemberController.partialUpdate(extraData:unsetProperties:)` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) +- Add `ChatChannelController.addMembers(_ members: [MemberInfo])` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) +- Exposes `ChatChannelMember.memberExtraData` property [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) ### 🐞 Fixed - Fix connection not resuming after guest user goes to background [#3483](https://github.com/GetStream/stream-chat-swift/pull/3483) +### 🔄 Changed +- Deprecates `ChatChannelController.addMembers(userIds: [UserId])` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) # [4.66.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.66.0) _November 05, 2024_ From c0f14dc78b604fd669320e9ccee47102e6e1d2d8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 15:58:05 +0000 Subject: [PATCH 10/22] Update gitignore to include VSCode / Cursor --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 8acf877a289..4b5fc4c0cd6 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,7 @@ Dependencies/ # Ignore internal scheme StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme + +# VSCode +.vscode +buildServer.json From 964be7ad149668ea00928037407b8479015ed7b6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 15:58:47 +0000 Subject: [PATCH 11/22] Add `CurrentUserController.updateMemberData()` --- .../CurrentUserController.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index b2aa812c6e3..8f16833491d 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -83,6 +83,9 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt client.apiClient ) + /// The worker used to update the current user member for a given channel. + private lazy var currentMemberUpdater = createMemberUpdater() + /// Creates a new `CurrentUserControllerGeneric`. /// /// - Parameters: @@ -195,6 +198,38 @@ public extension CurrentChatUserController { } } + /// Updates the current user member data in a specific channel. + /// + /// **Note**: If you want to observe member changes in real-time, use the `ChatClient.memberController()`. + /// + /// - Parameters: + /// - extraData: The additional data to be added to the member object. + /// - unsetProperties: The custom properties to be removed from the member object. + /// - channelId: The channel where the member data is updated. + /// - completion: Returns the updated member object or an error if the update fails. + func updateMemberData( + _ extraData: [String: RawJSON], + unsetProperties: [String]? = nil, + in channelId: ChannelId, + completion: ((Result) -> Void)? = nil + ) { + guard let currentUserId = client.currentUserId else { + completion?(.failure(ClientError.CurrentUserDoesNotExist())) + return + } + + currentMemberUpdater.partialUpdate( + userId: currentUserId, + in: channelId, + extraData: extraData, + unset: unsetProperties + ) { result in + self.callback { + completion?(result) + } + } + } + /// Fetches the most updated devices and syncs with the local database. /// - Parameter completion: Called when the devices are synced successfully, or with error. func synchronizeDevices(completion: ((Error?) -> Void)? = nil) { @@ -298,6 +333,10 @@ public extension CurrentChatUserController { } } } + + private func createMemberUpdater() -> ChannelMemberUpdater { + .init(database: client.databaseContainer, apiClient: client.apiClient) + } } // MARK: - Environment From b243982f7a83369a0c3282b1255325898ac4d538 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 15:59:04 +0000 Subject: [PATCH 12/22] Make unsetProperties optional --- .../Controllers/MemberController/MemberController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/MemberController/MemberController.swift b/Sources/StreamChat/Controllers/MemberController/MemberController.swift index 7c167081d82..53321ede1e9 100644 --- a/Sources/StreamChat/Controllers/MemberController/MemberController.swift +++ b/Sources/StreamChat/Controllers/MemberController/MemberController.swift @@ -161,7 +161,7 @@ public extension ChatChannelMemberController { /// - unsetProperties: Properties from the member to be cleared/unset. func partialUpdate( extraData: [String: RawJSON]?, - unsetProperties: [String]?, + unsetProperties: [String]? = nil, completion: ((Result) -> Void)? = nil ) { memberUpdater.partialUpdate( From 863766256fccbf7292a0c9559f9705daa2f1f0a9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 15:59:22 +0000 Subject: [PATCH 13/22] Only show Premium member feature in the Demo App if enabled --- .../AppConfigViewController.swift | 11 +- .../DemoChatChannelListRouter.swift | 235 +++++++++++------- 2 files changed, 151 insertions(+), 95 deletions(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 2fb096a2bcb..e95be7d94dc 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -22,6 +22,8 @@ struct DemoAppConfig { var tokenRefreshDetails: TokenRefreshDetails? /// A Boolean value that determines if a connection banner UI should be shown. var shouldShowConnectionBanner: Bool + /// A Boolean value to define if the premium member feature is enabled. This is to test custom member data. + var isPremiumMemberFeatureEnabled: Bool /// The details to generate expirable tokens in the demo app. struct TokenRefreshDetails { @@ -50,7 +52,8 @@ class AppConfig { isChannelPinningEnabled: false, isLocationAttachmentsEnabled: false, tokenRefreshDetails: nil, - shouldShowConnectionBanner: false + shouldShowConnectionBanner: false, + isPremiumMemberFeatureEnabled: false ) if StreamRuntimeCheck.isStreamInternalConfiguration { @@ -60,6 +63,7 @@ class AppConfig { demoAppConfig.isLocationAttachmentsEnabled = true demoAppConfig.isHardDeleteEnabled = true demoAppConfig.shouldShowConnectionBanner = true + demoAppConfig.isPremiumMemberFeatureEnabled = true StreamRuntimeCheck.assertionsEnabled = true } } @@ -169,6 +173,7 @@ class AppConfigViewController: UITableViewController { case isLocationAttachmentsEnabled case tokenRefreshDetails case shouldShowConnectionBanner + case isPremiumMemberFeatureEnabled } enum ComponentsConfigOption: String, CaseIterable { @@ -326,6 +331,10 @@ class AppConfigViewController: UITableViewController { cell.accessoryView = makeSwitchButton(demoAppConfig.shouldShowConnectionBanner) { [weak self] newValue in self?.demoAppConfig.shouldShowConnectionBanner = newValue } + case .isPremiumMemberFeatureEnabled: + cell.accessoryView = makeSwitchButton(demoAppConfig.isPremiumMemberFeatureEnabled) { [weak self] newValue in + self?.demoAppConfig.isPremiumMemberFeatureEnabled = newValue + } } } diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 55135868439..3b26ff93866 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -78,8 +78,9 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { let canMuteChannel = channelController.channel?.canMuteChannel == true let canSetChannelCooldown = channelController.channel?.canSetChannelCooldown == true let canSendMessage = channelController.channel?.canSendMessage == true + let isPremiumMemberFeatureEnabled = AppConfig.shared.demoAppConfig.isPremiumMemberFeatureEnabled - rootViewController.presentAlert(title: "Select an action", actions: [ + let actions: [UIAlertAction?] = [ .init(title: "Change nav bar translucency", handler: { [unowned self] _ in self.rootViewController.presentAlert( title: "Change nav bar translucency", @@ -158,6 +159,50 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), + .init(title: "Show Channel Info", handler: { [unowned self] _ in + let debugViewController = DebugObjectViewController(object: channelController.channel) + self.rootViewController.present(debugViewController, animated: true) + }), + .init(title: "Show Channel Members", handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + self.rootViewController.present(MembersViewController( + membersController: client.memberListController(query: .init(cid: cid, pageSize: 105)) + ), animated: true) + }), + .init(title: "Show Channel Moderators", handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + self.rootViewController.present(MembersViewController( + membersController: client.memberListController( + query: .init(cid: cid, filter: .equal(.isModerator, to: true)) + ) + ), animated: true) + }), + .init(title: "Show Banned Members", handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + self.rootViewController.present(MembersViewController( + membersController: client.memberListController( + query: .init(cid: cid, filter: .equal(.banned, to: true)) + ) + ), animated: true) + }), + .init(title: "Show Blocked Users", handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + client.currentUserController().loadBlockedUsers { result in + guard let blockedUsers = try? result.get() else { return } + self.rootViewController.present(MembersViewController( + membersController: client.memberListController( + query: .init( + cid: cid, + filter: .in(.id, values: blockedUsers.map(\.userId)) + ) + ) + ), animated: true) + } + }), .init(title: "Add member", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in self.rootViewController.presentAlert(title: "Enter user id", textFieldPlaceholder: "User ID") { id in guard let id = id, !id.isEmpty else { @@ -197,45 +242,6 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), - .init(title: "Add premium member", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in - self.rootViewController.presentAlert(title: "Enter user id", textFieldPlaceholder: "User ID") { id in - guard let id = id, !id.isEmpty else { - self.rootViewController.presentAlert(title: "User ID is not valid") - return - } - channelController.addMembers( - [MemberInfo(userId: id, extraData: ["is_premium": true])], - message: "Premium member added to the channel" - ) { error in - if let error = error { - self.rootViewController.presentAlert( - title: "Couldn't add user \(id) to channel \(cid)", - message: "\(error)" - ) - } - } - } - }), - .init(title: "Set member as premium", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in - let actions = channelController.channel?.lastActiveMembers.map { member in - UIAlertAction(title: member.id, style: .default) { _ in - channelController.client.memberController(userId: member.id, in: cid) - .partialUpdate(extraData: ["is_premium": true], unsetProperties: nil) { [unowned self] result in - do { - let data = try result.get() - print("Member updated. Premium: ", data.isPremium) - self.rootNavigationController?.popViewController(animated: true) - } catch { - self.rootViewController.presentAlert( - title: "Couldn't set user \(member.id) as premium.", - message: "\(error)" - ) - } - } - } - } ?? [] - self.rootViewController.presentAlert(title: "Select a member", actions: actions) - }), .init(title: "Remove a member", isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in let actions = channelController.channel?.lastActiveMembers.map { member in UIAlertAction(title: member.id, style: .default) { _ in @@ -307,6 +313,69 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } ?? [] self.rootViewController.presentAlert(title: "Select a member", actions: actions) }), + .init(title: "Show Channel Premium Members", isVisible: isPremiumMemberFeatureEnabled, handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + self.rootViewController.present(MembersViewController( + membersController: client.memberListController( + query: .init(cid: cid, filter: .equal("is_premium", to: true), pageSize: 105) + ) + ), animated: true) + }), + .init(title: "Add premium member", isVisible: isPremiumMemberFeatureEnabled, isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + self.rootViewController.presentAlert(title: "Enter user id", textFieldPlaceholder: "User ID") { id in + guard let id = id, !id.isEmpty else { + self.rootViewController.presentAlert(title: "User ID is not valid") + return + } + channelController.addMembers( + [MemberInfo(userId: id, extraData: ["is_premium": true])], + message: "Premium member added to the channel" + ) { error in + if let error = error { + self.rootViewController.presentAlert( + title: "Couldn't add user \(id) to channel \(cid)", + message: "\(error)" + ) + } + } + } + }), + .init(title: "Set member as premium", isVisible: isPremiumMemberFeatureEnabled, isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + let actions = channelController.channel?.lastActiveMembers.map { member in + UIAlertAction(title: member.id, style: .default) { _ in + channelController.client.memberController(userId: member.id, in: cid) + .partialUpdate(extraData: ["is_premium": true], unsetProperties: nil) { [unowned self] result in + do { + let data = try result.get() + print("Member updated. Premium: ", data.isPremium) + self.rootNavigationController?.popViewController(animated: true) + } catch { + self.rootViewController.presentAlert( + title: "Couldn't set user \(member.id) as premium.", + message: "\(error)" + ) + } + } + } + } ?? [] + self.rootViewController.presentAlert(title: "Select a member", actions: actions) + }), + .init(title: "Set current member as premium", isVisible: isPremiumMemberFeatureEnabled, isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + channelController.client.currentUserController() + .updateMemberData(["is_premium": true], in: cid) { [unowned self] result in + do { + let data = try result.get() + print("Member updated. Premium: ", data.isPremium) + self.rootNavigationController?.popViewController(animated: true) + } catch { + self.rootViewController.presentAlert( + title: "Couldn't set current user as premium.", + message: "\(error)" + ) + } + } + }), .init(title: "Freeze channel", isEnabled: canFreezeChannel, handler: { [unowned self] _ in channelController.freezeChannel { error in if let error = error { @@ -446,59 +515,6 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), - .init(title: "Show Channel Info", handler: { [unowned self] _ in - let debugViewController = DebugObjectViewController(object: channelController.channel) - self.rootViewController.present(debugViewController, animated: true) - }), - .init(title: "Show Channel Members", handler: { [unowned self] _ in - guard let cid = channelController.channel?.cid else { return } - let client = channelController.client - self.rootViewController.present(MembersViewController( - membersController: client.memberListController(query: .init(cid: cid, pageSize: 105)) - ), animated: true) - }), - .init(title: "Show Channel Premium Members", handler: { [unowned self] _ in - guard let cid = channelController.channel?.cid else { return } - let client = channelController.client - self.rootViewController.present(MembersViewController( - membersController: client.memberListController( - query: .init(cid: cid, filter: .equal("is_premium", to: true), pageSize: 105) - ) - ), animated: true) - }), - .init(title: "Show Channel Moderators", handler: { [unowned self] _ in - guard let cid = channelController.channel?.cid else { return } - let client = channelController.client - self.rootViewController.present(MembersViewController( - membersController: client.memberListController( - query: .init(cid: cid, filter: .equal(.isModerator, to: true)) - ) - ), animated: true) - }), - .init(title: "Show Banned Members", handler: { [unowned self] _ in - guard let cid = channelController.channel?.cid else { return } - let client = channelController.client - self.rootViewController.present(MembersViewController( - membersController: client.memberListController( - query: .init(cid: cid, filter: .equal(.banned, to: true)) - ) - ), animated: true) - }), - .init(title: "Show Blocked Users", handler: { [unowned self] _ in - guard let cid = channelController.channel?.cid else { return } - let client = channelController.client - client.currentUserController().loadBlockedUsers { result in - guard let blockedUsers = try? result.get() else { return } - self.rootViewController.present(MembersViewController( - membersController: client.memberListController( - query: .init( - cid: cid, - filter: .in(.id, values: blockedUsers.map(\.userId)) - ) - ) - ), animated: true) - } - }), .init(title: "Truncate channel w/o message", isEnabled: canUpdateChannel, handler: { _ in channelController.truncateChannel { [unowned self] error in if let error = error { @@ -602,7 +618,12 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { self.rootViewController.presentAlert(title: error.localizedDescription) } }) - ]) + ] + + rootViewController.presentAlert( + title: "Select an action", + actions: actions.compactMap { $0 } + ) } // swiftlint:enable function_body_length @@ -618,6 +639,12 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } private extension UIAlertAction { + /// Convenience initializer to create an alert action with a custom isEnabled flag. + /// - Parameters: + /// - title: The title of the action. + /// - isEnabled: The flag saying if the action is enabled. + /// - style: The style of the action. + /// - handler: The block to execute when the user selects the action. convenience init( title: String?, isEnabled: Bool = true, @@ -631,4 +658,24 @@ private extension UIAlertAction { ) self.isEnabled = isEnabled } + + /// Convenience initializer to create an alert action only if should be visible. + /// - Parameters: + /// - title: The title of the action. + /// - isVisible: The flag saying if the action should be visible. + /// - isEnabled: The flag saying if the action is enabled. + /// - style: The style of the action. + /// - handler: The block to execute when the user selects the action. + convenience init?( + title: String?, + isVisible: Bool = true, + isEnabled: Bool = true, + style: Style = .default, + handler: ((UIAlertAction) -> Void)? + ) { + if !isVisible { + return nil + } + self.init(title: title, isEnabled: isEnabled, style: style, handler: handler) + } } From 978b9ec0ccd27df434d3bf6ed7da11acaae27089 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:00:51 +0000 Subject: [PATCH 14/22] Make the DemoApp-StreamDevelopers scheme accessible --- .gitignore | 3 - .../DemoApp-StreamDevelopers.xcscheme | 90 +++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme diff --git a/.gitignore b/.gitignore index 4b5fc4c0cd6..3d34c5774fd 100644 --- a/.gitignore +++ b/.gitignore @@ -102,9 +102,6 @@ Products/ # Ignore Dependencies folder Dependencies/ -# Ignore internal scheme -StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme - # VSCode .vscode buildServer.json diff --git a/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme b/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme new file mode 100644 index 00000000000..fe8fca18fa3 --- /dev/null +++ b/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From c8676cf52d6a7178863e0df409dda7baefcbbca4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:01:58 +0000 Subject: [PATCH 15/22] Make MemberInfo.extraData optional --- Sources/StreamChat/Models/Member.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Models/Member.swift b/Sources/StreamChat/Models/Member.swift index 2265bccdedf..d61a76a4ca3 100644 --- a/Sources/StreamChat/Models/Member.swift +++ b/Sources/StreamChat/Models/Member.swift @@ -156,7 +156,7 @@ public struct MemberInfo { public var userId: UserId public var extraData: [String: RawJSON]? - public init(userId: UserId, extraData: [String: RawJSON]?) { + public init(userId: UserId, extraData: [String: RawJSON]? = nil) { self.userId = userId self.extraData = extraData } From 920ddced22dfd566dd1d541d59922e8939a3d7e6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:03:07 +0000 Subject: [PATCH 16/22] Do not deprecate the previous method --- .../Controllers/ChannelController/ChannelController.swift | 1 - Sources/StreamChat/StateLayer/Chat.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 0305a918877..891123dd2fe 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -873,7 +873,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - message: Optional system message sent when adding members. /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. /// If request fails, the completion will be called with an error. - @available(*, deprecated, message: "A new addMembers function is now available that supports adding MemberInfo instead of only the user id.") public func addMembers( userIds: Set, hideHistory: Bool = false, diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 46d85d6bac0..079fa18fcc8 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -225,7 +225,6 @@ public class Chat { /// - hideHistory: If true, the previous history is available for added members, otherwise they do not see the history. The default value is false. /// /// - Throws: An error while communicating with the Stream API. - @available(*, deprecated, message: "A new addMembers function is now available that supports adding MemberInfo instead of only the user id.") public func addMembers( _ members: [UserId], systemMessage: String? = nil, From d69c13073ee416d6cd0d9b8073e8bd5863f95852 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:08:13 +0000 Subject: [PATCH 17/22] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89dd55e851d..b2ec30eeaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add support for channel member extra data [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) - Add `ChatChannelMemberController.partialUpdate(extraData:unsetProperties:)` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) - Add `ChatChannelController.addMembers(_ members: [MemberInfo])` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) +- Add `CurrentUserController.updateMemberData()` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) - Exposes `ChatChannelMember.memberExtraData` property [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) ### 🐞 Fixed - Fix connection not resuming after guest user goes to background [#3483](https://github.com/GetStream/stream-chat-swift/pull/3483) From 334f4cab59da346f4145b7bd1fd33e5e8dbfc9c1 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:11:43 +0000 Subject: [PATCH 18/22] Updating current user member extra data does not require capability --- DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 3b26ff93866..5db993390d3 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -361,7 +361,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } ?? [] self.rootViewController.presentAlert(title: "Select a member", actions: actions) }), - .init(title: "Set current member as premium", isVisible: isPremiumMemberFeatureEnabled, isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + .init(title: "Set current member as premium", isVisible: isPremiumMemberFeatureEnabled, handler: { [unowned self] _ in channelController.client.currentUserController() .updateMemberData(["is_premium": true], in: cid) { [unowned self] result in do { From 357fed2483ae96e03cd76290d4623473c6493ca3 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:15:27 +0000 Subject: [PATCH 19/22] Update CHANGELOG.md --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ec30eeaae..256ea540a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🐞 Fixed - Fix connection not resuming after guest user goes to background [#3483](https://github.com/GetStream/stream-chat-swift/pull/3483) - Fix empty channel list if the channel list filter contains OR statement with only custom filtering keys [#3482](https://github.com/GetStream/stream-chat-swift/pull/3482) -### 🔄 Changed -- Deprecates `ChatChannelController.addMembers(userIds: [UserId])` [#3487](https://github.com/GetStream/stream-chat-swift/pull/3487) # [4.66.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.66.0) _November 05, 2024_ From 0dab83f6b45551654170c6ea18f452aabb393597 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:26:12 +0000 Subject: [PATCH 20/22] Fix AddMemberInput typo --- .../Controllers/ChannelController/ChannelController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 891123dd2fe..58c518c3aea 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -834,7 +834,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// Add users to the channel as members with additional data. /// /// - Parameters: - /// - members: An array of `AddMemberInput` objects, each representing a member to be added to the channel. + /// - members: An array of `MemberInfo` objects, each representing a member to be added to the channel. /// - hideHistory: Hide the history of the channel to the added member. By default, it is false. /// - message: Optional system message sent when adding members. /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. From 2779b47d74ce1b71b31bdc7f620379325ea712bc Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 14 Nov 2024 16:34:36 +0000 Subject: [PATCH 21/22] Do not show premium badge on the demo app if feature is disabled --- DemoApp/Screens/MembersViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/MembersViewController.swift b/DemoApp/Screens/MembersViewController.swift index 2a888d79530..8ad55a19a53 100644 --- a/DemoApp/Screens/MembersViewController.swift +++ b/DemoApp/Screens/MembersViewController.swift @@ -12,6 +12,10 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl let membersController: ChatChannelMemberListController private var members: [ChatChannelMember] = [] + var isPremiumMemberFeatureEnabled: Bool { + AppConfig.shared.demoAppConfig.isPremiumMemberFeatureEnabled + } + init(membersController: ChatChannelMemberListController) { self.membersController = membersController super.init(style: .insetGrouped) @@ -56,7 +60,7 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl } cell.nameLabel.text = member.name ?? member.id cell.removeButton.isHidden = true - cell.premiumImageView.isHidden = member.isPremium == false + cell.premiumImageView.isHidden = member.isPremium == false || !isPremiumMemberFeatureEnabled return cell } From ab4bc50fcc7bc60fc6f6eb7e9cd58d25105ed53f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 18 Nov 2024 16:41:54 +0000 Subject: [PATCH 22/22] Add unset premium member action in Demo App --- .../DemoChatChannelListRouter.swift | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 5db993390d3..50eda2daa8f 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -170,6 +170,15 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { membersController: client.memberListController(query: .init(cid: cid, pageSize: 105)) ), animated: true) }), + .init(title: "Show Channel Premium Members", isVisible: isPremiumMemberFeatureEnabled, handler: { [unowned self] _ in + guard let cid = channelController.channel?.cid else { return } + let client = channelController.client + self.rootViewController.present(MembersViewController( + membersController: client.memberListController( + query: .init(cid: cid, filter: .equal("is_premium", to: true), pageSize: 105) + ) + ), animated: true) + }), .init(title: "Show Channel Moderators", handler: { [unowned self] _ in guard let cid = channelController.channel?.cid else { return } let client = channelController.client @@ -313,15 +322,6 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } ?? [] self.rootViewController.presentAlert(title: "Select a member", actions: actions) }), - .init(title: "Show Channel Premium Members", isVisible: isPremiumMemberFeatureEnabled, handler: { [unowned self] _ in - guard let cid = channelController.channel?.cid else { return } - let client = channelController.client - self.rootViewController.present(MembersViewController( - membersController: client.memberListController( - query: .init(cid: cid, filter: .equal("is_premium", to: true), pageSize: 105) - ) - ), animated: true) - }), .init(title: "Add premium member", isVisible: isPremiumMemberFeatureEnabled, isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in self.rootViewController.presentAlert(title: "Enter user id", textFieldPlaceholder: "User ID") { id in guard let id = id, !id.isEmpty else { @@ -376,6 +376,26 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), + .init(title: "Unset premium from member", isVisible: isPremiumMemberFeatureEnabled, isEnabled: canUpdateChannelMembers, handler: { [unowned self] _ in + let actions = channelController.channel?.lastActiveMembers.map { member in + UIAlertAction(title: member.id, style: .default) { _ in + channelController.client.memberController(userId: member.id, in: cid) + .partialUpdate(extraData: nil, unsetProperties: ["is_premium"]) { [unowned self] result in + do { + let data = try result.get() + print("Member updated. Premium: ", data.isPremium) + self.rootNavigationController?.popViewController(animated: true) + } catch { + self.rootViewController.presentAlert( + title: "Couldn't set user \(member.id) as premium.", + message: "\(error)" + ) + } + } + } + } ?? [] + self.rootViewController.presentAlert(title: "Select a member", actions: actions) + }), .init(title: "Freeze channel", isEnabled: canFreezeChannel, handler: { [unowned self] _ in channelController.freezeChannel { error in if let error = error {