From 6015464ffeb97a8bd199fc37cb661afc2bd2f886 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Jul 2025 11:57:42 +0100 Subject: [PATCH 01/85] 1st WIP --- .../LivestreamChannelController.swift | 564 ++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 6 + 2 files changed, 570 insertions(+) create mode 100644 Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift new file mode 100644 index 0000000000..a0e6d5ea69 --- /dev/null +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -0,0 +1,564 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A controller for managing livestream channels that operates without local database persistence. +/// Unlike `ChatChannelController`, this controller manages all data in memory and communicates directly with the API. +public class LivestreamChannelController { + // MARK: - Public Properties + + /// The ChannelQuery this controller observes. + @Atomic public private(set) var channelQuery: ChannelQuery + + /// The identifier of a channel this controller observes. + public var cid: ChannelId? { channelQuery.cid } + + /// The `ChatClient` instance this controller belongs to. + public let client: ChatClient + + /// The channel the controller represents. + /// This is managed in memory and updated via API calls. + @Atomic public private(set) var channel: ChatChannel? + + /// The messages of the channel the controller represents. + /// This is managed in memory and updated via API calls. + @Atomic public private(set) var messages: [ChatMessage] = [] + + /// A Boolean value that returns whether the oldest messages have all been loaded or not. + public var hasLoadedAllPreviousMessages: Bool { + paginationStateHandler.state.hasLoadedAllPreviousMessages + } + + /// A Boolean value that returns whether the newest messages have all been loaded or not. + public var hasLoadedAllNextMessages: Bool { + paginationStateHandler.state.hasLoadedAllNextMessages || messages.isEmpty + } + + /// A Boolean value that returns whether the channel is currently loading previous (old) messages. + public var isLoadingPreviousMessages: Bool { + paginationStateHandler.state.isLoadingPreviousMessages + } + + /// A Boolean value that returns whether the channel is currently loading next (new) messages. + public var isLoadingNextMessages: Bool { + paginationStateHandler.state.isLoadingNextMessages + } + + /// A Boolean value that returns whether the channel is currently loading a page around a message. + public var isLoadingMiddleMessages: Bool { + paginationStateHandler.state.isLoadingMiddleMessages + } + + /// A Boolean value that returns whether the channel is currently in a mid-page. + public var isJumpingToMessage: Bool { + paginationStateHandler.state.isJumpingToMessage + } + + /// The id of the first unread message for the current user. + public var firstUnreadMessageId: MessageId? { + channel.flatMap { getFirstUnreadMessageId(for: $0) } + } + + /// The id of the message which the current user last read. + public var lastReadMessageId: MessageId? { + client.currentUserId.flatMap { channel?.lastReadMessageId(userId: $0) } + } + + /// Set the delegate to observe the changes in the system. + public weak var delegate: LivestreamChannelControllerDelegate? + + // MARK: - Private Properties + + /// The API client for making direct API calls + private let apiClient: APIClient + + /// Pagination state handler for managing message pagination + private let paginationStateHandler: MessagesPaginationStateHandling + + /// Flag indicating whether channel is created on backend + private var isChannelAlreadyCreated: Bool + + /// Current user ID for convenience + private var currentUserId: UserId? { client.currentUserId } + + // MARK: - Initialization + + /// Creates a new `LivestreamChannelController` + /// - Parameters: + /// - channelQuery: channel query for observing changes + /// - client: The `Client` this controller belongs to. + /// - isChannelAlreadyCreated: Flag indicating whether channel is created on backend. + public init( + channelQuery: ChannelQuery, + client: ChatClient, + isChannelAlreadyCreated: Bool = true + ) { + self.channelQuery = channelQuery + self.client = client + apiClient = client.apiClient + self.isChannelAlreadyCreated = isChannelAlreadyCreated + paginationStateHandler = MessagesPaginationStateHandler() + } + + // MARK: - Public Methods + + /// Synchronizes the controller with the backend data + /// - Parameter completion: Called when the synchronization is finished + public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { + updateChannelData( + channelQuery: channelQuery, + completion: completion + ) + } + + /// Loads previous messages from backend. + /// - Parameters: + /// - messageId: ID of the last fetched message. You will get messages `older` than the provided ID. + /// - limit: Limit for page size. By default it is 25. + /// - completion: Called when the network request is finished. + public func loadPreviousMessages( + before messageId: MessageId? = nil, + limit: Int? = nil, + completion: ((Error?) -> Void)? = nil + ) { + guard cid != nil, isChannelAlreadyCreated else { + completion?(ClientError.ChannelNotCreatedYet()) + return + } + + let messageId = messageId ?? paginationStateHandler.state.oldestFetchedMessage?.id ?? lastLocalMessageId() + guard let messageId = messageId else { + completion?(ClientError.ChannelEmptyMessages()) + return + } + + guard !hasLoadedAllPreviousMessages && !isLoadingPreviousMessages else { + completion?(nil) + return + } + + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize + var query = channelQuery + query.pagination = MessagesPagination(pageSize: limit, parameter: .lessThan(messageId)) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Loads next messages from backend. + /// - Parameters: + /// - messageId: ID of the current first message. You will get messages `newer` than the provided ID. + /// - limit: Limit for page size. By default it is 25. + /// - completion: Called when the network request is finished. + public func loadNextMessages( + after messageId: MessageId? = nil, + limit: Int? = nil, + completion: ((Error?) -> Void)? = nil + ) { + guard cid != nil, isChannelAlreadyCreated else { + completion?(ClientError.ChannelNotCreatedYet()) + return + } + + let messageId = messageId ?? paginationStateHandler.state.newestFetchedMessage?.id ?? messages.first?.id + guard let messageId = messageId else { + completion?(ClientError.ChannelEmptyMessages()) + return + } + + guard !hasLoadedAllNextMessages && !isLoadingNextMessages else { + completion?(nil) + return + } + + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize + var query = channelQuery + query.pagination = MessagesPagination(pageSize: limit, parameter: .greaterThan(messageId)) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Load messages around the given message id. + /// - Parameters: + /// - messageId: The message id of the message to jump to. + /// - limit: The number of messages to load in total, including the message to jump to. + /// - completion: Callback when the API call is completed. + public func loadPageAroundMessageId( + _ messageId: MessageId, + limit: Int? = nil, + completion: ((Error?) -> Void)? = nil + ) { + guard isChannelAlreadyCreated else { + completion?(ClientError.ChannelNotCreatedYet()) + return + } + + guard !isLoadingMiddleMessages else { + completion?(nil) + return + } + + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize + var query = channelQuery + query.pagination = MessagesPagination(pageSize: limit, parameter: .around(messageId)) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Cleans the current state and loads the first page again. + /// - Parameter completion: Callback when the API call is completed. + public func loadFirstPage(_ completion: ((_ error: Error?) -> Void)? = nil) { + var query = channelQuery + query.pagination = .init( + pageSize: channelQuery.pagination?.pageSize ?? .messagesPageSize, + parameter: nil + ) + + // Clear current messages when loading first page + messages = [] + + updateChannelData(channelQuery: query, completion: completion) + } + + // MARK: - Helper Methods + + public func getFirstUnreadMessageId(for channel: ChatChannel) -> MessageId? { + UnreadMessageLookup.firstUnreadMessageId( + in: channel, + messages: StreamCollection(messages), + hasLoadedAllPreviousMessages: hasLoadedAllPreviousMessages, + currentUserId: client.currentUserId + ) + } + + // MARK: - Private Methods + + private func updateChannelData( + channelQuery: ChannelQuery, + completion: ((Error?) -> Void)? = nil + ) { + if let pagination = channelQuery.pagination { + paginationStateHandler.begin(pagination: pagination) + } + + let isChannelCreate = !isChannelAlreadyCreated + let endpoint: Endpoint = isChannelCreate ? + .createChannel(query: channelQuery) : + .updateChannel(query: channelQuery) + + let requestCompletion: (Result) -> Void = { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let payload): + self.handleChannelPayload(payload, channelQuery: channelQuery) + completion?(nil) + + case .failure(let error): + if let pagination = channelQuery.pagination { + self.paginationStateHandler.end(pagination: pagination, with: .failure(error)) + } + completion?(error) + } + } + + apiClient.request(endpoint: endpoint, completion: requestCompletion) + } + + private func handleChannelPayload(_ payload: ChannelPayload, channelQuery: ChannelQuery) { + // Update pagination state + if let pagination = channelQuery.pagination { + paginationStateHandler.end(pagination: pagination, with: .success(payload.messages)) + } + + // Mark channel as created if it was a create operation + if !isChannelAlreadyCreated { + isChannelAlreadyCreated = true + // Update the channel query with the actual cid if it was generated + self.channelQuery = ChannelQuery(cid: payload.channel.cid, channelQuery: channelQuery) + } + + // Convert payloads to models + let newChannel = mapChannelPayload(payload) + let newMessages = payload.messages.compactMap { mapMessagePayload($0, cid: payload.channel.cid) } + + // Update channel + let oldChannel = channel + channel = newChannel + + // Update messages based on pagination type + updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) + + // Notify delegate + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let oldChannel = oldChannel { + self.delegate?.livestreamChannelController(self, didUpdateChannel: .update(newChannel)) + } else { + self.delegate?.livestreamChannelController(self, didUpdateChannel: .create(newChannel)) + } + + self.delegate?.livestreamChannelController(self, didUpdateMessages: self.messages) + } + } + + private func updateMessagesArray(with newMessages: [ChatMessage], pagination: MessagesPagination?) { + switch pagination?.parameter { + case .lessThan, .lessThanOrEqual: + // Loading older messages - append to end + messages.append(contentsOf: newMessages) + + case .greaterThan, .greaterThanOrEqual: + // Loading newer messages - insert at beginning + messages.insert(contentsOf: newMessages, at: 0) + + case .around, .none: + // Loading around a message or first page - replace all + messages = newMessages + } + } + + private func mapChannelPayload(_ payload: ChannelPayload) -> ChatChannel { + let channelPayload = payload.channel + + // Map members + let members = payload.members.compactMap { mapMemberPayload($0, channelId: channelPayload.cid) } + + // Map latest messages + let latestMessages = payload.messages.prefix(5).compactMap { mapMessagePayload($0, cid: channelPayload.cid) } + + // Map reads + let reads = payload.channelReads.compactMap { mapChannelReadPayload($0) } + + // Map watchers + let watchers = payload.watchers?.compactMap { mapUserPayload($0) } ?? [] + + // Map typing users (empty for livestream) + let typingUsers: Set = [] + + return ChatChannel( + cid: channelPayload.cid, + name: channelPayload.name, + imageURL: channelPayload.imageURL, + lastMessageAt: channelPayload.lastMessageAt, + createdAt: channelPayload.createdAt, + updatedAt: channelPayload.updatedAt, + deletedAt: channelPayload.deletedAt, + truncatedAt: channelPayload.truncatedAt, + isHidden: payload.isHidden ?? false, + createdBy: channelPayload.createdBy.flatMap { mapUserPayload($0) }, + config: channelPayload.config, + ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: channelPayload.isFrozen, + isDisabled: channelPayload.isDisabled, + isBlocked: channelPayload.isBlocked ?? false, + lastActiveMembers: Array(members.prefix(100)), + membership: payload.membership.flatMap { mapMemberPayload($0, channelId: channelPayload.cid) }, + currentlyTypingUsers: typingUsers, + lastActiveWatchers: Array(watchers.prefix(100)), + team: channelPayload.team, + unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), // Default values for livestream + watcherCount: payload.watcherCount ?? 0, + memberCount: channelPayload.memberCount, + reads: reads, + cooldownDuration: channelPayload.cooldownDuration, + extraData: channelPayload.extraData, + latestMessages: latestMessages, + lastMessageFromCurrentUser: latestMessages.first { $0.isSentByCurrentUser }, + pinnedMessages: payload.pinnedMessages.compactMap { mapMessagePayload($0, cid: channelPayload.cid) }, + muteDetails: nil, // Default value + previewMessage: latestMessages.first, + draftMessage: nil, // Default value for livestream + activeLiveLocations: [] // Default value + ) + } + + private func mapMessagePayload(_ payload: MessagePayload, cid: ChannelId) -> ChatMessage? { + let author = mapUserPayload(payload.user) + let mentionedUsers = Set(payload.mentionedUsers.compactMap { mapUserPayload($0) }) + let threadParticipants = payload.threadParticipants.compactMap { mapUserPayload($0) } + + // Map quoted message recursively + let quotedMessage = payload.quotedMessage.flatMap { mapMessagePayload($0, cid: cid) } + + // Map reactions + let latestReactions = Set(payload.latestReactions.compactMap { mapReactionPayload($0) }) + let currentUserReactions = Set(payload.ownReactions.compactMap { mapReactionPayload($0) }) + + // Map attachments (simplified for livestream) + let attachments: [AnyChatMessageAttachment] = [] + + return ChatMessage( + id: payload.id, + cid: cid, + text: payload.text, + type: payload.type, + command: payload.command, + createdAt: payload.createdAt, + locallyCreatedAt: nil, // Not applicable for API-only controller + updatedAt: payload.updatedAt, + deletedAt: payload.deletedAt, + arguments: payload.args, + parentMessageId: payload.parentId, + showReplyInChannel: payload.showReplyInChannel, + replyCount: payload.replyCount, + extraData: payload.extraData, + quotedMessage: quotedMessage, + isBounced: false, // Default value + isSilent: payload.isSilent, + isShadowed: payload.isShadowed, + reactionScores: payload.reactionScores, + reactionCounts: payload.reactionCounts, + reactionGroups: [:], // Default value for livestream + author: author, + mentionedUsers: mentionedUsers, + threadParticipants: threadParticipants, + attachments: attachments, + latestReplies: [], // Default value for livestream + localState: nil, // Not applicable for API-only controller + isFlaggedByCurrentUser: false, // Default value + latestReactions: latestReactions, + currentUserReactions: currentUserReactions, + isSentByCurrentUser: payload.user.id == currentUserId, + pinDetails: payload.pinned ? MessagePinDetails( + pinnedAt: payload.pinnedAt ?? payload.createdAt, + pinnedBy: payload.pinnedBy.flatMap { mapUserPayload($0) } ?? author, + expiresAt: payload.pinExpires + ) : nil, + translations: payload.translations, + originalLanguage: payload.originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, + moderationDetails: nil, // Default value for livestream + readBy: [], // Default value for livestream + poll: nil, // Default value for livestream + textUpdatedAt: payload.messageTextUpdatedAt, + draftReply: nil, // Default value for livestream + reminder: nil, // Default value for livestream + sharedLocation: nil // Default value for livestream + ) + } + + private func mapUserPayload(_ payload: UserPayload) -> ChatUser { + ChatUser( + id: payload.id, + name: payload.name, + imageURL: payload.imageURL, + isOnline: payload.isOnline, + isBanned: payload.isBanned, + isFlaggedByCurrentUser: false, // Default value + userRole: UserRole(rawValue: payload.role.rawValue), + teamsRole: payload.teamsRole?.mapValues { UserRole(rawValue: $0.rawValue) }, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + deactivatedAt: payload.deactivatedAt, + lastActiveAt: payload.lastActiveAt, + teams: Set(payload.teams), + language: payload.language.flatMap { TranslationLanguage(languageCode: $0) }, + extraData: payload.extraData + ) + } + + private func mapMemberPayload(_ payload: MemberPayload, channelId: ChannelId) -> ChatChannelMember? { + guard let userPayload = payload.user else { return nil } + let user = mapUserPayload(userPayload) + + return ChatChannelMember( + id: user.id, + name: user.name, + imageURL: user.imageURL, + isOnline: user.isOnline, + isBanned: user.isBanned, + isFlaggedByCurrentUser: user.isFlaggedByCurrentUser, + userRole: user.userRole, + teamsRole: user.teamsRole, + userCreatedAt: user.userCreatedAt, + userUpdatedAt: user.userUpdatedAt, + deactivatedAt: user.userDeactivatedAt, + lastActiveAt: user.lastActiveAt, + teams: user.teams, + language: user.language, + extraData: user.extraData, + memberRole: MemberRole(rawValue: payload.role?.rawValue ?? "member"), + memberCreatedAt: payload.createdAt, + memberUpdatedAt: payload.updatedAt, + isInvited: payload.isInvited ?? false, + inviteAcceptedAt: payload.inviteAcceptedAt, + inviteRejectedAt: payload.inviteRejectedAt, + archivedAt: payload.archivedAt, + pinnedAt: payload.pinnedAt, + isBannedFromChannel: payload.isBanned ?? false, + banExpiresAt: payload.banExpiresAt, + isShadowBannedFromChannel: payload.isShadowBanned ?? false, + notificationsMuted: false, // Default value + memberExtraData: [:] + ) + } + + private func mapChannelReadPayload(_ payload: ChannelReadPayload) -> ChatChannelRead { + ChatChannelRead( + lastReadAt: payload.lastReadAt, + lastReadMessageId: payload.lastReadMessageId, + unreadMessagesCount: payload.unreadMessagesCount, + user: mapUserPayload(payload.user) + ) + } + + private func mapReactionPayload(_ payload: MessageReactionPayload) -> ChatMessageReaction? { + ChatMessageReaction( + id: "\(payload.type.rawValue)_\(payload.user.id)", + type: payload.type, + score: payload.score, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + author: mapUserPayload(payload.user), + extraData: payload.extraData + ) + } + + private func lastLocalMessageId() -> MessageId? { + messages.last { _ in + // For livestream, all messages come from API so no local-only messages + true + }?.id + } +} + +// MARK: - Delegate Protocol + +/// Delegate protocol for `LivestreamChannelController` +public protocol LivestreamChannelControllerDelegate: AnyObject { + /// Called when the channel data is updated + /// - Parameters: + /// - controller: The controller that updated + /// - change: The change that occurred + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel change: EntityChange + ) + + /// Called when the messages are updated + /// - Parameters: + /// - controller: The controller that updated + /// - messages: The current messages array + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) +} + +// MARK: - Default Implementations + +public extension LivestreamChannelControllerDelegate { + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel change: EntityChange + ) {} + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) {} +} + +// MARK: - Extensions diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 89b2dd1d9f..b926d2146b 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1420,6 +1420,8 @@ AD17E1212E00985B001AF308 /* SharedLocationPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1202E009853001AF308 /* SharedLocationPayload.swift */; }; AD17E1232E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; + AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; + AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; @@ -4270,6 +4272,7 @@ AD17CDF827E4DB2700E0D092 /* PushProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushProvider.swift; sourceTree = ""; }; AD17E1202E009853001AF308 /* SharedLocationPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationPayload.swift; sourceTree = ""; }; AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; + AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; @@ -9524,6 +9527,7 @@ AD78568B298B268F00C2FEAD /* ChannelControllerDelegate.swift */, DAE566E624FFD22300E39431 /* ChannelController+SwiftUI.swift */, DA4AA3B12502718600FAAF6E /* ChannelController+Combine.swift */, + AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */, ); path = ChannelController; sourceTree = ""; @@ -11861,6 +11865,7 @@ 7991D83D24F7E93900D21BA3 /* ChannelListController+SwiftUI.swift in Sources */, 225D7FE225D191400094E555 /* ChatMessageImageAttachment.swift in Sources */, 8A0D64A724E57A520017A3C0 /* GuestUserTokenRequestPayload.swift in Sources */, + AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */, AD6E32A42BBC502D0073831B /* ThreadQuery.swift in Sources */, 888E8C39252B2ABB00195E03 /* UserController+Combine.swift in Sources */, 4F1BEE762BE384ED00B6685C /* ReactionList.swift in Sources */, @@ -12729,6 +12734,7 @@ C121E8AE274544B000023E4C /* ChannelController+SwiftUI.swift in Sources */, 4FE56B8E2D5DFE4600589F9A /* MarkdownParser.swift in Sources */, C121E8AF274544B000023E4C /* ChannelController+Combine.swift in Sources */, + AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */, 82C18FDD2C10C8E600C5283C /* BlockedUserPayload.swift in Sources */, C121E8B0274544B000023E4C /* ChannelListController.swift in Sources */, C121E8B1274544B000023E4C /* ChannelListController+SwiftUI.swift in Sources */, From 496a5d03804743377b20cc3f84ce656e19f6c1a0 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Jul 2025 15:11:13 +0100 Subject: [PATCH 02/85] Handle more mapping From b3eeb03afc96c3d3c78e3385f44be1a66ee19d26 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Jul 2025 16:21:35 +0100 Subject: [PATCH 03/85] Make message list diff kit to support updating from Array instead of LazyCollection --- .../ChatMessageListVC+DiffKit.swift | 6 ++++++ .../ChatMessageList/ChatMessageListView.swift | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift index 0c05b9fc2b..4b81362d58 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift @@ -16,4 +16,10 @@ extension ChatMessageListVC { listView.currentMessagesFromDataSource = messages listView.newMessagesSnapshot = messages } + + /// Set the new message snapshot reported by the data controller as an Array. + internal func setNewMessagesSnapshotArray(_ messages: [ChatMessage]) { + listView.currentMessagesFromDataSourceArray = messages + listView.newMessagesSnapshotArray = messages + } } diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift index 075b6ddad4..ff9f66eed1 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift @@ -25,10 +25,14 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { /// we update the messages data with the one originally reported by the data controller. internal var currentMessagesFromDataSource: LazyCachedMapCollection = [] + internal var currentMessagesFromDataSourceArray: [ChatMessage]? + /// The new messages snapshot reported by the channel or message controller. /// If messages are being skipped, this snapshot doesn't include skipped messages. internal var newMessagesSnapshot: LazyCachedMapCollection = [] + internal var newMessagesSnapshotArray: [ChatMessage]? + /// When inserting messages at the bottom, if the user is scrolled up, /// we skip adding the message to the UI until the user scrolls back /// to the bottom. This is to avoid message list jumps. @@ -212,8 +216,15 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { completion: (() -> Void)? = nil ) { let previousMessagesSnapshot = self.previousMessagesSnapshot - let newMessagesWithoutSkipped = newMessagesSnapshot.filter { - !self.skippedMessages.contains($0.id) + let newMessagesWithoutSkipped: [ChatMessage] + if let newMessagesSnapshotArray = newMessagesSnapshotArray { + newMessagesWithoutSkipped = newMessagesSnapshotArray.filter { + !self.skippedMessages.contains($0.id) + } + } else { + newMessagesWithoutSkipped = newMessagesSnapshot.filter { + !self.skippedMessages.contains($0.id) + } } adjustContentInsetToPositionMessagesAtTheTop() @@ -243,6 +254,7 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { internal func reloadSkippedMessages() { skippedMessages = [] newMessagesSnapshot = currentMessagesFromDataSource + newMessagesSnapshotArray = currentMessagesFromDataSourceArray onNewDataSource?(Array(newMessagesSnapshot)) reloadData() scrollToBottom() From 38ec8cafb5bb4ee87516989754300089f3c871a6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Jul 2025 18:08:02 +0100 Subject: [PATCH 04/85] Handle events in LivestreamChannelController --- .../LivestreamChannelController.swift | 216 +++++++++++++++--- 1 file changed, 182 insertions(+), 34 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index a0e6d5ea69..f64df31b36 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -6,7 +6,7 @@ import Foundation /// A controller for managing livestream channels that operates without local database persistence. /// Unlike `ChatChannelController`, this controller manages all data in memory and communicates directly with the API. -public class LivestreamChannelController { +public class LivestreamChannelController: EventsControllerDelegate { // MARK: - Public Properties /// The ChannelQuery this controller observes. @@ -77,6 +77,9 @@ public class LivestreamChannelController { /// Pagination state handler for managing message pagination private let paginationStateHandler: MessagesPaginationStateHandling + /// Events controller for listening to real-time events + private let eventsController: EventsController + /// Flag indicating whether channel is created on backend private var isChannelAlreadyCreated: Bool @@ -100,6 +103,10 @@ public class LivestreamChannelController { apiClient = client.apiClient self.isChannelAlreadyCreated = isChannelAlreadyCreated paginationStateHandler = MessagesPaginationStateHandler() + eventsController = client.eventsController() + + // Set up events delegate to listen for real-time events + eventsController.delegate = self } // MARK: - Public Methods @@ -248,18 +255,20 @@ public class LivestreamChannelController { .updateChannel(query: channelQuery) let requestCompletion: (Result) -> Void = { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let payload): - self.handleChannelPayload(payload, channelQuery: channelQuery) - completion?(nil) - - case .failure(let error): - if let pagination = channelQuery.pagination { - self.paginationStateHandler.end(pagination: pagination, with: .failure(error)) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + switch result { + case .success(let payload): + self.handleChannelPayload(payload, channelQuery: channelQuery) + completion?(nil) + + case .failure(let error): + if let pagination = channelQuery.pagination { + self.paginationStateHandler.end(pagination: pagination, with: .failure(error)) + } + completion?(error) } - completion?(error) } } @@ -294,7 +303,7 @@ public class LivestreamChannelController { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - if let oldChannel = oldChannel { + if oldChannel != nil { self.delegate?.livestreamChannelController(self, didUpdateChannel: .update(newChannel)) } else { self.delegate?.livestreamChannelController(self, didUpdateChannel: .create(newChannel)) @@ -305,6 +314,7 @@ public class LivestreamChannelController { } private func updateMessagesArray(with newMessages: [ChatMessage], pagination: MessagesPagination?) { + let newMessages = Array(newMessages.reversed()) switch pagination?.parameter { case .lessThan, .lessThanOrEqual: // Loading older messages - append to end @@ -397,7 +407,7 @@ public class LivestreamChannelController { type: payload.type, command: payload.command, createdAt: payload.createdAt, - locallyCreatedAt: nil, // Not applicable for API-only controller + locallyCreatedAt: nil, updatedAt: payload.updatedAt, deletedAt: payload.deletedAt, arguments: payload.args, @@ -406,19 +416,19 @@ public class LivestreamChannelController { replyCount: payload.replyCount, extraData: payload.extraData, quotedMessage: quotedMessage, - isBounced: false, // Default value + isBounced: false, // TODO: handle bounce isSilent: payload.isSilent, isShadowed: payload.isShadowed, reactionScores: payload.reactionScores, reactionCounts: payload.reactionCounts, - reactionGroups: [:], // Default value for livestream + reactionGroups: [:], author: author, mentionedUsers: mentionedUsers, threadParticipants: threadParticipants, attachments: attachments, - latestReplies: [], // Default value for livestream - localState: nil, // Not applicable for API-only controller - isFlaggedByCurrentUser: false, // Default value + latestReplies: [], + localState: nil, + isFlaggedByCurrentUser: false, latestReactions: latestReactions, currentUserReactions: currentUserReactions, isSentByCurrentUser: payload.user.id == currentUserId, @@ -428,14 +438,21 @@ public class LivestreamChannelController { expiresAt: payload.pinExpires ) : nil, translations: payload.translations, - originalLanguage: payload.originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, - moderationDetails: nil, // Default value for livestream - readBy: [], // Default value for livestream - poll: nil, // Default value for livestream + originalLanguage: payload.originalLanguage.flatMap { TranslationLanguage(languageCode: $0) + }, + moderationDetails: nil, // TODO: handle moderation + readBy: [], // TODO: no reads? + poll: nil, // TODO: handle polls textUpdatedAt: payload.messageTextUpdatedAt, - draftReply: nil, // Default value for livestream - reminder: nil, // Default value for livestream - sharedLocation: nil // Default value for livestream + draftReply: nil, // TODO: handle + reminder: payload.reminder.map { + .init( + remindAt: $0.remindAt, + createdAt: $0.createdAt, + updatedAt: $0.updatedAt + ) + }, + sharedLocation: nil ) } @@ -446,7 +463,7 @@ public class LivestreamChannelController { imageURL: payload.imageURL, isOnline: payload.isOnline, isBanned: payload.isBanned, - isFlaggedByCurrentUser: false, // Default value + isFlaggedByCurrentUser: false, userRole: UserRole(rawValue: payload.role.rawValue), teamsRole: payload.teamsRole?.mapValues { UserRole(rawValue: $0.rawValue) }, createdAt: payload.createdAt, @@ -490,7 +507,7 @@ public class LivestreamChannelController { isBannedFromChannel: payload.isBanned ?? false, banExpiresAt: payload.banExpiresAt, isShadowBannedFromChannel: payload.isShadowBanned ?? false, - notificationsMuted: false, // Default value + notificationsMuted: payload.notificationsMuted, memberExtraData: [:] ) } @@ -517,10 +534,143 @@ public class LivestreamChannelController { } private func lastLocalMessageId() -> MessageId? { - messages.last { _ in - // For livestream, all messages come from API so no local-only messages - true - }?.id + messages.last?.id + } + + // MARK: - EventsControllerDelegate + + public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { + return + } + + DispatchQueue.main.async { [weak self] in + self?.handleChannelEvent(event) + } + } + + // MARK: - Private Event Handling + + private func handleChannelEvent(_ event: Event) { + switch event { + case let messageNewEvent as MessageNewEvent: + handleNewMessage(messageNewEvent.message) + + case let localMessageNewEvent as NewMessagePendingEvent: + handleNewMessage(localMessageNewEvent.message) + + case let messageUpdatedEvent as MessageUpdatedEvent: + handleUpdatedMessage(messageUpdatedEvent.message) + + case let messageDeletedEvent as MessageDeletedEvent: + handleDeletedMessage(messageDeletedEvent.message) + + case let messageReadEvent as MessageReadEvent: + handleMessageRead(messageReadEvent) + + case let reactionNewEvent as ReactionNewEvent: + handleNewReaction(reactionNewEvent) + + case let reactionUpdatedEvent as ReactionUpdatedEvent: + handleUpdatedReaction(reactionUpdatedEvent) + + case let reactionDeletedEvent as ReactionDeletedEvent: + handleDeletedReaction(reactionDeletedEvent) + + default: + // Ignore other events for now + break + } + } + + private func handleNewMessage(_ message: ChatMessage) { + // Add new message to the beginning of the array (newest first) + var currentMessages = messages + + // Check if message already exists to avoid duplicates + if currentMessages.contains(where: { $0.id == message.id }) { + handleUpdatedMessage(message) + return + } + + currentMessages.insert(message, at: 0) + messages = currentMessages + + // Notify delegate + notifyDelegateOfChanges() + } + + private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { + var currentMessages = messages + + // Find and update the message + if let index = currentMessages.firstIndex(where: { $0.id == updatedMessage.id }) { + currentMessages[index] = updatedMessage + messages = currentMessages + + // Notify delegate + notifyDelegateOfChanges() + } + } + + private func handleDeletedMessage(_ deletedMessage: ChatMessage) { + var currentMessages = messages + + // Remove the message from the array + currentMessages.removeAll { $0.id == deletedMessage.id } + messages = currentMessages + + // Notify delegate + notifyDelegateOfChanges() + } + + private func handleMessageRead(_ readEvent: MessageReadEvent) { + // For livestream channels, we might want to update read status + // This could be implemented based on specific requirements + // For now, we'll just update the channel to trigger a delegate notification + // Update the channel with current timestamp to indicate changes + let updatedChannel = readEvent.channel + + channel = updatedChannel + notifyDelegateOfChanges() + } + + private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { + updateMessage(reactionEvent.message) + } + + private func handleUpdatedReaction(_ reactionEvent: ReactionUpdatedEvent) { + updateMessage(reactionEvent.message) + } + + private func handleDeletedReaction(_ reactionEvent: ReactionDeletedEvent) { + updateMessage(reactionEvent.message) + } + + private func updateMessage( + _ updatedMessage: ChatMessage + ) { + let messageId = updatedMessage.id + var currentMessages = messages + + // Find the message to update + guard let messageIndex = currentMessages.firstIndex(where: { $0.id == messageId }) else { + return + } + + // Update the message in the array + currentMessages[messageIndex] = updatedMessage + messages = currentMessages + + // Notify delegate of changes + notifyDelegateOfChanges() + } + + private func notifyDelegateOfChanges() { + guard let currentChannel = channel else { return } + + delegate?.livestreamChannelController(self, didUpdateChannel: .update(currentChannel)) + delegate?.livestreamChannelController(self, didUpdateMessages: messages) } } @@ -560,5 +710,3 @@ public extension LivestreamChannelControllerDelegate { didUpdateMessages messages: [ChatMessage] ) {} } - -// MARK: - Extensions From 99c49cf234cfe7fa033c5e657f48f04f5d3e75ed Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 23 Jul 2025 18:09:25 +0100 Subject: [PATCH 05/85] fixup message list view --- .../StreamChatUI/ChatMessageList/ChatMessageListView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift index ff9f66eed1..f300367fab 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift @@ -25,12 +25,16 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { /// we update the messages data with the one originally reported by the data controller. internal var currentMessagesFromDataSource: LazyCachedMapCollection = [] + /// The current messages from the data source, including skipped messages as an array. + /// Used mostly for the Livestream version of the message list. internal var currentMessagesFromDataSourceArray: [ChatMessage]? /// The new messages snapshot reported by the channel or message controller. /// If messages are being skipped, this snapshot doesn't include skipped messages. internal var newMessagesSnapshot: LazyCachedMapCollection = [] + /// The new messages snapshot reported by the channel or message controller as an array. + /// Used mostly for the Livestream version of the message list. internal var newMessagesSnapshotArray: [ChatMessage]? /// When inserting messages at the bottom, if the user is scrolled up, From 668c228711524d8bdc5ba6080200ba592357347f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 24 Jul 2025 00:52:51 +0100 Subject: [PATCH 06/85] Add reads and attachments --- .../LivestreamChannelController.swift | 84 +++++++++++++++---- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index f64df31b36..28696bc38b 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -290,11 +290,11 @@ public class LivestreamChannelController: EventsControllerDelegate { // Convert payloads to models let newChannel = mapChannelPayload(payload) - let newMessages = payload.messages.compactMap { mapMessagePayload($0, cid: payload.channel.cid) } - // Update channel let oldChannel = channel channel = newChannel + + let newMessages = payload.messages.compactMap { mapMessagePayload($0, cid: payload.channel.cid) } // Update messages based on pagination type updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) @@ -397,9 +397,30 @@ public class LivestreamChannelController: EventsControllerDelegate { let latestReactions = Set(payload.latestReactions.compactMap { mapReactionPayload($0) }) let currentUserReactions = Set(payload.ownReactions.compactMap { mapReactionPayload($0) }) - // Map attachments (simplified for livestream) - let attachments: [AnyChatMessageAttachment] = [] - + // Map attachments + let attachments: [AnyChatMessageAttachment] = payload.attachments + .enumerated() + .compactMap { offset, attachmentPayload in + guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { + return nil + } + return AnyChatMessageAttachment( + id: .init(cid: cid, messageId: payload.id, index: offset), + type: attachmentPayload.type, + payload: payloadData, + downloadingState: nil, + uploadingState: nil + ) + } + + let reads = channel?.reads ?? [] + let createdAtInterval = payload.createdAt.timeIntervalSince1970 + let messageUserId = payload.user.id + let readBy = reads.filter { read in + read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval + } + debugPrint("reads", reads, "readBy", readBy) + return ChatMessage( id: payload.id, cid: cid, @@ -416,7 +437,8 @@ public class LivestreamChannelController: EventsControllerDelegate { replyCount: payload.replyCount, extraData: payload.extraData, quotedMessage: quotedMessage, - isBounced: false, // TODO: handle bounce + isBounced: false, + // TODO: handle bounce isSilent: payload.isSilent, isShadowed: payload.isShadowed, reactionScores: payload.reactionScores, @@ -440,11 +462,14 @@ public class LivestreamChannelController: EventsControllerDelegate { translations: payload.translations, originalLanguage: payload.originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, - moderationDetails: nil, // TODO: handle moderation - readBy: [], // TODO: no reads? - poll: nil, // TODO: handle polls + moderationDetails: nil, + // TODO: handle moderation + readBy: Set(readBy.map(\.user)), + poll: nil, + // TODO: handle polls textUpdatedAt: payload.messageTextUpdatedAt, - draftReply: nil, // TODO: handle + draftReply: nil, + // TODO: handle reminder: payload.reminder.map { .init( remindAt: $0.remindAt, @@ -452,7 +477,19 @@ public class LivestreamChannelController: EventsControllerDelegate { updatedAt: $0.updatedAt ) }, - sharedLocation: nil + sharedLocation: payload.location.map { + .init( + messageId: $0.messageId, + channelId: cid, + userId: $0.userId, + createdByDeviceId: $0.createdByDeviceId, + latitude: $0.latitude, + longitude: $0.longitude, + updatedAt: $0.updatedAt, + createdAt: $0.createdAt, + endAt: $0.endAt + ) + } ) } @@ -625,14 +662,13 @@ public class LivestreamChannelController: EventsControllerDelegate { } private func handleMessageRead(_ readEvent: MessageReadEvent) { - // For livestream channels, we might want to update read status - // This could be implemented based on specific requirements - // For now, we'll just update the channel to trigger a delegate notification - // Update the channel with current timestamp to indicate changes let updatedChannel = readEvent.channel - channel = updatedChannel - notifyDelegateOfChanges() + + if var updatedMessage = messages.first { + updatedMessage.updateReadBy(with: updatedChannel.reads) + handleUpdatedMessage(updatedMessage) + } } private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { @@ -710,3 +746,17 @@ public extension LivestreamChannelControllerDelegate { didUpdateMessages messages: [ChatMessage] ) {} } + +extension ChatMessage { + mutating func updateReadBy( + with reads: [ChatChannelRead] + ) { + let createdAtInterval = createdAt.timeIntervalSince1970 + let messageUserId = author.id + let readBy = reads.filter { read in + read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval + } + let newMessage = changing(readBy: Set(readBy.map(\.user))) + self = newMessage + } +} From e60979342c8c108fd26ab7983466dea91327bd70 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 24 Jul 2025 15:07:49 +0100 Subject: [PATCH 07/85] Forward live stream events and don't save them into the DB --- .../Events/MessageEvents.swift | 3 +- .../Workers/EventNotificationCenter.swift | 65 ++++++++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 48afe3b92b..a3c2dcd143 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -237,8 +237,9 @@ class MessageReadEventDTO: EventDTO { } // Triggered when the current user creates a new message and is pending to be sent. -public struct NewMessagePendingEvent: Event { +public struct NewMessagePendingEvent: ChannelSpecificEvent { public var message: ChatMessage + public var cid: ChannelId { message.cid! } } // Triggered when a message failed being sent. diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 9cda5c7c0c..6201d394e4 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -17,8 +17,14 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { // Contains the ids of the new messages that are going to be added during the ongoing process private(set) var newMessageIds: Set = Set() - init(database: DatabaseContainer) { + private var optimizeLivestreamControllers: Bool + + init( + database: DatabaseContainer, + optimizeLivestreamControllers: Bool = false + ) { self.database = database + self.optimizeLivestreamControllers = optimizeLivestreamControllers super.init() } @@ -42,14 +48,33 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } var eventsToPost = [Event]() + var middlewareEvents = [Event]() + var livestreamEvents = [Event]() + + if optimizeLivestreamControllers { + events + .compactMap { $0 as? EventDTO } + .forEach { event in + if event.payload.cid?.type == .livestream { + livestreamEvents.append(event) + } else { + middlewareEvents.append(event) + } + } + } else { + middlewareEvents = events + } + + eventsToPost.append(contentsOf: forwardLivestreamEvents(livestreamEvents)) + database.write({ session in self.newMessageIds = Set(messageIds.compactMap { !session.messageExists(id: $0) ? $0 : nil }) - eventsToPost = events.compactMap { + eventsToPost.append(contentsOf: middlewareEvents.compactMap { self.middlewares.process(event: $0, session: session) - } + }) self.newMessageIds = [] }, completion: { _ in @@ -64,6 +89,40 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } }) } + + private func forwardLivestreamEvents(_ events: [Event]) -> [Event] { + events.compactMap { + guard let eventDTO = $0 as? EventDTO else { + return nil + } + let eventPayload = eventDTO.payload + switch eventPayload.eventType { + case .messageNew: + return nil + + case .messageUpdated: + return nil + + case .messageDeleted: + return nil + + case .messageRead: + return nil + + case .reactionNew: + return nil + + case .reactionDeleted: + return nil + + case .reactionUpdated: + return nil + + default: + return nil + } + } + } } extension EventNotificationCenter { From c3cbb28662c95c6604a4197554d286c95475fb86 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 24 Jul 2025 18:10:50 +0100 Subject: [PATCH 08/85] Use currentUserId and channel from DB for now (Maybe implement caching later) --- .../LivestreamChannelController.swift | 259 ++---------------- Sources/StreamChat/Models/Channel.swift | 139 ++++++++++ Sources/StreamChat/Models/ChatMessage.swift | 123 ++++++++- .../StreamChat/Models/MessageReaction.swift | 18 ++ Sources/StreamChat/Models/User.swift | 26 ++ .../Events/MessageEvents.swift | 22 -- .../Workers/EventNotificationCenter.swift | 250 ++++++++++++++--- .../StreamChat/Workers/UserListUpdater.swift | 22 -- 8 files changed, 540 insertions(+), 319 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 28696bc38b..3baaf6554f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -288,13 +288,26 @@ public class LivestreamChannelController: EventsControllerDelegate { self.channelQuery = ChannelQuery(cid: payload.channel.cid, channelQuery: channelQuery) } - // Convert payloads to models - let newChannel = mapChannelPayload(payload) + // Convert payloads to models using new model functions + let newChannel = payload.asModel( + members: payload.members, + messages: payload.messages, + channelReads: payload.channelReads, + watchers: payload.watchers ?? [], + membership: payload.membership, + pinnedMessages: payload.pinnedMessages, + isHidden: payload.isHidden ?? false, + watcherCount: payload.watcherCount, + currentUserId: currentUserId + ) + // Update channel let oldChannel = channel channel = newChannel - let newMessages = payload.messages.compactMap { mapMessagePayload($0, cid: payload.channel.cid) } + let newMessages = payload.messages.compactMap { + $0.asModel(cid: payload.channel.cid, currentUserId: currentUserId, channelReads: newChannel.reads) + } // Update messages based on pagination type updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) @@ -330,246 +343,6 @@ public class LivestreamChannelController: EventsControllerDelegate { } } - private func mapChannelPayload(_ payload: ChannelPayload) -> ChatChannel { - let channelPayload = payload.channel - - // Map members - let members = payload.members.compactMap { mapMemberPayload($0, channelId: channelPayload.cid) } - - // Map latest messages - let latestMessages = payload.messages.prefix(5).compactMap { mapMessagePayload($0, cid: channelPayload.cid) } - - // Map reads - let reads = payload.channelReads.compactMap { mapChannelReadPayload($0) } - - // Map watchers - let watchers = payload.watchers?.compactMap { mapUserPayload($0) } ?? [] - - // Map typing users (empty for livestream) - let typingUsers: Set = [] - - return ChatChannel( - cid: channelPayload.cid, - name: channelPayload.name, - imageURL: channelPayload.imageURL, - lastMessageAt: channelPayload.lastMessageAt, - createdAt: channelPayload.createdAt, - updatedAt: channelPayload.updatedAt, - deletedAt: channelPayload.deletedAt, - truncatedAt: channelPayload.truncatedAt, - isHidden: payload.isHidden ?? false, - createdBy: channelPayload.createdBy.flatMap { mapUserPayload($0) }, - config: channelPayload.config, - ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), - isFrozen: channelPayload.isFrozen, - isDisabled: channelPayload.isDisabled, - isBlocked: channelPayload.isBlocked ?? false, - lastActiveMembers: Array(members.prefix(100)), - membership: payload.membership.flatMap { mapMemberPayload($0, channelId: channelPayload.cid) }, - currentlyTypingUsers: typingUsers, - lastActiveWatchers: Array(watchers.prefix(100)), - team: channelPayload.team, - unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), // Default values for livestream - watcherCount: payload.watcherCount ?? 0, - memberCount: channelPayload.memberCount, - reads: reads, - cooldownDuration: channelPayload.cooldownDuration, - extraData: channelPayload.extraData, - latestMessages: latestMessages, - lastMessageFromCurrentUser: latestMessages.first { $0.isSentByCurrentUser }, - pinnedMessages: payload.pinnedMessages.compactMap { mapMessagePayload($0, cid: channelPayload.cid) }, - muteDetails: nil, // Default value - previewMessage: latestMessages.first, - draftMessage: nil, // Default value for livestream - activeLiveLocations: [] // Default value - ) - } - - private func mapMessagePayload(_ payload: MessagePayload, cid: ChannelId) -> ChatMessage? { - let author = mapUserPayload(payload.user) - let mentionedUsers = Set(payload.mentionedUsers.compactMap { mapUserPayload($0) }) - let threadParticipants = payload.threadParticipants.compactMap { mapUserPayload($0) } - - // Map quoted message recursively - let quotedMessage = payload.quotedMessage.flatMap { mapMessagePayload($0, cid: cid) } - - // Map reactions - let latestReactions = Set(payload.latestReactions.compactMap { mapReactionPayload($0) }) - let currentUserReactions = Set(payload.ownReactions.compactMap { mapReactionPayload($0) }) - - // Map attachments - let attachments: [AnyChatMessageAttachment] = payload.attachments - .enumerated() - .compactMap { offset, attachmentPayload in - guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { - return nil - } - return AnyChatMessageAttachment( - id: .init(cid: cid, messageId: payload.id, index: offset), - type: attachmentPayload.type, - payload: payloadData, - downloadingState: nil, - uploadingState: nil - ) - } - - let reads = channel?.reads ?? [] - let createdAtInterval = payload.createdAt.timeIntervalSince1970 - let messageUserId = payload.user.id - let readBy = reads.filter { read in - read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval - } - debugPrint("reads", reads, "readBy", readBy) - - return ChatMessage( - id: payload.id, - cid: cid, - text: payload.text, - type: payload.type, - command: payload.command, - createdAt: payload.createdAt, - locallyCreatedAt: nil, - updatedAt: payload.updatedAt, - deletedAt: payload.deletedAt, - arguments: payload.args, - parentMessageId: payload.parentId, - showReplyInChannel: payload.showReplyInChannel, - replyCount: payload.replyCount, - extraData: payload.extraData, - quotedMessage: quotedMessage, - isBounced: false, - // TODO: handle bounce - isSilent: payload.isSilent, - isShadowed: payload.isShadowed, - reactionScores: payload.reactionScores, - reactionCounts: payload.reactionCounts, - reactionGroups: [:], - author: author, - mentionedUsers: mentionedUsers, - threadParticipants: threadParticipants, - attachments: attachments, - latestReplies: [], - localState: nil, - isFlaggedByCurrentUser: false, - latestReactions: latestReactions, - currentUserReactions: currentUserReactions, - isSentByCurrentUser: payload.user.id == currentUserId, - pinDetails: payload.pinned ? MessagePinDetails( - pinnedAt: payload.pinnedAt ?? payload.createdAt, - pinnedBy: payload.pinnedBy.flatMap { mapUserPayload($0) } ?? author, - expiresAt: payload.pinExpires - ) : nil, - translations: payload.translations, - originalLanguage: payload.originalLanguage.flatMap { TranslationLanguage(languageCode: $0) - }, - moderationDetails: nil, - // TODO: handle moderation - readBy: Set(readBy.map(\.user)), - poll: nil, - // TODO: handle polls - textUpdatedAt: payload.messageTextUpdatedAt, - draftReply: nil, - // TODO: handle - reminder: payload.reminder.map { - .init( - remindAt: $0.remindAt, - createdAt: $0.createdAt, - updatedAt: $0.updatedAt - ) - }, - sharedLocation: payload.location.map { - .init( - messageId: $0.messageId, - channelId: cid, - userId: $0.userId, - createdByDeviceId: $0.createdByDeviceId, - latitude: $0.latitude, - longitude: $0.longitude, - updatedAt: $0.updatedAt, - createdAt: $0.createdAt, - endAt: $0.endAt - ) - } - ) - } - - private func mapUserPayload(_ payload: UserPayload) -> ChatUser { - ChatUser( - id: payload.id, - name: payload.name, - imageURL: payload.imageURL, - isOnline: payload.isOnline, - isBanned: payload.isBanned, - isFlaggedByCurrentUser: false, - userRole: UserRole(rawValue: payload.role.rawValue), - teamsRole: payload.teamsRole?.mapValues { UserRole(rawValue: $0.rawValue) }, - createdAt: payload.createdAt, - updatedAt: payload.updatedAt, - deactivatedAt: payload.deactivatedAt, - lastActiveAt: payload.lastActiveAt, - teams: Set(payload.teams), - language: payload.language.flatMap { TranslationLanguage(languageCode: $0) }, - extraData: payload.extraData - ) - } - - private func mapMemberPayload(_ payload: MemberPayload, channelId: ChannelId) -> ChatChannelMember? { - guard let userPayload = payload.user else { return nil } - let user = mapUserPayload(userPayload) - - return ChatChannelMember( - id: user.id, - name: user.name, - imageURL: user.imageURL, - isOnline: user.isOnline, - isBanned: user.isBanned, - isFlaggedByCurrentUser: user.isFlaggedByCurrentUser, - userRole: user.userRole, - teamsRole: user.teamsRole, - userCreatedAt: user.userCreatedAt, - userUpdatedAt: user.userUpdatedAt, - deactivatedAt: user.userDeactivatedAt, - lastActiveAt: user.lastActiveAt, - teams: user.teams, - language: user.language, - extraData: user.extraData, - memberRole: MemberRole(rawValue: payload.role?.rawValue ?? "member"), - memberCreatedAt: payload.createdAt, - memberUpdatedAt: payload.updatedAt, - isInvited: payload.isInvited ?? false, - inviteAcceptedAt: payload.inviteAcceptedAt, - inviteRejectedAt: payload.inviteRejectedAt, - archivedAt: payload.archivedAt, - pinnedAt: payload.pinnedAt, - isBannedFromChannel: payload.isBanned ?? false, - banExpiresAt: payload.banExpiresAt, - isShadowBannedFromChannel: payload.isShadowBanned ?? false, - notificationsMuted: payload.notificationsMuted, - memberExtraData: [:] - ) - } - - private func mapChannelReadPayload(_ payload: ChannelReadPayload) -> ChatChannelRead { - ChatChannelRead( - lastReadAt: payload.lastReadAt, - lastReadMessageId: payload.lastReadMessageId, - unreadMessagesCount: payload.unreadMessagesCount, - user: mapUserPayload(payload.user) - ) - } - - private func mapReactionPayload(_ payload: MessageReactionPayload) -> ChatMessageReaction? { - ChatMessageReaction( - id: "\(payload.type.rawValue)_\(payload.user.id)", - type: payload.type, - score: payload.score, - createdAt: payload.createdAt, - updatedAt: payload.updatedAt, - author: mapUserPayload(payload.user), - extraData: payload.extraData - ) - } - private func lastLocalMessageId() -> MessageId? { messages.last?.id } diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index d8ccb8736d..2bc68bdc05 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -602,3 +602,142 @@ public extension ChatChannel { ownCapabilities.contains(.shareLocation) } } + +// MARK: - Payload -> Model Mapping + +extension ChannelPayload { + /// Converts the ChannelPayload to a ChatChannel model + /// - Parameters: + /// - members: Array of member payloads to convert + /// - messages: Array of message payloads for latest messages + /// - channelReads: Array of channel read payloads + /// - watchers: Array of user payloads for watchers + /// - membership: Member payload for current user's membership + /// - pinnedMessages: Array of pinned message payloads + /// - isHidden: Whether the channel is hidden + /// - watcherCount: Number of watchers + /// - Returns: A ChatChannel instance + func asModel( + members: [MemberPayload] = [], + messages: [MessagePayload] = [], + channelReads: [ChannelReadPayload] = [], + watchers: [UserPayload] = [], + membership: MemberPayload? = nil, + pinnedMessages: [MessagePayload] = [], + isHidden: Bool = false, + watcherCount: Int? = nil, + currentUserId: UserId? = nil + ) -> ChatChannel { + let channelPayload = channel + + // Map members + let mappedMembers = members.compactMap { $0.asModel(channelId: channelPayload.cid) } + + // Map latest messages + let reads = channelReads.map { $0.asModel() } + let latestMessages = messages.prefix(5).compactMap { + $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + } + + // Map reads + let mappedReads = channelReads.map { $0.asModel() } + + // Map watchers + let mappedWatchers = watchers.map { $0.asModel() } + + // Map typing users (empty for payload conversion) + let typingUsers: Set = [] + + return ChatChannel( + cid: channelPayload.cid, + name: channelPayload.name, + imageURL: channelPayload.imageURL, + lastMessageAt: channelPayload.lastMessageAt, + createdAt: channelPayload.createdAt, + updatedAt: channelPayload.updatedAt, + deletedAt: channelPayload.deletedAt, + truncatedAt: channelPayload.truncatedAt, + isHidden: isHidden, + createdBy: channelPayload.createdBy?.asModel(), + config: channelPayload.config, + ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: channelPayload.isFrozen, + isDisabled: channelPayload.isDisabled, + isBlocked: channelPayload.isBlocked ?? false, + lastActiveMembers: Array(mappedMembers.prefix(100)), + membership: membership?.asModel(channelId: channelPayload.cid), + currentlyTypingUsers: typingUsers, + lastActiveWatchers: Array(mappedWatchers.prefix(100)), + team: channelPayload.team, + unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), // Default values for livestream + watcherCount: watcherCount ?? 0, + memberCount: channelPayload.memberCount, + reads: mappedReads, + cooldownDuration: channelPayload.cooldownDuration, + extraData: channelPayload.extraData, + latestMessages: latestMessages, + lastMessageFromCurrentUser: latestMessages.first { $0.isSentByCurrentUser }, + pinnedMessages: pinnedMessages.compactMap { + $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + }, + muteDetails: nil, // Default value + previewMessage: latestMessages.first, + draftMessage: nil, // Default value for livestream + activeLiveLocations: [] // Default value + ) + } +} + +extension MemberPayload { + /// Converts the MemberPayload to a ChatChannelMember model + /// - Parameter channelId: The channel ID the member belongs to + /// - Returns: A ChatChannelMember instance, or nil if user is missing + func asModel(channelId: ChannelId) -> ChatChannelMember? { + guard let userPayload = user else { return nil } + let user = userPayload.asModel() + + return ChatChannelMember( + id: user.id, + name: user.name, + imageURL: user.imageURL, + isOnline: user.isOnline, + isBanned: user.isBanned, + isFlaggedByCurrentUser: user.isFlaggedByCurrentUser, + userRole: user.userRole, + teamsRole: user.teamsRole, + userCreatedAt: user.userCreatedAt, + userUpdatedAt: user.userUpdatedAt, + deactivatedAt: user.userDeactivatedAt, + lastActiveAt: user.lastActiveAt, + teams: user.teams, + language: user.language, + extraData: user.extraData, + memberRole: MemberRole(rawValue: role?.rawValue ?? "member"), + memberCreatedAt: createdAt, + memberUpdatedAt: updatedAt, + isInvited: isInvited ?? false, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + archivedAt: archivedAt, + pinnedAt: pinnedAt, + isBannedFromChannel: isBanned ?? false, + banExpiresAt: banExpiresAt, + isShadowBannedFromChannel: isShadowBanned ?? false, + notificationsMuted: notificationsMuted, + memberExtraData: [:] + ) + } +} + +extension ChannelReadPayload { + /// Converts the ChannelReadPayload to a ChatChannelRead model + /// - Returns: A ChatChannelRead instance + func asModel() -> ChatChannelRead { + ChatChannelRead( + lastReadAt: lastReadAt, + lastReadMessageId: lastReadMessageId, + unreadMessagesCount: unreadMessagesCount, + user: user.asModel() + ) + } +} diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index dc3af467a2..d2f89ca1ba 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -284,6 +284,7 @@ public struct ChatMessage { translations: [TranslationLanguage: String]? = nil, originalLanguage: TranslationLanguage? = nil, moderationDetails: MessageModerationDetails? = nil, + readBy: Set? = nil, extraData: [String: RawJSON]? = nil ) -> ChatMessage { .init( @@ -322,7 +323,7 @@ public struct ChatMessage { translations: translations ?? self.translations, originalLanguage: originalLanguage ?? self.originalLanguage, moderationDetails: moderationDetails ?? self.moderationDetails, - readBy: readBy, + readBy: readBy ?? self.readBy, poll: poll, textUpdatedAt: textUpdatedAt, draftReply: draftReply, @@ -692,3 +693,123 @@ public struct MessageDeliveryStatus: RawRepresentable, Hashable { /// The message delivery state for message failed to be sent/edited/deleted. public static let failed = Self(rawValue: "failed") } + +// MARK: - Payload -> Model Mapping + +extension MessagePayload { + /// Converts the MessagePayload to a ChatMessage model + /// - Parameters: + /// - cid: The channel ID the message belongs to + /// - currentUserId: The current user's ID for determining sent status + /// - channelReads: Channel reads for determining readBy status + /// - Returns: A ChatMessage instance + func asModel( + cid: ChannelId, + currentUserId: UserId? = nil, + channelReads: [ChatChannelRead] = [] + ) -> ChatMessage? { + let author = user.asModel() + let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) + let threadParticipants = threadParticipants.compactMap { $0.asModel() } + + // Map quoted message recursively + let quotedMessage = quotedMessage?.asModel( + cid: cid, + currentUserId: currentUserId, + channelReads: channelReads + ) + + // Map reactions + let latestReactions = Set(latestReactions.compactMap { $0.asModel() }) + let currentUserReactions = Set(ownReactions.compactMap { $0.asModel() }) + + // Map attachments + let attachments: [AnyChatMessageAttachment] = attachments + .enumerated() + .compactMap { offset, attachmentPayload in + guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { + return nil + } + return AnyChatMessageAttachment( + id: .init(cid: cid, messageId: id, index: offset), + type: attachmentPayload.type, + payload: payloadData, + downloadingState: nil, + uploadingState: nil + ) + } + + // Calculate readBy from channel reads + let createdAtInterval = createdAt.timeIntervalSince1970 + let messageUserId = user.id + let readBy = channelReads.filter { read in + read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval + } + + return ChatMessage( + id: id, + cid: cid, + text: text, + type: type, + command: command, + createdAt: createdAt, + locallyCreatedAt: nil, + updatedAt: updatedAt, + deletedAt: deletedAt, + arguments: args, + parentMessageId: parentId, + showReplyInChannel: showReplyInChannel, + replyCount: replyCount, + extraData: extraData, + quotedMessage: quotedMessage, + isBounced: false, + isSilent: isSilent, + isShadowed: isShadowed, + reactionScores: reactionScores, + reactionCounts: reactionCounts, + reactionGroups: [:], + author: author, + mentionedUsers: mentionedUsers, + threadParticipants: threadParticipants, + attachments: attachments, + latestReplies: [], + localState: nil, + isFlaggedByCurrentUser: false, + latestReactions: latestReactions, + currentUserReactions: currentUserReactions, + isSentByCurrentUser: user.id == currentUserId, + pinDetails: pinned ? MessagePinDetails( + pinnedAt: pinnedAt ?? createdAt, + pinnedBy: pinnedBy?.asModel() ?? author, + expiresAt: pinExpires + ) : nil, + translations: translations, + originalLanguage: originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, + moderationDetails: nil, + readBy: Set(readBy.map(\.user)), + poll: nil, + textUpdatedAt: messageTextUpdatedAt, + draftReply: nil, + reminder: reminder.map { + .init( + remindAt: $0.remindAt, + createdAt: $0.createdAt, + updatedAt: $0.updatedAt + ) + }, + sharedLocation: location.map { + .init( + messageId: $0.messageId, + channelId: cid, + userId: $0.userId, + createdByDeviceId: $0.createdByDeviceId, + latitude: $0.latitude, + longitude: $0.longitude, + updatedAt: $0.updatedAt, + createdAt: $0.createdAt, + endAt: $0.endAt + ) + } + ) + } +} diff --git a/Sources/StreamChat/Models/MessageReaction.swift b/Sources/StreamChat/Models/MessageReaction.swift index fe81ae467b..98adeeee70 100644 --- a/Sources/StreamChat/Models/MessageReaction.swift +++ b/Sources/StreamChat/Models/MessageReaction.swift @@ -28,3 +28,21 @@ public struct ChatMessageReaction: Hashable { /// Custom data public let extraData: [String: RawJSON] } + +// MARK: - Payload -> Model Mapping + +extension MessageReactionPayload { + /// Converts the MessageReactionPayload to a ChatMessageReaction model + /// - Returns: A ChatMessageReaction instance + func asModel() -> ChatMessageReaction { + ChatMessageReaction( + id: "\(type.rawValue)_\(user.id)", + type: type, + score: score, + createdAt: createdAt, + updatedAt: updatedAt, + author: user.asModel(), + extraData: extraData + ) + } +} diff --git a/Sources/StreamChat/Models/User.swift b/Sources/StreamChat/Models/User.swift index a71a3dd92e..6e922f5ad3 100644 --- a/Sources/StreamChat/Models/User.swift +++ b/Sources/StreamChat/Models/User.swift @@ -170,3 +170,29 @@ public extension UserRole { } } } + +// MARK: - Payload -> Model Mapping + +extension UserPayload { + /// Converts the UserPayload to a ChatUser model + /// - Returns: A ChatUser instance + func asModel() -> ChatUser { + ChatUser( + id: id, + name: name, + imageURL: imageURL, + isOnline: isOnline, + isBanned: isBanned, + isFlaggedByCurrentUser: false, + userRole: role, + teamsRole: teamsRole, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: deactivatedAt, + lastActiveAt: lastActiveAt, + teams: Set(teams), + language: language.flatMap { TranslationLanguage(languageCode: $0) }, + extraData: extraData + ) + } +} diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index a3c2dcd143..348173b2a1 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -301,25 +301,3 @@ private extension MessagePayload { ) } } - -private extension UserPayload { - func asModel() -> ChatUser { - .init( - id: id, - name: name, - imageURL: imageURL, - isOnline: isOnline, - isBanned: isBanned, - isFlaggedByCurrentUser: false, - userRole: role, - teamsRole: teamsRole, - createdAt: createdAt, - updatedAt: updatedAt, - deactivatedAt: deactivatedAt, - lastActiveAt: lastActiveAt, - teams: Set(teams), - language: language.map { TranslationLanguage(languageCode: $0) }, - extraData: extraData - ) - } -} diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 6201d394e4..a2667b163a 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -51,23 +51,26 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { var middlewareEvents = [Event]() var livestreamEvents = [Event]() - if optimizeLivestreamControllers { - events - .compactMap { $0 as? EventDTO } - .forEach { event in - if event.payload.cid?.type == .livestream { - livestreamEvents.append(event) - } else { - middlewareEvents.append(event) + database.write({ session in + if self.optimizeLivestreamControllers { + events + .forEach { event in + guard let eventDTO = event as? EventDTO else { + middlewareEvents.append(event) + return + } + if eventDTO.payload.cid?.rawValue == "messaging:28F0F56D-F" { + livestreamEvents.append(event) + } else { + middlewareEvents.append(event) + } } - } - } else { - middlewareEvents = events - } + } else { + middlewareEvents = events + } - eventsToPost.append(contentsOf: forwardLivestreamEvents(livestreamEvents)) + eventsToPost.append(contentsOf: self.forwardLivestreamEvents(livestreamEvents)) - database.write({ session in self.newMessageIds = Set(messageIds.compactMap { !session.messageExists(id: $0) ? $0 : nil }) @@ -91,38 +94,223 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } private func forwardLivestreamEvents(_ events: [Event]) -> [Event] { - events.compactMap { - guard let eventDTO = $0 as? EventDTO else { + events.compactMap { event in + guard let eventDTO = event as? EventDTO else { return nil } + let eventPayload = eventDTO.payload - switch eventPayload.eventType { - case .messageNew: - return nil - case .messageUpdated: + guard let cid = eventPayload.cid else { return nil + } + switch eventPayload.eventType { + case .messageNew: + return createMessageNewEvent(from: eventPayload, cid: cid) + + case .messageUpdated: + return createMessageUpdatedEvent(from: eventPayload, cid: cid) + case .messageDeleted: - return nil - + return createMessageDeletedEvent(from: eventPayload, cid: cid) + case .messageRead: - return nil - + return createMessageReadEvent(from: eventPayload, cid: cid) + case .reactionNew: - return nil - - case .reactionDeleted: - return nil - + return createReactionNewEvent(from: eventPayload, cid: cid) + case .reactionUpdated: - return nil - + return createReactionUpdatedEvent(from: eventPayload, cid: cid) + + case .reactionDeleted: + return createReactionDeletedEvent(from: eventPayload, cid: cid) + default: return nil } } } + + // MARK: - Event Creation Helpers + + private func createChannelFromPayload(_ channelPayload: ChannelDetailPayload, cid: ChannelId) -> ChatChannel { + ChatChannel( + cid: cid, + name: channelPayload.name, + imageURL: channelPayload.imageURL, + lastMessageAt: channelPayload.lastMessageAt, + createdAt: channelPayload.createdAt, + updatedAt: channelPayload.updatedAt, + deletedAt: channelPayload.deletedAt, + truncatedAt: channelPayload.truncatedAt, + isHidden: false, + createdBy: channelPayload.createdBy?.asModel(), + config: channelPayload.config, + ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: channelPayload.isFrozen, + isDisabled: channelPayload.isDisabled, + isBlocked: channelPayload.isBlocked ?? false, + lastActiveMembers: [], + membership: nil, + currentlyTypingUsers: [], + lastActiveWatchers: [], + team: channelPayload.team, + unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), + watcherCount: 0, + memberCount: channelPayload.memberCount, + reads: [], + cooldownDuration: channelPayload.cooldownDuration, + extraData: channelPayload.extraData, + latestMessages: [], + lastMessageFromCurrentUser: nil, + pinnedMessages: [], + muteDetails: nil, + previewMessage: nil, + draftMessage: nil, + activeLiveLocations: [] + ) + } + + private func createMessageNewEvent(from payload: EventPayload, cid: ChannelId) -> MessageNewEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let currentUserId = database.writableContext.currentUser?.user.id, + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId) + else { + return nil + } + + return MessageNewEvent( + user: userPayload.asModel(), + message: message, + channel: channel, + createdAt: createdAt, + watcherCount: payload.watcherCount, + unreadCount: payload.unreadCount.map { + .init( + channels: $0.channels ?? 0, + messages: $0.messages ?? 0, + threads: $0.threads ?? 0 + ) + } + ) + } + + private func createMessageUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> MessageUpdatedEvent? { + guard + let userPayload = try? payload.value(at: \.user) as UserPayload, + let messagePayload = try? payload.value(at: \.message) as MessagePayload, + let createdAt = try? payload.value(at: \.createdAt) as Date, + let message = messagePayload.asModel(cid: cid), + let channelPayload = payload.channel + else { return nil } + + let channel = createChannelFromPayload(channelPayload, cid: cid) + + return MessageUpdatedEvent( + user: userPayload.asModel(), + channel: channel, + message: message, + createdAt: createdAt + ) + } + + private func createMessageDeletedEvent(from payload: EventPayload, cid: ChannelId) -> MessageDeletedEvent? { + guard + let messagePayload = try? payload.value(at: \.message) as MessagePayload, + let createdAt = try? payload.value(at: \.createdAt) as Date, + let message = messagePayload.asModel(cid: cid), + let channelPayload = payload.channel + else { return nil } + + let userPayload = try? payload.value(at: \.user) as UserPayload? + let channel = createChannelFromPayload(channelPayload, cid: cid) + + return MessageDeletedEvent( + user: userPayload?.asModel(), + channel: channel, + message: message, + createdAt: createdAt, + isHardDelete: payload.hardDelete + ) + } + + private func createMessageReadEvent(from payload: EventPayload, cid: ChannelId) -> MessageReadEvent? { + guard + let userPayload = try? payload.value(at: \.user) as UserPayload, + let createdAt = try? payload.value(at: \.createdAt) as Date, + let channelPayload = payload.channel + else { return nil } + + let channel = createChannelFromPayload(channelPayload, cid: cid) + + return MessageReadEvent( + user: userPayload.asModel(), + channel: channel, + thread: nil, // Livestream channels don't support threads typically + createdAt: createdAt, + unreadCount: nil // Livestream channels don't track unread counts + ) + } + + private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { + guard + let userPayload = try? payload.value(at: \.user) as UserPayload, + let messagePayload = try? payload.value(at: \.message) as MessagePayload, + let reactionPayload = try? payload.value(at: \.reaction) as MessageReactionPayload, + let createdAt = try? payload.value(at: \.createdAt) as Date, + let message = messagePayload.asModel(cid: cid) + else { return nil } + + return ReactionNewEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(), + createdAt: createdAt + ) + } + + private func createReactionUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionUpdatedEvent? { + guard + let userPayload = try? payload.value(at: \.user) as UserPayload, + let messagePayload = try? payload.value(at: \.message) as MessagePayload, + let reactionPayload = try? payload.value(at: \.reaction) as MessageReactionPayload, + let createdAt = try? payload.value(at: \.createdAt) as Date, + let message = messagePayload.asModel(cid: cid) + else { return nil } + + return ReactionUpdatedEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(), + createdAt: createdAt + ) + } + + private func createReactionDeletedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionDeletedEvent? { + guard + let userPayload = try? payload.value(at: \.user) as UserPayload, + let messagePayload = try? payload.value(at: \.message) as MessagePayload, + let reactionPayload = try? payload.value(at: \.reaction) as MessageReactionPayload, + let createdAt = try? payload.value(at: \.createdAt) as Date, + let message = messagePayload.asModel(cid: cid) + else { return nil } + + return ReactionDeletedEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(), + createdAt: createdAt + ) + } } extension EventNotificationCenter { diff --git a/Sources/StreamChat/Workers/UserListUpdater.swift b/Sources/StreamChat/Workers/UserListUpdater.swift index 15b1c01d94..2c0bd1d822 100644 --- a/Sources/StreamChat/Workers/UserListUpdater.swift +++ b/Sources/StreamChat/Workers/UserListUpdater.swift @@ -121,25 +121,3 @@ extension UserListQuery { return query } } - -private extension UserPayload { - func asModel() -> ChatUser { - ChatUser( - id: id, - name: name, - imageURL: imageURL, - isOnline: isOnline, - isBanned: isBanned, - isFlaggedByCurrentUser: false, - userRole: role, - teamsRole: teamsRole, - createdAt: createdAt, - updatedAt: updatedAt, - deactivatedAt: deactivatedAt, - lastActiveAt: lastActiveAt, - teams: Set(teams), - language: language.map(TranslationLanguage.init), - extraData: extraData - ) - } -} From 0a9684b24fb8515893b8e65045cfcc50f1e499aa Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 24 Jul 2025 19:03:09 +0100 Subject: [PATCH 09/85] Simplify model mapping --- .../LivestreamChannelController.swift | 14 +-- Sources/StreamChat/Models/Channel.swift | 88 ++++++++----- Sources/StreamChat/Models/ChatMessage.swift | 4 +- .../Workers/EventNotificationCenter.swift | 117 +++++++----------- 4 files changed, 105 insertions(+), 118 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 3baaf6554f..d7d94d06ee 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -290,17 +290,11 @@ public class LivestreamChannelController: EventsControllerDelegate { // Convert payloads to models using new model functions let newChannel = payload.asModel( - members: payload.members, - messages: payload.messages, - channelReads: payload.channelReads, - watchers: payload.watchers ?? [], - membership: payload.membership, - pinnedMessages: payload.pinnedMessages, - isHidden: payload.isHidden ?? false, - watcherCount: payload.watcherCount, - currentUserId: currentUserId + currentUserId: currentUserId, + currentlyTypingUsers: channel?.currentlyTypingUsers, + unreadCount: channel?.unreadCount ) - + // Update channel let oldChannel = channel channel = newChannel diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 2bc68bdc05..59a7654f61 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -607,47 +607,29 @@ public extension ChatChannel { extension ChannelPayload { /// Converts the ChannelPayload to a ChatChannel model - /// - Parameters: - /// - members: Array of member payloads to convert - /// - messages: Array of message payloads for latest messages - /// - channelReads: Array of channel read payloads - /// - watchers: Array of user payloads for watchers - /// - membership: Member payload for current user's membership - /// - pinnedMessages: Array of pinned message payloads - /// - isHidden: Whether the channel is hidden - /// - watcherCount: Number of watchers /// - Returns: A ChatChannel instance func asModel( - members: [MemberPayload] = [], - messages: [MessagePayload] = [], - channelReads: [ChannelReadPayload] = [], - watchers: [UserPayload] = [], - membership: MemberPayload? = nil, - pinnedMessages: [MessagePayload] = [], - isHidden: Bool = false, - watcherCount: Int? = nil, - currentUserId: UserId? = nil + currentUserId: UserId?, + currentlyTypingUsers: Set?, + unreadCount: ChannelUnreadCount? ) -> ChatChannel { let channelPayload = channel - + // Map members let mappedMembers = members.compactMap { $0.asModel(channelId: channelPayload.cid) } - + // Map latest messages let reads = channelReads.map { $0.asModel() } let latestMessages = messages.prefix(5).compactMap { - $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + $0.asModel(cid: channel.cid, currentUserId: currentUserId, channelReads: reads) } - + // Map reads let mappedReads = channelReads.map { $0.asModel() } // Map watchers - let mappedWatchers = watchers.map { $0.asModel() } - - // Map typing users (empty for payload conversion) - let typingUsers: Set = [] - + let mappedWatchers = watchers?.map { $0.asModel() } ?? [] + return ChatChannel( cid: channelPayload.cid, name: channelPayload.name, @@ -657,7 +639,7 @@ extension ChannelPayload { updatedAt: channelPayload.updatedAt, deletedAt: channelPayload.deletedAt, truncatedAt: channelPayload.truncatedAt, - isHidden: isHidden, + isHidden: isHidden ?? false, createdBy: channelPayload.createdBy?.asModel(), config: channelPayload.config, ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), @@ -666,10 +648,10 @@ extension ChannelPayload { isBlocked: channelPayload.isBlocked ?? false, lastActiveMembers: Array(mappedMembers.prefix(100)), membership: membership?.asModel(channelId: channelPayload.cid), - currentlyTypingUsers: typingUsers, + currentlyTypingUsers: currentlyTypingUsers ?? [], lastActiveWatchers: Array(mappedWatchers.prefix(100)), team: channelPayload.team, - unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), // Default values for livestream + unreadCount: unreadCount ?? .noUnread, watcherCount: watcherCount ?? 0, memberCount: channelPayload.memberCount, reads: mappedReads, @@ -680,10 +662,50 @@ extension ChannelPayload { pinnedMessages: pinnedMessages.compactMap { $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) }, - muteDetails: nil, // Default value + muteDetails: nil, previewMessage: latestMessages.first, - draftMessage: nil, // Default value for livestream - activeLiveLocations: [] // Default value + draftMessage: nil, + activeLiveLocations: [] + ) + } +} + +extension ChannelDetailPayload { + func asModel() -> ChatChannel { + ChatChannel( + cid: cid, + name: name, + imageURL: imageURL, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + truncatedAt: truncatedAt, + isHidden: false, + createdBy: createdBy?.asModel(), + config: config, + ownCapabilities: Set(ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: isFrozen, + isDisabled: isDisabled, + isBlocked: isBlocked ?? false, + lastActiveMembers: members?.compactMap { $0.asModel(channelId: cid) } ?? [], + membership: nil, + currentlyTypingUsers: [], + lastActiveWatchers: [], + team: team, + unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), + watcherCount: 0, + memberCount: memberCount, + reads: [], + cooldownDuration: cooldownDuration, + extraData: extraData, + latestMessages: [], + lastMessageFromCurrentUser: nil, + pinnedMessages: [], + muteDetails: nil, + previewMessage: nil, + draftMessage: nil, + activeLiveLocations: [] ) } } diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index d2f89ca1ba..0245fc4f8a 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -705,8 +705,8 @@ extension MessagePayload { /// - Returns: A ChatMessage instance func asModel( cid: ChannelId, - currentUserId: UserId? = nil, - channelReads: [ChatChannelRead] = [] + currentUserId: UserId?, + channelReads: [ChatChannelRead] ) -> ChatMessage? { let author = user.asModel() let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index a2667b163a..0f2481818c 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -135,44 +135,6 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { // MARK: - Event Creation Helpers - private func createChannelFromPayload(_ channelPayload: ChannelDetailPayload, cid: ChannelId) -> ChatChannel { - ChatChannel( - cid: cid, - name: channelPayload.name, - imageURL: channelPayload.imageURL, - lastMessageAt: channelPayload.lastMessageAt, - createdAt: channelPayload.createdAt, - updatedAt: channelPayload.updatedAt, - deletedAt: channelPayload.deletedAt, - truncatedAt: channelPayload.truncatedAt, - isHidden: false, - createdBy: channelPayload.createdBy?.asModel(), - config: channelPayload.config, - ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), - isFrozen: channelPayload.isFrozen, - isDisabled: channelPayload.isDisabled, - isBlocked: channelPayload.isBlocked ?? false, - lastActiveMembers: [], - membership: nil, - currentlyTypingUsers: [], - lastActiveWatchers: [], - team: channelPayload.team, - unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), - watcherCount: 0, - memberCount: channelPayload.memberCount, - reads: [], - cooldownDuration: channelPayload.cooldownDuration, - extraData: channelPayload.extraData, - latestMessages: [], - lastMessageFromCurrentUser: nil, - pinnedMessages: [], - muteDetails: nil, - previewMessage: nil, - draftMessage: nil, - activeLiveLocations: [] - ) - } - private func createMessageNewEvent(from payload: EventPayload, cid: ChannelId) -> MessageNewEvent? { guard let userPayload = payload.user, @@ -180,7 +142,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let createdAt = payload.createdAt, let channel = try? database.writableContext.channel(cid: cid)?.asModel(), let currentUserId = database.writableContext.currentUser?.user.id, - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId) + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } @@ -203,15 +165,14 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { private func createMessageUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> MessageUpdatedEvent? { guard - let userPayload = try? payload.value(at: \.user) as UserPayload, - let messagePayload = try? payload.value(at: \.message) as MessagePayload, - let createdAt = try? payload.value(at: \.createdAt) as Date, - let message = messagePayload.asModel(cid: cid), - let channelPayload = payload.channel + let userPayload = payload.user, + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - let channel = createChannelFromPayload(channelPayload, cid: cid) - return MessageUpdatedEvent( user: userPayload.asModel(), channel: channel, @@ -222,14 +183,14 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { private func createMessageDeletedEvent(from payload: EventPayload, cid: ChannelId) -> MessageDeletedEvent? { guard - let messagePayload = try? payload.value(at: \.message) as MessagePayload, - let createdAt = try? payload.value(at: \.createdAt) as Date, - let message = messagePayload.asModel(cid: cid), - let channelPayload = payload.channel + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - let userPayload = try? payload.value(at: \.user) as UserPayload? - let channel = createChannelFromPayload(channelPayload, cid: cid) + let userPayload = payload.user return MessageDeletedEvent( user: userPayload?.asModel(), @@ -242,29 +203,35 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { private func createMessageReadEvent(from payload: EventPayload, cid: ChannelId) -> MessageReadEvent? { guard - let userPayload = try? payload.value(at: \.user) as UserPayload, - let createdAt = try? payload.value(at: \.createdAt) as Date, - let channelPayload = payload.channel + let userPayload = payload.user, + let createdAt = payload.createdAt, + let channel = try? database.writableContext.channel(cid: cid)?.asModel() else { return nil } - let channel = createChannelFromPayload(channelPayload, cid: cid) - return MessageReadEvent( user: userPayload.asModel(), channel: channel, thread: nil, // Livestream channels don't support threads typically createdAt: createdAt, - unreadCount: nil // Livestream channels don't track unread counts + unreadCount: payload.unreadCount.map { + .init( + channels: $0.channels ?? 0, + messages: $0.messages ?? 0, + threads: $0.threads ?? 0 + ) + } ) } private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { guard - let userPayload = try? payload.value(at: \.user) as UserPayload, - let messagePayload = try? payload.value(at: \.message) as MessagePayload, - let reactionPayload = try? payload.value(at: \.reaction) as MessageReactionPayload, - let createdAt = try? payload.value(at: \.createdAt) as Date, - let message = messagePayload.asModel(cid: cid) + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } return ReactionNewEvent( @@ -278,11 +245,13 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { private func createReactionUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionUpdatedEvent? { guard - let userPayload = try? payload.value(at: \.user) as UserPayload, - let messagePayload = try? payload.value(at: \.message) as MessagePayload, - let reactionPayload = try? payload.value(at: \.reaction) as MessageReactionPayload, - let createdAt = try? payload.value(at: \.createdAt) as Date, - let message = messagePayload.asModel(cid: cid) + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } return ReactionUpdatedEvent( @@ -296,11 +265,13 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { private func createReactionDeletedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionDeletedEvent? { guard - let userPayload = try? payload.value(at: \.user) as UserPayload, - let messagePayload = try? payload.value(at: \.message) as MessagePayload, - let reactionPayload = try? payload.value(at: \.reaction) as MessageReactionPayload, - let createdAt = try? payload.value(at: \.createdAt) as Date, - let message = messagePayload.asModel(cid: cid) + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } return ReactionDeletedEvent( From bbec5b7401daf30159d178a7abeafb22f3f08c0d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 24 Jul 2025 19:41:20 +0100 Subject: [PATCH 10/85] Fix current user reactions --- Sources/StreamChat/Models/ChatMessage.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 0245fc4f8a..4d89a86342 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -721,7 +721,13 @@ extension MessagePayload { // Map reactions let latestReactions = Set(latestReactions.compactMap { $0.asModel() }) - let currentUserReactions = Set(ownReactions.compactMap { $0.asModel() }) + + let currentUserReactions: Set + if ownReactions.isEmpty { + currentUserReactions = latestReactions.filter { $0.author.id == currentUserId } + } else { + currentUserReactions = Set(ownReactions.compactMap { $0.asModel() }) + } // Map attachments let attachments: [AnyChatMessageAttachment] = attachments From c0803ed7e1fe0af3ebd5978b06ed4ef8d13a8e58 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 00:13:56 +0100 Subject: [PATCH 11/85] Improve how event notification center knows when to do manually event handling --- .../LivestreamChannelController.swift | 86 +++++++------------ .../Workers/EventNotificationCenter.swift | 48 ++++++----- 2 files changed, 58 insertions(+), 76 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index d7d94d06ee..9c21cf4cb8 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -4,7 +4,17 @@ import Foundation +public extension ChatClient { + /// Creates a new `LivestreamChannelController` for the given channel query. + /// - Parameter channelQuery: The query to observe the channel. + /// - Returns: A new `LivestreamChannelController` instance. + func livestreamChannelController(for channelQuery: ChannelQuery) -> LivestreamChannelController { + LivestreamChannelController(channelQuery: channelQuery, client: self) + } +} + /// A controller for managing livestream channels that operates without local database persistence. +/// /// Unlike `ChatChannelController`, this controller manages all data in memory and communicates directly with the API. public class LivestreamChannelController: EventsControllerDelegate { // MARK: - Public Properties @@ -80,9 +90,6 @@ public class LivestreamChannelController: EventsControllerDelegate { /// Events controller for listening to real-time events private let eventsController: EventsController - /// Flag indicating whether channel is created on backend - private var isChannelAlreadyCreated: Bool - /// Current user ID for convenience private var currentUserId: UserId? { client.currentUserId } @@ -92,21 +99,20 @@ public class LivestreamChannelController: EventsControllerDelegate { /// - Parameters: /// - channelQuery: channel query for observing changes /// - client: The `Client` this controller belongs to. - /// - isChannelAlreadyCreated: Flag indicating whether channel is created on backend. - public init( + init( channelQuery: ChannelQuery, - client: ChatClient, - isChannelAlreadyCreated: Bool = true + client: ChatClient ) { self.channelQuery = channelQuery self.client = client apiClient = client.apiClient - self.isChannelAlreadyCreated = isChannelAlreadyCreated paginationStateHandler = MessagesPaginationStateHandler() eventsController = client.eventsController() - - // Set up events delegate to listen for real-time events eventsController.delegate = self + + if let cid = channelQuery.cid { + client.eventNotificationCenter.registerManualEventHandling(for: cid) + } } // MARK: - Public Methods @@ -130,7 +136,7 @@ public class LivestreamChannelController: EventsControllerDelegate { limit: Int? = nil, completion: ((Error?) -> Void)? = nil ) { - guard cid != nil, isChannelAlreadyCreated else { + guard cid != nil else { completion?(ClientError.ChannelNotCreatedYet()) return } @@ -163,7 +169,7 @@ public class LivestreamChannelController: EventsControllerDelegate { limit: Int? = nil, completion: ((Error?) -> Void)? = nil ) { - guard cid != nil, isChannelAlreadyCreated else { + guard cid != nil else { completion?(ClientError.ChannelNotCreatedYet()) return } @@ -196,11 +202,6 @@ public class LivestreamChannelController: EventsControllerDelegate { limit: Int? = nil, completion: ((Error?) -> Void)? = nil ) { - guard isChannelAlreadyCreated else { - completion?(ClientError.ChannelNotCreatedYet()) - return - } - guard !isLoadingMiddleMessages else { completion?(nil) return @@ -248,10 +249,8 @@ public class LivestreamChannelController: EventsControllerDelegate { if let pagination = channelQuery.pagination { paginationStateHandler.begin(pagination: pagination) } - - let isChannelCreate = !isChannelAlreadyCreated - let endpoint: Endpoint = isChannelCreate ? - .createChannel(query: channelQuery) : + + let endpoint: Endpoint = .updateChannel(query: channelQuery) let requestCompletion: (Result) -> Void = { [weak self] result in @@ -276,37 +275,25 @@ public class LivestreamChannelController: EventsControllerDelegate { } private func handleChannelPayload(_ payload: ChannelPayload, channelQuery: ChannelQuery) { - // Update pagination state if let pagination = channelQuery.pagination { paginationStateHandler.end(pagination: pagination, with: .success(payload.messages)) } - - // Mark channel as created if it was a create operation - if !isChannelAlreadyCreated { - isChannelAlreadyCreated = true - // Update the channel query with the actual cid if it was generated - self.channelQuery = ChannelQuery(cid: payload.channel.cid, channelQuery: channelQuery) - } - - // Convert payloads to models using new model functions + let newChannel = payload.asModel( currentUserId: currentUserId, currentlyTypingUsers: channel?.currentlyTypingUsers, unreadCount: channel?.unreadCount ) - // Update channel let oldChannel = channel channel = newChannel let newMessages = payload.messages.compactMap { $0.asModel(cid: payload.channel.cid, currentUserId: currentUserId, channelReads: newChannel.reads) } - - // Update messages based on pagination type + updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) - - // Notify delegate + DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -332,7 +319,6 @@ public class LivestreamChannelController: EventsControllerDelegate { messages.insert(contentsOf: newMessages, at: 0) case .around, .none: - // Loading around a message or first page - replace all messages = newMessages } } @@ -382,7 +368,6 @@ public class LivestreamChannelController: EventsControllerDelegate { handleDeletedReaction(reactionDeletedEvent) default: - // Ignore other events for now break } } @@ -390,8 +375,8 @@ public class LivestreamChannelController: EventsControllerDelegate { private func handleNewMessage(_ message: ChatMessage) { // Add new message to the beginning of the array (newest first) var currentMessages = messages - - // Check if message already exists to avoid duplicates + + // If message already exists, update it instead if currentMessages.contains(where: { $0.id == message.id }) { handleUpdatedMessage(message) return @@ -406,25 +391,21 @@ public class LivestreamChannelController: EventsControllerDelegate { private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { var currentMessages = messages - - // Find and update the message + if let index = currentMessages.firstIndex(where: { $0.id == updatedMessage.id }) { currentMessages[index] = updatedMessage messages = currentMessages - - // Notify delegate + notifyDelegateOfChanges() } } private func handleDeletedMessage(_ deletedMessage: ChatMessage) { var currentMessages = messages - - // Remove the message from the array + currentMessages.removeAll { $0.id == deletedMessage.id } messages = currentMessages - - // Notify delegate + notifyDelegateOfChanges() } @@ -455,17 +436,14 @@ public class LivestreamChannelController: EventsControllerDelegate { ) { let messageId = updatedMessage.id var currentMessages = messages - - // Find the message to update + guard let messageIndex = currentMessages.firstIndex(where: { $0.id == messageId }) else { return } - // Update the message in the array currentMessages[messageIndex] = updatedMessage messages = currentMessages - - // Notify delegate of changes + notifyDelegateOfChanges() } @@ -514,7 +492,7 @@ public extension LivestreamChannelControllerDelegate { ) {} } -extension ChatMessage { +private extension ChatMessage { mutating func updateReadBy( with reads: [ChatChannelRead] ) { diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 0f2481818c..25f072e080 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -17,17 +17,25 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { // Contains the ids of the new messages that are going to be added during the ongoing process private(set) var newMessageIds: Set = Set() - private var optimizeLivestreamControllers: Bool + // The channels for which events will not be processed by the middlewares. + private var manualEventHandlingChannelIds: Set = [] init( - database: DatabaseContainer, - optimizeLivestreamControllers: Bool = false + database: DatabaseContainer ) { self.database = database - self.optimizeLivestreamControllers = optimizeLivestreamControllers super.init() } + /// Registers a channel for manual event handling. + /// + /// The middleware's will not process events for this channel. + func registerManualEventHandling(for cid: ChannelId) { + eventPostingQueue.async { [weak self] in + self?.manualEventHandlingChannelIds.insert(cid) + } + } + func add(middlewares: [EventMiddleware]) { self.middlewares.append(contentsOf: middlewares) } @@ -49,27 +57,23 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { var eventsToPost = [Event]() var middlewareEvents = [Event]() - var livestreamEvents = [Event]() + var manualHandlingEvents = [Event]() database.write({ session in - if self.optimizeLivestreamControllers { - events - .forEach { event in - guard let eventDTO = event as? EventDTO else { - middlewareEvents.append(event) - return - } - if eventDTO.payload.cid?.rawValue == "messaging:28F0F56D-F" { - livestreamEvents.append(event) - } else { - middlewareEvents.append(event) - } - } - } else { - middlewareEvents = events + events.forEach { event in + guard let eventDTO = event as? EventDTO else { + middlewareEvents.append(event) + return + } + if let cid = eventDTO.payload.cid, self.manualEventHandlingChannelIds.contains(cid) { + manualHandlingEvents.append(event) + } else { + middlewareEvents.append(event) + } } - eventsToPost.append(contentsOf: self.forwardLivestreamEvents(livestreamEvents)) + let manualEvents = self.convertManualEventsToDomain(manualHandlingEvents) + eventsToPost.append(contentsOf: manualEvents) self.newMessageIds = Set(messageIds.compactMap { !session.messageExists(id: $0) ? $0 : nil @@ -93,7 +97,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { }) } - private func forwardLivestreamEvents(_ events: [Event]) -> [Event] { + private func convertManualEventsToDomain(_ events: [Event]) -> [Event] { events.compactMap { event in guard let eventDTO = event as? EventDTO else { return nil From 9d905881d10823a89365f856dcbeb33578af1b71 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 16:28:25 +0100 Subject: [PATCH 12/85] Do WIP on reads --- .../LivestreamChannelController.swift | 41 ++++++++++++----- Sources/StreamChat/Models/Channel.swift | 44 +++++++++++++++++++ .../Events/MessageEvents.swift | 6 ++- .../Workers/EventNotificationCenter.swift | 14 +++--- 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 9c21cf4cb8..e3de66b675 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -353,8 +353,12 @@ public class LivestreamChannelController: EventsControllerDelegate { handleUpdatedMessage(messageUpdatedEvent.message) case let messageDeletedEvent as MessageDeletedEvent: - handleDeletedMessage(messageDeletedEvent.message) - + if messageDeletedEvent.isHardDelete { + handleDeletedMessage(messageDeletedEvent.message) + return + } + handleUpdatedMessage(messageDeletedEvent.message) + case let messageReadEvent as MessageReadEvent: handleMessageRead(messageReadEvent) @@ -373,7 +377,6 @@ public class LivestreamChannelController: EventsControllerDelegate { } private func handleNewMessage(_ message: ChatMessage) { - // Add new message to the beginning of the array (newest first) var currentMessages = messages // If message already exists, update it instead @@ -385,7 +388,6 @@ public class LivestreamChannelController: EventsControllerDelegate { currentMessages.insert(message, at: 0) messages = currentMessages - // Notify delegate notifyDelegateOfChanges() } @@ -410,13 +412,29 @@ public class LivestreamChannelController: EventsControllerDelegate { } private func handleMessageRead(_ readEvent: MessageReadEvent) { - let updatedChannel = readEvent.channel + var updatedChannel = channel + + // TODO: Update existing reads + if let lastReadMessageId = readEvent.lastReadMessageId { + var currentReads = updatedChannel?.reads ?? [] + currentReads.append( + .init( + lastReadAt: readEvent.createdAt, + lastReadMessageId: lastReadMessageId, + unreadMessagesCount: 0, + user: readEvent.user + ) + ) + updatedChannel = updatedChannel?.changing(reads: currentReads) + } + channel = updatedChannel - if var updatedMessage = messages.first { - updatedMessage.updateReadBy(with: updatedChannel.reads) - handleUpdatedMessage(updatedMessage) + messages = messages.map { + $0.updateReadBy(with: channel?.reads ?? []) } + + notifyDelegateOfChanges() } private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { @@ -493,15 +511,14 @@ public extension LivestreamChannelControllerDelegate { } private extension ChatMessage { - mutating func updateReadBy( + func updateReadBy( with reads: [ChatChannelRead] - ) { + ) -> ChatMessage { let createdAtInterval = createdAt.timeIntervalSince1970 let messageUserId = author.id let readBy = reads.filter { read in read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval } - let newMessage = changing(readBy: Set(readBy.map(\.user))) - self = newMessage + return changing(readBy: Set(readBy.map(\.user))) } } diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 59a7654f61..69cb477e1d 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -281,6 +281,50 @@ public struct ChatChannel { activeLiveLocations: activeLiveLocations ) } + + /// Returns a new `ChatChannel` with the provided data changed. + public func changing( + name: String? = nil, + imageURL: URL? = nil, + reads: [ChatChannelRead]? = nil, + extraData: [String: RawJSON]? = nil + ) -> ChatChannel { + .init( + cid: cid, + name: name ?? self.name, + imageURL: imageURL ?? self.imageURL, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + truncatedAt: truncatedAt, + isHidden: isHidden, + createdBy: createdBy, + config: config, + ownCapabilities: ownCapabilities, + isFrozen: isFrozen, + isDisabled: isDisabled, + isBlocked: isBlocked, + lastActiveMembers: lastActiveMembers, + membership: membership, + currentlyTypingUsers: currentlyTypingUsers, + lastActiveWatchers: lastActiveWatchers, + team: team, + unreadCount: unreadCount, + watcherCount: watcherCount, + memberCount: memberCount, + reads: reads ?? self.reads, + cooldownDuration: cooldownDuration, + extraData: extraData ?? [:], + latestMessages: latestMessages, + lastMessageFromCurrentUser: lastMessageFromCurrentUser, + pinnedMessages: pinnedMessages, + muteDetails: muteDetails, + previewMessage: previewMessage, + draftMessage: draftMessage, + activeLiveLocations: activeLiveLocations + ) + } } extension ChatChannel { diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 348173b2a1..ffc234237d 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -197,6 +197,9 @@ public struct MessageReadEvent: ChannelSpecificEvent { /// The unread counts of the current user. public let unreadCount: UnreadCount? + + /// The last read message id. + public let lastReadMessageId: MessageId? } class MessageReadEventDTO: EventDTO { @@ -231,7 +234,8 @@ class MessageReadEventDTO: EventDTO { channel: channelDTO.asModel(), thread: threadDTO?.asModel(), createdAt: createdAt, - unreadCount: UnreadCount(currentUserDTO: currentUser) + unreadCount: UnreadCount(currentUserDTO: currentUser), + lastReadMessageId: nil ) } } diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 25f072e080..606f367e0f 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -119,7 +119,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { case .messageDeleted: return createMessageDeletedEvent(from: eventPayload, cid: cid) - case .messageRead: + case .messageRead, .notificationMarkRead: return createMessageReadEvent(from: eventPayload, cid: cid) case .reactionNew: @@ -207,13 +207,14 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { private func createMessageReadEvent(from payload: EventPayload, cid: ChannelId) -> MessageReadEvent? { guard - let userPayload = payload.user, + let user = payload.user?.asModel(), let createdAt = payload.createdAt, - let channel = try? database.writableContext.channel(cid: cid)?.asModel() + var channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let lastReadMessageId = payload.lastReadMessageId else { return nil } - + return MessageReadEvent( - user: userPayload.asModel(), + user: user, channel: channel, thread: nil, // Livestream channels don't support threads typically createdAt: createdAt, @@ -223,7 +224,8 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { messages: $0.messages ?? 0, threads: $0.threads ?? 0 ) - } + }, + lastReadMessageId: lastReadMessageId ) } From bb5173314a9d71b851f9d01e7129973d25040d09 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 17:02:24 +0100 Subject: [PATCH 13/85] Remove reads since it is not needed for live streams --- .../LivestreamChannelController.swift | 29 ------------------- .../Workers/EventNotificationCenter.swift | 27 ----------------- 2 files changed, 56 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index e3de66b675..a60be4b1d2 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -358,9 +358,6 @@ public class LivestreamChannelController: EventsControllerDelegate { return } handleUpdatedMessage(messageDeletedEvent.message) - - case let messageReadEvent as MessageReadEvent: - handleMessageRead(messageReadEvent) case let reactionNewEvent as ReactionNewEvent: handleNewReaction(reactionNewEvent) @@ -410,33 +407,7 @@ public class LivestreamChannelController: EventsControllerDelegate { notifyDelegateOfChanges() } - - private func handleMessageRead(_ readEvent: MessageReadEvent) { - var updatedChannel = channel - - // TODO: Update existing reads - if let lastReadMessageId = readEvent.lastReadMessageId { - var currentReads = updatedChannel?.reads ?? [] - currentReads.append( - .init( - lastReadAt: readEvent.createdAt, - lastReadMessageId: lastReadMessageId, - unreadMessagesCount: 0, - user: readEvent.user - ) - ) - updatedChannel = updatedChannel?.changing(reads: currentReads) - } - - channel = updatedChannel - messages = messages.map { - $0.updateReadBy(with: channel?.reads ?? []) - } - - notifyDelegateOfChanges() - } - private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { updateMessage(reactionEvent.message) } diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 606f367e0f..1a6dfc9c58 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -119,9 +119,6 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { case .messageDeleted: return createMessageDeletedEvent(from: eventPayload, cid: cid) - case .messageRead, .notificationMarkRead: - return createMessageReadEvent(from: eventPayload, cid: cid) - case .reactionNew: return createReactionNewEvent(from: eventPayload, cid: cid) @@ -205,30 +202,6 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { ) } - private func createMessageReadEvent(from payload: EventPayload, cid: ChannelId) -> MessageReadEvent? { - guard - let user = payload.user?.asModel(), - let createdAt = payload.createdAt, - var channel = try? database.writableContext.channel(cid: cid)?.asModel(), - let lastReadMessageId = payload.lastReadMessageId - else { return nil } - - return MessageReadEvent( - user: user, - channel: channel, - thread: nil, // Livestream channels don't support threads typically - createdAt: createdAt, - unreadCount: payload.unreadCount.map { - .init( - channels: $0.channels ?? 0, - messages: $0.messages ?? 0, - threads: $0.threads ?? 0 - ) - }, - lastReadMessageId: lastReadMessageId - ) - } - private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { guard let userPayload = payload.user, From 197bb7372116c0ee26639f7d84d152e0e6b1f3ac Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 17:21:34 +0100 Subject: [PATCH 14/85] Add DemoLivestreamChannelVC to the DemoApp --- .../Components/DemoLivestreamChannelVC.swift | 701 ++++++++++++++++++ .../LivestreamChannelController.swift | 24 +- StreamChat.xcodeproj/project.pbxproj | 4 + 3 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift diff --git a/DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift b/DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift new file mode 100644 index 0000000000..25e5237e27 --- /dev/null +++ b/DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift @@ -0,0 +1,701 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +open class DemoLivestreamChannelVC: _ViewController, + ThemeProvider, + ChatMessageListVCDataSource, + ChatMessageListVCDelegate, + LivestreamChannelControllerDelegate, + EventsControllerDelegate, + AudioQueuePlayerDatasource +{ + /// Controller for observing data changes within the channel. + open var channelController: LivestreamChannelController! + + /// User search controller for suggestion users when typing in the composer. + open lazy var userSuggestionSearchController: ChatUserSearchController = + channelController.client.userSearchController() + + /// A controller for observing web socket events. + public lazy var eventsController: EventsController = client.eventsController() + + /// The size of the channel avatar. + open var channelAvatarSize: CGSize { + CGSize(width: 32, height: 32) + } + + public var client: ChatClient { + channelController.client + } + + /// Component responsible for setting the correct offset when keyboard frame is changed. + open lazy var keyboardHandler: KeyboardHandler = ComposerKeyboardHandler( + composerParentVC: self, + composerBottomConstraint: messageComposerBottomConstraint, + messageListVC: messageListVC + ) + + /// The message list component responsible to render the messages. + open lazy var messageListVC: ChatMessageListVC = components + .messageListVC + .init() + + /// Controller that handles the composer view + open private(set) lazy var messageComposerVC = components + .messageComposerVC + .init() + + /// The audioPlayer that will be used for the playback of VoiceRecordings + open private(set) lazy var audioPlayer: AudioPlaying = components + .audioPlayer + .init() + + /// The provider that will be asked to provide the next VoiceRecording to play automatically once the + /// currently playing one, finishes. + open private(set) lazy var audioQueuePlayerNextItemProvider: AudioQueuePlayerNextItemProvider = components + .audioQueuePlayerNextItemProvider + .init() + + /// The navigation header view. + open private(set) lazy var headerView: ChatChannelHeaderView = components + .channelHeaderView.init() + .withoutAutoresizingMaskConstraints + + /// View for displaying the channel image in the navigation bar. + open private(set) lazy var channelAvatarView = components + .channelAvatarView.init() + .withoutAutoresizingMaskConstraints + + /// The message composer bottom constraint used for keyboard animation handling. + public var messageComposerBottomConstraint: NSLayoutConstraint? + + /// A boolean value indicating whether the last message is fully visible or not. + open var isLastMessageFullyVisible: Bool { + messageListVC.listView.isLastCellFullyVisible + } + + /// A boolean value indicating whether it should mark the channel read. + open var shouldMarkChannelRead: Bool { + guard isViewVisible else { + return false + } + + guard components.isJumpToUnreadEnabled else { + return isLastMessageFullyVisible && isFirstPageLoaded + } + + return isLastMessageVisibleOrSeen && hasSeenFirstUnreadMessage && isFirstPageLoaded && !hasMarkedMessageAsUnread + } + + private var isLastMessageVisibleOrSeen: Bool { + hasSeenLastMessage || isLastMessageFullyVisible + } + + /// A component responsible to handle when to load new or old messages. + private lazy var viewPaginationHandler: StatefulViewPaginationHandling = { + InvertedScrollViewPaginationHandler.make(scrollView: messageListVC.listView) + }() + + /// The throttler to make sure that the marking read is not spammed. + var markReadThrottler: Throttler = Throttler(interval: 3, queue: .main) + + /// Determines if a messaged had been marked as unread in the current session + private var hasMarkedMessageAsUnread: Bool { + false + } + + /// Determines whether first unread message has been seen + private var hasSeenFirstUnreadMessage: Bool = false + + /// Determines whether last cell has been seen since the last time it was marked as read + private var hasSeenLastMessage: Bool = false + + /// The id of the first unread message + private var firstUnreadMessageId: MessageId? + + /// In case the given around message id is from a thread, we need to jump to the parent message and then the reply. + internal var initialReplyId: MessageId? + + override open func setUp() { + super.setUp() + + eventsController.delegate = self + + messageListVC.delegate = self + messageListVC.dataSource = self + messageListVC.client = client + + messageComposerVC.userSearchController = userSuggestionSearchController + + setChannelControllerToComposerIfNeeded(cid: channelController.cid) + + channelController.delegate = self + channelController.synchronize { [weak self] error in + self?.didFinishSynchronizing(with: error) + } + + if channelController.channelQuery.pagination?.parameter == nil { + // Load initial messages from cache if loading the first page + messages = Array(channelController.messages) + } + + // Handle pagination + viewPaginationHandler.onNewTopPage = { [weak self] notifyElementsCount, completion in + notifyElementsCount(self?.channelController.messages.count ?? 0) + self?.loadPreviousMessages(completion: completion) + } + viewPaginationHandler.onNewBottomPage = { [weak self] notifyElementsCount, completion in + notifyElementsCount(self?.channelController.messages.count ?? 0) + self?.loadNextMessages(completion: completion) + } + + messageListVC.audioPlayer = audioPlayer + messageComposerVC.audioPlayer = audioPlayer + + if let queueAudioPlayer = audioPlayer as? StreamAudioQueuePlayer { + queueAudioPlayer.dataSource = self + } + + messageListVC.swipeToReplyGestureHandler.onReply = { [weak self] message in + self?.messageComposerVC.content.quoteMessage(message) + } + + updateScrollToBottomButtonCount() + } + + private func setChannelControllerToComposerIfNeeded(cid: ChannelId?) { + guard messageComposerVC.channelController == nil, let cid = cid else { return } + messageComposerVC.channelController = client.channelController(for: cid) + } + + override open func setUpLayout() { + super.setUpLayout() + + view.backgroundColor = appearance.colorPalette.background + + addChildViewController(messageListVC, targetView: view) + messageListVC.view.pin(anchors: [.top, .leading, .trailing], to: view.safeAreaLayoutGuide) + + addChildViewController(messageComposerVC, targetView: view) + messageComposerVC.view.pin(anchors: [.leading, .trailing], to: view) + messageComposerVC.view.topAnchor.pin(equalTo: messageListVC.view.bottomAnchor).isActive = true + messageComposerBottomConstraint = messageComposerVC.view.bottomAnchor.pin(equalTo: view.bottomAnchor) + messageComposerBottomConstraint?.isActive = true + + NSLayoutConstraint.activate([ + channelAvatarView.widthAnchor.pin(equalToConstant: channelAvatarSize.width), + channelAvatarView.heightAnchor.pin(equalToConstant: channelAvatarSize.height) + ]) + + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: channelAvatarView) + channelAvatarView.content = (channelController.channel, client.currentUserId) + + if let cid = channelController.cid { + headerView.channelController = client.channelController(for: cid) + } + + navigationItem.titleView = headerView + navigationItem.largeTitleDisplayMode = .never + } + + override open func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + keyboardHandler.start() + + if shouldMarkChannelRead { + markRead() + } + } + + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let draftMessage = channelController.channel?.draftMessage { + messageComposerVC.content.draftMessage(draftMessage) + } + } + + override open func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + markReadThrottler.cancel() + + keyboardHandler.stop() + + resignFirstResponder() + } + + /// Called when the syncing of the `channelController` is finished. + /// - Parameter error: An `error` if the syncing failed; `nil` if it was successful. + open func didFinishSynchronizing(with error: Error?) { + if let error = error { + log.error("Error when synchronizing ChannelController: \(error)") + } + setChannelControllerToComposerIfNeeded(cid: channelController.cid) + messageComposerVC.updateContent() + + updateAllUnreadMessagesRelatedComponents() + + if let messageId = channelController.channelQuery.pagination?.parameter?.aroundMessageId { + // Jump to a message when opening the channel. + jumpToMessage(id: messageId, animated: components.shouldAnimateJumpToMessageWhenOpeningChannel) + + if let replyId = initialReplyId { + // Jump to a parent message when opening the channel, and then to the reply. + // The delay is necessary so that the animation does not happen to quickly. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.jumpToMessage( + id: replyId, + animated: self.components.shouldAnimateJumpToMessageWhenOpeningChannel + ) + } + } + } else if components.shouldJumpToUnreadWhenOpeningChannel { + // Jump to the unread message. + messageListVC.jumpToUnreadMessage(animated: components.shouldAnimateJumpToMessageWhenOpeningChannel) + } + } + + // MARK: - Actions + + /// Marks the channel read and updates the UI optimistically. + public func markRead() { + channelController.markRead() + hasSeenLastMessage = false + updateJumpToUnreadRelatedComponents() + updateScrollToBottomButtonCount() + } + + /// Jump to a given message. + /// In case the message is already loaded, it directly goes to it. + /// If not, it will load the messages around it and go to that page. + /// + /// This function is an high-level abstraction of `messageListVC.jumpToMessage(id:onHighlight:)`. + /// + /// - Parameters: + /// - id: The id of message which the message list should go to. + /// - animated: `true` if you want to animate the change in position; `false` if it should be immediate. + /// - shouldHighlight: Whether the message should be highlighted when jumping to it. By default it is highlighted. + public func jumpToMessage(id: MessageId, animated: Bool = true, shouldHighlight: Bool = true) { + if shouldHighlight { + messageListVC.jumpToMessage(id: id, animated: animated) { [weak self] indexPath in + self?.messageListVC.highlightCell(at: indexPath) + } + return + } + + messageListVC.jumpToMessage(id: id, animated: animated) + } + + // MARK: - Loading previous and next messages state handling. + + /// Called when the channel will load previous (older) messages. + open func loadPreviousMessages(completion: @escaping (Error?) -> Void) { + channelController.loadPreviousMessages { [weak self] error in + completion(error) + self?.didFinishLoadingPreviousMessages(with: error) + } + } + + /// Called when the channel finished requesting previous (older) messages. + /// Can be used to handle state changes or UI updates. + open func didFinishLoadingPreviousMessages(with error: Error?) { + // no-op, override to handle the completion of loading previous messages. + } + + /// Called when the channel will load next (newer) messages. + open func loadNextMessages(completion: @escaping (Error?) -> Void) { + channelController.loadNextMessages { [weak self] error in + completion(error) + self?.didFinishLoadingNextMessages(with: error) + } + } + + /// Called when the channel finished requesting next (newer) messages. + open func didFinishLoadingNextMessages(with error: Error?) { + // no-op, override to handle the completion of loading next messages. + } + + // MARK: - ChatMessageListVCDataSource + + public var messages: [ChatMessage] = [] + + public var isFirstPageLoaded: Bool { + channelController.hasLoadedAllNextMessages + } + + public var isLastPageLoaded: Bool { + channelController.hasLoadedAllPreviousMessages + } + + open func channel(for vc: ChatMessageListVC) -> ChatChannel? { + channelController.channel + } + + open func numberOfMessages(in vc: ChatMessageListVC) -> Int { + messages.count + } + + open func chatMessageListVC(_ vc: ChatMessageListVC, messageAt indexPath: IndexPath) -> ChatMessage? { + messages[safe: indexPath.item] + } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + messageLayoutOptionsAt indexPath: IndexPath + ) -> ChatMessageLayoutOptions { + guard let channel = channelController.channel else { return [] } + + return components.messageLayoutOptionsResolver.optionsForMessage( + at: indexPath, + in: channel, + with: AnyRandomAccessCollection(messages), + appearance: appearance + ) + } + + public func chatMessageListVC( + _ vc: ChatMessageListVC, + shouldLoadPageAroundMessageId messageId: MessageId, + _ completion: @escaping ((Error?) -> Void) + ) { + if let message = channelController.messages.first(where: { $0.id == messageId }), + let parentMessageId = getParentMessageId(forMessageInsideThread: message) { + let replyId = message.id + messageListVC.showThread(messageId: parentMessageId, at: replyId) + return + } + + channelController.loadPageAroundMessageId(messageId) { [weak self] error in + self?.updateJumpToUnreadRelatedComponents() + completion(error) + } + } + + open func chatMessageListVCShouldLoadFirstPage( + _ vc: ChatMessageListVC + ) { + channelController.loadFirstPage() + } + + // MARK: - ChatMessageListVCDelegate + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + willDisplayMessageAt indexPath: IndexPath + ) { + guard !hasSeenFirstUnreadMessage else { return } + + let message = chatMessageListVC(vc, messageAt: indexPath) + if message?.id == firstUnreadMessageId { + hasSeenFirstUnreadMessage = true + } + } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + didTapOnAction actionItem: ChatMessageActionItem, + for message: ChatMessage + ) { + switch actionItem { + case is EditActionItem: + dismiss(animated: true) { [weak self] in + self?.messageComposerVC.content.editMessage(message) + self?.messageComposerVC.composerView.inputMessageView.textView.becomeFirstResponder() + } + case is InlineReplyActionItem: + dismiss(animated: true) { [weak self] in + self?.messageComposerVC.content.quoteMessage(message) + } + case is ThreadReplyActionItem: + dismiss(animated: true) { [weak self] in + self?.messageListVC.showThread(messageId: message.id) + } + case is MarkUnreadActionItem: + dismiss(animated: true) { [weak self] in +// self?.channelController.markUnread(from: message.id) { result in +// if case let .success(channel) = result { +// self?.updateAllUnreadMessagesRelatedComponents(channel: channel) +// } +// } TODOX + } + default: + return + } + } + + public func chatMessageListShouldShowJumpToUnread(_ vc: ChatMessageListVC) -> Bool { + true + } + + public func chatMessageListDidDiscardUnreadMessages(_ vc: ChatMessageListVC) { + markRead() + } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + scrollViewDidScroll scrollView: UIScrollView + ) { + if !hasSeenLastMessage && isLastMessageFullyVisible { + hasSeenLastMessage = true + } + if shouldMarkChannelRead { + markReadThrottler.execute { [weak self] in + self?.markRead() + } + } + } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + didTapOnMessageListView messageListView: ChatMessageListView, + with gestureRecognizer: UITapGestureRecognizer + ) { + messageComposerVC.dismissSuggestions() + } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + headerViewForMessage message: ChatMessage, + at indexPath: IndexPath + ) -> ChatMessageDecorationView? { + let shouldShowDate = vc.shouldShowDateSeparator(forMessage: message, at: indexPath) + let shouldShowUnreadMessages = components.isUnreadMessagesSeparatorEnabled && message.id == firstUnreadMessageId + + guard (shouldShowDate || shouldShowUnreadMessages), let channel = channelController.channel else { + return nil + } + + let header = components.messageHeaderDecorationView.init() + header.content = ChatChannelMessageHeaderDecoratorViewContent( + message: message, + channel: channel, + dateFormatter: vc.dateSeparatorFormatter, + shouldShowDate: shouldShowDate, + shouldShowUnreadMessages: shouldShowUnreadMessages + ) + return header + } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + footerViewForMessage message: ChatMessage, + at indexPath: IndexPath + ) -> ChatMessageDecorationView? { + nil + } + + // MARK: - ChatChannelControllerDelegate + + public func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel change: EntityChange + ) { + updateScrollToBottomButtonCount() + updateJumpToUnreadRelatedComponents() + + if headerView.channelController == nil, let cid = channelController.cid { + headerView.channelController = client.channelController(for: cid) + } + + channelAvatarView.content = (channelController.channel, client.currentUserId) + } + + public func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + messageListVC.setPreviousMessagesSnapshot(self.messages) + messageListVC.setNewMessagesSnapshotArray(channelController.messages) + let changes = channelController.messages.difference(from: self.messages) + .map { change in + switch change { + case let .insert(offset, element, _): + return ListChange.insert(element, index: IndexPath(row: offset, section: 0)) + case let .remove(offset, element, _): + return ListChange.remove(element, index: IndexPath(row: offset, section: 0)) + } + } + messageListVC.updateMessages(with: changes) { [weak self] in + guard let self = self else { return } + + if let unreadCount = channelController.channel?.unreadCount.messages, channelController.firstUnreadMessageId == nil && unreadCount == 0 { + self.hasSeenFirstUnreadMessage = true + } + + self.updateJumpToUnreadRelatedComponents() + if self.shouldMarkChannelRead { + self.markReadThrottler.execute { + self.markRead() + } + } else if !self.hasSeenFirstUnreadMessage { + self.updateUnreadMessagesBannerRelatedComponents() + } + } + viewPaginationHandler.updateElementsCount(with: channelController.messages.count) + } + + open func channelController( + _ channelController: ChatChannelController, + didUpdateMessages changes: [ListChange] + ) { + messageListVC.setPreviousMessagesSnapshot(messages) + messageListVC.setNewMessagesSnapshot(channelController.messages) + messageListVC.updateMessages(with: changes) { [weak self] in + guard let self = self else { return } + + if let unreadCount = channelController.channel?.unreadCount.messages, channelController.firstUnreadMessageId == nil && unreadCount == 0 { + self.hasSeenFirstUnreadMessage = true + } + + self.updateJumpToUnreadRelatedComponents() + if self.shouldMarkChannelRead { + self.markReadThrottler.execute { + self.markRead() + } + } else if !self.hasSeenFirstUnreadMessage { + self.updateUnreadMessagesBannerRelatedComponents() + } + } + viewPaginationHandler.updateElementsCount(with: channelController.messages.count) + } + + open func channelController( + _ channelController: ChatChannelController, + didUpdateChannel channel: EntityChange + ) { + updateScrollToBottomButtonCount() + updateJumpToUnreadRelatedComponents() + + if headerView.channelController == nil, let cid = channelController.cid { + headerView.channelController = client.channelController(for: cid) + } + + channelAvatarView.content = (channelController.channel, client.currentUserId) + } + + open func channelController( + _ channelController: ChatChannelController, + didChangeTypingUsers typingUsers: Set + ) { + guard channelController.channel?.canSendTypingEvents == true else { return } + + let typingUsersWithoutCurrentUser = typingUsers + .sorted { $0.id < $1.id } + .filter { $0.id != self.client.currentUserId } + + if typingUsersWithoutCurrentUser.isEmpty { + messageListVC.hideTypingIndicator() + } else { + messageListVC.showTypingIndicator(typingUsers: typingUsersWithoutCurrentUser) + } + } + + // MARK: - EventsControllerDelegate + + open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + if let newMessagePendingEvent = event as? NewMessagePendingEvent { + let newMessage = newMessagePendingEvent.message + if !isFirstPageLoaded && newMessage.isSentByCurrentUser && !newMessage.isPartOfThread { + channelController.loadFirstPage() + } + } + + if let newMessageErrorEvent = event as? NewMessageErrorEvent { + let messageId = newMessageErrorEvent.messageId + let error = newMessageErrorEvent.error + guard let message = channelController.messages.first(where: { $0.id == messageId }) else { + debugPrint("New Message Error: \(error) MessageId: \(messageId)") + return + } + debugPrint("New Message Error: \(error) Message: \(message)") + } + + if let draftUpdatedEvent = event as? DraftUpdatedEvent, + let draft = channelController.channel?.draftMessage, + draftUpdatedEvent.cid == channelController.cid, draftUpdatedEvent.draftMessage.threadId == nil { + messageComposerVC.content.draftMessage(draft) + } + + if let draftDeletedEvent = event as? DraftDeletedEvent, + draftDeletedEvent.cid == channelController.cid, draftDeletedEvent.threadId == nil { + messageComposerVC.content.clear() + } + } + + // MARK: - AudioQueuePlayerDatasource + + open func audioQueuePlayerNextAssetURL( + _ audioPlayer: AudioPlaying, + currentAssetURL: URL? + ) -> URL? { + audioQueuePlayerNextItemProvider.findNextItem( + in: messages, + currentVoiceRecordingURL: currentAssetURL, + lookUpScope: .subsequentMessagesFromUser + ) + } +} + +// MARK: - Helpers + +private extension ChatChannelVC { + /// Returns a parent message id if the given message is a reply inside a thread only. + func getParentMessageId(forMessageInsideThread message: ChatMessage) -> MessageId? { + guard message.isPartOfThread && !message.showReplyInChannel else { + return nil + } + + return message.parentMessageId + } + + func makeChannelController(forParentMessageId parentMessageId: MessageId) -> ChatChannelController { + var newQuery = channelController.channelQuery + let pageSize = newQuery.pagination?.pageSize ?? .messagesPageSize + newQuery.pagination = MessagesPagination(pageSize: pageSize, parameter: .around(parentMessageId)) + return client.channelController( + for: newQuery + ) + } + + func updateAllUnreadMessagesRelatedComponents(channel: ChatChannel? = nil) { + updateScrollToBottomButtonCount(channel: channel) + updateJumpToUnreadRelatedComponents(channel: channel) + updateUnreadMessagesBannerRelatedComponents(channel: channel) + } + + func updateScrollToBottomButtonCount(channel: ChatChannel? = nil) { + let channelUnreadCount = (channel ?? channelController.channel)?.unreadCount ?? .noUnread + messageListVC.scrollToBottomButton.content = channelUnreadCount + } + + func updateJumpToUnreadRelatedComponents(channel: ChatChannel? = nil) { + let firstUnreadMessageId = channel.flatMap { channelController.getFirstUnreadMessageId(for: $0) } ?? channelController.firstUnreadMessageId + let lastReadMessageId = client.currentUserId.flatMap { channel?.lastReadMessageId(userId: $0) } ?? channelController.lastReadMessageId + + messageListVC.updateJumpToUnreadMessageId( + firstUnreadMessageId, + lastReadMessageId: lastReadMessageId + ) + messageListVC.updateJumpToUnreadButtonVisibility() + } + + func updateUnreadMessagesBannerRelatedComponents(channel: ChatChannel? = nil) { + let firstUnreadMessageId = channel.flatMap { channelController.getFirstUnreadMessageId(for: $0) } ?? channelController.firstUnreadMessageId + self.firstUnreadMessageId = firstUnreadMessageId + messageListVC.updateUnreadMessagesSeparator(at: firstUnreadMessageId) + } +} + +private extension UIView { + var withoutAutoresizingMaskConstraints: Self { + translatesAutoresizingMaskIntoConstraints = false + return self + } +} diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index a60be4b1d2..630b1d6be4 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -16,6 +16,10 @@ public extension ChatClient { /// A controller for managing livestream channels that operates without local database persistence. /// /// Unlike `ChatChannelController`, this controller manages all data in memory and communicates directly with the API. +/// It is more performant than `ChatChannelController` but is more simpler and it has less features, like for example: +/// - Read updates +/// - Typing indicators +/// - etc.. public class LivestreamChannelController: EventsControllerDelegate { // MARK: - Public Properties @@ -228,7 +232,25 @@ public class LivestreamChannelController: EventsControllerDelegate { updateChannelData(channelQuery: query, completion: completion) } - + + /// Marks the channel as read. + public func markRead(completion: ((Error?) -> Void)? = nil) { + guard let channel = channel else { + return + } + + /// Read events are not enabled for this channel + guard channel.canReceiveReadEvents == true else { + return + } + + apiClient.request(endpoint: .markRead(cid: channel.cid)) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + // MARK: - Helper Methods public func getFirstUnreadMessageId(for channel: ChatChannel) -> MessageId? { diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b926d2146b..16a00582ba 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1422,6 +1422,7 @@ AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; + AD1B9F452E33E5EE0091A37A /* DemoLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* DemoLivestreamChannelVC.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; @@ -4273,6 +4274,7 @@ AD17E1202E009853001AF308 /* SharedLocationPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationPayload.swift; sourceTree = ""; }; AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController.swift; sourceTree = ""; }; + AD1B9F442E33E5E10091A37A /* DemoLivestreamChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChannelVC.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; @@ -6879,6 +6881,7 @@ A3227E6E284A4BB700EBE6CC /* Components */ = { isa = PBXGroup; children = ( + AD1B9F442E33E5E10091A37A /* DemoLivestreamChannelVC.swift */, 7933060A256FF94800FBB586 /* DemoChatChannelListRouter.swift */, A3227E79284A4CE000EBE6CC /* DemoChatChannelListVC.swift */, AD82903C2A7C5A8F00396782 /* DemoChatChannelListItemView.swift */, @@ -11198,6 +11201,7 @@ A3227E60284A497300EBE6CC /* GroupUserCell.swift in Sources */, AD7BE1682C1CB183000A5756 /* DebugObjectViewController.swift in Sources */, ADD328612C06463600BAD0E9 /* DemoChatThreadListVC.swift in Sources */, + AD1B9F452E33E5EE0091A37A /* DemoLivestreamChannelVC.swift in Sources */, ADD3285A2C04DD8300BAD0E9 /* DemoAppTabBarController.swift in Sources */, 8440860D28FBFE520027849C /* DemoAppCoordinator+DemoApp.swift in Sources */, 647F66D5261E22C200111B19 /* DemoConnectionBannerView.swift in Sources */, From 6763223fd152cae619bc40b615c11c5fdb4b9cd8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 17:41:58 +0100 Subject: [PATCH 15/85] Remove unread stuff from LivestreamChannelController --- .../LivestreamChannelController.swift | 42 ++++--------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 630b1d6be4..79f4b3ef6b 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -70,11 +70,6 @@ public class LivestreamChannelController: EventsControllerDelegate { paginationStateHandler.state.isJumpingToMessage } - /// The id of the first unread message for the current user. - public var firstUnreadMessageId: MessageId? { - channel.flatMap { getFirstUnreadMessageId(for: $0) } - } - /// The id of the message which the current user last read. public var lastReadMessageId: MessageId? { client.currentUserId.flatMap { channel?.lastReadMessageId(userId: $0) } @@ -121,7 +116,7 @@ public class LivestreamChannelController: EventsControllerDelegate { // MARK: - Public Methods - /// Synchronizes the controller with the backend data + /// Synchronizes the controller with the backend data. /// - Parameter completion: Called when the synchronization is finished public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { updateChannelData( @@ -250,17 +245,6 @@ public class LivestreamChannelController: EventsControllerDelegate { } } } - - // MARK: - Helper Methods - - public func getFirstUnreadMessageId(for channel: ChatChannel) -> MessageId? { - UnreadMessageLookup.firstUnreadMessageId( - in: channel, - messages: StreamCollection(messages), - hasLoadedAllPreviousMessages: hasLoadedAllPreviousMessages, - currentUserId: client.currentUserId - ) - } // MARK: - Private Methods @@ -316,17 +300,7 @@ public class LivestreamChannelController: EventsControllerDelegate { updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - if oldChannel != nil { - self.delegate?.livestreamChannelController(self, didUpdateChannel: .update(newChannel)) - } else { - self.delegate?.livestreamChannelController(self, didUpdateChannel: .create(newChannel)) - } - - self.delegate?.livestreamChannelController(self, didUpdateMessages: self.messages) - } + notifyDelegateOfChanges() } private func updateMessagesArray(with newMessages: [ChatMessage], pagination: MessagesPagination?) { @@ -459,9 +433,9 @@ public class LivestreamChannelController: EventsControllerDelegate { } private func notifyDelegateOfChanges() { - guard let currentChannel = channel else { return } - - delegate?.livestreamChannelController(self, didUpdateChannel: .update(currentChannel)) + guard let channel = channel else { return } + + delegate?.livestreamChannelController(self, didUpdateChannel: channel) delegate?.livestreamChannelController(self, didUpdateMessages: messages) } } @@ -473,10 +447,10 @@ public protocol LivestreamChannelControllerDelegate: AnyObject { /// Called when the channel data is updated /// - Parameters: /// - controller: The controller that updated - /// - change: The change that occurred + /// - channel: The updated channel the controller manages. func livestreamChannelController( _ controller: LivestreamChannelController, - didUpdateChannel change: EntityChange + didUpdateChannel channel: ChatChannel ) /// Called when the messages are updated @@ -494,7 +468,7 @@ public protocol LivestreamChannelControllerDelegate: AnyObject { public extension LivestreamChannelControllerDelegate { func livestreamChannelController( _ controller: LivestreamChannelController, - didUpdateChannel change: EntityChange + didUpdateChannel channel: ChatChannel ) {} func livestreamChannelController( From 1bca1e402fa81fdfa1b7466c95e97381fdc560f4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 18:02:11 +0100 Subject: [PATCH 16/85] Add ChatLivestreamChannelVC to be able to test --- .../DemoChatChannelListRouter.swift | 8 + .../ChatChannel/ChatLivestreamChannelVC.swift | 233 ++---------------- .../StreamChatUI/Composer/ComposerVC.swift | 2 +- StreamChat.xcodeproj/project.pbxproj | 10 +- 4 files changed, 35 insertions(+), 218 deletions(-) rename DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift => Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift (64%) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 655ff16719..6d75b0ea0d 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -116,6 +116,14 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { ) self.channelPresentingStyle = .embeddedInTabBar }), + .init(title: "Show as Livestream Controller", handler: { [unowned self] _ in + let client = self.rootViewController.controller.client + let livestreamController = client.livestreamChannelController(for: .init(cid: cid)) + let vc = ChatLivestreamChannelVC() + vc.channelController = livestreamController + vc.hidesBottomBarWhenPushed = true + self.rootViewController.navigationController?.pushViewController(vc, animated: true) + }), .init(title: "Update channel name", isEnabled: canUpdateChannel, handler: { [unowned self] _ in self.rootViewController.presentAlert(title: "Enter channel name", textFieldPlaceholder: "Channel name") { name in guard let name = name, !name.isEmpty else { diff --git a/DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift similarity index 64% rename from DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift rename to Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift index 25e5237e27..b0d556604a 100644 --- a/DemoApp/StreamChat/Components/DemoLivestreamChannelVC.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift @@ -3,10 +3,9 @@ // import StreamChat -import StreamChatUI import UIKit -open class DemoLivestreamChannelVC: _ViewController, +open class ChatLivestreamChannelVC: _ViewController, ThemeProvider, ChatMessageListVCDataSource, ChatMessageListVCDelegate, @@ -81,19 +80,11 @@ open class DemoLivestreamChannelVC: _ViewController, /// A boolean value indicating whether it should mark the channel read. open var shouldMarkChannelRead: Bool { - guard isViewVisible else { - return false - } - - guard components.isJumpToUnreadEnabled else { - return isLastMessageFullyVisible && isFirstPageLoaded - } - - return isLastMessageVisibleOrSeen && hasSeenFirstUnreadMessage && isFirstPageLoaded && !hasMarkedMessageAsUnread + isLastMessageVisibleOrSeen && isFirstPageLoaded } private var isLastMessageVisibleOrSeen: Bool { - hasSeenLastMessage || isLastMessageFullyVisible + isLastMessageFullyVisible } /// A component responsible to handle when to load new or old messages. @@ -104,23 +95,6 @@ open class DemoLivestreamChannelVC: _ViewController, /// The throttler to make sure that the marking read is not spammed. var markReadThrottler: Throttler = Throttler(interval: 3, queue: .main) - /// Determines if a messaged had been marked as unread in the current session - private var hasMarkedMessageAsUnread: Bool { - false - } - - /// Determines whether first unread message has been seen - private var hasSeenFirstUnreadMessage: Bool = false - - /// Determines whether last cell has been seen since the last time it was marked as read - private var hasSeenLastMessage: Bool = false - - /// The id of the first unread message - private var firstUnreadMessageId: MessageId? - - /// In case the given around message id is from a thread, we need to jump to the parent message and then the reply. - internal var initialReplyId: MessageId? - override open func setUp() { super.setUp() @@ -237,29 +211,9 @@ open class DemoLivestreamChannelVC: _ViewController, if let error = error { log.error("Error when synchronizing ChannelController: \(error)") } + setChannelControllerToComposerIfNeeded(cid: channelController.cid) messageComposerVC.updateContent() - - updateAllUnreadMessagesRelatedComponents() - - if let messageId = channelController.channelQuery.pagination?.parameter?.aroundMessageId { - // Jump to a message when opening the channel. - jumpToMessage(id: messageId, animated: components.shouldAnimateJumpToMessageWhenOpeningChannel) - - if let replyId = initialReplyId { - // Jump to a parent message when opening the channel, and then to the reply. - // The delay is necessary so that the animation does not happen to quickly. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.jumpToMessage( - id: replyId, - animated: self.components.shouldAnimateJumpToMessageWhenOpeningChannel - ) - } - } - } else if components.shouldJumpToUnreadWhenOpeningChannel { - // Jump to the unread message. - messageListVC.jumpToUnreadMessage(animated: components.shouldAnimateJumpToMessageWhenOpeningChannel) - } } // MARK: - Actions @@ -267,8 +221,6 @@ open class DemoLivestreamChannelVC: _ViewController, /// Marks the channel read and updates the UI optimistically. public func markRead() { channelController.markRead() - hasSeenLastMessage = false - updateJumpToUnreadRelatedComponents() updateScrollToBottomButtonCount() } @@ -365,15 +317,7 @@ open class DemoLivestreamChannelVC: _ViewController, shouldLoadPageAroundMessageId messageId: MessageId, _ completion: @escaping ((Error?) -> Void) ) { - if let message = channelController.messages.first(where: { $0.id == messageId }), - let parentMessageId = getParentMessageId(forMessageInsideThread: message) { - let replyId = message.id - messageListVC.showThread(messageId: parentMessageId, at: replyId) - return - } - - channelController.loadPageAroundMessageId(messageId) { [weak self] error in - self?.updateJumpToUnreadRelatedComponents() + channelController.loadPageAroundMessageId(messageId) { error in completion(error) } } @@ -390,12 +334,7 @@ open class DemoLivestreamChannelVC: _ViewController, _ vc: ChatMessageListVC, willDisplayMessageAt indexPath: IndexPath ) { - guard !hasSeenFirstUnreadMessage else { return } - - let message = chatMessageListVC(vc, messageAt: indexPath) - if message?.id == firstUnreadMessageId { - hasSeenFirstUnreadMessage = true - } + // no-op } open func chatMessageListVC( @@ -418,33 +357,16 @@ open class DemoLivestreamChannelVC: _ViewController, self?.messageListVC.showThread(messageId: message.id) } case is MarkUnreadActionItem: - dismiss(animated: true) { [weak self] in -// self?.channelController.markUnread(from: message.id) { result in -// if case let .success(channel) = result { -// self?.updateAllUnreadMessagesRelatedComponents(channel: channel) -// } -// } TODOX - } + dismiss(animated: true) default: return } } - public func chatMessageListShouldShowJumpToUnread(_ vc: ChatMessageListVC) -> Bool { - true - } - - public func chatMessageListDidDiscardUnreadMessages(_ vc: ChatMessageListVC) { - markRead() - } - open func chatMessageListVC( _ vc: ChatMessageListVC, scrollViewDidScroll scrollView: UIScrollView ) { - if !hasSeenLastMessage && isLastMessageFullyVisible { - hasSeenLastMessage = true - } if shouldMarkChannelRead { markReadThrottler.execute { [weak self] in self?.markRead() @@ -466,9 +388,7 @@ open class DemoLivestreamChannelVC: _ViewController, at indexPath: IndexPath ) -> ChatMessageDecorationView? { let shouldShowDate = vc.shouldShowDateSeparator(forMessage: message, at: indexPath) - let shouldShowUnreadMessages = components.isUnreadMessagesSeparatorEnabled && message.id == firstUnreadMessageId - - guard (shouldShowDate || shouldShowUnreadMessages), let channel = channelController.channel else { + guard shouldShowDate, let channel = channelController.channel else { return nil } @@ -478,7 +398,7 @@ open class DemoLivestreamChannelVC: _ViewController, channel: channel, dateFormatter: vc.dateSeparatorFormatter, shouldShowDate: shouldShowDate, - shouldShowUnreadMessages: shouldShowUnreadMessages + shouldShowUnreadMessages: false ) return header } @@ -491,15 +411,12 @@ open class DemoLivestreamChannelVC: _ViewController, nil } - // MARK: - ChatChannelControllerDelegate + // MARK: - LivestreamChannelControllerDelegate public func livestreamChannelController( _ controller: LivestreamChannelController, didUpdateChannel change: EntityChange ) { - updateScrollToBottomButtonCount() - updateJumpToUnreadRelatedComponents() - if headerView.channelController == nil, let cid = channelController.cid { headerView.channelController = client.channelController(for: cid) } @@ -513,88 +430,28 @@ open class DemoLivestreamChannelVC: _ViewController, ) { messageListVC.setPreviousMessagesSnapshot(self.messages) messageListVC.setNewMessagesSnapshotArray(channelController.messages) - let changes = channelController.messages.difference(from: self.messages) - .map { change in - switch change { - case let .insert(offset, element, _): - return ListChange.insert(element, index: IndexPath(row: offset, section: 0)) - case let .remove(offset, element, _): - return ListChange.remove(element, index: IndexPath(row: offset, section: 0)) - } - } - messageListVC.updateMessages(with: changes) { [weak self] in - guard let self = self else { return } - - if let unreadCount = channelController.channel?.unreadCount.messages, channelController.firstUnreadMessageId == nil && unreadCount == 0 { - self.hasSeenFirstUnreadMessage = true - } - self.updateJumpToUnreadRelatedComponents() - if self.shouldMarkChannelRead { - self.markReadThrottler.execute { - self.markRead() - } - } else if !self.hasSeenFirstUnreadMessage { - self.updateUnreadMessagesBannerRelatedComponents() + let diff = channelController.messages.difference(from: self.messages) + let changes = diff.map { change in + switch change { + case let .insert(offset, element, _): + return ListChange.insert(element, index: IndexPath(row: offset, section: 0)) + case let .remove(offset, element, _): + return ListChange.remove(element, index: IndexPath(row: offset, section: 0)) } } - viewPaginationHandler.updateElementsCount(with: channelController.messages.count) - } - open func channelController( - _ channelController: ChatChannelController, - didUpdateMessages changes: [ListChange] - ) { - messageListVC.setPreviousMessagesSnapshot(messages) - messageListVC.setNewMessagesSnapshot(channelController.messages) messageListVC.updateMessages(with: changes) { [weak self] in guard let self = self else { return } - if let unreadCount = channelController.channel?.unreadCount.messages, channelController.firstUnreadMessageId == nil && unreadCount == 0 { - self.hasSeenFirstUnreadMessage = true - } - - self.updateJumpToUnreadRelatedComponents() if self.shouldMarkChannelRead { self.markReadThrottler.execute { self.markRead() } - } else if !self.hasSeenFirstUnreadMessage { - self.updateUnreadMessagesBannerRelatedComponents() } } - viewPaginationHandler.updateElementsCount(with: channelController.messages.count) - } - open func channelController( - _ channelController: ChatChannelController, - didUpdateChannel channel: EntityChange - ) { - updateScrollToBottomButtonCount() - updateJumpToUnreadRelatedComponents() - - if headerView.channelController == nil, let cid = channelController.cid { - headerView.channelController = client.channelController(for: cid) - } - - channelAvatarView.content = (channelController.channel, client.currentUserId) - } - - open func channelController( - _ channelController: ChatChannelController, - didChangeTypingUsers typingUsers: Set - ) { - guard channelController.channel?.canSendTypingEvents == true else { return } - - let typingUsersWithoutCurrentUser = typingUsers - .sorted { $0.id < $1.id } - .filter { $0.id != self.client.currentUserId } - - if typingUsersWithoutCurrentUser.isEmpty { - messageListVC.hideTypingIndicator() - } else { - messageListVC.showTypingIndicator(typingUsers: typingUsersWithoutCurrentUser) - } + viewPaginationHandler.updateElementsCount(with: channelController.messages.count) } // MARK: - EventsControllerDelegate @@ -641,61 +498,11 @@ open class DemoLivestreamChannelVC: _ViewController, lookUpScope: .subsequentMessagesFromUser ) } -} - -// MARK: - Helpers -private extension ChatChannelVC { - /// Returns a parent message id if the given message is a reply inside a thread only. - func getParentMessageId(forMessageInsideThread message: ChatMessage) -> MessageId? { - guard message.isPartOfThread && !message.showReplyInChannel else { - return nil - } + // MARK: - Helpers - return message.parentMessageId - } - - func makeChannelController(forParentMessageId parentMessageId: MessageId) -> ChatChannelController { - var newQuery = channelController.channelQuery - let pageSize = newQuery.pagination?.pageSize ?? .messagesPageSize - newQuery.pagination = MessagesPagination(pageSize: pageSize, parameter: .around(parentMessageId)) - return client.channelController( - for: newQuery - ) - } - - func updateAllUnreadMessagesRelatedComponents(channel: ChatChannel? = nil) { - updateScrollToBottomButtonCount(channel: channel) - updateJumpToUnreadRelatedComponents(channel: channel) - updateUnreadMessagesBannerRelatedComponents(channel: channel) - } - - func updateScrollToBottomButtonCount(channel: ChatChannel? = nil) { + private func updateScrollToBottomButtonCount(channel: ChatChannel? = nil) { let channelUnreadCount = (channel ?? channelController.channel)?.unreadCount ?? .noUnread messageListVC.scrollToBottomButton.content = channelUnreadCount } - - func updateJumpToUnreadRelatedComponents(channel: ChatChannel? = nil) { - let firstUnreadMessageId = channel.flatMap { channelController.getFirstUnreadMessageId(for: $0) } ?? channelController.firstUnreadMessageId - let lastReadMessageId = client.currentUserId.flatMap { channel?.lastReadMessageId(userId: $0) } ?? channelController.lastReadMessageId - - messageListVC.updateJumpToUnreadMessageId( - firstUnreadMessageId, - lastReadMessageId: lastReadMessageId - ) - messageListVC.updateJumpToUnreadButtonVisibility() - } - - func updateUnreadMessagesBannerRelatedComponents(channel: ChatChannel? = nil) { - let firstUnreadMessageId = channel.flatMap { channelController.getFirstUnreadMessageId(for: $0) } ?? channelController.firstUnreadMessageId - self.firstUnreadMessageId = firstUnreadMessageId - messageListVC.updateUnreadMessagesSeparator(at: firstUnreadMessageId) - } -} - -private extension UIView { - var withoutAutoresizingMaskConstraints: Self { - translatesAutoresizingMaskIntoConstraints = false - return self - } } diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index a046247dbe..48838dfcdd 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -1767,7 +1767,7 @@ open class ComposerVC: _ViewController, present(alert, animated: true) } - private func removeMentionUserIfNotIncluded(in currentText: String) { + func removeMentionUserIfNotIncluded(in currentText: String) { // If the user included some mentions via suggestions, // but then removed them from text, we should remove them from // the content we'll send diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 16a00582ba..bf048248a2 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1422,7 +1422,8 @@ AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; - AD1B9F452E33E5EE0091A37A /* DemoLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* DemoLivestreamChannelVC.swift */; }; + AD1B9F462E33E78E0091A37A /* ChatLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */; }; + AD1B9F472E33E7950091A37A /* ChatLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; @@ -4274,7 +4275,7 @@ AD17E1202E009853001AF308 /* SharedLocationPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationPayload.swift; sourceTree = ""; }; AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController.swift; sourceTree = ""; }; - AD1B9F442E33E5E10091A37A /* DemoLivestreamChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChannelVC.swift; sourceTree = ""; }; + AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLivestreamChannelVC.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; @@ -6881,7 +6882,6 @@ A3227E6E284A4BB700EBE6CC /* Components */ = { isa = PBXGroup; children = ( - AD1B9F442E33E5E10091A37A /* DemoLivestreamChannelVC.swift */, 7933060A256FF94800FBB586 /* DemoChatChannelListRouter.swift */, A3227E79284A4CE000EBE6CC /* DemoChatChannelListVC.swift */, AD82903C2A7C5A8F00396782 /* DemoChatChannelListItemView.swift */, @@ -9182,6 +9182,7 @@ ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */, AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */, 64B059E12670EFFE0024CE90 /* ChatChannelVC+SwiftUI.swift */, + AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */, ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */, ); path = ChatChannel; @@ -10776,6 +10777,7 @@ AD9632DC2C09E0350073B814 /* ChatThreadListRouter.swift in Sources */, AD4118842D5E1368000EF88E /* UILabel+highlightText.swift in Sources */, 40FA4DE52A12A45400DA21D2 /* VoiceRecordingAttachmentComposerPreview.swift in Sources */, + AD1B9F462E33E78E0091A37A /* ChatLivestreamChannelVC.swift in Sources */, ADC4AAB02788C8850004BB35 /* Appearance+Formatters.swift in Sources */, AD6F531927175FDB00D428B4 /* ChatMessageGiphyView+GiphyBadge.swift in Sources */, ACA3C98726CA23F300EB8B07 /* DateUtils.swift in Sources */, @@ -11201,7 +11203,6 @@ A3227E60284A497300EBE6CC /* GroupUserCell.swift in Sources */, AD7BE1682C1CB183000A5756 /* DebugObjectViewController.swift in Sources */, ADD328612C06463600BAD0E9 /* DemoChatThreadListVC.swift in Sources */, - AD1B9F452E33E5EE0091A37A /* DemoLivestreamChannelVC.swift in Sources */, ADD3285A2C04DD8300BAD0E9 /* DemoAppTabBarController.swift in Sources */, 8440860D28FBFE520027849C /* DemoAppCoordinator+DemoApp.swift in Sources */, 647F66D5261E22C200111B19 /* DemoConnectionBannerView.swift in Sources */, @@ -13168,6 +13169,7 @@ AD9610702C2DD874004F543C /* BannerView.swift in Sources */, C121EC132746A1EC00023E4C /* ImageCache.swift in Sources */, C121EC142746A1EC00023E4C /* ImageTask.swift in Sources */, + AD1B9F472E33E7950091A37A /* ChatLivestreamChannelVC.swift in Sources */, C121EC152746A1EC00023E4C /* ImagePipeline.swift in Sources */, C121EC162746A1EC00023E4C /* ImageProcessing.swift in Sources */, AD7EFDB82C78DC6700625FC5 /* PollCommentListSectionFooterView.swift in Sources */, From 46bef9fdbb1c1d9e04d846d2caa8124ead7796d2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 18:18:35 +0100 Subject: [PATCH 17/85] Handle message errors --- .../LivestreamChannelController.swift | 9 +++++++-- .../WebSocketClient/Events/MessageEvents.swift | 3 ++- .../Workers/Background/MessageSender.swift | 14 ++++++++++++-- .../ChatChannel/ChatLivestreamChannelVC.swift | 10 ---------- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 79f4b3ef6b..2feba520ac 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -291,7 +291,6 @@ public class LivestreamChannelController: EventsControllerDelegate { unreadCount: channel?.unreadCount ) - let oldChannel = channel channel = newChannel let newMessages = payload.messages.compactMap { @@ -354,7 +353,13 @@ public class LivestreamChannelController: EventsControllerDelegate { return } handleUpdatedMessage(messageDeletedEvent.message) - + case let newMessageErrorEvent as NewMessageErrorEvent: + guard let message = messages.first(where: { $0.id == newMessageErrorEvent.messageId }) else { + return + } + let errorMessage = message.changing(state: .sendingFailed) + handleUpdatedMessage(errorMessage) + case let reactionNewEvent as ReactionNewEvent: handleNewReaction(reactionNewEvent) diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index ffc234237d..86c180e22f 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -247,8 +247,9 @@ public struct NewMessagePendingEvent: ChannelSpecificEvent { } // Triggered when a message failed being sent. -public struct NewMessageErrorEvent: Event { +public struct NewMessageErrorEvent: ChannelSpecificEvent { public let messageId: MessageId + public let cid: ChannelId public let error: Error } diff --git a/Sources/StreamChat/Workers/Background/MessageSender.swift b/Sources/StreamChat/Workers/Background/MessageSender.swift index f12a129082..b58d817cf2 100644 --- a/Sources/StreamChat/Workers/Background/MessageSender.swift +++ b/Sources/StreamChat/Workers/Background/MessageSender.swift @@ -87,6 +87,7 @@ class MessageSender: Worker { newRequests[cid] = newRequests[cid] ?? [] newRequests[cid]!.append(.init( messageId: dto.id, + cid: cid, createdLocallyAt: (dto.locallyCreatedAt ?? dto.createdAt).bridgeDate )) } @@ -229,10 +230,18 @@ private class MessageSendingQueue { if let repositoryError = result.error { switch repositoryError { case .messageDoesNotExist, .messageNotPendingSend, .messageDoesNotHaveValidChannel: - let event = NewMessageErrorEvent(messageId: request.messageId, error: repositoryError) + let event = NewMessageErrorEvent( + messageId: request.messageId, + cid: request.cid, + error: repositoryError + ) eventsNotificationCenter.process(event) case .failedToSendMessage(let clientError): - let event = NewMessageErrorEvent(messageId: request.messageId, error: clientError) + let event = NewMessageErrorEvent( + messageId: request.messageId, + cid: request.cid, + error: clientError + ) eventsNotificationCenter.process(event) if ClientError.isEphemeral(error: clientError) { @@ -250,6 +259,7 @@ private class MessageSendingQueue { extension MessageSendingQueue { struct SendRequest: Hashable { let messageId: MessageId + let cid: ChannelId let createdLocallyAt: Date static func == (lhs: Self, rhs: Self) -> Bool { diff --git a/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift index b0d556604a..3c782217a2 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift @@ -464,16 +464,6 @@ open class ChatLivestreamChannelVC: _ViewController, } } - if let newMessageErrorEvent = event as? NewMessageErrorEvent { - let messageId = newMessageErrorEvent.messageId - let error = newMessageErrorEvent.error - guard let message = channelController.messages.first(where: { $0.id == messageId }) else { - debugPrint("New Message Error: \(error) MessageId: \(messageId)") - return - } - debugPrint("New Message Error: \(error) Message: \(message)") - } - if let draftUpdatedEvent = event as? DraftUpdatedEvent, let draft = channelController.channel?.draftMessage, draftUpdatedEvent.cid == channelController.cid, draftUpdatedEvent.draftMessage.threadId == nil { From 7de628b24b2bdd9fd73becaa1ef2a7abc8e7bb1b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 20:58:07 +0100 Subject: [PATCH 18/85] Fix regular controller not working after showing as livestream --- .../ChannelController/LivestreamChannelController.swift | 8 +++++++- Sources/StreamChat/Workers/EventNotificationCenter.swift | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 2feba520ac..af03b7b8a8 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -113,7 +113,13 @@ public class LivestreamChannelController: EventsControllerDelegate { client.eventNotificationCenter.registerManualEventHandling(for: cid) } } - + + deinit { + if let cid { + client.eventNotificationCenter.unregisterManualEventHandling(for: cid) + } + } + // MARK: - Public Methods /// Synchronizes the controller with the backend data. diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 1a6dfc9c58..16017e7595 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -36,6 +36,13 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } } + /// Unregister a channel for manual event handling. + func unregisterManualEventHandling(for cid: ChannelId) { + eventPostingQueue.async { [weak self] in + self?.manualEventHandlingChannelIds.remove(cid) + } + } + func add(middlewares: [EventMiddleware]) { self.middlewares.append(contentsOf: middlewares) } From 83b82d4035bcbc792e7e835bfbf66f41e8e506ee Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 21:00:11 +0100 Subject: [PATCH 19/85] Fix sometimes messages disappearing from the view --- .../StreamChatUI/ChatMessageList/ChatMessageListView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift index f300367fab..6fc65f2b5d 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift @@ -259,7 +259,11 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { skippedMessages = [] newMessagesSnapshot = currentMessagesFromDataSource newMessagesSnapshotArray = currentMessagesFromDataSourceArray - onNewDataSource?(Array(newMessagesSnapshot)) + if let newMessagesSnapshotArray { + onNewDataSource?(newMessagesSnapshotArray) + } else { + onNewDataSource?(Array(newMessagesSnapshot)) + } reloadData() scrollToBottom() } From 2c21ba918625fbac58d1db37a4319104ff6db24e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 25 Jul 2025 22:56:51 +0100 Subject: [PATCH 20/85] Quick jumps fix for Live Stream --- Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift index 4ab27d17f4..52740ee146 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift @@ -1344,7 +1344,8 @@ private extension ChatMessageListVC { let isNewestChangeInsertion = newestChange?.isInsertion == true let isNewestChangeNotByCurrentUser = newestChange?.item.isSentByCurrentUser == false let isNewestChangeNotVisible = !listView.isLastCellFullyVisible && !listView.previousMessagesSnapshot.isEmpty - let isLoadingNewPage = insertions.count > 1 && insertions.count == changes.count + let isLiveStreamController = dataSource is ChatLivestreamChannelVC + let isLoadingNewPage = insertions.count > 1 && insertions.count == changes.count && !isLiveStreamController let shouldSkipMessages = isFirstPageLoaded && isNewestChangeNotVisible From 6e535819e3a9071316d7088bd5a6151890415c23 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 28 Jul 2025 11:50:25 +0100 Subject: [PATCH 21/85] Fix some minor typos --- .../LivestreamChannelController.swift | 13 ------------- Sources/StreamChat/Models/Channel.swift | 6 +++--- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index af03b7b8a8..26986638cc 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -487,16 +487,3 @@ public extension LivestreamChannelControllerDelegate { didUpdateMessages messages: [ChatMessage] ) {} } - -private extension ChatMessage { - func updateReadBy( - with reads: [ChatChannelRead] - ) -> ChatMessage { - let createdAtInterval = createdAt.timeIntervalSince1970 - let messageUserId = author.id - let readBy = reads.filter { read in - read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval - } - return changing(readBy: Set(readBy.map(\.user))) - } -} diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 69cb477e1d..2e99fc08ec 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -664,7 +664,7 @@ extension ChannelPayload { // Map latest messages let reads = channelReads.map { $0.asModel() } - let latestMessages = messages.prefix(5).compactMap { + let latestMessages = messages.compactMap { $0.asModel(cid: channel.cid, currentUserId: currentUserId, channelReads: reads) } @@ -690,10 +690,10 @@ extension ChannelPayload { isFrozen: channelPayload.isFrozen, isDisabled: channelPayload.isDisabled, isBlocked: channelPayload.isBlocked ?? false, - lastActiveMembers: Array(mappedMembers.prefix(100)), + lastActiveMembers: Array(mappedMembers), membership: membership?.asModel(channelId: channelPayload.cid), currentlyTypingUsers: currentlyTypingUsers ?? [], - lastActiveWatchers: Array(mappedWatchers.prefix(100)), + lastActiveWatchers: Array(mappedWatchers), team: channelPayload.team, unreadCount: unreadCount ?? .noUnread, watcherCount: watcherCount ?? 0, From 164ae66315b854b78526e426b0daba826aa9ceec Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 28 Jul 2025 12:35:27 +0100 Subject: [PATCH 22/85] Move Payload to asModel() to seperate files --- Sources/StreamChat/Models/Channel.swift | 159 ----------------- Sources/StreamChat/Models/ChatMessage.swift | 124 ------------- .../StreamChat/Models/MessageReaction.swift | 16 -- .../ChannelPayload+asModel.swift | 164 ++++++++++++++++++ .../MessagePayload+asModel.swift | 145 ++++++++++++++++ .../Payload+asModel/UserPayload+asModel.swift | 29 ++++ Sources/StreamChat/Models/User.swift | 24 --- .../Workers/EventNotificationCenter.swift | 6 +- StreamChat.xcodeproj/project.pbxproj | 28 ++- 9 files changed, 368 insertions(+), 327 deletions(-) create mode 100644 Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift create mode 100644 Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift create mode 100644 Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 2e99fc08ec..7c496d4de3 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -648,162 +648,3 @@ public extension ChatChannel { } // MARK: - Payload -> Model Mapping - -extension ChannelPayload { - /// Converts the ChannelPayload to a ChatChannel model - /// - Returns: A ChatChannel instance - func asModel( - currentUserId: UserId?, - currentlyTypingUsers: Set?, - unreadCount: ChannelUnreadCount? - ) -> ChatChannel { - let channelPayload = channel - - // Map members - let mappedMembers = members.compactMap { $0.asModel(channelId: channelPayload.cid) } - - // Map latest messages - let reads = channelReads.map { $0.asModel() } - let latestMessages = messages.compactMap { - $0.asModel(cid: channel.cid, currentUserId: currentUserId, channelReads: reads) - } - - // Map reads - let mappedReads = channelReads.map { $0.asModel() } - - // Map watchers - let mappedWatchers = watchers?.map { $0.asModel() } ?? [] - - return ChatChannel( - cid: channelPayload.cid, - name: channelPayload.name, - imageURL: channelPayload.imageURL, - lastMessageAt: channelPayload.lastMessageAt, - createdAt: channelPayload.createdAt, - updatedAt: channelPayload.updatedAt, - deletedAt: channelPayload.deletedAt, - truncatedAt: channelPayload.truncatedAt, - isHidden: isHidden ?? false, - createdBy: channelPayload.createdBy?.asModel(), - config: channelPayload.config, - ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), - isFrozen: channelPayload.isFrozen, - isDisabled: channelPayload.isDisabled, - isBlocked: channelPayload.isBlocked ?? false, - lastActiveMembers: Array(mappedMembers), - membership: membership?.asModel(channelId: channelPayload.cid), - currentlyTypingUsers: currentlyTypingUsers ?? [], - lastActiveWatchers: Array(mappedWatchers), - team: channelPayload.team, - unreadCount: unreadCount ?? .noUnread, - watcherCount: watcherCount ?? 0, - memberCount: channelPayload.memberCount, - reads: mappedReads, - cooldownDuration: channelPayload.cooldownDuration, - extraData: channelPayload.extraData, - latestMessages: latestMessages, - lastMessageFromCurrentUser: latestMessages.first { $0.isSentByCurrentUser }, - pinnedMessages: pinnedMessages.compactMap { - $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) - }, - muteDetails: nil, - previewMessage: latestMessages.first, - draftMessage: nil, - activeLiveLocations: [] - ) - } -} - -extension ChannelDetailPayload { - func asModel() -> ChatChannel { - ChatChannel( - cid: cid, - name: name, - imageURL: imageURL, - lastMessageAt: lastMessageAt, - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - truncatedAt: truncatedAt, - isHidden: false, - createdBy: createdBy?.asModel(), - config: config, - ownCapabilities: Set(ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), - isFrozen: isFrozen, - isDisabled: isDisabled, - isBlocked: isBlocked ?? false, - lastActiveMembers: members?.compactMap { $0.asModel(channelId: cid) } ?? [], - membership: nil, - currentlyTypingUsers: [], - lastActiveWatchers: [], - team: team, - unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), - watcherCount: 0, - memberCount: memberCount, - reads: [], - cooldownDuration: cooldownDuration, - extraData: extraData, - latestMessages: [], - lastMessageFromCurrentUser: nil, - pinnedMessages: [], - muteDetails: nil, - previewMessage: nil, - draftMessage: nil, - activeLiveLocations: [] - ) - } -} - -extension MemberPayload { - /// Converts the MemberPayload to a ChatChannelMember model - /// - Parameter channelId: The channel ID the member belongs to - /// - Returns: A ChatChannelMember instance, or nil if user is missing - func asModel(channelId: ChannelId) -> ChatChannelMember? { - guard let userPayload = user else { return nil } - let user = userPayload.asModel() - - return ChatChannelMember( - id: user.id, - name: user.name, - imageURL: user.imageURL, - isOnline: user.isOnline, - isBanned: user.isBanned, - isFlaggedByCurrentUser: user.isFlaggedByCurrentUser, - userRole: user.userRole, - teamsRole: user.teamsRole, - userCreatedAt: user.userCreatedAt, - userUpdatedAt: user.userUpdatedAt, - deactivatedAt: user.userDeactivatedAt, - lastActiveAt: user.lastActiveAt, - teams: user.teams, - language: user.language, - extraData: user.extraData, - memberRole: MemberRole(rawValue: role?.rawValue ?? "member"), - memberCreatedAt: createdAt, - memberUpdatedAt: updatedAt, - isInvited: isInvited ?? false, - inviteAcceptedAt: inviteAcceptedAt, - inviteRejectedAt: inviteRejectedAt, - archivedAt: archivedAt, - pinnedAt: pinnedAt, - isBannedFromChannel: isBanned ?? false, - banExpiresAt: banExpiresAt, - isShadowBannedFromChannel: isShadowBanned ?? false, - notificationsMuted: notificationsMuted, - memberExtraData: [:] - ) - } -} - -extension ChannelReadPayload { - /// Converts the ChannelReadPayload to a ChatChannelRead model - /// - Returns: A ChatChannelRead instance - func asModel() -> ChatChannelRead { - ChatChannelRead( - lastReadAt: lastReadAt, - lastReadMessageId: lastReadMessageId, - unreadMessagesCount: unreadMessagesCount, - user: user.asModel() - ) - } -} diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 4d89a86342..35fbd738e0 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -695,127 +695,3 @@ public struct MessageDeliveryStatus: RawRepresentable, Hashable { } // MARK: - Payload -> Model Mapping - -extension MessagePayload { - /// Converts the MessagePayload to a ChatMessage model - /// - Parameters: - /// - cid: The channel ID the message belongs to - /// - currentUserId: The current user's ID for determining sent status - /// - channelReads: Channel reads for determining readBy status - /// - Returns: A ChatMessage instance - func asModel( - cid: ChannelId, - currentUserId: UserId?, - channelReads: [ChatChannelRead] - ) -> ChatMessage? { - let author = user.asModel() - let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) - let threadParticipants = threadParticipants.compactMap { $0.asModel() } - - // Map quoted message recursively - let quotedMessage = quotedMessage?.asModel( - cid: cid, - currentUserId: currentUserId, - channelReads: channelReads - ) - - // Map reactions - let latestReactions = Set(latestReactions.compactMap { $0.asModel() }) - - let currentUserReactions: Set - if ownReactions.isEmpty { - currentUserReactions = latestReactions.filter { $0.author.id == currentUserId } - } else { - currentUserReactions = Set(ownReactions.compactMap { $0.asModel() }) - } - - // Map attachments - let attachments: [AnyChatMessageAttachment] = attachments - .enumerated() - .compactMap { offset, attachmentPayload in - guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { - return nil - } - return AnyChatMessageAttachment( - id: .init(cid: cid, messageId: id, index: offset), - type: attachmentPayload.type, - payload: payloadData, - downloadingState: nil, - uploadingState: nil - ) - } - - // Calculate readBy from channel reads - let createdAtInterval = createdAt.timeIntervalSince1970 - let messageUserId = user.id - let readBy = channelReads.filter { read in - read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval - } - - return ChatMessage( - id: id, - cid: cid, - text: text, - type: type, - command: command, - createdAt: createdAt, - locallyCreatedAt: nil, - updatedAt: updatedAt, - deletedAt: deletedAt, - arguments: args, - parentMessageId: parentId, - showReplyInChannel: showReplyInChannel, - replyCount: replyCount, - extraData: extraData, - quotedMessage: quotedMessage, - isBounced: false, - isSilent: isSilent, - isShadowed: isShadowed, - reactionScores: reactionScores, - reactionCounts: reactionCounts, - reactionGroups: [:], - author: author, - mentionedUsers: mentionedUsers, - threadParticipants: threadParticipants, - attachments: attachments, - latestReplies: [], - localState: nil, - isFlaggedByCurrentUser: false, - latestReactions: latestReactions, - currentUserReactions: currentUserReactions, - isSentByCurrentUser: user.id == currentUserId, - pinDetails: pinned ? MessagePinDetails( - pinnedAt: pinnedAt ?? createdAt, - pinnedBy: pinnedBy?.asModel() ?? author, - expiresAt: pinExpires - ) : nil, - translations: translations, - originalLanguage: originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, - moderationDetails: nil, - readBy: Set(readBy.map(\.user)), - poll: nil, - textUpdatedAt: messageTextUpdatedAt, - draftReply: nil, - reminder: reminder.map { - .init( - remindAt: $0.remindAt, - createdAt: $0.createdAt, - updatedAt: $0.updatedAt - ) - }, - sharedLocation: location.map { - .init( - messageId: $0.messageId, - channelId: cid, - userId: $0.userId, - createdByDeviceId: $0.createdByDeviceId, - latitude: $0.latitude, - longitude: $0.longitude, - updatedAt: $0.updatedAt, - createdAt: $0.createdAt, - endAt: $0.endAt - ) - } - ) - } -} diff --git a/Sources/StreamChat/Models/MessageReaction.swift b/Sources/StreamChat/Models/MessageReaction.swift index 98adeeee70..0c3caa4a90 100644 --- a/Sources/StreamChat/Models/MessageReaction.swift +++ b/Sources/StreamChat/Models/MessageReaction.swift @@ -30,19 +30,3 @@ public struct ChatMessageReaction: Hashable { } // MARK: - Payload -> Model Mapping - -extension MessageReactionPayload { - /// Converts the MessageReactionPayload to a ChatMessageReaction model - /// - Returns: A ChatMessageReaction instance - func asModel() -> ChatMessageReaction { - ChatMessageReaction( - id: "\(type.rawValue)_\(user.id)", - type: type, - score: score, - createdAt: createdAt, - updatedAt: updatedAt, - author: user.asModel(), - extraData: extraData - ) - } -} diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift new file mode 100644 index 0000000000..9851912928 --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift @@ -0,0 +1,164 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension ChannelPayload { + /// Converts the ChannelPayload to a ChatChannel model + /// - Returns: A ChatChannel instance + func asModel( + currentUserId: UserId?, + currentlyTypingUsers: Set?, + unreadCount: ChannelUnreadCount? + ) -> ChatChannel { + let channelPayload = channel + + // Map members + let mappedMembers = members.compactMap { $0.asModel(channelId: channelPayload.cid) } + + // Map latest messages + let reads = channelReads.map { $0.asModel() } + let latestMessages = messages.compactMap { + $0.asModel(cid: channel.cid, currentUserId: currentUserId, channelReads: reads) + } + + // Map reads + let mappedReads = channelReads.map { $0.asModel() } + + // Map watchers + let mappedWatchers = watchers?.map { $0.asModel() } ?? [] + + return ChatChannel( + cid: channelPayload.cid, + name: channelPayload.name, + imageURL: channelPayload.imageURL, + lastMessageAt: channelPayload.lastMessageAt, + createdAt: channelPayload.createdAt, + updatedAt: channelPayload.updatedAt, + deletedAt: channelPayload.deletedAt, + truncatedAt: channelPayload.truncatedAt, + isHidden: isHidden ?? false, + createdBy: channelPayload.createdBy?.asModel(), + config: channelPayload.config, + ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: channelPayload.isFrozen, + isDisabled: channelPayload.isDisabled, + isBlocked: channelPayload.isBlocked ?? false, + lastActiveMembers: Array(mappedMembers), + membership: membership?.asModel(channelId: channelPayload.cid), + currentlyTypingUsers: currentlyTypingUsers ?? [], + lastActiveWatchers: Array(mappedWatchers), + team: channelPayload.team, + unreadCount: unreadCount ?? .noUnread, + watcherCount: watcherCount ?? 0, + memberCount: channelPayload.memberCount, + reads: mappedReads, + cooldownDuration: channelPayload.cooldownDuration, + extraData: channelPayload.extraData, + latestMessages: latestMessages, + lastMessageFromCurrentUser: latestMessages.first { $0.isSentByCurrentUser }, + pinnedMessages: pinnedMessages.compactMap { + $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + }, + muteDetails: nil, + previewMessage: latestMessages.first, + draftMessage: nil, + activeLiveLocations: [] + ) + } +} + +extension ChannelDetailPayload { + func asModel() -> ChatChannel { + ChatChannel( + cid: cid, + name: name, + imageURL: imageURL, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + truncatedAt: truncatedAt, + isHidden: false, + createdBy: createdBy?.asModel(), + config: config, + ownCapabilities: Set(ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: isFrozen, + isDisabled: isDisabled, + isBlocked: isBlocked ?? false, + lastActiveMembers: members?.compactMap { $0.asModel(channelId: cid) } ?? [], + membership: nil, + currentlyTypingUsers: [], + lastActiveWatchers: [], + team: team, + unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), + watcherCount: 0, + memberCount: memberCount, + reads: [], + cooldownDuration: cooldownDuration, + extraData: extraData, + latestMessages: [], + lastMessageFromCurrentUser: nil, + pinnedMessages: [], + muteDetails: nil, + previewMessage: nil, + draftMessage: nil, + activeLiveLocations: [] + ) + } +} + +extension MemberPayload { + /// Converts the MemberPayload to a ChatChannelMember model + /// - Parameter channelId: The channel ID the member belongs to + /// - Returns: A ChatChannelMember instance, or nil if user is missing + func asModel(channelId: ChannelId) -> ChatChannelMember? { + guard let userPayload = user else { return nil } + let user = userPayload.asModel() + + return ChatChannelMember( + id: user.id, + name: user.name, + imageURL: user.imageURL, + isOnline: user.isOnline, + isBanned: user.isBanned, + isFlaggedByCurrentUser: user.isFlaggedByCurrentUser, + userRole: user.userRole, + teamsRole: user.teamsRole, + userCreatedAt: user.userCreatedAt, + userUpdatedAt: user.userUpdatedAt, + deactivatedAt: user.userDeactivatedAt, + lastActiveAt: user.lastActiveAt, + teams: user.teams, + language: user.language, + extraData: user.extraData, + memberRole: MemberRole(rawValue: role?.rawValue ?? "member"), + memberCreatedAt: createdAt, + memberUpdatedAt: updatedAt, + isInvited: isInvited ?? false, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + archivedAt: archivedAt, + pinnedAt: pinnedAt, + isBannedFromChannel: isBanned ?? false, + banExpiresAt: banExpiresAt, + isShadowBannedFromChannel: isShadowBanned ?? false, + notificationsMuted: notificationsMuted, + memberExtraData: [:] + ) + } +} + +extension ChannelReadPayload { + /// Converts the ChannelReadPayload to a ChatChannelRead model + /// - Returns: A ChatChannelRead instance + func asModel() -> ChatChannelRead { + ChatChannelRead( + lastReadAt: lastReadAt, + lastReadMessageId: lastReadMessageId, + unreadMessagesCount: unreadMessagesCount, + user: user.asModel() + ) + } +} diff --git a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift new file mode 100644 index 0000000000..ea611f8df2 --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift @@ -0,0 +1,145 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension MessagePayload { + /// Converts the MessagePayload to a ChatMessage model + /// - Parameters: + /// - cid: The channel ID the message belongs to + /// - currentUserId: The current user's ID for determining sent status + /// - channelReads: Channel reads for determining readBy status + /// - Returns: A ChatMessage instance + func asModel( + cid: ChannelId, + currentUserId: UserId?, + channelReads: [ChatChannelRead] + ) -> ChatMessage? { + let author = user.asModel() + let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) + let threadParticipants = threadParticipants.compactMap { $0.asModel() } + + // Map quoted message recursively + let quotedMessage = quotedMessage?.asModel( + cid: cid, + currentUserId: currentUserId, + channelReads: channelReads + ) + + // Map reactions + let latestReactions = Set(latestReactions.compactMap { $0.asModel(messageId: id) }) + + let currentUserReactions: Set + if ownReactions.isEmpty { + currentUserReactions = latestReactions.filter { $0.author.id == currentUserId } + } else { + currentUserReactions = Set(ownReactions.compactMap { $0.asModel(messageId: id) }) + } + + // Map attachments + let attachments: [AnyChatMessageAttachment] = attachments + .enumerated() + .compactMap { offset, attachmentPayload in + guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { + return nil + } + return AnyChatMessageAttachment( + id: .init(cid: cid, messageId: id, index: offset), + type: attachmentPayload.type, + payload: payloadData, + downloadingState: nil, + uploadingState: nil + ) + } + + // Calculate readBy from channel reads + let createdAtInterval = createdAt.timeIntervalSince1970 + let messageUserId = user.id + let readBy = channelReads.filter { read in + read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval + } + + return ChatMessage( + id: id, + cid: cid, + text: text, + type: type, + command: command, + createdAt: createdAt, + locallyCreatedAt: nil, + updatedAt: updatedAt, + deletedAt: deletedAt, + arguments: args, + parentMessageId: parentId, + showReplyInChannel: showReplyInChannel, + replyCount: replyCount, + extraData: extraData, + quotedMessage: quotedMessage, + isBounced: false, + isSilent: isSilent, + isShadowed: isShadowed, + reactionScores: reactionScores, + reactionCounts: reactionCounts, + reactionGroups: [:], + author: author, + mentionedUsers: mentionedUsers, + threadParticipants: threadParticipants, + attachments: attachments, + latestReplies: [], + localState: nil, + isFlaggedByCurrentUser: false, + latestReactions: latestReactions, + currentUserReactions: currentUserReactions, + isSentByCurrentUser: user.id == currentUserId, + pinDetails: pinned ? MessagePinDetails( + pinnedAt: pinnedAt ?? createdAt, + pinnedBy: pinnedBy?.asModel() ?? author, + expiresAt: pinExpires + ) : nil, + translations: translations, + originalLanguage: originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, + moderationDetails: nil, + readBy: Set(readBy.map(\.user)), + poll: nil, + textUpdatedAt: messageTextUpdatedAt, + draftReply: nil, + reminder: reminder.map { + .init( + remindAt: $0.remindAt, + createdAt: $0.createdAt, + updatedAt: $0.updatedAt + ) + }, + sharedLocation: location.map { + .init( + messageId: $0.messageId, + channelId: cid, + userId: $0.userId, + createdByDeviceId: $0.createdByDeviceId, + latitude: $0.latitude, + longitude: $0.longitude, + updatedAt: $0.updatedAt, + createdAt: $0.createdAt, + endAt: $0.endAt + ) + } + ) + } +} + +extension MessageReactionPayload { + /// Converts the MessageReactionPayload to a ChatMessageReaction model + /// - Returns: A ChatMessageReaction instance + func asModel(messageId: MessageId) -> ChatMessageReaction { + ChatMessageReaction( + id: [user.id, messageId, type.rawValue].joined(separator: "/"), + type: type, + score: score, + createdAt: createdAt, + updatedAt: updatedAt, + author: user.asModel(), + extraData: extraData + ) + } +} diff --git a/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift new file mode 100644 index 0000000000..2caa941d59 --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension UserPayload { + /// Converts the UserPayload to a ChatUser model + /// - Returns: A ChatUser instance + func asModel() -> ChatUser { + ChatUser( + id: id, + name: name, + imageURL: imageURL, + isOnline: isOnline, + isBanned: isBanned, + isFlaggedByCurrentUser: false, + userRole: role, + teamsRole: teamsRole, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: deactivatedAt, + lastActiveAt: lastActiveAt, + teams: Set(teams), + language: language.flatMap { TranslationLanguage(languageCode: $0) }, + extraData: extraData + ) + } +} diff --git a/Sources/StreamChat/Models/User.swift b/Sources/StreamChat/Models/User.swift index 6e922f5ad3..3a7a7a3ab4 100644 --- a/Sources/StreamChat/Models/User.swift +++ b/Sources/StreamChat/Models/User.swift @@ -172,27 +172,3 @@ public extension UserRole { } // MARK: - Payload -> Model Mapping - -extension UserPayload { - /// Converts the UserPayload to a ChatUser model - /// - Returns: A ChatUser instance - func asModel() -> ChatUser { - ChatUser( - id: id, - name: name, - imageURL: imageURL, - isOnline: isOnline, - isBanned: isBanned, - isFlaggedByCurrentUser: false, - userRole: role, - teamsRole: teamsRole, - createdAt: createdAt, - updatedAt: updatedAt, - deactivatedAt: deactivatedAt, - lastActiveAt: lastActiveAt, - teams: Set(teams), - language: language.flatMap { TranslationLanguage(languageCode: $0) }, - extraData: extraData - ) - } -} diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 16017e7595..993a552fff 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -224,7 +224,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { user: userPayload.asModel(), cid: cid, message: message, - reaction: reactionPayload.asModel(), + reaction: reactionPayload.asModel(messageId: messagePayload.id), createdAt: createdAt ) } @@ -244,7 +244,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { user: userPayload.asModel(), cid: cid, message: message, - reaction: reactionPayload.asModel(), + reaction: reactionPayload.asModel(messageId: messagePayload.id), createdAt: createdAt ) } @@ -264,7 +264,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { user: userPayload.asModel(), cid: cid, message: message, - reaction: reactionPayload.asModel(), + reaction: reactionPayload.asModel(messageId: messagePayload.id), createdAt: createdAt ) } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index bf048248a2..8a0a843d9f 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1479,6 +1479,12 @@ AD4C8C232C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */; }; AD4CDD85296499160057BC8A /* ScrollViewPaginationHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */; }; AD4CDD862964991A0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CDD83296498EB0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift */; }; + AD4E87972E37947300223A1C /* ChannelPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */; }; + AD4E87982E37947300223A1C /* UserPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87962E37947300223A1C /* UserPayload+asModel.swift */; }; + AD4E87992E37947300223A1C /* MessagePayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */; }; + AD4E879B2E37947300223A1C /* ChannelPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */; }; + AD4E879C2E37947300223A1C /* UserPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87962E37947300223A1C /* UserPayload+asModel.swift */; }; + AD4E879D2E37947300223A1C /* MessagePayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */; }; AD4F89D02C666471006DF7E5 /* PollResultsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */; }; AD4F89D12C666471006DF7E5 /* PollResultsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */; }; AD4F89D42C666471006DF7E5 /* PollResultsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CC2C666471006DF7E5 /* PollResultsVC.swift */; }; @@ -4315,6 +4321,9 @@ AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedUserAvatarsView.swift; sourceTree = ""; }; AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; AD4CDD83296498EB0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; + AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPayload+asModel.swift"; sourceTree = ""; }; + AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagePayload+asModel.swift"; sourceTree = ""; }; + AD4E87962E37947300223A1C /* UserPayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserPayload+asModel.swift"; sourceTree = ""; }; AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsSectionHeaderView.swift; sourceTree = ""; }; AD4F89CC2C666471006DF7E5 /* PollResultsVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsVC.swift; sourceTree = ""; }; AD4F89CD2C666471006DF7E5 /* PollResultsVoteItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsVoteItemCell.swift; sourceTree = ""; }; @@ -6035,6 +6044,7 @@ children = ( 225D807625D316B10094E555 /* Attachments */, ADFCA5B52D121EE9000F515F /* Location */, + AD4E879F2E37967200223A1C /* Payload+asModel */, AD8C7C5C2BA3BE1E00260715 /* AppSettings.swift */, 8A62706D24BF45360040BFD6 /* BanEnabling.swift */, 82BE0ACC2C009A17008DA9DC /* BlockedUserDetails.swift */, @@ -6045,10 +6055,10 @@ 79896D5D25065E6900BA8F1C /* ChannelRead.swift */, 79877A042498E4BB00015F8B /* ChannelType.swift */, ADA03A212D64EFE900DFE048 /* DraftMessage.swift */, - 799C9431247D2FB9001F1104 /* ChatMessage.swift */, 79877A052498E4BC00015F8B /* CurrentUser.swift */, 79877A022498E4BB00015F8B /* Device.swift */, 79877A032498E4BB00015F8B /* Member.swift */, + 799C9431247D2FB9001F1104 /* ChatMessage.swift */, AD70DC3B2ADEF09C00CFC3B7 /* MessageModerationDetails.swift */, ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */, AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */, @@ -8674,6 +8684,16 @@ path = ViewPaginationHandling; sourceTree = ""; }; + AD4E879F2E37967200223A1C /* Payload+asModel */ = { + isa = PBXGroup; + children = ( + AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */, + AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */, + AD4E87962E37947300223A1C /* UserPayload+asModel.swift */, + ); + path = "Payload+asModel"; + sourceTree = ""; + }; AD4EA229264ADE0100DF8EE2 /* Composer */ = { isa = PBXGroup; children = ( @@ -11853,6 +11873,9 @@ C1EE53A927BA662B00B1A6CA /* QueuedRequestDTO.swift in Sources */, 790A4C42252DD377001F4A23 /* DeviceEndpoints.swift in Sources */, C1CEF9092A1CDF7600414931 /* UserUpdateMiddleware.swift in Sources */, + AD4E879B2E37947300223A1C /* ChannelPayload+asModel.swift in Sources */, + AD4E879C2E37947300223A1C /* UserPayload+asModel.swift in Sources */, + AD4E879D2E37947300223A1C /* MessagePayload+asModel.swift in Sources */, 797A756424814E7A003CF16D /* WebSocketConnectPayload.swift in Sources */, F6FF1DA624FD17B400151735 /* MessageController.swift in Sources */, E7AD954F25D536AA00076DC3 /* SystemEnvironment+Version.swift in Sources */, @@ -12667,6 +12690,9 @@ AD0F7F1A2B613EDC00914C4C /* TextLinkDetector.swift in Sources */, C121E884274544AF00023E4C /* ChatMessageFileAttachment.swift in Sources */, C121E885274544AF00023E4C /* ChatMessageVideoAttachment.swift in Sources */, + AD4E87972E37947300223A1C /* ChannelPayload+asModel.swift in Sources */, + AD4E87982E37947300223A1C /* UserPayload+asModel.swift in Sources */, + AD4E87992E37947300223A1C /* MessagePayload+asModel.swift in Sources */, 4F9494BC2C41086F00B5C9CE /* BackgroundEntityDatabaseObserver.swift in Sources */, C121E886274544AF00023E4C /* ChatMessageImageAttachment.swift in Sources */, 43D3F0FD28410A0200B74921 /* CreateCallRequestBody.swift in Sources */, From 1497e1184c62e868d62461f51581cb0f213af400 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 28 Jul 2025 12:39:36 +0100 Subject: [PATCH 23/85] Handling new messages while first page is not loaded --- .../LivestreamChannelController.swift | 132 +++++++++--------- 1 file changed, 69 insertions(+), 63 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 26986638cc..6e459af58f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -22,78 +22,78 @@ public extension ChatClient { /// - etc.. public class LivestreamChannelController: EventsControllerDelegate { // MARK: - Public Properties - + /// The ChannelQuery this controller observes. @Atomic public private(set) var channelQuery: ChannelQuery - + /// The identifier of a channel this controller observes. public var cid: ChannelId? { channelQuery.cid } - + /// The `ChatClient` instance this controller belongs to. public let client: ChatClient - + /// The channel the controller represents. /// This is managed in memory and updated via API calls. @Atomic public private(set) var channel: ChatChannel? - + /// The messages of the channel the controller represents. /// This is managed in memory and updated via API calls. @Atomic public private(set) var messages: [ChatMessage] = [] - + /// A Boolean value that returns whether the oldest messages have all been loaded or not. public var hasLoadedAllPreviousMessages: Bool { paginationStateHandler.state.hasLoadedAllPreviousMessages } - + /// A Boolean value that returns whether the newest messages have all been loaded or not. public var hasLoadedAllNextMessages: Bool { paginationStateHandler.state.hasLoadedAllNextMessages || messages.isEmpty } - + /// A Boolean value that returns whether the channel is currently loading previous (old) messages. public var isLoadingPreviousMessages: Bool { paginationStateHandler.state.isLoadingPreviousMessages } - + /// A Boolean value that returns whether the channel is currently loading next (new) messages. public var isLoadingNextMessages: Bool { paginationStateHandler.state.isLoadingNextMessages } - + /// A Boolean value that returns whether the channel is currently loading a page around a message. public var isLoadingMiddleMessages: Bool { paginationStateHandler.state.isLoadingMiddleMessages } - + /// A Boolean value that returns whether the channel is currently in a mid-page. public var isJumpingToMessage: Bool { paginationStateHandler.state.isJumpingToMessage } - + /// The id of the message which the current user last read. public var lastReadMessageId: MessageId? { client.currentUserId.flatMap { channel?.lastReadMessageId(userId: $0) } } - + /// Set the delegate to observe the changes in the system. public weak var delegate: LivestreamChannelControllerDelegate? - + // MARK: - Private Properties - + /// The API client for making direct API calls private let apiClient: APIClient - + /// Pagination state handler for managing message pagination private let paginationStateHandler: MessagesPaginationStateHandling - + /// Events controller for listening to real-time events private let eventsController: EventsController - + /// Current user ID for convenience private var currentUserId: UserId? { client.currentUserId } - + // MARK: - Initialization - + /// Creates a new `LivestreamChannelController` /// - Parameters: /// - channelQuery: channel query for observing changes @@ -121,7 +121,7 @@ public class LivestreamChannelController: EventsControllerDelegate { } // MARK: - Public Methods - + /// Synchronizes the controller with the backend data. /// - Parameter completion: Called when the synchronization is finished public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { @@ -130,7 +130,7 @@ public class LivestreamChannelController: EventsControllerDelegate { completion: completion ) } - + /// Loads previous messages from backend. /// - Parameters: /// - messageId: ID of the last fetched message. You will get messages `older` than the provided ID. @@ -145,25 +145,25 @@ public class LivestreamChannelController: EventsControllerDelegate { completion?(ClientError.ChannelNotCreatedYet()) return } - + let messageId = messageId ?? paginationStateHandler.state.oldestFetchedMessage?.id ?? lastLocalMessageId() guard let messageId = messageId else { completion?(ClientError.ChannelEmptyMessages()) return } - + guard !hasLoadedAllPreviousMessages && !isLoadingPreviousMessages else { completion?(nil) return } - + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize var query = channelQuery query.pagination = MessagesPagination(pageSize: limit, parameter: .lessThan(messageId)) - + updateChannelData(channelQuery: query, completion: completion) } - + /// Loads next messages from backend. /// - Parameters: /// - messageId: ID of the current first message. You will get messages `newer` than the provided ID. @@ -178,25 +178,25 @@ public class LivestreamChannelController: EventsControllerDelegate { completion?(ClientError.ChannelNotCreatedYet()) return } - + let messageId = messageId ?? paginationStateHandler.state.newestFetchedMessage?.id ?? messages.first?.id guard let messageId = messageId else { completion?(ClientError.ChannelEmptyMessages()) return } - + guard !hasLoadedAllNextMessages && !isLoadingNextMessages else { completion?(nil) return } - + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize var query = channelQuery query.pagination = MessagesPagination(pageSize: limit, parameter: .greaterThan(messageId)) - + updateChannelData(channelQuery: query, completion: completion) } - + /// Load messages around the given message id. /// - Parameters: /// - messageId: The message id of the message to jump to. @@ -211,14 +211,14 @@ public class LivestreamChannelController: EventsControllerDelegate { completion?(nil) return } - + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize var query = channelQuery query.pagination = MessagesPagination(pageSize: limit, parameter: .around(messageId)) - + updateChannelData(channelQuery: query, completion: completion) } - + /// Cleans the current state and loads the first page again. /// - Parameter completion: Callback when the API call is completed. public func loadFirstPage(_ completion: ((_ error: Error?) -> Void)? = nil) { @@ -227,10 +227,10 @@ public class LivestreamChannelController: EventsControllerDelegate { pageSize: channelQuery.pagination?.pageSize ?? .messagesPageSize, parameter: nil ) - + // Clear current messages when loading first page messages = [] - + updateChannelData(channelQuery: query, completion: completion) } @@ -251,9 +251,9 @@ public class LivestreamChannelController: EventsControllerDelegate { } } } - + // MARK: - Private Methods - + private func updateChannelData( channelQuery: ChannelQuery, completion: ((Error?) -> Void)? = nil @@ -264,7 +264,7 @@ public class LivestreamChannelController: EventsControllerDelegate { let endpoint: Endpoint = .updateChannel(query: channelQuery) - + let requestCompletion: (Result) -> Void = { [weak self] result in DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -282,10 +282,10 @@ public class LivestreamChannelController: EventsControllerDelegate { } } } - + apiClient.request(endpoint: endpoint, completion: requestCompletion) } - + private func handleChannelPayload(_ payload: ChannelPayload, channelQuery: ChannelQuery) { if let pagination = channelQuery.pagination { paginationStateHandler.end(pagination: pagination, with: .success(payload.messages)) @@ -307,29 +307,29 @@ public class LivestreamChannelController: EventsControllerDelegate { notifyDelegateOfChanges() } - + private func updateMessagesArray(with newMessages: [ChatMessage], pagination: MessagesPagination?) { let newMessages = Array(newMessages.reversed()) switch pagination?.parameter { case .lessThan, .lessThanOrEqual: // Loading older messages - append to end messages.append(contentsOf: newMessages) - + case .greaterThan, .greaterThanOrEqual: // Loading newer messages - insert at beginning messages.insert(contentsOf: newMessages, at: 0) - + case .around, .none: messages = newMessages } } - + private func lastLocalMessageId() -> MessageId? { messages.last?.id } - + // MARK: - EventsControllerDelegate - + public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { return @@ -339,9 +339,9 @@ public class LivestreamChannelController: EventsControllerDelegate { self?.handleChannelEvent(event) } } - + // MARK: - Private Event Handling - + private func handleChannelEvent(_ event: Event) { switch event { case let messageNewEvent as MessageNewEvent: @@ -352,7 +352,7 @@ public class LivestreamChannelController: EventsControllerDelegate { case let messageUpdatedEvent as MessageUpdatedEvent: handleUpdatedMessage(messageUpdatedEvent.message) - + case let messageDeletedEvent as MessageDeletedEvent: if messageDeletedEvent.isHardDelete { handleDeletedMessage(messageDeletedEvent.message) @@ -368,18 +368,18 @@ public class LivestreamChannelController: EventsControllerDelegate { case let reactionNewEvent as ReactionNewEvent: handleNewReaction(reactionNewEvent) - + case let reactionUpdatedEvent as ReactionUpdatedEvent: handleUpdatedReaction(reactionUpdatedEvent) - + case let reactionDeletedEvent as ReactionDeletedEvent: handleDeletedReaction(reactionDeletedEvent) - + default: break } } - + private func handleNewMessage(_ message: ChatMessage) { var currentMessages = messages @@ -389,12 +389,18 @@ public class LivestreamChannelController: EventsControllerDelegate { return } + // If we don't have the first page loaded, do not insert new messages + // they will be inserted once we load the first page again. + if !hasLoadedAllNextMessages { + return + } + currentMessages.insert(message, at: 0) messages = currentMessages notifyDelegateOfChanges() } - + private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { var currentMessages = messages @@ -405,7 +411,7 @@ public class LivestreamChannelController: EventsControllerDelegate { notifyDelegateOfChanges() } } - + private func handleDeletedMessage(_ deletedMessage: ChatMessage) { var currentMessages = messages @@ -418,15 +424,15 @@ public class LivestreamChannelController: EventsControllerDelegate { private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { updateMessage(reactionEvent.message) } - + private func handleUpdatedReaction(_ reactionEvent: ReactionUpdatedEvent) { updateMessage(reactionEvent.message) } - + private func handleDeletedReaction(_ reactionEvent: ReactionDeletedEvent) { updateMessage(reactionEvent.message) } - + private func updateMessage( _ updatedMessage: ChatMessage ) { @@ -442,7 +448,7 @@ public class LivestreamChannelController: EventsControllerDelegate { notifyDelegateOfChanges() } - + private func notifyDelegateOfChanges() { guard let channel = channel else { return } @@ -463,7 +469,7 @@ public protocol LivestreamChannelControllerDelegate: AnyObject { _ controller: LivestreamChannelController, didUpdateChannel channel: ChatChannel ) - + /// Called when the messages are updated /// - Parameters: /// - controller: The controller that updated @@ -481,7 +487,7 @@ public extension LivestreamChannelControllerDelegate { _ controller: LivestreamChannelController, didUpdateChannel channel: ChatChannel ) {} - + func livestreamChannelController( _ controller: LivestreamChannelController, didUpdateMessages messages: [ChatMessage] From 854b3e731b7e6918e0aaac31900089ab7cc8c1a4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 28 Jul 2025 12:40:18 +0100 Subject: [PATCH 24/85] Remove atomics --- .../ChannelController/LivestreamChannelController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 6e459af58f..cf9353f8f5 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -24,7 +24,7 @@ public class LivestreamChannelController: EventsControllerDelegate { // MARK: - Public Properties /// The ChannelQuery this controller observes. - @Atomic public private(set) var channelQuery: ChannelQuery + public private(set) var channelQuery: ChannelQuery /// The identifier of a channel this controller observes. public var cid: ChannelId? { channelQuery.cid } @@ -34,11 +34,11 @@ public class LivestreamChannelController: EventsControllerDelegate { /// The channel the controller represents. /// This is managed in memory and updated via API calls. - @Atomic public private(set) var channel: ChatChannel? + public private(set) var channel: ChatChannel? /// The messages of the channel the controller represents. /// This is managed in memory and updated via API calls. - @Atomic public private(set) var messages: [ChatMessage] = [] + public private(set) var messages: [ChatMessage] = [] /// A Boolean value that returns whether the oldest messages have all been loaded or not. public var hasLoadedAllPreviousMessages: Bool { From 78adc4307c0179410c436f4cc4e74d21c72519c9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 28 Jul 2025 18:12:57 +0100 Subject: [PATCH 25/85] Add API only message actions to the livestream controller - deleteMessages - loadReactins - flag - unflag - addReaction - deleteReaction - pin - unpin --- .../LivestreamChannelController.swift | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index cf9353f8f5..4bc3557542 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -252,6 +252,169 @@ public class LivestreamChannelController: EventsControllerDelegate { } } + // MARK: - Message Actions + + /// Deletes a message from the channel. + /// - Parameters: + /// - messageId: The message identifier to delete. + /// - hard: A Boolean value to determine if the message will be delete permanently on the backend. By default it is `false`. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func deleteMessage( + messageId: MessageId, + hard: Bool = false, + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .deleteMessage(messageId: messageId, hard: hard)) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + + /// Loads reactions for a specific message. + /// - Parameters: + /// - messageId: The message identifier to load reactions for. + /// - limit: The number of reactions to load. Default is 25. + /// - offset: The starting position from the desired range to be fetched. Default is 0. + /// - completion: Called when the network request is finished. Returns reactions array or error. + public func loadReactions( + for messageId: MessageId, + limit: Int = 25, + offset: Int = 0, + completion: @escaping (Result<[ChatMessageReaction], Error>) -> Void + ) { + let pagination = Pagination(pageSize: limit, offset: offset) + apiClient.request(endpoint: .loadReactions(messageId: messageId, pagination: pagination)) { result in + DispatchQueue.main.async { + switch result { + case .success(let payload): + let reactions = payload.reactions.compactMap { $0.asModel(messageId: messageId) } + completion(.success(reactions)) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /// Flags a message. + /// - Parameters: + /// - messageId: The message identifier to flag. + /// - reason: The flag reason. + /// - extraData: Additional data associated with the flag request. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func flag( + messageId: MessageId, + reason: String? = nil, + extraData: [String: RawJSON]? = nil, + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .flagMessage(true, with: messageId, reason: reason, extraData: extraData)) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + + /// Unflags a message. + /// - Parameters: + /// - messageId: The message identifier to unflag. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func unflag( + messageId: MessageId, + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .flagMessage(false, with: messageId, reason: nil, extraData: nil)) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + + /// Adds a reaction to a message. + /// - Parameters: + /// - type: The reaction type. + /// - messageId: The message identifier to add the reaction to. + /// - score: The reaction score. Default is 1. + /// - enforceUnique: If set to `true`, new reaction will replace all reactions the user has (if any) on this message. Default is false. + /// - extraData: The reaction extra data. Default is empty. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func addReaction( + _ type: MessageReactionType, + to messageId: MessageId, + score: Int = 1, + enforceUnique: Bool = false, + extraData: [String: RawJSON] = [:], + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .addReaction( + type, + score: score, + enforceUnique: enforceUnique, + extraData: extraData, + messageId: messageId + )) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + + /// Deletes a reaction from a message. + /// - Parameters: + /// - type: The reaction type to delete. + /// - messageId: The message identifier to delete the reaction from. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func deleteReaction( + _ type: MessageReactionType, + from messageId: MessageId, + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .deleteReaction(type, messageId: messageId)) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + + /// Pins a message. + /// - Parameters: + /// - messageId: The message identifier to pin. + /// - pinning: The pinning expiration information. It supports setting an infinite expiration, setting a date, or the amount of time a message is pinned. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func pin( + messageId: MessageId, + pinning: MessagePinning = .noExpiration, + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: true)) + )) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + + /// Unpins a message. + /// - Parameters: + /// - messageId: The message identifier to unpin. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func unpin( + messageId: MessageId, + completion: ((Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: false)) + )) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + // MARK: - Private Methods private func updateChannelData( From 4029055c06d705721f883344650e022ebfbe47de Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 28 Jul 2025 20:27:00 +0100 Subject: [PATCH 26/85] Add createNewMessage to `LivestreamChannelController` --- .../LivestreamChannelController.swift | 151 ++++++++++++++++-- 1 file changed, 135 insertions(+), 16 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 4bc3557542..eceacacd2e 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -89,6 +89,9 @@ public class LivestreamChannelController: EventsControllerDelegate { /// Events controller for listening to real-time events private let eventsController: EventsController + /// The worker used to fetch the remote data and communicate with servers. + private let updater: ChannelUpdater + /// Current user ID for convenience private var currentUserId: UserId? { client.currentUserId } @@ -107,6 +110,13 @@ public class LivestreamChannelController: EventsControllerDelegate { apiClient = client.apiClient paginationStateHandler = MessagesPaginationStateHandler() eventsController = client.eventsController() + updater = ChannelUpdater( + channelRepository: client.channelRepository, + messageRepository: client.messageRepository, + paginationStateHandler: client.makeMessagesPaginationStateHandler(), + database: client.databaseContainer, + apiClient: client.apiClient + ) eventsController.delegate = self if let cid = channelQuery.cid { @@ -234,26 +244,66 @@ public class LivestreamChannelController: EventsControllerDelegate { updateChannelData(channelQuery: query, completion: completion) } - /// Marks the channel as read. - public func markRead(completion: ((Error?) -> Void)? = nil) { - guard let channel = channel else { - return - } - - /// Read events are not enabled for this channel - guard channel.canReceiveReadEvents == true else { - return + /// Creates a new message locally and schedules it for send. + /// + /// - Parameters: + /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. + /// - text: Text of the message. + /// - pinning: Pins the new message. `nil` if should not be pinned. + /// - isSilent: A flag indicating whether the message is a silent message. Silent messages are special messages that don't increase the unread messages count nor mark a channel as unread. + /// - attachments: An array of the attachments for the message. + /// `Note`: can be built-in types, custom attachment types conforming to `AttachmentEnvelope` protocol + /// and `ChatMessageAttachmentSeed`s. + /// - quotedMessageId: An id of the message new message quotes. (inline reply) + /// - skipPush: If true, skips sending push notification to channel members. + /// - skipEnrichUrl: If true, the url preview won't be attached to the message. + /// - restrictedVisibility: The list of user ids that should be able to see the message. + /// - location: The new location information of the message. + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes. + /// + public func createNewMessage( + messageId: MessageId? = nil, + text: String, + pinning: MessagePinning? = nil, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + skipPush: Bool = false, + skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + var transformableInfo = NewMessageTransformableInfo( + text: text, + attachments: attachments, + extraData: extraData + ) + if let transformer = client.config.modelsTransformer { + transformableInfo = transformer.transform(newMessageInfo: transformableInfo) } - apiClient.request(endpoint: .markRead(cid: channel.cid)) { result in - DispatchQueue.main.async { - completion?(result.error) - } - } + createNewMessage( + messageId: messageId, + text: transformableInfo.text, + pinning: pinning, + isSilent: isSilent, + attachments: transformableInfo.attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, + location: location, + extraData: transformableInfo.extraData, + poll: nil, + completion: completion + ) } - // MARK: - Message Actions - /// Deletes a message from the channel. /// - Parameters: /// - messageId: The message identifier to delete. @@ -415,6 +465,24 @@ public class LivestreamChannelController: EventsControllerDelegate { } } + /// Marks the channel as read. + public func markRead(completion: ((Error?) -> Void)? = nil) { + guard let channel = channel else { + return + } + + /// Read events are not enabled for this channel + guard channel.canReceiveReadEvents == true else { + return + } + + apiClient.request(endpoint: .markRead(cid: channel.cid)) { result in + DispatchQueue.main.async { + completion?(result.error) + } + } + } + // MARK: - Private Methods private func updateChannelData( @@ -618,6 +686,57 @@ public class LivestreamChannelController: EventsControllerDelegate { delegate?.livestreamChannelController(self, didUpdateChannel: channel) delegate?.livestreamChannelController(self, didUpdateMessages: messages) } + + private func createNewMessage( + messageId: MessageId? = nil, + text: String, + pinning: MessagePinning? = nil, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + skipPush: Bool = false, + skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, + extraData: [String: RawJSON] = [:], + poll: PollPayload?, + completion: ((Result) -> Void)? = nil + ) { + /// Perform action only if channel is already created on backend side and have a valid `cid`. + guard let cid = cid else { + let error = ClientError.ChannelNotCreatedYet() + completion?(.failure(error)) + return + } + + updater.createNewMessage( + in: cid, + messageId: messageId, + text: text, + pinning: pinning, + isSilent: isSilent, + isSystem: false, + command: nil, + arguments: nil, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, + poll: poll, + location: location, + extraData: extraData + ) { result in + if let newMessage = try? result.get() { + self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + } + DispatchQueue.main.async { + completion?(result.map(\.id)) + } + } + } } // MARK: - Delegate Protocol From 8e4b08bde5ab1e6163dfc463d124b515bcdc029e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 00:30:46 +0100 Subject: [PATCH 27/85] Remove markRead from livestream controller --- .../LivestreamChannelController.swift | 23 ------------ .../ChatChannel/ChatLivestreamChannelVC.swift | 37 +------------------ 2 files changed, 2 insertions(+), 58 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index eceacacd2e..5eca5d800f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -70,11 +70,6 @@ public class LivestreamChannelController: EventsControllerDelegate { paginationStateHandler.state.isJumpingToMessage } - /// The id of the message which the current user last read. - public var lastReadMessageId: MessageId? { - client.currentUserId.flatMap { channel?.lastReadMessageId(userId: $0) } - } - /// Set the delegate to observe the changes in the system. public weak var delegate: LivestreamChannelControllerDelegate? @@ -465,24 +460,6 @@ public class LivestreamChannelController: EventsControllerDelegate { } } - /// Marks the channel as read. - public func markRead(completion: ((Error?) -> Void)? = nil) { - guard let channel = channel else { - return - } - - /// Read events are not enabled for this channel - guard channel.canReceiveReadEvents == true else { - return - } - - apiClient.request(endpoint: .markRead(cid: channel.cid)) { result in - DispatchQueue.main.async { - completion?(result.error) - } - } - } - // MARK: - Private Methods private func updateChannelData( diff --git a/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift index 3c782217a2..5dbb2ca14c 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift @@ -78,11 +78,6 @@ open class ChatLivestreamChannelVC: _ViewController, messageListVC.listView.isLastCellFullyVisible } - /// A boolean value indicating whether it should mark the channel read. - open var shouldMarkChannelRead: Bool { - isLastMessageVisibleOrSeen && isFirstPageLoaded - } - private var isLastMessageVisibleOrSeen: Bool { isLastMessageFullyVisible } @@ -92,9 +87,6 @@ open class ChatLivestreamChannelVC: _ViewController, InvertedScrollViewPaginationHandler.make(scrollView: messageListVC.listView) }() - /// The throttler to make sure that the marking read is not spammed. - var markReadThrottler: Throttler = Throttler(interval: 3, queue: .main) - override open func setUp() { super.setUp() @@ -181,10 +173,6 @@ open class ChatLivestreamChannelVC: _ViewController, super.viewDidAppear(animated) keyboardHandler.start() - - if shouldMarkChannelRead { - markRead() - } } override open func viewWillAppear(_ animated: Bool) { @@ -198,8 +186,6 @@ open class ChatLivestreamChannelVC: _ViewController, override open func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - markReadThrottler.cancel() - keyboardHandler.stop() resignFirstResponder() @@ -218,12 +204,6 @@ open class ChatLivestreamChannelVC: _ViewController, // MARK: - Actions - /// Marks the channel read and updates the UI optimistically. - public func markRead() { - channelController.markRead() - updateScrollToBottomButtonCount() - } - /// Jump to a given message. /// In case the message is already loaded, it directly goes to it. /// If not, it will load the messages around it and go to that page. @@ -367,11 +347,7 @@ open class ChatLivestreamChannelVC: _ViewController, _ vc: ChatMessageListVC, scrollViewDidScroll scrollView: UIScrollView ) { - if shouldMarkChannelRead { - markReadThrottler.execute { [weak self] in - self?.markRead() - } - } + // no-op } open func chatMessageListVC( @@ -441,16 +417,7 @@ open class ChatLivestreamChannelVC: _ViewController, } } - messageListVC.updateMessages(with: changes) { [weak self] in - guard let self = self else { return } - - if self.shouldMarkChannelRead { - self.markReadThrottler.execute { - self.markRead() - } - } - } - + messageListVC.updateMessages(with: changes) viewPaginationHandler.updateElementsCount(with: channelController.messages.count) } From 7d8816f5e4f617610067dd9fa96ba99a6ac4e918 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 01:53:44 +0100 Subject: [PATCH 28/85] Load initial page from cache --- .../LivestreamChannelController.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 5eca5d800f..4dbe425b1b 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -20,7 +20,7 @@ public extension ChatClient { /// - Read updates /// - Typing indicators /// - etc.. -public class LivestreamChannelController: EventsControllerDelegate { +public class LivestreamChannelController: DataStoreProvider, EventsControllerDelegate { // MARK: - Public Properties /// The ChannelQuery this controller observes. @@ -70,6 +70,9 @@ public class LivestreamChannelController: EventsControllerDelegate { paginationStateHandler.state.isJumpingToMessage } + /// A Boolean value that indicates whether to load initial messages from the cache. + public var loadInitialMessagesFromCache: Bool = true + /// Set the delegate to observe the changes in the system. public weak var delegate: LivestreamChannelControllerDelegate? @@ -130,6 +133,13 @@ public class LivestreamChannelController: EventsControllerDelegate { /// Synchronizes the controller with the backend data. /// - Parameter completion: Called when the synchronization is finished public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { + // Populate the initial data with existing cache. + if loadInitialMessagesFromCache, let cid = self.cid, let channel = dataStore.channel(cid: cid) { + self.channel = channel + messages = channel.latestMessages + notifyDelegateOfChanges() + } + updateChannelData( channelQuery: channelQuery, completion: completion From 757d45a8c8267a6c53102f569557eccb8d9aba1f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 15:00:18 +0100 Subject: [PATCH 29/85] Add reaction groups and moderation details to Message Payload model mapping --- .../MessagePayload+asModel.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift index ea611f8df2..45039463db 100644 --- a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift @@ -76,12 +76,20 @@ extension MessagePayload { replyCount: replyCount, extraData: extraData, quotedMessage: quotedMessage, - isBounced: false, + isBounced: moderationDetails?.action == MessageModerationAction.bounce.rawValue, isSilent: isSilent, isShadowed: isShadowed, reactionScores: reactionScores, reactionCounts: reactionCounts, - reactionGroups: [:], + reactionGroups: reactionGroups.reduce(into: [:]) { acc, element in + acc[element.key] = ChatMessageReactionGroup( + type: element.key, + sumScores: element.value.sumScores, + count: element.value.count, + firstReactionAt: element.value.firstReactionAt, + lastReactionAt: element.value.lastReactionAt + ) + }, author: author, mentionedUsers: mentionedUsers, threadParticipants: threadParticipants, @@ -99,7 +107,15 @@ extension MessagePayload { ) : nil, translations: translations, originalLanguage: originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, - moderationDetails: nil, + moderationDetails: moderationDetails.map { .init( + originalText: $0.originalText, + action: .init(rawValue: $0.action), + textHarms: $0.textHarms, + imageHarms: $0.imageHarms, + blocklistMatched: $0.blocklistMatched, + semanticFilterMatched: $0.semanticFilterMatched, + platformCircumvented: $0.platformCircumvented + ) }, readBy: Set(readBy.map(\.user)), poll: nil, textUpdatedAt: messageTextUpdatedAt, From a822943225d205d0f3327ad37e0998dc4163c750 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 15:32:58 +0100 Subject: [PATCH 30/85] If event is not handled, fallback to middlewares --- .../Workers/EventNotificationCenter.swift | 100 +++++++++--------- 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 993a552fff..0d0205c943 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -72,20 +72,20 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { middlewareEvents.append(event) return } - if let cid = eventDTO.payload.cid, self.manualEventHandlingChannelIds.contains(cid) { - manualHandlingEvents.append(event) + if let cid = eventDTO.payload.cid, + self.manualEventHandlingChannelIds.contains(cid), + let manualEvent = self.convertManualEventToDomain(event) { + manualHandlingEvents.append(manualEvent) } else { middlewareEvents.append(event) } } - let manualEvents = self.convertManualEventsToDomain(manualHandlingEvents) - eventsToPost.append(contentsOf: manualEvents) - self.newMessageIds = Set(messageIds.compactMap { !session.messageExists(id: $0) ? $0 : nil }) + eventsToPost.append(contentsOf: manualHandlingEvents) eventsToPost.append(contentsOf: middlewareEvents.compactMap { self.middlewares.process(event: $0, session: session) }) @@ -104,45 +104,43 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { }) } - private func convertManualEventsToDomain(_ events: [Event]) -> [Event] { - events.compactMap { event in - guard let eventDTO = event as? EventDTO else { - return nil - } + private func convertManualEventToDomain(_ event: Event) -> Event? { + guard let eventDTO = event as? EventDTO else { + return nil + } - let eventPayload = eventDTO.payload + let eventPayload = eventDTO.payload - guard let cid = eventPayload.cid else { - return nil - } + guard let cid = eventPayload.cid else { + return nil + } - switch eventPayload.eventType { - case .messageNew: - return createMessageNewEvent(from: eventPayload, cid: cid) - - case .messageUpdated: - return createMessageUpdatedEvent(from: eventPayload, cid: cid) - - case .messageDeleted: - return createMessageDeletedEvent(from: eventPayload, cid: cid) - - case .reactionNew: - return createReactionNewEvent(from: eventPayload, cid: cid) - - case .reactionUpdated: - return createReactionUpdatedEvent(from: eventPayload, cid: cid) - - case .reactionDeleted: - return createReactionDeletedEvent(from: eventPayload, cid: cid) - - default: - return nil - } + switch eventPayload.eventType { + case .messageNew: + return createMessageNewEvent(from: eventPayload, cid: cid) + + case .messageUpdated: + return createMessageUpdatedEvent(from: eventPayload, cid: cid) + + case .messageDeleted: + return createMessageDeletedEvent(from: eventPayload, cid: cid) + + case .reactionNew: + return createReactionNewEvent(from: eventPayload, cid: cid) + + case .reactionUpdated: + return createReactionUpdatedEvent(from: eventPayload, cid: cid) + + case .reactionDeleted: + return createReactionDeletedEvent(from: eventPayload, cid: cid) + + default: + return nil } } - + // MARK: - Event Creation Helpers - + private func createMessageNewEvent(from payload: EventPayload, cid: ChannelId) -> MessageNewEvent? { guard let userPayload = payload.user, @@ -170,7 +168,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } ) } - + private func createMessageUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> MessageUpdatedEvent? { guard let userPayload = payload.user, @@ -180,7 +178,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let channel = try? database.writableContext.channel(cid: cid)?.asModel(), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - + return MessageUpdatedEvent( user: userPayload.asModel(), channel: channel, @@ -188,7 +186,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { createdAt: createdAt ) } - + private func createMessageDeletedEvent(from payload: EventPayload, cid: ChannelId) -> MessageDeletedEvent? { guard let messagePayload = payload.message, @@ -197,9 +195,9 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let channel = try? database.writableContext.channel(cid: cid)?.asModel(), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - + let userPayload = payload.user - + return MessageDeletedEvent( user: userPayload?.asModel(), channel: channel, @@ -208,7 +206,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { isHardDelete: payload.hardDelete ) } - + private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { guard let userPayload = payload.user, @@ -219,7 +217,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let channel = try? database.writableContext.channel(cid: cid)?.asModel(), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - + return ReactionNewEvent( user: userPayload.asModel(), cid: cid, @@ -228,7 +226,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { createdAt: createdAt ) } - + private func createReactionUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionUpdatedEvent? { guard let userPayload = payload.user, @@ -239,7 +237,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let channel = try? database.writableContext.channel(cid: cid)?.asModel(), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - + return ReactionUpdatedEvent( user: userPayload.asModel(), cid: cid, @@ -248,7 +246,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { createdAt: createdAt ) } - + private func createReactionDeletedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionDeletedEvent? { guard let userPayload = payload.user, @@ -259,7 +257,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let channel = try? database.writableContext.channel(cid: cid)?.asModel(), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } - + return ReactionDeletedEvent( user: userPayload.asModel(), cid: cid, @@ -288,7 +286,7 @@ extension EventNotificationCenter { .receive(on: DispatchQueue.main) .sink(receiveValue: handler) } - + func subscribe( filter: @escaping (Event) -> Bool = { _ in true }, handler: @escaping (Event) -> Void @@ -299,7 +297,7 @@ extension EventNotificationCenter { .receive(on: DispatchQueue.main) .sink(receiveValue: handler) } - + static func channelFilter(cid: ChannelId, event: Event) -> Bool { switch event { case let channelEvent as ChannelSpecificEvent: From b0bf7c3253f5db6f4cfe485c9d2c4ee1c67a00c2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 16:17:38 +0100 Subject: [PATCH 31/85] Add Combine support for livestream controller --- .../LivestreamChannelController+Combine.swift | 56 +++++++++++++++++++ .../LivestreamChannelController.swift | 43 +++++++++++--- StreamChat.xcodeproj/project.pbxproj | 6 ++ 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift new file mode 100644 index 0000000000..79cd7735c5 --- /dev/null +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift @@ -0,0 +1,56 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +extension LivestreamChannelController { + /// A publisher emitting a new value every time the channel changes. + public var channelChangePublisher: AnyPublisher { + basePublishers.channelChange.keepAlive(self) + } + + /// A publisher emitting a new value every time the list of messages changes. + public var messagesChangesPublisher: AnyPublisher<[ChatMessage], Never> { + basePublishers.messagesChanges.keepAlive(self) + } + + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose + /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, + /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + class BasePublishers { + /// The wrapper controller + unowned let controller: LivestreamChannelController + + /// A backing subject for `channelChangePublisher`. + let channelChange: CurrentValueSubject + + /// A backing subject for `messagesChangesPublisher`. + let messagesChanges: CurrentValueSubject<[ChatMessage], Never> + + init(controller: LivestreamChannelController) { + self.controller = controller + channelChange = .init(controller.channel) + messagesChanges = .init(controller.messages) + + controller.multicastDelegate.add(additionalDelegate: self) + } + } +} + +extension LivestreamChannelController.BasePublishers: LivestreamChannelControllerDelegate { + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) { + channelChange.send(channel) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + messagesChanges.send(messages) + } +} diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 4dbe425b1b..08438436a9 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -20,7 +20,9 @@ public extension ChatClient { /// - Read updates /// - Typing indicators /// - etc.. -public class LivestreamChannelController: DataStoreProvider, EventsControllerDelegate { +public class LivestreamChannelController: DataStoreProvider, DelegateCallable, EventsControllerDelegate { + public typealias Delegate = LivestreamChannelControllerDelegate + // MARK: - Public Properties /// The ChannelQuery this controller observes. @@ -74,7 +76,13 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public var loadInitialMessagesFromCache: Bool = true /// Set the delegate to observe the changes in the system. - public weak var delegate: LivestreamChannelControllerDelegate? + public var delegate: LivestreamChannelControllerDelegate? { + get { multicastDelegate.mainDelegate } + set { multicastDelegate.set(mainDelegate: newValue) } + } + + /// A type-erased multicast delegate. + internal var multicastDelegate: MulticastDelegate = .init() // MARK: - Private Properties @@ -93,6 +101,18 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// Current user ID for convenience private var currentUserId: UserId? { client.currentUserId } + var _basePublishers: Any? + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose + /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, + /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + var basePublishers: BasePublishers { + if let value = _basePublishers as? BasePublishers { + return value + } + _basePublishers = BasePublishers(controller: self) + return _basePublishers as? BasePublishers ?? .init(controller: self) + } + // MARK: - Initialization /// Creates a new `LivestreamChannelController` @@ -484,7 +504,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel .updateChannel(query: channelQuery) let requestCompletion: (Result) -> Void = { [weak self] result in - DispatchQueue.main.async { [weak self] in + self?.callback { [weak self] in guard let self = self else { return } switch result { @@ -553,11 +573,18 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel return } - DispatchQueue.main.async { [weak self] in + callback { [weak self] in self?.handleChannelEvent(event) } } + /// Helper method to execute a callbacks on the main thread. + func callback(_ action: @escaping () -> Void) { + DispatchQueue.main.async { + action() + } + } + // MARK: - Private Event Handling private func handleChannelEvent(_ event: Event) { @@ -670,8 +697,10 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel private func notifyDelegateOfChanges() { guard let channel = channel else { return } - delegate?.livestreamChannelController(self, didUpdateChannel: channel) - delegate?.livestreamChannelController(self, didUpdateMessages: messages) + delegateCallback { + $0.livestreamChannelController(self, didUpdateChannel: channel) + $0.livestreamChannelController(self, didUpdateMessages: self.messages) + } } private func createNewMessage( @@ -719,7 +748,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel if let newMessage = try? result.get() { self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) } - DispatchQueue.main.async { + self.callback { completion?(result.map(\.id)) } } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 8a0a843d9f..d3929c9f11 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1485,6 +1485,8 @@ AD4E879B2E37947300223A1C /* ChannelPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */; }; AD4E879C2E37947300223A1C /* UserPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87962E37947300223A1C /* UserPayload+asModel.swift */; }; AD4E879D2E37947300223A1C /* MessagePayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */; }; + AD4E87A12E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */; }; + AD4E87A22E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */; }; AD4F89D02C666471006DF7E5 /* PollResultsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */; }; AD4F89D12C666471006DF7E5 /* PollResultsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */; }; AD4F89D42C666471006DF7E5 /* PollResultsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CC2C666471006DF7E5 /* PollResultsVC.swift */; }; @@ -4324,6 +4326,7 @@ AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPayload+asModel.swift"; sourceTree = ""; }; AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagePayload+asModel.swift"; sourceTree = ""; }; AD4E87962E37947300223A1C /* UserPayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserPayload+asModel.swift"; sourceTree = ""; }; + AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LivestreamChannelController+Combine.swift"; sourceTree = ""; }; AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsSectionHeaderView.swift; sourceTree = ""; }; AD4F89CC2C666471006DF7E5 /* PollResultsVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsVC.swift; sourceTree = ""; }; AD4F89CD2C666471006DF7E5 /* PollResultsVoteItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsVoteItemCell.swift; sourceTree = ""; }; @@ -9552,6 +9555,7 @@ DAE566E624FFD22300E39431 /* ChannelController+SwiftUI.swift */, DA4AA3B12502718600FAAF6E /* ChannelController+Combine.swift */, AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */, + AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */, ); path = ChannelController; sourceTree = ""; @@ -11700,6 +11704,7 @@ DA4AA3B8250271BD00FAAF6E /* CurrentUserController+Combine.swift in Sources */, 79280F712487CD2B00CDEB89 /* Atomic.swift in Sources */, AD7AC99B260A9572004AADA5 /* MessagePinning.swift in Sources */, + AD4E87A22E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */, ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */, 88DA57642631CF1F00FA8C53 /* MuteDetails.swift in Sources */, 7978FBBA26E15A58002CA2DF /* MessageSearchQuery.swift in Sources */, @@ -12693,6 +12698,7 @@ AD4E87972E37947300223A1C /* ChannelPayload+asModel.swift in Sources */, AD4E87982E37947300223A1C /* UserPayload+asModel.swift in Sources */, AD4E87992E37947300223A1C /* MessagePayload+asModel.swift in Sources */, + AD4E87A12E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */, 4F9494BC2C41086F00B5C9CE /* BackgroundEntityDatabaseObserver.swift in Sources */, C121E886274544AF00023E4C /* ChatMessageImageAttachment.swift in Sources */, 43D3F0FD28410A0200B74921 /* CreateCallRequestBody.swift in Sources */, From 20a971855ed68d77b7c458820f6e96d71f7e6f57 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 17:09:08 +0100 Subject: [PATCH 32/85] Handle channel update events and make it easier to notify delegates --- .../LivestreamChannelController.swift | 75 ++++++++++--------- .../Workers/EventNotificationCenter.swift | 20 +++++ 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 08438436a9..cfbf79bde3 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -36,11 +36,24 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// The channel the controller represents. /// This is managed in memory and updated via API calls. - public private(set) var channel: ChatChannel? + public private(set) var channel: ChatChannel? { + didSet { + guard let channel else { return } + delegateCallback { + $0.livestreamChannelController(self, didUpdateChannel: channel) + } + } + } /// The messages of the channel the controller represents. /// This is managed in memory and updated via API calls. - public private(set) var messages: [ChatMessage] = [] + public private(set) var messages: [ChatMessage] = [] { + didSet { + delegateCallback { + $0.livestreamChannelController(self, didUpdateMessages: self.messages) + } + } + } /// A Boolean value that returns whether the oldest messages have all been loaded or not. public var hasLoadedAllPreviousMessages: Bool { @@ -157,7 +170,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E if loadInitialMessagesFromCache, let cid = self.cid, let channel = dataStore.channel(cid: cid) { self.channel = channel messages = channel.latestMessages - notifyDelegateOfChanges() } updateChannelData( @@ -339,8 +351,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E hard: Bool = false, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .deleteMessage(messageId: messageId, hard: hard)) { result in - DispatchQueue.main.async { + apiClient.request(endpoint: .deleteMessage(messageId: messageId, hard: hard)) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -359,8 +371,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E completion: @escaping (Result<[ChatMessageReaction], Error>) -> Void ) { let pagination = Pagination(pageSize: limit, offset: offset) - apiClient.request(endpoint: .loadReactions(messageId: messageId, pagination: pagination)) { result in - DispatchQueue.main.async { + apiClient.request(endpoint: .loadReactions(messageId: messageId, pagination: pagination)) { [weak self] result in + self?.callback { switch result { case .success(let payload): let reactions = payload.reactions.compactMap { $0.asModel(messageId: messageId) } @@ -384,8 +396,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E extraData: [String: RawJSON]? = nil, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .flagMessage(true, with: messageId, reason: reason, extraData: extraData)) { result in - DispatchQueue.main.async { + apiClient.request(endpoint: .flagMessage(true, with: messageId, reason: reason, extraData: extraData)) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -399,8 +411,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E messageId: MessageId, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .flagMessage(false, with: messageId, reason: nil, extraData: nil)) { result in - DispatchQueue.main.async { + apiClient.request(endpoint: .flagMessage(false, with: messageId, reason: nil, extraData: nil)) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -428,8 +440,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E enforceUnique: enforceUnique, extraData: extraData, messageId: messageId - )) { result in - DispatchQueue.main.async { + )) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -445,8 +457,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E from messageId: MessageId, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .deleteReaction(type, messageId: messageId)) { result in - DispatchQueue.main.async { + apiClient.request(endpoint: .deleteReaction(type, messageId: messageId)) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -465,8 +477,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E apiClient.request(endpoint: .pinMessage( messageId: messageId, request: .init(set: .init(pinned: true)) - )) { result in - DispatchQueue.main.async { + )) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -483,8 +495,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E apiClient.request(endpoint: .pinMessage( messageId: messageId, request: .init(set: .init(pinned: false)) - )) { result in - DispatchQueue.main.async { + )) { [weak self] result in + self?.callback { completion?(result.error) } } @@ -542,8 +554,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) - - notifyDelegateOfChanges() } private func updateMessagesArray(with newMessages: [ChatMessage], pagination: MessagesPagination?) { @@ -578,7 +588,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } - /// Helper method to execute a callbacks on the main thread. + /// Helper method to execute the callbacks on the main thread. func callback(_ action: @escaping () -> Void) { DispatchQueue.main.async { action() @@ -604,6 +614,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E return } handleUpdatedMessage(messageDeletedEvent.message) + case let newMessageErrorEvent as NewMessageErrorEvent: guard let message = messages.first(where: { $0.id == newMessageErrorEvent.messageId }) else { return @@ -620,6 +631,9 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E case let reactionDeletedEvent as ReactionDeletedEvent: handleDeletedReaction(reactionDeletedEvent) + case let channelUpdatedEvent as ChannelUpdatedEvent: + handleChannelUpdated(channelUpdatedEvent) + default: break } @@ -642,8 +656,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E currentMessages.insert(message, at: 0) messages = currentMessages - - notifyDelegateOfChanges() } private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { @@ -652,8 +664,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E if let index = currentMessages.firstIndex(where: { $0.id == updatedMessage.id }) { currentMessages[index] = updatedMessage messages = currentMessages - - notifyDelegateOfChanges() } } @@ -662,8 +672,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E currentMessages.removeAll { $0.id == deletedMessage.id } messages = currentMessages - - notifyDelegateOfChanges() } private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { @@ -690,17 +698,10 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E currentMessages[messageIndex] = updatedMessage messages = currentMessages - - notifyDelegateOfChanges() } - private func notifyDelegateOfChanges() { - guard let channel = channel else { return } - - delegateCallback { - $0.livestreamChannelController(self, didUpdateChannel: channel) - $0.livestreamChannelController(self, didUpdateMessages: self.messages) - } + private func handleChannelUpdated(_ event: ChannelUpdatedEvent) { + channel = event.channel } private func createNewMessage( diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 0d0205c943..7bda0f2d18 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -134,6 +134,9 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { case .reactionDeleted: return createReactionDeletedEvent(from: eventPayload, cid: cid) + case .channelUpdated: + return createChannelUpdatedEvent(from: eventPayload, cid: cid) + default: return nil } @@ -266,6 +269,23 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { createdAt: createdAt ) } + + private func createChannelUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ChannelUpdatedEvent? { + guard + let createdAt = payload.createdAt, + let channel = payload.channel?.asModel() + else { return nil } + + let currentUserId = database.writableContext.currentUser?.user.id + let channelReads = channel.reads + + return ChannelUpdatedEvent( + channel: channel, + user: payload.user?.asModel(), + message: payload.message?.asModel(cid: cid, currentUserId: currentUserId, channelReads: channelReads), + createdAt: createdAt + ) + } } extension EventNotificationCenter { From 128cec82009b12245a27e0674227d5a149e77759 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 18:52:05 +0100 Subject: [PATCH 33/85] Cache local channels when handling manual events --- .../Workers/EventNotificationCenter.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 7bda0f2d18..6e140293ea 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -20,6 +20,10 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { // The channels for which events will not be processed by the middlewares. private var manualEventHandlingChannelIds: Set = [] + // Some events require the chat channel data, so we need to fetch it from local DB. + // We try to only do this once, to avoid unnecessary DB fetches. + private var manualEventHandlingCachedChannels: [ChannelId: ChatChannel] = [:] + init( database: DatabaseContainer ) { @@ -40,6 +44,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { func unregisterManualEventHandling(for cid: ChannelId) { eventPostingQueue.async { [weak self] in self?.manualEventHandlingChannelIds.remove(cid) + self?.manualEventHandlingCachedChannels.removeValue(forKey: cid) } } @@ -149,7 +154,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let userPayload = payload.user, let messagePayload = payload.message, let createdAt = payload.createdAt, - let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let channel = getLocalChannel(id: cid), let currentUserId = database.writableContext.currentUser?.user.id, let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { @@ -178,7 +183,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let messagePayload = payload.message, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let channel = getLocalChannel(id: cid), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } @@ -195,7 +200,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let messagePayload = payload.message, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let channel = getLocalChannel(id: cid), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } @@ -217,7 +222,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let reactionPayload = payload.reaction, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let channel = getLocalChannel(id: cid), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } @@ -237,7 +242,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let reactionPayload = payload.reaction, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let channel = getLocalChannel(id: cid), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } @@ -257,7 +262,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { let reactionPayload = payload.reaction, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = try? database.writableContext.channel(cid: cid)?.asModel(), + let channel = getLocalChannel(id: cid), let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) else { return nil } @@ -286,6 +291,17 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { createdAt: createdAt ) } + + // This is only needed because some events wrongly require the channel to create them. + private func getLocalChannel(id: ChannelId) -> ChatChannel? { + if let cachedChannel = manualEventHandlingCachedChannels[id] { + return cachedChannel + } + + let channel = try? database.writableContext.channel(cid: id)?.asModel() + manualEventHandlingCachedChannels[id] = channel + return channel + } } extension EventNotificationCenter { From c6140b5673d1415ab5564d5d99fb7eb75378dac5 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 29 Jul 2025 19:40:47 +0100 Subject: [PATCH 34/85] Fix not loading older pages when in skip logic --- Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift index 52740ee146..4ab27d17f4 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift @@ -1344,8 +1344,7 @@ private extension ChatMessageListVC { let isNewestChangeInsertion = newestChange?.isInsertion == true let isNewestChangeNotByCurrentUser = newestChange?.item.isSentByCurrentUser == false let isNewestChangeNotVisible = !listView.isLastCellFullyVisible && !listView.previousMessagesSnapshot.isEmpty - let isLiveStreamController = dataSource is ChatLivestreamChannelVC - let isLoadingNewPage = insertions.count > 1 && insertions.count == changes.count && !isLiveStreamController + let isLoadingNewPage = insertions.count > 1 && insertions.count == changes.count let shouldSkipMessages = isFirstPageLoaded && isNewestChangeNotVisible From ecf7e9bbb4992ae05a8a7a285729e3401460d9f1 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 09:58:48 +0100 Subject: [PATCH 35/85] Add slow mode support to Livestream Controller --- .../ChannelController/ChannelController.swift | 8 +- .../LivestreamChannelController.swift | 60 +++++++++++++ .../StreamChat/Workers/ChannelUpdater.swift | 89 +++++++++++-------- 3 files changed, 111 insertions(+), 46 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 5c700c3858..29937a71f8 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -1376,12 +1376,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP channelModificationFailed(completion) return } - guard cooldownDuration >= 1, cooldownDuration <= 120 else { - callback { - completion?(ClientError.InvalidCooldownDuration()) - } - return - } updater.enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) { error in self.callback { completion?(error) @@ -1401,7 +1395,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP channelModificationFailed(completion) return } - updater.enableSlowMode(cid: cid, cooldownDuration: 0) { error in + updater.disableSlowMode(cid: cid) { error in self.callback { completion?(error) } diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index cfbf79bde3..251a1d87c5 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -502,6 +502,66 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } + // Returns the current cooldown time for the channel. Returns 0 in case there is no cooldown active. + public func currentCooldownTime() -> Int { + guard let cooldownDuration = channel?.cooldownDuration, cooldownDuration > 0, + let currentUserLatestMessage = messages.first(where: { $0.author.id == currentUserId }), + channel?.ownCapabilities.contains(.skipSlowMode) == false else { + return 0 + } + + let currentTime = Date().timeIntervalSince(currentUserLatestMessage.createdAt) + return max(0, cooldownDuration - Int(currentTime)) + } + + /// Enables slow mode for the channel + /// + /// When slow mode is enabled, users can only send a message every `cooldownDuration` time interval. + /// `cooldownDuration` is specified in seconds, and should be between 1-120. + /// For more information, please check [documentation](https://getstream.io/chat/docs/javascript/slow_mode/?language=swift). + /// + /// - Parameters: + /// - cooldownDuration: Duration of the time interval users have to wait between messages. + /// Specified in seconds. Should be between 1-120. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + public func enableSlowMode(cooldownDuration: Int, completion: ((Error?) -> Void)? = nil) { + guard let cid else { + callback { + completion?(ClientError.ChannelNotCreatedYet()) + } + return + } + + apiClient.request( + endpoint: .enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) + ) { result in + self.callback { + completion?(result.error) + } + } + } + + /// Disables slow mode for the channel + /// + /// For more information, please check [documentation](https://getstream.io/chat/docs/javascript/slow_mode/?language=swift). + /// + /// - Parameters: + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + public func disableSlowMode(completion: ((Error?) -> Void)? = nil) { + guard let cid else { + callback { + completion?(ClientError.ChannelNotCreatedYet()) + } + return + } + + updater.disableSlowMode(cid: cid) { error in + self.callback { + completion?(error) + } + } + } + // MARK: - Private Methods private func updateChannelData( diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 7dc2eb6d0e..6943d1dbbd 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -50,7 +50,7 @@ class ChannelUpdater: Worker { if let pagination = channelQuery.pagination { paginationStateHandler.begin(pagination: pagination) } - + let didLoadFirstPage = channelQuery.pagination?.parameter == nil let didJumpToMessage: Bool = channelQuery.pagination?.parameter?.isJumpingToMessage == true let resetMembersAndReads = didLoadFirstPage @@ -73,7 +73,7 @@ class ChannelUpdater: Worker { // Fetching channel data should prepopulate it. Then we can save an API call // for providing member data. let memberListQuery = ChannelMemberListQuery(cid: payload.channel.cid, sort: actions?.updateMemberList ?? []) - + if let channelDTO = session.channel(cid: payload.channel.cid) { if resetMessages { channelDTO.cleanAllMessagesExcludingLocalOnly() @@ -89,11 +89,11 @@ class ChannelUpdater: Worker { channelDTO.watchers.removeAll() } } - + let updatedChannel = try session.saveChannel(payload: payload) updatedChannel.oldestMessageAt = self.paginationState.oldestMessageAt?.bridgeDate updatedChannel.newestMessageAt = self.paginationState.newestMessageAt?.bridgeDate - + // Share member data with member list query without any filters (requres ChannelDTO to be saved first) let memberListQueryDTO: ChannelMemberListQueryDTO = try { if let dto = session.channelMemberListQuery(queryHash: memberListQuery.queryHash) { @@ -148,7 +148,7 @@ class ChannelUpdater: Worker { completion?($0.error) } } - + /// Loads channel members and reads for these members using channel query endpoint. /// /// - Note: Use it only if we would like to paginate channel reads (reads pagination can only be done through paginating members using the channel query endpoint). @@ -180,7 +180,7 @@ class ChannelUpdater: Worker { // In addition to this, we want to save channel data because reads are // stored and returned through channel data. let memberListQuery = ChannelMemberListQuery(cid: cid, sort: memberListSorting) - + // Keep the default logic where loading the first page, resets the pagination state. if membersPagination.offset == 0 { let channelDTO = session.channel(cid: cid) @@ -191,7 +191,7 @@ class ChannelUpdater: Worker { let updatedChannel = try session.saveChannel(payload: payload) let memberListQueryDTO = try session.saveQuery(memberListQuery) memberListQueryDTO.members.formUnion(updatedChannel.members) - + paginatedMembers = payload.members.compactMapLoggingError { try session.member(userId: $0.userId, cid: cid)?.asModel() } } completion: { error in if let paginatedMembers { @@ -593,14 +593,25 @@ class ChannelUpdater: Worker { /// - Parameters: /// - cid: Channel id of the channel to be marked as read /// - cooldownDuration: Duration of the time interval users have to wait between messages. - /// Specified in seconds. Should be between 0-120. Pass 0 to disable slow mode. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. func enableSlowMode(cid: ChannelId, cooldownDuration: Int, completion: ((Error?) -> Void)? = nil) { + guard cooldownDuration >= 1, cooldownDuration <= 120 else { + completion?(ClientError.InvalidCooldownDuration()) + return + } + apiClient.request(endpoint: .enableSlowMode(cid: cid, cooldownDuration: cooldownDuration)) { completion?($0.error) } } + /// Disables slow mode for the channel. + func disableSlowMode(cid: ChannelId, completion: @escaping ((Error?) -> Void)) { + apiClient.request(endpoint: .enableSlowMode(cid: cid, cooldownDuration: 0)) { + completion($0.error) + } + } + /// Start watching a channel /// /// Watching a channel is defined as observing notifications about this channel. @@ -756,21 +767,21 @@ class ChannelUpdater: Worker { } } } - + func deleteFile(in cid: ChannelId, url: String, completion: ((Error?) -> Void)? = nil) { apiClient.request(endpoint: .deleteFile(cid: cid, url: url), completion: { completion?($0.error) }) } - + func deleteImage(in cid: ChannelId, url: String, completion: ((Error?) -> Void)? = nil) { apiClient.request(endpoint: .deleteImage(cid: cid, url: url), completion: { completion?($0.error) }) } - + // MARK: - private - + private func messagePayload(text: String?, currentUserId: UserId?) -> MessageRequestBody? { var messagePayload: MessageRequestBody? if let text = text, let currentUserId = currentUserId { @@ -803,7 +814,7 @@ extension ChannelUpdater { } } } - + func addMembers( currentUserId: UserId? = nil, cid: ChannelId, @@ -823,7 +834,7 @@ extension ChannelUpdater { } } } - + func channelWatchers(for query: ChannelWatcherListQuery) async throws -> [ChatUser] { let payload = try await withCheckedThrowingContinuation { continuation in channelWatchers(query: query) { result in @@ -835,7 +846,7 @@ extension ChannelUpdater { try ids.compactMap { try session.user(id: $0)?.asModel() } } } - + func createNewMessage( in cid: ChannelId, messageId: MessageId?, @@ -875,7 +886,7 @@ extension ChannelUpdater { } } } - + func deleteChannel(cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in deleteChannel(cid: cid) { error in @@ -883,7 +894,7 @@ extension ChannelUpdater { } } } - + func deleteFile(in cid: ChannelId, url: String) async throws { try await withCheckedThrowingContinuation { continuation in deleteFile(in: cid, url: url) { error in @@ -891,7 +902,7 @@ extension ChannelUpdater { } } } - + func deleteImage(in cid: ChannelId, url: String) async throws { try await withCheckedThrowingContinuation { continuation in deleteImage(in: cid, url: url) { error in @@ -899,7 +910,7 @@ extension ChannelUpdater { } } } - + func enableSlowMode(cid: ChannelId, cooldownDuration: Int) async throws { try await withCheckedThrowingContinuation { continuation in enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) { error in @@ -907,7 +918,7 @@ extension ChannelUpdater { } } } - + func enrichUrl(_ url: URL) async throws -> LinkAttachmentPayload { try await withCheckedThrowingContinuation { continuation in enrichUrl(url) { result in @@ -915,7 +926,7 @@ extension ChannelUpdater { } } } - + func freezeChannel(_ freeze: Bool, cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in freezeChannel(freeze, cid: cid) { error in @@ -923,7 +934,7 @@ extension ChannelUpdater { } } } - + func hideChannel(cid: ChannelId, clearHistory: Bool) async throws { try await withCheckedThrowingContinuation { continuation in hideChannel(cid: cid, clearHistory: clearHistory) { error in @@ -931,7 +942,7 @@ extension ChannelUpdater { } } } - + func inviteMembers(cid: ChannelId, userIds: Set) async throws { try await withCheckedThrowingContinuation { continuation in inviteMembers(cid: cid, userIds: userIds) { error in @@ -939,7 +950,7 @@ extension ChannelUpdater { } } } - + func loadMembersWithReads( in cid: ChannelId, membersPagination: Pagination, @@ -951,7 +962,7 @@ extension ChannelUpdater { } } } - + func loadPinnedMessages(in cid: ChannelId, query: PinnedMessagesQuery) async throws -> [ChatMessage] { try await withCheckedThrowingContinuation { continuation in loadPinnedMessages(in: cid, query: query) { result in @@ -959,7 +970,7 @@ extension ChannelUpdater { } } } - + func muteChannel(cid: ChannelId, expiration: Int? = nil) async throws { try await withCheckedThrowingContinuation { continuation in muteChannel(cid: cid, expiration: expiration) { error in @@ -983,7 +994,7 @@ extension ChannelUpdater { } } } - + func removeMembers( currentUserId: UserId? = nil, cid: ChannelId, @@ -1001,7 +1012,7 @@ extension ChannelUpdater { } } } - + func showChannel(cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in showChannel(cid: cid) { error in @@ -1009,7 +1020,7 @@ extension ChannelUpdater { } } } - + func startWatching(cid: ChannelId, isInRecoveryMode: Bool) async throws { try await withCheckedThrowingContinuation { continuation in startWatching(cid: cid, isInRecoveryMode: isInRecoveryMode) { error in @@ -1017,7 +1028,7 @@ extension ChannelUpdater { } } } - + func stopWatching(cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in stopWatching(cid: cid) { error in @@ -1025,7 +1036,7 @@ extension ChannelUpdater { } } } - + func truncateChannel( cid: ChannelId, skipPush: Bool, @@ -1060,7 +1071,7 @@ extension ChannelUpdater { ) } } - + func update(channelPayload: ChannelEditDetailPayload) async throws { try await withCheckedThrowingContinuation { continuation in updateChannel(channelPayload: channelPayload) { error in @@ -1068,7 +1079,7 @@ extension ChannelUpdater { } } } - + func updatePartial(channelPayload: ChannelEditDetailPayload, unsetProperties: [String]) async throws { try await withCheckedThrowingContinuation { continuation in partialChannelUpdate(updates: channelPayload, unsetProperties: unsetProperties) { error in @@ -1076,7 +1087,7 @@ extension ChannelUpdater { } } } - + func uploadFile( type: AttachmentType, localFileURL: URL, @@ -1094,9 +1105,9 @@ extension ChannelUpdater { } } } - + // MARK: - - + func loadMessages(with channelQuery: ChannelQuery, pagination: MessagesPagination) async throws -> [ChatMessage] { let payload = try await update(channelQuery: channelQuery.withPagination(pagination)) guard let cid = channelQuery.cid else { return [] } @@ -1104,7 +1115,7 @@ extension ChannelUpdater { guard let toDate = payload.messages.last?.createdAt else { return [] } return try await messageRepository.messages(from: fromDate, to: toDate, in: cid) } - + func loadMessages( before messageId: MessageId?, limit: Int?, @@ -1137,7 +1148,7 @@ extension ChannelUpdater { let pagination = MessagesPagination(pageSize: limit, parameter: .greaterThan(messageId)) try await update(channelQuery: channelQuery.withPagination(pagination)) } - + func loadMessages( around messageId: MessageId, limit: Int?, @@ -1179,7 +1190,7 @@ extension ChannelQuery { result.pagination = pagination return result } - + func withOptions(forWatching watch: Bool) -> Self { var result = self result.options = watch ? .all : .state From de3a1ccbfacefd42dc765b25e276f13b012d29de Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 11:46:38 +0100 Subject: [PATCH 36/85] Extract manual event handling logic to a ManualEventHandler --- .../Workers/EventNotificationCenter.swift | 218 +-------------- .../Workers/ManualEventHandler.swift | 249 ++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 6 + 3 files changed, 263 insertions(+), 210 deletions(-) create mode 100644 Sources/StreamChat/Workers/ManualEventHandler.swift diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 6e140293ea..25f1a3754a 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -17,17 +17,15 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { // Contains the ids of the new messages that are going to be added during the ongoing process private(set) var newMessageIds: Set = Set() - // The channels for which events will not be processed by the middlewares. - private var manualEventHandlingChannelIds: Set = [] - - // Some events require the chat channel data, so we need to fetch it from local DB. - // We try to only do this once, to avoid unnecessary DB fetches. - private var manualEventHandlingCachedChannels: [ChannelId: ChatChannel] = [:] + /// Handles manual event processing for channels that opt out of middleware processing. + private let manualEventHandler: ManualEventHandler init( - database: DatabaseContainer + database: DatabaseContainer, + manualEventHandler: ManualEventHandler? = nil ) { self.database = database + self.manualEventHandler = manualEventHandler ?? ManualEventHandler(database: database) super.init() } @@ -35,17 +33,12 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { /// /// The middleware's will not process events for this channel. func registerManualEventHandling(for cid: ChannelId) { - eventPostingQueue.async { [weak self] in - self?.manualEventHandlingChannelIds.insert(cid) - } + manualEventHandler.register(channelId: cid) } /// Unregister a channel for manual event handling. func unregisterManualEventHandling(for cid: ChannelId) { - eventPostingQueue.async { [weak self] in - self?.manualEventHandlingChannelIds.remove(cid) - self?.manualEventHandlingCachedChannels.removeValue(forKey: cid) - } + manualEventHandler.unregister(channelId: cid) } func add(middlewares: [EventMiddleware]) { @@ -78,8 +71,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { return } if let cid = eventDTO.payload.cid, - self.manualEventHandlingChannelIds.contains(cid), - let manualEvent = self.convertManualEventToDomain(event) { + let manualEvent = self.manualEventHandler.handle(event) { manualHandlingEvents.append(manualEvent) } else { middlewareEvents.append(event) @@ -108,200 +100,6 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } }) } - - private func convertManualEventToDomain(_ event: Event) -> Event? { - guard let eventDTO = event as? EventDTO else { - return nil - } - - let eventPayload = eventDTO.payload - - guard let cid = eventPayload.cid else { - return nil - } - - switch eventPayload.eventType { - case .messageNew: - return createMessageNewEvent(from: eventPayload, cid: cid) - - case .messageUpdated: - return createMessageUpdatedEvent(from: eventPayload, cid: cid) - - case .messageDeleted: - return createMessageDeletedEvent(from: eventPayload, cid: cid) - - case .reactionNew: - return createReactionNewEvent(from: eventPayload, cid: cid) - - case .reactionUpdated: - return createReactionUpdatedEvent(from: eventPayload, cid: cid) - - case .reactionDeleted: - return createReactionDeletedEvent(from: eventPayload, cid: cid) - - case .channelUpdated: - return createChannelUpdatedEvent(from: eventPayload, cid: cid) - - default: - return nil - } - } - - // MARK: - Event Creation Helpers - - private func createMessageNewEvent(from payload: EventPayload, cid: ChannelId) -> MessageNewEvent? { - guard - let userPayload = payload.user, - let messagePayload = payload.message, - let createdAt = payload.createdAt, - let channel = getLocalChannel(id: cid), - let currentUserId = database.writableContext.currentUser?.user.id, - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) - else { - return nil - } - - return MessageNewEvent( - user: userPayload.asModel(), - message: message, - channel: channel, - createdAt: createdAt, - watcherCount: payload.watcherCount, - unreadCount: payload.unreadCount.map { - .init( - channels: $0.channels ?? 0, - messages: $0.messages ?? 0, - threads: $0.threads ?? 0 - ) - } - ) - } - - private func createMessageUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> MessageUpdatedEvent? { - guard - let userPayload = payload.user, - let messagePayload = payload.message, - let createdAt = payload.createdAt, - let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) - else { return nil } - - return MessageUpdatedEvent( - user: userPayload.asModel(), - channel: channel, - message: message, - createdAt: createdAt - ) - } - - private func createMessageDeletedEvent(from payload: EventPayload, cid: ChannelId) -> MessageDeletedEvent? { - guard - let messagePayload = payload.message, - let createdAt = payload.createdAt, - let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) - else { return nil } - - let userPayload = payload.user - - return MessageDeletedEvent( - user: userPayload?.asModel(), - channel: channel, - message: message, - createdAt: createdAt, - isHardDelete: payload.hardDelete - ) - } - - private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { - guard - let userPayload = payload.user, - let messagePayload = payload.message, - let reactionPayload = payload.reaction, - let createdAt = payload.createdAt, - let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) - else { return nil } - - return ReactionNewEvent( - user: userPayload.asModel(), - cid: cid, - message: message, - reaction: reactionPayload.asModel(messageId: messagePayload.id), - createdAt: createdAt - ) - } - - private func createReactionUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionUpdatedEvent? { - guard - let userPayload = payload.user, - let messagePayload = payload.message, - let reactionPayload = payload.reaction, - let createdAt = payload.createdAt, - let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) - else { return nil } - - return ReactionUpdatedEvent( - user: userPayload.asModel(), - cid: cid, - message: message, - reaction: reactionPayload.asModel(messageId: messagePayload.id), - createdAt: createdAt - ) - } - - private func createReactionDeletedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionDeletedEvent? { - guard - let userPayload = payload.user, - let messagePayload = payload.message, - let reactionPayload = payload.reaction, - let createdAt = payload.createdAt, - let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) - else { return nil } - - return ReactionDeletedEvent( - user: userPayload.asModel(), - cid: cid, - message: message, - reaction: reactionPayload.asModel(messageId: messagePayload.id), - createdAt: createdAt - ) - } - - private func createChannelUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ChannelUpdatedEvent? { - guard - let createdAt = payload.createdAt, - let channel = payload.channel?.asModel() - else { return nil } - - let currentUserId = database.writableContext.currentUser?.user.id - let channelReads = channel.reads - - return ChannelUpdatedEvent( - channel: channel, - user: payload.user?.asModel(), - message: payload.message?.asModel(cid: cid, currentUserId: currentUserId, channelReads: channelReads), - createdAt: createdAt - ) - } - - // This is only needed because some events wrongly require the channel to create them. - private func getLocalChannel(id: ChannelId) -> ChatChannel? { - if let cachedChannel = manualEventHandlingCachedChannels[id] { - return cachedChannel - } - - let channel = try? database.writableContext.channel(cid: id)?.asModel() - manualEventHandlingCachedChannels[id] = channel - return channel - } } extension EventNotificationCenter { diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift new file mode 100644 index 0000000000..065cc35c22 --- /dev/null +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -0,0 +1,249 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Handles manual event processing for channels that opt out of middleware processing. +class ManualEventHandler { + /// The database used when evaluating events. + private let database: DatabaseContainer + + /// The queue for thread-safe operations. + private let queue: DispatchQueue + + // The channels for which events will not be processed by the middlewares. + private var channelIds: Set = [] + + // Some events require the chat channel data, so we need to fetch it from local DB. + // We try to only do this once, to avoid unnecessary DB fetches. + private var cachedChannels: [ChannelId: ChatChannel] = [:] + + init( + database: DatabaseContainer, + queue: DispatchQueue = DispatchQueue(label: "io.getstream.chat.manualEventHandler", qos: .background) + ) { + self.database = database + self.queue = queue + } + + /// Registers a channel for manual event handling. + /// + /// The middleware's will not process events for this channel. + func register(channelId: ChannelId) { + queue.async { [weak self] in + self?.channelIds.insert(channelId) + } + } + + /// Unregister a channel for manual event handling. + func unregister(channelId: ChannelId) { + queue.async { [weak self] in + self?.channelIds.remove(channelId) + self?.cachedChannels.removeValue(forKey: channelId) + } + } + + /// Converts a manual event to its domain representation. + func handle(_ event: Event) -> Event? { + guard let eventDTO = event as? EventDTO else { + return nil + } + + let eventPayload = eventDTO.payload + + guard let cid = eventPayload.cid else { + return nil + } + + guard isRegistered(channelId: cid) else { + return nil + } + + switch eventPayload.eventType { + case .messageNew: + return createMessageNewEvent(from: eventPayload, cid: cid) + + case .messageUpdated: + return createMessageUpdatedEvent(from: eventPayload, cid: cid) + + case .messageDeleted: + return createMessageDeletedEvent(from: eventPayload, cid: cid) + + case .reactionNew: + return createReactionNewEvent(from: eventPayload, cid: cid) + + case .reactionUpdated: + return createReactionUpdatedEvent(from: eventPayload, cid: cid) + + case .reactionDeleted: + return createReactionDeletedEvent(from: eventPayload, cid: cid) + + case .channelUpdated: + return createChannelUpdatedEvent(from: eventPayload, cid: cid) + + default: + return nil + } + } + + private func isRegistered(channelId: ChannelId) -> Bool { + queue.sync { channelIds.contains(channelId) } + } + + // MARK: - Event Creation Helpers + + private func createMessageNewEvent(from payload: EventPayload, cid: ChannelId) -> MessageNewEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let channel = getLocalChannel(id: cid), + let currentUserId = database.writableContext.currentUser?.user.id, + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + else { + return nil + } + + return MessageNewEvent( + user: userPayload.asModel(), + message: message, + channel: channel, + createdAt: createdAt, + watcherCount: payload.watcherCount, + unreadCount: payload.unreadCount.map { + .init( + channels: $0.channels ?? 0, + messages: $0.messages ?? 0, + threads: $0.threads ?? 0 + ) + } + ) + } + + private func createMessageUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> MessageUpdatedEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + else { return nil } + + return MessageUpdatedEvent( + user: userPayload.asModel(), + channel: channel, + message: message, + createdAt: createdAt + ) + } + + private func createMessageDeletedEvent(from payload: EventPayload, cid: ChannelId) -> MessageDeletedEvent? { + guard + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + else { return nil } + + let userPayload = payload.user + + return MessageDeletedEvent( + user: userPayload?.asModel(), + channel: channel, + message: message, + createdAt: createdAt, + isHardDelete: payload.hardDelete + ) + } + + private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + else { return nil } + + return ReactionNewEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(messageId: messagePayload.id), + createdAt: createdAt + ) + } + + private func createReactionUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionUpdatedEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + else { return nil } + + return ReactionUpdatedEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(messageId: messagePayload.id), + createdAt: createdAt + ) + } + + private func createReactionDeletedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionDeletedEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid), + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + else { return nil } + + return ReactionDeletedEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(messageId: messagePayload.id), + createdAt: createdAt + ) + } + + private func createChannelUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ChannelUpdatedEvent? { + guard + let createdAt = payload.createdAt, + let channel = payload.channel?.asModel() + else { return nil } + + let currentUserId = database.writableContext.currentUser?.user.id + let channelReads = channel.reads + + return ChannelUpdatedEvent( + channel: channel, + user: payload.user?.asModel(), + message: payload.message?.asModel(cid: cid, currentUserId: currentUserId, channelReads: channelReads), + createdAt: createdAt + ) + } + + // This is only needed because some events wrongly require the channel to create them. + private func getLocalChannel(id: ChannelId) -> ChatChannel? { + if let cachedChannel = cachedChannels[id] { + return cachedChannel + } + + let channel = try? database.writableContext.channel(cid: id)?.asModel() + cachedChannels[id] = channel + return channel + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index d3929c9f11..be68166d37 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1424,6 +1424,8 @@ AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; AD1B9F462E33E78E0091A37A /* ChatLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */; }; AD1B9F472E33E7950091A37A /* ChatLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */; }; + AD1BA40B2E3A2D180092D602 /* ManualEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */; }; + AD1BA40C2E3A2D180092D602 /* ManualEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; @@ -4284,6 +4286,7 @@ AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController.swift; sourceTree = ""; }; AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLivestreamChannelVC.swift; sourceTree = ""; }; + AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; @@ -6009,6 +6012,7 @@ 799C9427247D2FB9001F1104 /* Workers */ = { isa = PBXGroup; children = ( + AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */, 4F45802D2BEE0B4B0099F540 /* ChannelListLinker.swift */, 792A4F1A247FE84900EAF71D /* ChannelListUpdater.swift */, 882C5755252C791400E60C44 /* ChannelMemberListUpdater.swift */, @@ -11930,6 +11934,7 @@ AD37D7CA2BC98A5300800D8C /* ThreadReadDTO.swift in Sources */, ADE40043291B1A510000C98B /* AttachmentUploader.swift in Sources */, AD37D7D32BC9938E00800D8C /* ThreadRead.swift in Sources */, + AD1BA40C2E3A2D180092D602 /* ManualEventHandler.swift in Sources */, 841BAA542BD26136000C73E4 /* PollOption.swift in Sources */, 8836FFBB2540741D009FDF73 /* FlagUserPayload.swift in Sources */, ADF2BBEB2B9B622B0069D467 /* AppSettingsPayload.swift in Sources */, @@ -12864,6 +12869,7 @@ C121E8E1274544B100023E4C /* OptionalDecodable.swift in Sources */, C1CEF90A2A1CDF7600414931 /* UserUpdateMiddleware.swift in Sources */, C121E8E2274544B200023E4C /* Codable+Extensions.swift in Sources */, + AD1BA40B2E3A2D180092D602 /* ManualEventHandler.swift in Sources */, C121E8E3274544B200023E4C /* Data+Gzip.swift in Sources */, C121E8E4274544B200023E4C /* LazyCachedMapCollection.swift in Sources */, 40789D3D29F6AD9C0018C2BB /* Debouncer.swift in Sources */, From 6cf949307dcc5ef461d621449f8aa2a40403e3e2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 16:41:56 +0100 Subject: [PATCH 37/85] Fix merge conflict --- Sources/StreamChat/ChatClientFactory.swift | 2 +- .../LivestreamChannelController.swift | 32 ++++++++++++------- Sources/StreamChat/Models/Channel.swift | 1 + .../ChannelPayload+asModel.swift | 9 ++++-- .../MessagePayload+asModel.swift | 2 +- .../Payload+asModel/UserPayload+asModel.swift | 1 + .../Events/MessageEvents.swift | 7 +++- .../Workers/ManualEventHandler.swift | 29 ++++++++++------- 8 files changed, 54 insertions(+), 29 deletions(-) diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift index 7c2697d129..73ce923970 100644 --- a/Sources/StreamChat/ChatClientFactory.swift +++ b/Sources/StreamChat/ChatClientFactory.swift @@ -115,7 +115,7 @@ class ChatClientFactory { databaseContainer: DatabaseContainer, currentUserId: @escaping () -> UserId? ) -> EventNotificationCenter { - let center = environment.notificationCenterBuilder(databaseContainer) + let center = environment.notificationCenterBuilder(databaseContainer, nil) let middlewares: [EventMiddleware] = [ EventDataProcessorMiddleware(), TypingStartCleanupMiddleware( diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 251a1d87c5..309cc81892 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -418,29 +418,37 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } - /// Adds a reaction to a message. + /// Adds new reaction to the message this controller manages. /// - Parameters: /// - type: The reaction type. /// - messageId: The message identifier to add the reaction to. - /// - score: The reaction score. Default is 1. - /// - enforceUnique: If set to `true`, new reaction will replace all reactions the user has (if any) on this message. Default is false. - /// - extraData: The reaction extra data. Default is empty. - /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + /// - score: The reaction score. + /// - enforceUnique: If set to `true`, new reaction will replace all reactions the user has (if any) on this message. + /// - skipPush: If set to `true`, skips sending push notification when reacting a message. + /// - pushEmojiCode: The emoji code when receiving a reaction push notification. + /// - extraData: The reaction extra data. + /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. public func addReaction( _ type: MessageReactionType, to messageId: MessageId, score: Int = 1, enforceUnique: Bool = false, + skipPush: Bool = false, + pushEmojiCode: String? = nil, extraData: [String: RawJSON] = [:], completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .addReaction( - type, - score: score, - enforceUnique: enforceUnique, - extraData: extraData, - messageId: messageId - )) { [weak self] result in + apiClient.request( + endpoint: .addReaction( + type, + score: score, + enforceUnique: enforceUnique, + extraData: extraData, + skipPush: skipPush, + emojiCode: pushEmojiCode, + messageId: messageId + ) + ) { [weak self] result in self?.callback { completion?(result.error) } diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 3868adc0a5..e10b1e4811 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -326,6 +326,7 @@ public struct ChatChannel { latestMessages: latestMessages, lastMessageFromCurrentUser: lastMessageFromCurrentUser, pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, muteDetails: muteDetails, previewMessage: previewMessage, draftMessage: draftMessage, diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift index 9851912928..b11edd5363 100644 --- a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift @@ -25,7 +25,7 @@ extension ChannelPayload { // Map reads let mappedReads = channelReads.map { $0.asModel() } - + // Map watchers let mappedWatchers = watchers?.map { $0.asModel() } ?? [] @@ -61,6 +61,9 @@ extension ChannelPayload { pinnedMessages: pinnedMessages.compactMap { $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) }, + pendingMessages: (pendingMessages ?? []).compactMap { + $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + }, muteDetails: nil, previewMessage: latestMessages.first, draftMessage: nil, @@ -101,6 +104,7 @@ extension ChannelDetailPayload { latestMessages: [], lastMessageFromCurrentUser: nil, pinnedMessages: [], + pendingMessages: [], muteDetails: nil, previewMessage: nil, draftMessage: nil, @@ -116,7 +120,7 @@ extension MemberPayload { func asModel(channelId: ChannelId) -> ChatChannelMember? { guard let userPayload = user else { return nil } let user = userPayload.asModel() - + return ChatChannelMember( id: user.id, name: user.name, @@ -145,6 +149,7 @@ extension MemberPayload { banExpiresAt: banExpiresAt, isShadowBannedFromChannel: isShadowBanned ?? false, notificationsMuted: notificationsMuted, + avgResponseTime: user.avgResponseTime, memberExtraData: [:] ) } diff --git a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift index 45039463db..ae41ff9b56 100644 --- a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift @@ -15,7 +15,7 @@ extension MessagePayload { cid: ChannelId, currentUserId: UserId?, channelReads: [ChatChannelRead] - ) -> ChatMessage? { + ) -> ChatMessage { let author = user.asModel() let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) let threadParticipants = threadParticipants.compactMap { $0.asModel() } diff --git a/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift index 2caa941d59..a94381c51b 100644 --- a/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift @@ -23,6 +23,7 @@ extension UserPayload { lastActiveAt: lastActiveAt, teams: Set(teams), language: language.flatMap { TranslationLanguage(languageCode: $0) }, + avgResponseTime: avgResponseTime, extraData: extraData ) } diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 8ff69ac132..70e401be05 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -163,7 +163,12 @@ class MessageDeletedEventDTO: EventDTO { // If the message is hard deleted, it is not available as DTO. // So we map the Payload Directly to the Model. - let message = (try? messageDTO?.asModel()) ?? message.asModel(currentUser: session.currentUser) + let channelReads = (try? channelDTO.asModel().reads) ?? [] + let message = message.asModel( + cid: cid, + currentUserId: session.currentUser?.user.id, + channelReads: channelReads + ) return try? MessageDeletedEvent( user: userDTO?.asModel(), diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift index 065cc35c22..63657121a1 100644 --- a/Sources/StreamChat/Workers/ManualEventHandler.swift +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -99,12 +99,13 @@ class ManualEventHandler { let messagePayload = payload.message, let createdAt = payload.createdAt, let channel = getLocalChannel(id: cid), - let currentUserId = database.writableContext.currentUser?.user.id, - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let currentUserId = database.writableContext.currentUser?.user.id else { return nil } + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + return MessageNewEvent( user: userPayload.asModel(), message: message, @@ -127,10 +128,11 @@ class ManualEventHandler { let messagePayload = payload.message, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let channel = getLocalChannel(id: cid) else { return nil } + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + return MessageUpdatedEvent( user: userPayload.asModel(), channel: channel, @@ -144,10 +146,10 @@ class ManualEventHandler { let messagePayload = payload.message, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let channel = getLocalChannel(id: cid) else { return nil } + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) let userPayload = payload.user return MessageDeletedEvent( @@ -166,10 +168,11 @@ class ManualEventHandler { let reactionPayload = payload.reaction, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let channel = getLocalChannel(id: cid) else { return nil } + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + return ReactionNewEvent( user: userPayload.asModel(), cid: cid, @@ -186,10 +189,11 @@ class ManualEventHandler { let reactionPayload = payload.reaction, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let channel = getLocalChannel(id: cid) else { return nil } + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + return ReactionUpdatedEvent( user: userPayload.asModel(), cid: cid, @@ -206,10 +210,11 @@ class ManualEventHandler { let reactionPayload = payload.reaction, let createdAt = payload.createdAt, let currentUserId = database.writableContext.currentUser?.user.id, - let channel = getLocalChannel(id: cid), - let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let channel = getLocalChannel(id: cid) else { return nil } + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + return ReactionDeletedEvent( user: userPayload.asModel(), cid: cid, From e2505e3239215ee80ec8da7c9ea47bc850b780aa Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 21:29:41 +0100 Subject: [PATCH 38/85] CodeRabit review feedback changes --- .../ChannelController/ChannelController.swift | 28 ++++++++++++++++--- .../LivestreamChannelController.swift | 9 +++++- .../MessageController/MessageController.swift | 7 ++++- .../ChannelPayload+asModel.swift | 2 +- .../MessagePayload+asModel.swift | 1 + Sources/StreamChat/StateLayer/Chat.swift | 9 ++++-- .../Events/MessageEvents.swift | 3 +- 7 files changed, 47 insertions(+), 12 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 29937a71f8..9176858319 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -836,7 +836,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -891,7 +896,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -962,7 +972,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -1734,7 +1749,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 309cc81892..4be8b07f61 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -86,6 +86,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } /// A Boolean value that indicates whether to load initial messages from the cache. + /// + /// Only the initial page will be loaded from cache, to avoid an initial blank screen. public var loadInitialMessagesFromCache: Bool = true /// Set the delegate to observe the changes in the system. @@ -815,7 +817,12 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 0d653a15a0..ea44356c8d 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -417,7 +417,12 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP extraData: transformableInfo.extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: self.cid + ) + ) } self.callback { completion?(result.map(\.id)) diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift index b11edd5363..3be745ae26 100644 --- a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift @@ -150,7 +150,7 @@ extension MemberPayload { isShadowBannedFromChannel: isShadowBanned ?? false, notificationsMuted: notificationsMuted, avgResponseTime: user.avgResponseTime, - memberExtraData: [:] + memberExtraData: extraData ?? [:] ) } } diff --git a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift index ae41ff9b56..b39e19784d 100644 --- a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift @@ -42,6 +42,7 @@ extension MessagePayload { .enumerated() .compactMap { offset, attachmentPayload in guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { + log.error("Failed to encode attachment payload at index \(offset) for message \(id)") return nil } return AnyChatMessageAttachment( diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 60dc9c480a..ccec50b9f1 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -508,6 +508,7 @@ public class Chat { restrictedVisibility: [UserId] = [] ) async throws -> ChatMessage { Task { try await stopTyping() } // errors explicitly ignored + let cid = try await self.cid let localMessage = try await channelUpdater.createNewMessage( in: cid, messageId: messageId, @@ -527,7 +528,7 @@ public class Chat { ) // Important to set up the waiter immediately async let sentMessage = try await waitForAPIRequest(localMessage: localMessage) - eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage)) + eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage, cid: cid)) return try await sentMessage } @@ -545,6 +546,7 @@ public class Chat { restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:] ) async throws -> ChatMessage { + let cid = try await self.cid let localMessage = try await channelUpdater.createNewMessage( in: cid, messageId: messageId, @@ -564,7 +566,7 @@ public class Chat { ) // Important to set up the waiter immediately async let sentMessage = try await waitForAPIRequest(localMessage: localMessage) - eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage)) + eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage, cid: cid)) return try await sentMessage } @@ -979,6 +981,7 @@ public class Chat { messageId: MessageId? = nil ) async throws -> ChatMessage { Task { try await stopTyping() } // errors explicitly ignored + let cid = try await self.cid let localMessage = try await messageUpdater.createNewReply( in: cid, messageId: messageId, @@ -997,7 +1000,7 @@ public class Chat { extraData: extraData ) async let sentMessage = try await waitForAPIRequest(localMessage: localMessage) - eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage)) + eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage, cid: cid)) return try await sentMessage } diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 70e401be05..4dec4f0e56 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -159,7 +159,6 @@ class MessageDeletedEventDTO: EventDTO { } let userDTO = user.flatMap { session.user(id: $0.id) } - let messageDTO = session.message(id: message.id) // If the message is hard deleted, it is not available as DTO. // So we map the Payload Directly to the Model. @@ -248,7 +247,7 @@ class MessageReadEventDTO: EventDTO { // Triggered when the current user creates a new message and is pending to be sent. public struct NewMessagePendingEvent: ChannelSpecificEvent { public var message: ChatMessage - public var cid: ChannelId { message.cid! } + public var cid: ChannelId } // Triggered when a message failed being sent. From 1d082491baa548e0b243a2458954ecb2b96348c8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 22:32:49 +0100 Subject: [PATCH 39/85] Cleanuo livestream channel controller --- .../LivestreamChannelController.swift | 95 ++++++++++++------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 4be8b07f61..59b467a243 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -35,7 +35,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public let client: ChatClient /// The channel the controller represents. - /// This is managed in memory and updated via API calls. public private(set) var channel: ChatChannel? { didSet { guard let channel else { return } @@ -46,7 +45,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } /// The messages of the channel the controller represents. - /// This is managed in memory and updated via API calls. public private(set) var messages: [ChatMessage] = [] { didSet { delegateCallback { @@ -101,25 +99,22 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E // MARK: - Private Properties - /// The API client for making direct API calls + /// The API client for making direct API calls. private let apiClient: APIClient - /// Pagination state handler for managing message pagination + /// Pagination state handler for managing message pagination. private let paginationStateHandler: MessagesPaginationStateHandling - /// Events controller for listening to real-time events + /// Events controller for listening to real-time events. private let eventsController: EventsController - /// The worker used to fetch the remote data and communicate with servers. + /// The channel updater to reuse actions from channel controller which is safe to use without DB. private let updater: ChannelUpdater - /// Current user ID for convenience + /// The current user id. private var currentUserId: UserId? { client.currentUserId } - var _basePublishers: Any? - /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose - /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, - /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + /// An internal backing object for all publicly available Combine publishers. var basePublishers: BasePublishers { if let value = _basePublishers as? BasePublishers { return value @@ -128,6 +123,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E return _basePublishers as? BasePublishers ?? .init(controller: self) } + var _basePublishers: Any? + // MARK: - Initialization /// Creates a new `LivestreamChannelController` @@ -166,7 +163,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E // MARK: - Public Methods /// Synchronizes the controller with the backend data. - /// - Parameter completion: Called when the synchronization is finished + /// - Parameter completion: Called when the synchronization is finished. public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { // Populate the initial data with existing cache. if loadInitialMessagesFromCache, let cid = self.cid, let channel = dataStore.channel(cid: cid) { @@ -180,7 +177,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E ) } - /// Loads previous messages from backend. + /// Loads previous (older) messages from backend. /// - Parameters: /// - messageId: ID of the last fetched message. You will get messages `older` than the provided ID. /// - limit: Limit for page size. By default it is 25. @@ -195,7 +192,10 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E return } - let messageId = messageId ?? paginationStateHandler.state.oldestFetchedMessage?.id ?? lastLocalMessageId() + let messageId = messageId + ?? paginationStateHandler.state.oldestFetchedMessage?.id + ?? messages.last?.id + guard let messageId = messageId else { completion?(ClientError.ChannelEmptyMessages()) return @@ -228,7 +228,10 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E return } - let messageId = messageId ?? paginationStateHandler.state.newestFetchedMessage?.id ?? messages.first?.id + let messageId = messageId + ?? paginationStateHandler.state.newestFetchedMessage?.id + ?? messages.first?.id + guard let messageId = messageId else { completion?(ClientError.ChannelEmptyMessages()) return @@ -283,7 +286,10 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E updateChannelData(channelQuery: query, completion: completion) } - /// Creates a new message locally and schedules it for send. + /// Creates a new message and schedules it for send. + /// + /// This is the only method that still uses the DB to create data. + /// This is mostly to reuse the complex logic of the Message Sender. /// /// - Parameters: /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. @@ -300,7 +306,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// - location: The new location information of the message. /// - extraData: Additional extra data of the message object. /// - completion: Called when saving the message to the local DB finishes. - /// public func createNewMessage( messageId: MessageId? = nil, text: String, @@ -347,13 +352,19 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// - Parameters: /// - messageId: The message identifier to delete. /// - hard: A Boolean value to determine if the message will be delete permanently on the backend. By default it is `false`. - /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + /// - completion: Called when the network request is finished. + /// If request fails, the completion will be called with an error. public func deleteMessage( messageId: MessageId, hard: Bool = false, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .deleteMessage(messageId: messageId, hard: hard)) { [weak self] result in + apiClient.request( + endpoint: .deleteMessage( + messageId: messageId, + hard: hard + ) + ) { [weak self] result in self?.callback { completion?(result.error) } @@ -373,11 +384,15 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E completion: @escaping (Result<[ChatMessageReaction], Error>) -> Void ) { let pagination = Pagination(pageSize: limit, offset: offset) - apiClient.request(endpoint: .loadReactions(messageId: messageId, pagination: pagination)) { [weak self] result in + apiClient.request( + endpoint: .loadReactions(messageId: messageId, pagination: pagination) + ) { [weak self] result in self?.callback { switch result { case .success(let payload): - let reactions = payload.reactions.compactMap { $0.asModel(messageId: messageId) } + let reactions = payload.reactions.compactMap { + $0.asModel(messageId: messageId) + } completion(.success(reactions)) case .failure(let error): completion(.failure(error)) @@ -391,14 +406,22 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// - messageId: The message identifier to flag. /// - reason: The flag reason. /// - extraData: Additional data associated with the flag request. - /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + /// - completion: Called when the network request is finished. + /// If request fails, the completion will be called with an error. public func flag( messageId: MessageId, reason: String? = nil, extraData: [String: RawJSON]? = nil, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .flagMessage(true, with: messageId, reason: reason, extraData: extraData)) { [weak self] result in + apiClient.request( + endpoint: .flagMessage( + true, + with: messageId, + reason: reason, + extraData: extraData + ) + ) { [weak self] result in self?.callback { completion?(result.error) } @@ -408,19 +431,27 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Unflags a message. /// - Parameters: /// - messageId: The message identifier to unflag. - /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + /// - completion: Called when the network request is finished. + /// If request fails, the completion will be called with an error. public func unflag( messageId: MessageId, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .flagMessage(false, with: messageId, reason: nil, extraData: nil)) { [weak self] result in + apiClient.request( + endpoint: .flagMessage( + false, + with: messageId, + reason: nil, + extraData: nil + ) + ) { [weak self] result in self?.callback { completion?(result.error) } } } - /// Adds new reaction to the message this controller manages. + /// Adds a new reaction to a message. /// - Parameters: /// - type: The reaction type. /// - messageId: The message identifier to add the reaction to. @@ -429,7 +460,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// - skipPush: If set to `true`, skips sending push notification when reacting a message. /// - pushEmojiCode: The emoji code when receiving a reaction push notification. /// - extraData: The reaction extra data. - /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. + /// - completion: The completion. Will be called when the network request is finished. public func addReaction( _ type: MessageReactionType, to messageId: MessageId, @@ -630,11 +661,9 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E let newMessages = Array(newMessages.reversed()) switch pagination?.parameter { case .lessThan, .lessThanOrEqual: - // Loading older messages - append to end messages.append(contentsOf: newMessages) case .greaterThan, .greaterThanOrEqual: - // Loading newer messages - insert at beginning messages.insert(contentsOf: newMessages, at: 0) case .around, .none: @@ -642,10 +671,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } - private func lastLocalMessageId() -> MessageId? { - messages.last?.id - } - // MARK: - EventsControllerDelegate public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { @@ -658,6 +683,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } + // MARK: - Helpers + /// Helper method to execute the callbacks on the main thread. func callback(_ action: @escaping () -> Void) { DispatchQueue.main.async { @@ -665,8 +692,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } - // MARK: - Private Event Handling - private func handleChannelEvent(_ event: Event) { switch event { case let messageNewEvent as MessageNewEvent: From 298f0d3bf88ddf98414d8e18f90cf1f831b296ff Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 22:43:58 +0100 Subject: [PATCH 40/85] Cleanup model mapping --- Sources/StreamChat/Models/Channel.swift | 2 -- Sources/StreamChat/Models/ChatMessage.swift | 2 -- .../StreamChat/Models/MessageReaction.swift | 2 -- .../MessagePayload+asModel.swift | 24 ++++++++----------- .../Payload+asModel/UserPayload+asModel.swift | 4 ++-- Sources/StreamChat/Models/User.swift | 2 -- .../Events/MessageEvents.swift | 6 +---- .../StreamChat/Workers/ChannelUpdater.swift | 2 -- 8 files changed, 13 insertions(+), 31 deletions(-) diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index e10b1e4811..0e5cc07feb 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -654,5 +654,3 @@ public extension ChatChannel { ownCapabilities.contains(.shareLocation) } } - -// MARK: - Payload -> Model Mapping diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 35fbd738e0..7bb764a2c9 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -693,5 +693,3 @@ public struct MessageDeliveryStatus: RawRepresentable, Hashable { /// The message delivery state for message failed to be sent/edited/deleted. public static let failed = Self(rawValue: "failed") } - -// MARK: - Payload -> Model Mapping diff --git a/Sources/StreamChat/Models/MessageReaction.swift b/Sources/StreamChat/Models/MessageReaction.swift index 0c3caa4a90..fe81ae467b 100644 --- a/Sources/StreamChat/Models/MessageReaction.swift +++ b/Sources/StreamChat/Models/MessageReaction.swift @@ -28,5 +28,3 @@ public struct ChatMessageReaction: Hashable { /// Custom data public let extraData: [String: RawJSON] } - -// MARK: - Payload -> Model Mapping diff --git a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift index b39e19784d..7bba9ebd11 100644 --- a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift @@ -5,12 +5,12 @@ import Foundation extension MessagePayload { - /// Converts the MessagePayload to a ChatMessage model + /// Converts the MessagePayload to a ChatMessage model. /// - Parameters: - /// - cid: The channel ID the message belongs to - /// - currentUserId: The current user's ID for determining sent status - /// - channelReads: Channel reads for determining readBy status - /// - Returns: A ChatMessage instance + /// - cid: The channel ID the message belongs to. + /// - currentUserId: The current user's ID for determining sent status. + /// - channelReads: Channel reads for determining readBy status. + /// - Returns: A ChatMessage instance. func asModel( cid: ChannelId, currentUserId: UserId?, @@ -19,15 +19,13 @@ extension MessagePayload { let author = user.asModel() let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) let threadParticipants = threadParticipants.compactMap { $0.asModel() } - - // Map quoted message recursively + let quotedMessage = quotedMessage?.asModel( cid: cid, currentUserId: currentUserId, channelReads: channelReads ) - - // Map reactions + let latestReactions = Set(latestReactions.compactMap { $0.asModel(messageId: id) }) let currentUserReactions: Set @@ -36,8 +34,7 @@ extension MessagePayload { } else { currentUserReactions = Set(ownReactions.compactMap { $0.asModel(messageId: id) }) } - - // Map attachments + let attachments: [AnyChatMessageAttachment] = attachments .enumerated() .compactMap { offset, attachmentPayload in @@ -54,7 +51,6 @@ extension MessagePayload { ) } - // Calculate readBy from channel reads let createdAtInterval = createdAt.timeIntervalSince1970 let messageUserId = user.id let readBy = channelReads.filter { read in @@ -146,8 +142,8 @@ extension MessagePayload { } extension MessageReactionPayload { - /// Converts the MessageReactionPayload to a ChatMessageReaction model - /// - Returns: A ChatMessageReaction instance + /// Converts the MessageReactionPayload to a ChatMessageReaction model. + /// - Returns: A ChatMessageReaction instance. func asModel(messageId: MessageId) -> ChatMessageReaction { ChatMessageReaction( id: [user.id, messageId, type.rawValue].joined(separator: "/"), diff --git a/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift index a94381c51b..a7f1ea11a9 100644 --- a/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift @@ -5,8 +5,8 @@ import Foundation extension UserPayload { - /// Converts the UserPayload to a ChatUser model - /// - Returns: A ChatUser instance + /// Converts the UserPayload to a ChatUser model. + /// - Returns: A ChatUser instance. func asModel() -> ChatUser { ChatUser( id: id, diff --git a/Sources/StreamChat/Models/User.swift b/Sources/StreamChat/Models/User.swift index c96e7f12ad..f0dffe89ab 100644 --- a/Sources/StreamChat/Models/User.swift +++ b/Sources/StreamChat/Models/User.swift @@ -176,5 +176,3 @@ public extension UserRole { } } } - -// MARK: - Payload -> Model Mapping diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 4dec4f0e56..fc4c0280d9 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -201,9 +201,6 @@ public struct MessageReadEvent: ChannelSpecificEvent { /// The unread counts of the current user. public let unreadCount: UnreadCount? - - /// The last read message id. - public let lastReadMessageId: MessageId? } class MessageReadEventDTO: EventDTO { @@ -238,8 +235,7 @@ class MessageReadEventDTO: EventDTO { channel: channelDTO.asModel(), thread: threadDTO?.asModel(), createdAt: createdAt, - unreadCount: UnreadCount(currentUserDTO: currentUser), - lastReadMessageId: nil + unreadCount: UnreadCount(currentUserDTO: currentUser) ) } } diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 6943d1dbbd..46bcf4b1ae 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -1106,8 +1106,6 @@ extension ChannelUpdater { } } - // MARK: - - func loadMessages(with channelQuery: ChannelQuery, pagination: MessagesPagination) async throws -> [ChatMessage] { let payload = try await update(channelQuery: channelQuery.withPagination(pagination)) guard let cid = channelQuery.cid else { return [] } From 03dce1bd6eb651926fd2219b23e07ba416c0de59 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:04:50 +0100 Subject: [PATCH 41/85] Move livestream UI to the demo app --- .../Screens/DemoLivestreamChatChannelVC.swift | 162 +++++------------- .../DemoChatChannelListRouter.swift | 2 +- .../ChatMessageListVC+DiffKit.swift | 8 +- StreamChat.xcodeproj/project.pbxproj | 10 +- 4 files changed, 56 insertions(+), 126 deletions(-) rename Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift => DemoApp/Screens/DemoLivestreamChatChannelVC.swift (68%) diff --git a/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift similarity index 68% rename from Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift rename to DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 5dbb2ca14c..a977e5e568 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatLivestreamChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -3,15 +3,15 @@ // import StreamChat +import StreamChatUI import UIKit -open class ChatLivestreamChannelVC: _ViewController, +open class DemoLivestreamChatChannelVC: _ViewController, ThemeProvider, ChatMessageListVCDataSource, ChatMessageListVCDelegate, LivestreamChannelControllerDelegate, - EventsControllerDelegate, - AudioQueuePlayerDatasource + EventsControllerDelegate { /// Controller for observing data changes within the channel. open var channelController: LivestreamChannelController! @@ -49,22 +49,6 @@ open class ChatLivestreamChannelVC: _ViewController, .messageComposerVC .init() - /// The audioPlayer that will be used for the playback of VoiceRecordings - open private(set) lazy var audioPlayer: AudioPlaying = components - .audioPlayer - .init() - - /// The provider that will be asked to provide the next VoiceRecording to play automatically once the - /// currently playing one, finishes. - open private(set) lazy var audioQueuePlayerNextItemProvider: AudioQueuePlayerNextItemProvider = components - .audioQueuePlayerNextItemProvider - .init() - - /// The navigation header view. - open private(set) lazy var headerView: ChatChannelHeaderView = components - .channelHeaderView.init() - .withoutAutoresizingMaskConstraints - /// View for displaying the channel image in the navigation bar. open private(set) lazy var channelAvatarView = components .channelAvatarView.init() @@ -83,9 +67,9 @@ open class ChatLivestreamChannelVC: _ViewController, } /// A component responsible to handle when to load new or old messages. - private lazy var viewPaginationHandler: StatefulViewPaginationHandling = { - InvertedScrollViewPaginationHandler.make(scrollView: messageListVC.listView) - }() +// private lazy var viewPaginationHandler: StatefulViewPaginationHandling = { +// InvertedScrollViewPaginationHandler.make(scrollView: messageListVC.listView) +// }() override open func setUp() { super.setUp() @@ -105,33 +89,19 @@ open class ChatLivestreamChannelVC: _ViewController, self?.didFinishSynchronizing(with: error) } - if channelController.channelQuery.pagination?.parameter == nil { - // Load initial messages from cache if loading the first page - messages = Array(channelController.messages) - } - // Handle pagination - viewPaginationHandler.onNewTopPage = { [weak self] notifyElementsCount, completion in - notifyElementsCount(self?.channelController.messages.count ?? 0) - self?.loadPreviousMessages(completion: completion) - } - viewPaginationHandler.onNewBottomPage = { [weak self] notifyElementsCount, completion in - notifyElementsCount(self?.channelController.messages.count ?? 0) - self?.loadNextMessages(completion: completion) - } - - messageListVC.audioPlayer = audioPlayer - messageComposerVC.audioPlayer = audioPlayer - - if let queueAudioPlayer = audioPlayer as? StreamAudioQueuePlayer { - queueAudioPlayer.dataSource = self - } +// viewPaginationHandler.onNewTopPage = { [weak self] notifyElementsCount, completion in +// notifyElementsCount(self?.channelController.messages.count ?? 0) +// self?.loadPreviousMessages(completion: completion) +// } +// viewPaginationHandler.onNewBottomPage = { [weak self] notifyElementsCount, completion in +// notifyElementsCount(self?.channelController.messages.count ?? 0) +// self?.loadNextMessages(completion: completion) +// } messageListVC.swipeToReplyGestureHandler.onReply = { [weak self] message in self?.messageComposerVC.content.quoteMessage(message) } - - updateScrollToBottomButtonCount() } private func setChannelControllerToComposerIfNeeded(cid: ChannelId?) { @@ -145,28 +115,29 @@ open class ChatLivestreamChannelVC: _ViewController, view.backgroundColor = appearance.colorPalette.background addChildViewController(messageListVC, targetView: view) - messageListVC.view.pin(anchors: [.top, .leading, .trailing], to: view.safeAreaLayoutGuide) + NSLayoutConstraint.activate([ + messageListVC.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + messageListVC.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + messageListVC.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) + ]) addChildViewController(messageComposerVC, targetView: view) - messageComposerVC.view.pin(anchors: [.leading, .trailing], to: view) - messageComposerVC.view.topAnchor.pin(equalTo: messageListVC.view.bottomAnchor).isActive = true - messageComposerBottomConstraint = messageComposerVC.view.bottomAnchor.pin(equalTo: view.bottomAnchor) + NSLayoutConstraint.activate([ + messageComposerVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + messageComposerVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + messageComposerVC.view.topAnchor.constraint(equalTo: messageListVC.view.bottomAnchor) + ]) + messageComposerBottomConstraint = messageComposerVC.view.bottomAnchor + .constraint(equalTo: view.bottomAnchor) messageComposerBottomConstraint?.isActive = true NSLayoutConstraint.activate([ - channelAvatarView.widthAnchor.pin(equalToConstant: channelAvatarSize.width), - channelAvatarView.heightAnchor.pin(equalToConstant: channelAvatarSize.height) + channelAvatarView.widthAnchor.constraint(equalToConstant: channelAvatarSize.width), + channelAvatarView.heightAnchor.constraint(equalToConstant: channelAvatarSize.height) ]) navigationItem.rightBarButtonItem = UIBarButtonItem(customView: channelAvatarView) channelAvatarView.content = (channelController.channel, client.currentUserId) - - if let cid = channelController.cid { - headerView.channelController = client.channelController(for: cid) - } - - navigationItem.titleView = headerView - navigationItem.largeTitleDisplayMode = .never } override open func viewDidAppear(_ animated: Bool) { @@ -275,7 +246,7 @@ open class ChatLivestreamChannelVC: _ViewController, } open func chatMessageListVC(_ vc: ChatMessageListVC, messageAt indexPath: IndexPath) -> ChatMessage? { - messages[safe: indexPath.item] + messages[indexPath.item] } open func chatMessageListVC( @@ -324,18 +295,11 @@ open class ChatLivestreamChannelVC: _ViewController, ) { switch actionItem { case is EditActionItem: - dismiss(animated: true) { [weak self] in - self?.messageComposerVC.content.editMessage(message) - self?.messageComposerVC.composerView.inputMessageView.textView.becomeFirstResponder() - } + dismiss(animated: true) case is InlineReplyActionItem: - dismiss(animated: true) { [weak self] in - self?.messageComposerVC.content.quoteMessage(message) - } + dismiss(animated: true) case is ThreadReplyActionItem: - dismiss(animated: true) { [weak self] in - self?.messageListVC.showThread(messageId: message.id) - } + dismiss(animated: true) case is MarkUnreadActionItem: dismiss(animated: true) default: @@ -363,20 +327,7 @@ open class ChatLivestreamChannelVC: _ViewController, headerViewForMessage message: ChatMessage, at indexPath: IndexPath ) -> ChatMessageDecorationView? { - let shouldShowDate = vc.shouldShowDateSeparator(forMessage: message, at: indexPath) - guard shouldShowDate, let channel = channelController.channel else { - return nil - } - - let header = components.messageHeaderDecorationView.init() - header.content = ChatChannelMessageHeaderDecoratorViewContent( - message: message, - channel: channel, - dateFormatter: vc.dateSeparatorFormatter, - shouldShowDate: shouldShowDate, - shouldShowUnreadMessages: false - ) - return header + nil } open func chatMessageListVC( @@ -391,13 +342,10 @@ open class ChatLivestreamChannelVC: _ViewController, public func livestreamChannelController( _ controller: LivestreamChannelController, - didUpdateChannel change: EntityChange + didUpdateChannel channel: ChatChannel ) { - if headerView.channelController == nil, let cid = channelController.cid { - headerView.channelController = client.channelController(for: cid) - } - channelAvatarView.content = (channelController.channel, client.currentUserId) + navigationItem.title = channel.name } public func livestreamChannelController( @@ -418,7 +366,6 @@ open class ChatLivestreamChannelVC: _ViewController, } messageListVC.updateMessages(with: changes) - viewPaginationHandler.updateElementsCount(with: channelController.messages.count) } // MARK: - EventsControllerDelegate @@ -430,36 +377,21 @@ open class ChatLivestreamChannelVC: _ViewController, channelController.loadFirstPage() } } - - if let draftUpdatedEvent = event as? DraftUpdatedEvent, - let draft = channelController.channel?.draftMessage, - draftUpdatedEvent.cid == channelController.cid, draftUpdatedEvent.draftMessage.threadId == nil { - messageComposerVC.content.draftMessage(draft) - } - - if let draftDeletedEvent = event as? DraftDeletedEvent, - draftDeletedEvent.cid == channelController.cid, draftDeletedEvent.threadId == nil { - messageComposerVC.content.clear() - } } +} - // MARK: - AudioQueuePlayerDatasource - - open func audioQueuePlayerNextAssetURL( - _ audioPlayer: AudioPlaying, - currentAssetURL: URL? - ) -> URL? { - audioQueuePlayerNextItemProvider.findNextItem( - in: messages, - currentVoiceRecordingURL: currentAssetURL, - lookUpScope: .subsequentMessagesFromUser - ) +private extension UIView { + var withoutAutoresizingMaskConstraints: Self { + translatesAutoresizingMaskIntoConstraints = false + return self } +} - // MARK: - Helpers - - private func updateScrollToBottomButtonCount(channel: ChatChannel? = nil) { - let channelUnreadCount = (channel ?? channelController.channel)?.unreadCount ?? .noUnread - messageListVC.scrollToBottomButton.content = channelUnreadCount +private extension UIViewController { + func addChildViewController(_ child: UIViewController, targetView superview: UIView) { + addChild(child) + child.view.translatesAutoresizingMaskIntoConstraints = false + superview.addSubview(child.view) + child.didMove(toParent: self) } } diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 6d75b0ea0d..362a36ffa9 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -119,7 +119,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { .init(title: "Show as Livestream Controller", handler: { [unowned self] _ in let client = self.rootViewController.controller.client let livestreamController = client.livestreamChannelController(for: .init(cid: cid)) - let vc = ChatLivestreamChannelVC() + let vc = DemoLivestreamChatChannelVC() vc.channelController = livestreamController vc.hidesBottomBarWhenPushed = true self.rootViewController.navigationController?.pushViewController(vc, animated: true) diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift index 4b81362d58..65ee6ab34e 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift @@ -5,20 +5,20 @@ import Foundation import StreamChat -extension ChatMessageListVC { +public extension ChatMessageListVC { /// Set the previous message snapshot before the data controller reports new messages. - internal func setPreviousMessagesSnapshot(_ messages: [ChatMessage]) { + func setPreviousMessagesSnapshot(_ messages: [ChatMessage]) { listView.previousMessagesSnapshot = messages } /// Set the new message snapshot reported by the data controller. - internal func setNewMessagesSnapshot(_ messages: LazyCachedMapCollection) { + func setNewMessagesSnapshot(_ messages: LazyCachedMapCollection) { listView.currentMessagesFromDataSource = messages listView.newMessagesSnapshot = messages } /// Set the new message snapshot reported by the data controller as an Array. - internal func setNewMessagesSnapshotArray(_ messages: [ChatMessage]) { + func setNewMessagesSnapshotArray(_ messages: [ChatMessage]) { listView.currentMessagesFromDataSourceArray = messages listView.newMessagesSnapshotArray = messages } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index be68166d37..39b9347526 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1422,13 +1422,12 @@ AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; - AD1B9F462E33E78E0091A37A /* ChatLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */; }; - AD1B9F472E33E7950091A37A /* ChatLivestreamChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */; }; AD1BA40B2E3A2D180092D602 /* ManualEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */; }; AD1BA40C2E3A2D180092D602 /* ManualEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; + AD26CB772E3ACAB9002FC1A7 /* DemoLivestreamChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */; }; AD29395D2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD29395C2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift */; }; AD29395E2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD29395C2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift */; }; AD2C94DC29CB8CC40096DCA1 /* PartiallyFailingChannelListPayload.json in Sources */ = {isa = PBXBuildFile; fileRef = AD2C94DB29CB8CC40096DCA1 /* PartiallyFailingChannelListPayload.json */; }; @@ -4285,11 +4284,11 @@ AD17E1202E009853001AF308 /* SharedLocationPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationPayload.swift; sourceTree = ""; }; AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController.swift; sourceTree = ""; }; - AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLivestreamChannelVC.swift; sourceTree = ""; }; AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; + AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatChannelVC.swift; sourceTree = ""; }; AD29395C2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyGestureHandler.swift; sourceTree = ""; }; AD2C94DB29CB8CC40096DCA1 /* PartiallyFailingChannelListPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = PartiallyFailingChannelListPayload.json; sourceTree = ""; }; AD2C94DE29CB93C40096DCA1 /* FailingChannelListPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FailingChannelListPayload.json; sourceTree = ""; }; @@ -6919,6 +6918,7 @@ A3227ECA284A607D00EBE6CC /* Screens */ = { isa = PBXGroup; children = ( + AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */, ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */, AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */, ADD328592C04DD8300BAD0E9 /* DemoAppTabBarController.swift */, @@ -9209,7 +9209,6 @@ ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */, AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */, 64B059E12670EFFE0024CE90 /* ChatChannelVC+SwiftUI.swift */, - AD1B9F442E33E5E10091A37A /* ChatLivestreamChannelVC.swift */, ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */, ); path = ChatChannel; @@ -10805,7 +10804,6 @@ AD9632DC2C09E0350073B814 /* ChatThreadListRouter.swift in Sources */, AD4118842D5E1368000EF88E /* UILabel+highlightText.swift in Sources */, 40FA4DE52A12A45400DA21D2 /* VoiceRecordingAttachmentComposerPreview.swift in Sources */, - AD1B9F462E33E78E0091A37A /* ChatLivestreamChannelVC.swift in Sources */, ADC4AAB02788C8850004BB35 /* Appearance+Formatters.swift in Sources */, AD6F531927175FDB00D428B4 /* ChatMessageGiphyView+GiphyBadge.swift in Sources */, ACA3C98726CA23F300EB8B07 /* DateUtils.swift in Sources */, @@ -11272,6 +11270,7 @@ ADA2D64A2C46B66E001D2B44 /* DemoChatChannelListErrorView.swift in Sources */, A3227E5B284A489000EBE6CC /* UIViewController+Alert.swift in Sources */, A3227E6D284A4B6A00EBE6CC /* UserCredentialsCell.swift in Sources */, + AD26CB772E3ACAB9002FC1A7 /* DemoLivestreamChatChannelVC.swift in Sources */, 84A33ABA28F86B8500CEC8FD /* StreamChatWrapper+DemoApp.swift in Sources */, AD053BA92B336331003612B6 /* LocationDetailViewController.swift in Sources */, ADFCA5B72D1232B3000F515F /* LocationProvider.swift in Sources */, @@ -13207,7 +13206,6 @@ AD9610702C2DD874004F543C /* BannerView.swift in Sources */, C121EC132746A1EC00023E4C /* ImageCache.swift in Sources */, C121EC142746A1EC00023E4C /* ImageTask.swift in Sources */, - AD1B9F472E33E7950091A37A /* ChatLivestreamChannelVC.swift in Sources */, C121EC152746A1EC00023E4C /* ImagePipeline.swift in Sources */, C121EC162746A1EC00023E4C /* ImageProcessing.swift in Sources */, AD7EFDB82C78DC6700625FC5 /* PollCommentListSectionFooterView.swift in Sources */, From 60b21c5305450a63a27d03ad13fb66677d23cd97 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:25:06 +0100 Subject: [PATCH 42/85] Quick fix typo --- Sources/StreamChat/Workers/EventNotificationCenter.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 25f1a3754a..49673c377d 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -70,8 +70,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { middlewareEvents.append(event) return } - if let cid = eventDTO.payload.cid, - let manualEvent = self.manualEventHandler.handle(event) { + if let manualEvent = self.manualEventHandler.handle(event) { manualHandlingEvents.append(manualEvent) } else { middlewareEvents.append(event) From 4c8973ab2b73e06a0890200bf22e4b0f2c308e73 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:25:46 +0100 Subject: [PATCH 43/85] Handle pagination with willDisplayAt --- .../Screens/DemoLivestreamChatChannelVC.swift | 71 +++++-------------- 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index a977e5e568..5a56a9440c 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -66,11 +66,6 @@ open class DemoLivestreamChatChannelVC: _ViewController, isLastMessageFullyVisible } - /// A component responsible to handle when to load new or old messages. -// private lazy var viewPaginationHandler: StatefulViewPaginationHandling = { -// InvertedScrollViewPaginationHandler.make(scrollView: messageListVC.listView) -// }() - override open func setUp() { super.setUp() @@ -89,16 +84,6 @@ open class DemoLivestreamChatChannelVC: _ViewController, self?.didFinishSynchronizing(with: error) } - // Handle pagination -// viewPaginationHandler.onNewTopPage = { [weak self] notifyElementsCount, completion in -// notifyElementsCount(self?.channelController.messages.count ?? 0) -// self?.loadPreviousMessages(completion: completion) -// } -// viewPaginationHandler.onNewBottomPage = { [weak self] notifyElementsCount, completion in -// notifyElementsCount(self?.channelController.messages.count ?? 0) -// self?.loadNextMessages(completion: completion) -// } - messageListVC.swipeToReplyGestureHandler.onReply = { [weak self] message in self?.messageComposerVC.content.quoteMessage(message) } @@ -196,35 +181,6 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageListVC.jumpToMessage(id: id, animated: animated) } - // MARK: - Loading previous and next messages state handling. - - /// Called when the channel will load previous (older) messages. - open func loadPreviousMessages(completion: @escaping (Error?) -> Void) { - channelController.loadPreviousMessages { [weak self] error in - completion(error) - self?.didFinishLoadingPreviousMessages(with: error) - } - } - - /// Called when the channel finished requesting previous (older) messages. - /// Can be used to handle state changes or UI updates. - open func didFinishLoadingPreviousMessages(with error: Error?) { - // no-op, override to handle the completion of loading previous messages. - } - - /// Called when the channel will load next (newer) messages. - open func loadNextMessages(completion: @escaping (Error?) -> Void) { - channelController.loadNextMessages { [weak self] error in - completion(error) - self?.didFinishLoadingNextMessages(with: error) - } - } - - /// Called when the channel finished requesting next (newer) messages. - open func didFinishLoadingNextMessages(with error: Error?) { - // no-op, override to handle the completion of loading next messages. - } - // MARK: - ChatMessageListVCDataSource public var messages: [ChatMessage] = [] @@ -283,10 +239,28 @@ open class DemoLivestreamChatChannelVC: _ViewController, open func chatMessageListVC( _ vc: ChatMessageListVC, - willDisplayMessageAt indexPath: IndexPath + scrollViewDidScroll scrollView: UIScrollView ) { // no-op } + + open func chatMessageListVC( + _ vc: ChatMessageListVC, + willDisplayMessageAt indexPath: IndexPath + ) { + let messageCount = messages.count + guard messageCount > 0 else { return } + + // Load newer messages when displaying messages near index 0 + if indexPath.item < 10 && !isFirstPageLoaded { + channelController.loadNextMessages() + } + + // Load older messages when displaying messages near the end of the array + if indexPath.item >= messageCount - 10 && !isLastPageLoaded { + channelController.loadPreviousMessages() + } + } open func chatMessageListVC( _ vc: ChatMessageListVC, @@ -307,13 +281,6 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - open func chatMessageListVC( - _ vc: ChatMessageListVC, - scrollViewDidScroll scrollView: UIScrollView - ) { - // no-op - } - open func chatMessageListVC( _ vc: ChatMessageListVC, didTapOnMessageListView messageListView: ChatMessageListView, From d76dcad7d38401f98466ca1f78f2d201380da29f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:41:00 +0100 Subject: [PATCH 44/85] Log a warning instead of a error when a feature is disabled --- .../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 9176858319..94f25e5ed9 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -2003,7 +2003,7 @@ private extension ChatChannelController { /// ie. VCs should use the `are{FEATURE_NAME}Enabled` props (ie. `areReadEventsEnabled`) before using any feature private func channelFeatureDisabled(feature: String, completion: ((Error?) -> Void)?) { let error = ClientError.ChannelFeatureDisabled("Channel feature: \(feature) is disabled for this channel.") - log.error(error.localizedDescription) + log.warning(error.localizedDescription) callback { completion?(error) } From 7b3831cf494b2486648359e60a9d2bd390cc56c6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:51:47 +0100 Subject: [PATCH 45/85] Do not use channel controller in the composer --- .../Screens/DemoLivestreamChatChannelVC.swift | 95 +++++++++++++------ .../DemoChatChannelListRouter.swift | 2 +- 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 5a56a9440c..35881d0a7f 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -14,11 +14,11 @@ open class DemoLivestreamChatChannelVC: _ViewController, EventsControllerDelegate { /// Controller for observing data changes within the channel. - open var channelController: LivestreamChannelController! + open var livestreamChannelController: LivestreamChannelController! /// User search controller for suggestion users when typing in the composer. open lazy var userSuggestionSearchController: ChatUserSearchController = - channelController.client.userSearchController() + livestreamChannelController.client.userSearchController() /// A controller for observing web socket events. public lazy var eventsController: EventsController = client.eventsController() @@ -29,7 +29,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, } public var client: ChatClient { - channelController.client + livestreamChannelController.client } /// Component responsible for setting the correct offset when keyboard frame is changed. @@ -45,9 +45,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, .init() /// Controller that handles the composer view - open private(set) lazy var messageComposerVC = components - .messageComposerVC - .init() + private(set) lazy var messageComposerVC = DemoLivestreamComposerVC() /// View for displaying the channel image in the navigation bar. open private(set) lazy var channelAvatarView = components @@ -77,10 +75,10 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageComposerVC.userSearchController = userSuggestionSearchController - setChannelControllerToComposerIfNeeded(cid: channelController.cid) + setChannelControllerToComposerIfNeeded() - channelController.delegate = self - channelController.synchronize { [weak self] error in + livestreamChannelController.delegate = self + livestreamChannelController.synchronize { [weak self] error in self?.didFinishSynchronizing(with: error) } @@ -89,9 +87,9 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - private func setChannelControllerToComposerIfNeeded(cid: ChannelId?) { - guard messageComposerVC.channelController == nil, let cid = cid else { return } - messageComposerVC.channelController = client.channelController(for: cid) + private func setChannelControllerToComposerIfNeeded() { + messageComposerVC.channelController = nil + messageComposerVC.livestreamChannelController = livestreamChannelController } override open func setUpLayout() { @@ -122,7 +120,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, ]) navigationItem.rightBarButtonItem = UIBarButtonItem(customView: channelAvatarView) - channelAvatarView.content = (channelController.channel, client.currentUserId) + channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) } override open func viewDidAppear(_ animated: Bool) { @@ -134,7 +132,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if let draftMessage = channelController.channel?.draftMessage { + if let draftMessage = livestreamChannelController.channel?.draftMessage { messageComposerVC.content.draftMessage(draftMessage) } } @@ -154,7 +152,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, log.error("Error when synchronizing ChannelController: \(error)") } - setChannelControllerToComposerIfNeeded(cid: channelController.cid) + setChannelControllerToComposerIfNeeded() messageComposerVC.updateContent() } @@ -186,15 +184,15 @@ open class DemoLivestreamChatChannelVC: _ViewController, public var messages: [ChatMessage] = [] public var isFirstPageLoaded: Bool { - channelController.hasLoadedAllNextMessages + livestreamChannelController.hasLoadedAllNextMessages } public var isLastPageLoaded: Bool { - channelController.hasLoadedAllPreviousMessages + livestreamChannelController.hasLoadedAllPreviousMessages } open func channel(for vc: ChatMessageListVC) -> ChatChannel? { - channelController.channel + livestreamChannelController.channel } open func numberOfMessages(in vc: ChatMessageListVC) -> Int { @@ -209,7 +207,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, _ vc: ChatMessageListVC, messageLayoutOptionsAt indexPath: IndexPath ) -> ChatMessageLayoutOptions { - guard let channel = channelController.channel else { return [] } + guard let channel = livestreamChannelController.channel else { return [] } return components.messageLayoutOptionsResolver.optionsForMessage( at: indexPath, @@ -224,7 +222,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, shouldLoadPageAroundMessageId messageId: MessageId, _ completion: @escaping ((Error?) -> Void) ) { - channelController.loadPageAroundMessageId(messageId) { error in + livestreamChannelController.loadPageAroundMessageId(messageId) { error in completion(error) } } @@ -232,7 +230,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, open func chatMessageListVCShouldLoadFirstPage( _ vc: ChatMessageListVC ) { - channelController.loadFirstPage() + livestreamChannelController.loadFirstPage() } // MARK: - ChatMessageListVCDelegate @@ -253,12 +251,12 @@ open class DemoLivestreamChatChannelVC: _ViewController, // Load newer messages when displaying messages near index 0 if indexPath.item < 10 && !isFirstPageLoaded { - channelController.loadNextMessages() + livestreamChannelController.loadNextMessages() } // Load older messages when displaying messages near the end of the array if indexPath.item >= messageCount - 10 && !isLastPageLoaded { - channelController.loadPreviousMessages() + livestreamChannelController.loadPreviousMessages() } } @@ -311,7 +309,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, _ controller: LivestreamChannelController, didUpdateChannel channel: ChatChannel ) { - channelAvatarView.content = (channelController.channel, client.currentUserId) + channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) navigationItem.title = channel.name } @@ -320,9 +318,9 @@ open class DemoLivestreamChatChannelVC: _ViewController, didUpdateMessages messages: [ChatMessage] ) { messageListVC.setPreviousMessagesSnapshot(self.messages) - messageListVC.setNewMessagesSnapshotArray(channelController.messages) + messageListVC.setNewMessagesSnapshotArray(livestreamChannelController.messages) - let diff = channelController.messages.difference(from: self.messages) + let diff = livestreamChannelController.messages.difference(from: self.messages) let changes = diff.map { change in switch change { case let .insert(offset, element, _): @@ -341,12 +339,55 @@ open class DemoLivestreamChatChannelVC: _ViewController, if let newMessagePendingEvent = event as? NewMessagePendingEvent { let newMessage = newMessagePendingEvent.message if !isFirstPageLoaded && newMessage.isSentByCurrentUser && !newMessage.isPartOfThread { - channelController.loadFirstPage() + livestreamChannelController.loadFirstPage() } } } } +/// A custom composer view controller for livestream channels that uses LivestreamChannelController +/// and disables voice recording functionality. +class DemoLivestreamComposerVC: ComposerVC { + /// Reference to the livestream channel controller + var livestreamChannelController: LivestreamChannelController? + + /// Override message creation to use livestream controller + override func createNewMessage(text: String) { + guard let livestreamController = livestreamChannelController else { + // Fallback to the regular implementation if livestream controller is not available + super.createNewMessage(text: text) + return + } + + if let threadParentMessageId = content.threadMessage?.id { + // For thread replies, we still need to use the regular channel controller + // since LivestreamChannelController doesn't support thread operations + super.createNewMessage(text: text) + return + } + + livestreamController.createNewMessage( + text: text, + pinning: nil, + attachments: content.attachments, + mentionedUserIds: content.mentionedUsers.map(\.id), + quotedMessageId: content.quotingMessage?.id, + skipEnrichUrl: content.skipEnrichUrl, + extraData: content.extraData + ) + } + + /// Override to hide the record button for livestream + override func updateRecordButtonVisibility() { + composerView.recordButton.isHidden = true + } + + /// Override to ensure voice recording is disabled + override func setupVoiceRecordingView() { + // Do not set up voice recording for livestream + } +} + private extension UIView { var withoutAutoresizingMaskConstraints: Self { translatesAutoresizingMaskIntoConstraints = false diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 362a36ffa9..5f24dd9435 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -120,7 +120,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { let client = self.rootViewController.controller.client let livestreamController = client.livestreamChannelController(for: .init(cid: cid)) let vc = DemoLivestreamChatChannelVC() - vc.channelController = livestreamController + vc.livestreamChannelController = livestreamController vc.hidesBottomBarWhenPushed = true self.rootViewController.navigationController?.pushViewController(vc, animated: true) }), From 396985b06373b83c9eaccca3fd4416901e8c36a9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:57:59 +0100 Subject: [PATCH 46/85] Fix message actions on livestream demo UI --- DemoApp/Screens/DemoLivestreamChatChannelVC.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 35881d0a7f..3a554a61d6 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -267,11 +267,18 @@ open class DemoLivestreamChatChannelVC: _ViewController, ) { switch actionItem { case is EditActionItem: - dismiss(animated: true) + dismiss(animated: true) { [weak self] in + self?.messageComposerVC.content.editMessage(message) + self?.messageComposerVC.composerView.inputMessageView.textView.becomeFirstResponder() + } case is InlineReplyActionItem: - dismiss(animated: true) + dismiss(animated: true) { [weak self] in + self?.messageComposerVC.content.quoteMessage(message) + } case is ThreadReplyActionItem: - dismiss(animated: true) + dismiss(animated: true) { [weak self] in + self?.messageListVC.showThread(messageId: message.id) + } case is MarkUnreadActionItem: dismiss(animated: true) default: From bd2d32149446950aaa57c195d6dd33a04ad16935 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 30 Jul 2025 23:59:26 +0100 Subject: [PATCH 47/85] Fix minor warning --- DemoApp/Screens/DemoLivestreamChatChannelVC.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 3a554a61d6..4ec4828dd0 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -366,7 +366,7 @@ class DemoLivestreamComposerVC: ComposerVC { return } - if let threadParentMessageId = content.threadMessage?.id { + if content.threadMessage?.id != nil { // For thread replies, we still need to use the regular channel controller // since LivestreamChannelController doesn't support thread operations super.createNewMessage(text: text) From fb04092ffa693e24cb83db6909393ea0f9955c10 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 31 Jul 2025 15:43:50 +0100 Subject: [PATCH 48/85] Add support for setting max number of messages in livestream controller --- .../Screens/DemoLivestreamChatChannelVC.swift | 85 ++++++++++- .../LivestreamChannelController.swift | 138 +++++++++++++++--- 2 files changed, 195 insertions(+), 28 deletions(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 4ec4828dd0..6a824fcf09 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -64,6 +64,35 @@ open class DemoLivestreamChatChannelVC: _ViewController, isLastMessageFullyVisible } + /// Banner view to show when chat is paused due to scrolling + private lazy var pauseBannerView: UIView = { + let banner = UIView() + banner.backgroundColor = appearance.colorPalette.background2 + banner.layer.cornerRadius = 12 + banner.layer.shadowColor = UIColor.black.cgColor + banner.layer.shadowOffset = CGSize(width: 0, height: 2) + banner.layer.shadowOpacity = 0.1 + banner.layer.shadowRadius = 4 + banner.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = "Chat paused due to scroll" + label.font = appearance.fonts.footnote + label.textColor = appearance.colorPalette.text + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + + banner.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -16), + label.topAnchor.constraint(equalTo: banner.topAnchor, constant: 8), + label.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -8) + ]) + + return banner + }() + override open func setUp() { super.setUp() @@ -85,6 +114,12 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageListVC.swipeToReplyGestureHandler.onReply = { [weak self] message in self?.messageComposerVC.content.quoteMessage(message) } + + // Initialize messages from controller + messages = livestreamChannelController.messages + + // Initialize pause banner state + pauseBannerView.alpha = 0.0 } private func setChannelControllerToComposerIfNeeded() { @@ -121,6 +156,22 @@ open class DemoLivestreamChatChannelVC: _ViewController, navigationItem.rightBarButtonItem = UIBarButtonItem(customView: channelAvatarView) channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) + + // Add pause banner + view.addSubview(pauseBannerView) + NSLayoutConstraint.activate([ + pauseBannerView.widthAnchor.constraint(equalToConstant: 200), + pauseBannerView.centerXAnchor.constraint( + equalTo: view.centerXAnchor + ), + pauseBannerView.bottomAnchor.constraint( + equalTo: messageComposerVC.view.topAnchor, + constant: -16 + ) + ]) + + // Initially hide the banner + pauseBannerView.isHidden = true } override open func viewDidAppear(_ animated: Bool) { @@ -239,23 +290,28 @@ open class DemoLivestreamChatChannelVC: _ViewController, _ vc: ChatMessageListVC, scrollViewDidScroll scrollView: UIScrollView ) { - // no-op + if isLastMessageFullyVisible && livestreamChannelController.isPaused { + livestreamChannelController.resume() + } } - + open func chatMessageListVC( _ vc: ChatMessageListVC, willDisplayMessageAt indexPath: IndexPath ) { let messageCount = messages.count guard messageCount > 0 else { return } - + // Load newer messages when displaying messages near index 0 if indexPath.item < 10 && !isFirstPageLoaded { livestreamChannelController.loadNextMessages() } - + // Load older messages when displaying messages near the end of the array - if indexPath.item >= messageCount - 10 && !isLastPageLoaded { + if indexPath.item >= messageCount - 10 { + if messageListVC.listView.isDragging && !messageListVC.listView.isLastCellFullyVisible { + livestreamChannelController.pause() + } livestreamChannelController.loadPreviousMessages() } } @@ -340,6 +396,13 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageListVC.updateMessages(with: changes) } + public func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) { + showPauseBanner(isPaused) + } + // MARK: - EventsControllerDelegate open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { @@ -350,6 +413,14 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } } + + /// Shows or hides the pause banner with animation + private func showPauseBanner(_ show: Bool) { + UIView.animate(withDuration: 0.3, animations: { + self.pauseBannerView.isHidden = !show + self.pauseBannerView.alpha = show ? 1.0 : 0.0 + }) + } } /// A custom composer view controller for livestream channels that uses LivestreamChannelController @@ -365,14 +436,14 @@ class DemoLivestreamComposerVC: ComposerVC { super.createNewMessage(text: text) return } - + if content.threadMessage?.id != nil { // For thread replies, we still need to use the regular channel controller // since LivestreamChannelController doesn't support thread operations super.createNewMessage(text: text) return } - + livestreamController.createNewMessage( text: text, pinning: nil, diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 59b467a243..7343913562 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -53,6 +53,18 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } + /// A Boolean value that indicates whether message processing is paused. + /// + /// When paused, new messages from other users will not be added to the messages array. + /// This is useful when loading previous messages to prevent the array from being modified. + public private(set) var isPaused: Bool = false { + didSet { + delegateCallback { + $0.livestreamChannelController(self, didChangePauseState: self.isPaused) + } + } + } + /// A Boolean value that returns whether the oldest messages have all been loaded or not. public var hasLoadedAllPreviousMessages: Bool { paginationStateHandler.state.hasLoadedAllPreviousMessages @@ -88,6 +100,11 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Only the initial page will be loaded from cache, to avoid an initial blank screen. public var loadInitialMessagesFromCache: Bool = true + /// Configuration for message limiting behaviour. + /// + /// Set to `nil` to disable message limiting. Default uses 200 max messages with 50 discard amount. + public var maxMessageLimitOptions: MaxMessageLimitOptions? = .default + /// Set the delegate to observe the changes in the system. public var delegate: LivestreamChannelControllerDelegate? { get { multicastDelegate.mainDelegate } @@ -280,9 +297,6 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E parameter: nil ) - // Clear current messages when loading first page - messages = [] - updateChannelData(channelQuery: query, completion: completion) } @@ -533,10 +547,12 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E messageId: MessageId, completion: ((Error?) -> Void)? = nil ) { - apiClient.request(endpoint: .pinMessage( - messageId: messageId, - request: .init(set: .init(pinned: false)) - )) { [weak self] result in + apiClient.request( + endpoint: .pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: false)) + ) + ) { [weak self] result in self?.callback { completion?(result.error) } @@ -603,6 +619,37 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } + /// Pauses the collecting of new messages. + /// + /// When paused, new messages from other users will not be added to the messages array. + /// This is useful for the loading of previous message to not conflict with the max limit of the messages array. + public func pause() { + guard !isPaused else { return } + isPaused = true + } + + /// Resumes the collecting of new messages. + /// + /// This will load the first page, reseting the current messages and returning to the latest messages. + /// After resuming, new messages will be added to the messages array again. + public func resume() { + guard isPaused else { return } + isPaused = false + loadFirstPage() + } + + // MARK: - EventsControllerDelegate + + public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { + return + } + + callback { [weak self] in + self?.handleChannelEvent(event) + } + } + // MARK: - Private Methods private func updateChannelData( @@ -671,20 +718,16 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } - // MARK: - EventsControllerDelegate - - public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { - guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { + private func applyMessageLimit() { + guard let options = maxMessageLimitOptions, + messages.count > options.maxLimit else { return } - callback { [weak self] in - self?.handleChannelEvent(event) - } + let newCount = options.maxLimit - options.discardAmount + messages = Array(messages.prefix(newCount)) } - // MARK: - Helpers - /// Helper method to execute the callbacks on the main thread. func callback(_ action: @escaping () -> Void) { DispatchQueue.main.async { @@ -743,6 +786,11 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E return } + // If paused and the message is not from the current user, skip processing + if isPaused && message.author.id != currentUserId { + return + } + // If we don't have the first page loaded, do not insert new messages // they will be inserted once we load the first page again. if !hasLoadedAllNextMessages { @@ -751,6 +799,17 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E currentMessages.insert(message, at: 0) messages = currentMessages + + // Apply message limit only when not paused + if !isPaused { + applyMessageLimit() + } + + // If paused and the message is from the current user, load the first page + // to go back to the latest messages + if isPaused && message.author.id == currentUserId { + loadFirstPage() + } } private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { @@ -860,23 +919,32 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Delegate protocol for `LivestreamChannelController` public protocol LivestreamChannelControllerDelegate: AnyObject { - /// Called when the channel data is updated + /// Called when the channel data is updated. /// - Parameters: - /// - controller: The controller that updated + /// - controller: The controller that updated. /// - channel: The updated channel the controller manages. func livestreamChannelController( _ controller: LivestreamChannelController, didUpdateChannel channel: ChatChannel ) - /// Called when the messages are updated + /// Called when the messages are updated. /// - Parameters: - /// - controller: The controller that updated - /// - messages: The current messages array + /// - controller: The controller that updated. + /// - messages: The current messages array. func livestreamChannelController( _ controller: LivestreamChannelController, didUpdateMessages messages: [ChatMessage] ) + + /// Called when the pause state changes. + /// - Parameters: + /// - controller: The controller that updated. + /// - isPaused: The new pause state. + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) } // MARK: - Default Implementations @@ -891,4 +959,32 @@ public extension LivestreamChannelControllerDelegate { _ controller: LivestreamChannelController, didUpdateMessages messages: [ChatMessage] ) {} + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) {} +} + +/// Configuration options for message limiting in LivestreamChannelController. +public struct MaxMessageLimitOptions { + /// The maximum number of messages to keep in memory. + /// When this limit is reached, older messages will be discarded. + public let maxLimit: Int + + /// The number of messages to discard when the maximum limit is reached. + /// This should be less than maxLimit to avoid discarding all messages. + public let discardAmount: Int + + /// Creates a new MaxMessageLimitOptions configuration. + /// - Parameters: + /// - maxLimit: The maximum number of messages to keep. Default is 200. + /// - discardAmount: The number of messages to discard when limit is reached. Default is 50. + public init(maxLimit: Int = 200, discardAmount: Int = 50) { + self.maxLimit = maxLimit + self.discardAmount = discardAmount + } + + /// Default configuration with 200 max messages and 50 discard amount. + public static let `default` = MaxMessageLimitOptions() } From bb01c13d8fbfa1acec5d0157e9208b3713e2cbed Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 31 Jul 2025 17:46:51 +0100 Subject: [PATCH 49/85] Add a way to check how much messages were skipped when the controller was paused --- .../Screens/DemoLivestreamChatChannelVC.swift | 7 ++++++ .../LivestreamChannelController+Combine.swift | 16 ++++++++++++++ .../LivestreamChannelController.swift | 22 +++++++++++++++++++ .../ScrollToBottomButton.swift | 2 +- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 6a824fcf09..9ba72d550e 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -403,6 +403,13 @@ open class DemoLivestreamChatChannelVC: _ViewController, showPauseBanner(isPaused) } + public func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) { + messageListVC.scrollToBottomButton.content = .init(messages: skippedMessagesAmount, mentions: 0) + } + // MARK: - EventsControllerDelegate open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift index 79cd7735c5..d6602de836 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift @@ -16,6 +16,11 @@ extension LivestreamChannelController { basePublishers.messagesChanges.keepAlive(self) } + /// A publisher emitting a new value every time the skipped messages amount changes. + public var skippedMessagesAmountPublisher: AnyPublisher { + basePublishers.skippedMessagesAmount.keepAlive(self) + } + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, /// and expose the published values by mapping them to a read-only `AnyPublisher` type. @@ -29,10 +34,14 @@ extension LivestreamChannelController { /// A backing subject for `messagesChangesPublisher`. let messagesChanges: CurrentValueSubject<[ChatMessage], Never> + /// A backing subject for `skippedMessagesAmountPublisher`. + let skippedMessagesAmount: CurrentValueSubject + init(controller: LivestreamChannelController) { self.controller = controller channelChange = .init(controller.channel) messagesChanges = .init(controller.messages) + skippedMessagesAmount = .init(controller.skippedMessagesAmount) controller.multicastDelegate.add(additionalDelegate: self) } @@ -53,4 +62,11 @@ extension LivestreamChannelController.BasePublishers: LivestreamChannelControlle ) { messagesChanges.send(messages) } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) { + self.skippedMessagesAmount.send(skippedMessagesAmount) + } } diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 7343913562..8ce1f06d51 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -65,6 +65,15 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } + /// The amount of messages that were skipped during the pause state. + public private(set) var skippedMessagesAmount: Int = 0 { + didSet { + delegateCallback { + $0.livestreamChannelController(self, didChangeSkippedMessagesAmount: self.skippedMessagesAmount) + } + } + } + /// A Boolean value that returns whether the oldest messages have all been loaded or not. public var hasLoadedAllPreviousMessages: Bool { paginationStateHandler.state.hasLoadedAllPreviousMessages @@ -635,6 +644,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func resume() { guard isPaused else { return } isPaused = false + skippedMessagesAmount = 0 loadFirstPage() } @@ -788,6 +798,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E // If paused and the message is not from the current user, skip processing if isPaused && message.author.id != currentUserId { + skippedMessagesAmount += 1 return } @@ -945,6 +956,12 @@ public protocol LivestreamChannelControllerDelegate: AnyObject { _ controller: LivestreamChannelController, didChangePauseState isPaused: Bool ) + + /// Called when the skipped messages amount changes. + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) } // MARK: - Default Implementations @@ -964,6 +981,11 @@ public extension LivestreamChannelControllerDelegate { _ controller: LivestreamChannelController, didChangePauseState isPaused: Bool ) {} + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) {} } /// Configuration options for message limiting in LivestreamChannelController. diff --git a/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift b/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift index df6920977d..c981d4a09f 100644 --- a/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift +++ b/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift @@ -11,7 +11,7 @@ public typealias ScrollToLatestMessageButton = ScrollToBottomButton /// A Button that is used to indicate unread messages in the Message list. open class ScrollToBottomButton: _Button, ThemeProvider { /// The unread count that will be shown on the button as a badge icon. - var content: ChannelUnreadCount = .noUnread { + public var content: ChannelUnreadCount = .noUnread { didSet { updateContentIfNeeded() } From 22a297a6a75317adbf42ec677c1f57e595cb0f37 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 31 Jul 2025 18:19:21 +0100 Subject: [PATCH 50/85] Add `isPause` publisher --- .../LivestreamChannelController+Combine.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift index d6602de836..4a7799d759 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift @@ -16,6 +16,11 @@ extension LivestreamChannelController { basePublishers.messagesChanges.keepAlive(self) } + /// A publisher emitting a new value every time the pause state changes. + public var isPausedPublisher: AnyPublisher { + basePublishers.isPaused.keepAlive(self) + } + /// A publisher emitting a new value every time the skipped messages amount changes. public var skippedMessagesAmountPublisher: AnyPublisher { basePublishers.skippedMessagesAmount.keepAlive(self) @@ -34,7 +39,10 @@ extension LivestreamChannelController { /// A backing subject for `messagesChangesPublisher`. let messagesChanges: CurrentValueSubject<[ChatMessage], Never> - /// A backing subject for `skippedMessagesAmountPublisher`. + /// A backing subject for `isPausedPublisher`. + let isPaused: CurrentValueSubject + + // A backing subject for `skippedMessagesAmountPublisher`. let skippedMessagesAmount: CurrentValueSubject init(controller: LivestreamChannelController) { @@ -42,7 +50,7 @@ extension LivestreamChannelController { channelChange = .init(controller.channel) messagesChanges = .init(controller.messages) skippedMessagesAmount = .init(controller.skippedMessagesAmount) - + isPaused = .init(controller.isPaused) controller.multicastDelegate.add(additionalDelegate: self) } } @@ -63,6 +71,13 @@ extension LivestreamChannelController.BasePublishers: LivestreamChannelControlle messagesChanges.send(messages) } + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) { + self.isPaused.send(isPaused) + } + func livestreamChannelController( _ controller: LivestreamChannelController, didChangeSkippedMessagesAmount skippedMessagesAmount: Int From 81e9b91860b646de7368bf1d08dba6b6ef3e8701 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 31 Jul 2025 18:54:06 +0100 Subject: [PATCH 51/85] Disable the discarding of messages by default --- .../Components/DemoChatChannelListRouter.swift | 1 + .../LivestreamChannelController.swift | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 5f24dd9435..5da976f481 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -119,6 +119,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { .init(title: "Show as Livestream Controller", handler: { [unowned self] _ in let client = self.rootViewController.controller.client let livestreamController = client.livestreamChannelController(for: .init(cid: cid)) + livestreamController.maxMessageLimitOptions = .recommended let vc = DemoLivestreamChatChannelVC() vc.livestreamChannelController = livestreamController vc.hidesBottomBarWhenPushed = true diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 8ce1f06d51..7a9431613d 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -111,8 +111,17 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Configuration for message limiting behaviour. /// - /// Set to `nil` to disable message limiting. Default uses 200 max messages with 50 discard amount. - public var maxMessageLimitOptions: MaxMessageLimitOptions? = .default + /// Disabled by default. If enabled, older messages will be automatically discarded + /// once the limit is reached. The `MaxMessageLimitOptions.recommended` is the recommended + /// configuration which uses 200 max messages with 50 discard amount. + /// This can be used to further improve the memory usage of the controller. + /// + /// - Note: In order to use this, if you want to support loading previous messages, + /// you will need to use `pause()` method before loading older messages. Otherwise the + /// pagination will also be capped. Once the user scrolls back to the newest messages, you + /// can call `resume()`. Whenever the user creates a new message, the controller will + /// automatically resume the controller. + public var maxMessageLimitOptions: MaxMessageLimitOptions? /// Set the delegate to observe the changes in the system. public var delegate: LivestreamChannelControllerDelegate? { @@ -1007,6 +1016,6 @@ public struct MaxMessageLimitOptions { self.discardAmount = discardAmount } - /// Default configuration with 200 max messages and 50 discard amount. - public static let `default` = MaxMessageLimitOptions() + /// The recommended configuration with 200 max messages and 50 discard amount. + public static let recommended = MaxMessageLimitOptions() } From 8a58b462d931eaf0d1322dfef425f2676f1574f2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 31 Jul 2025 19:53:09 +0100 Subject: [PATCH 52/85] Reset the live stream channel controller when memory warning is received --- .../StreamChat/Audio/AppStateObserving.swift | 27 +++++++++++++++++++ .../LivestreamChannelController.swift | 15 ++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Audio/AppStateObserving.swift b/Sources/StreamChat/Audio/AppStateObserving.swift index 287947f9b5..9537a80799 100644 --- a/Sources/StreamChat/Audio/AppStateObserving.swift +++ b/Sources/StreamChat/Audio/AppStateObserving.swift @@ -15,6 +15,20 @@ protocol AppStateObserverDelegate: AnyObject { /// Will be triggered when the app moves to the foreground func applicationDidMoveToForeground() + + /// Will be triggered when the app receives a memory warning + func applicationDidReceiveMemoryWarning() +} + +extension AppStateObserverDelegate { + /// Default implementation of `applicationDidMoveToBackground` that does nothing. + public func applicationDidMoveToBackground() {} + + /// Default implementation of `applicationDidMoveToForeground` that does nothing. + public func applicationDidMoveToForeground() {} + + /// Default implementation of `applicationDidReceiveMemoryWarning` that does nothing. + public func applicationDidReceiveMemoryWarning() {} } /// This protocol describes an object that observes the state of an App and provides related information @@ -37,6 +51,7 @@ final class StreamAppStateObserver: AppStateObserving { /// The observation tokens that are used to retain the notification subscription on the NotificationCenter private var didMoveToBackgroundObservationToken: Any? private var didMoveToForegroundObservationToken: Any? + private var didReceiveMemoryWarningObservationToken: Any? /// A multicastDelegate instance that is being used as subscribers handler. Manages the following operations: /// - Subscribe @@ -88,6 +103,13 @@ final class StreamAppStateObserver: AppStateObserving { object: nil ) + notificationCenter.addObserver( + self, + selector: #selector(handleAppDidReceiveMemoryWarning), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + #endif } @@ -100,4 +122,9 @@ final class StreamAppStateObserver: AppStateObserving { @objc private func handleAppDidMoveToForeground() { delegate.invoke { $0.applicationDidMoveToForeground() } } + + /// Handles the app receiving memory warning notification by invoking the delegate method. + @objc private func handleAppDidReceiveMemoryWarning() { + delegate.invoke { $0.applicationDidReceiveMemoryWarning() } + } } diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 7a9431613d..4ef4768542 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -20,7 +20,7 @@ public extension ChatClient { /// - Read updates /// - Typing indicators /// - etc.. -public class LivestreamChannelController: DataStoreProvider, DelegateCallable, EventsControllerDelegate { +public class LivestreamChannelController: DataStoreProvider, DelegateCallable, EventsControllerDelegate, AppStateObserverDelegate { public typealias Delegate = LivestreamChannelControllerDelegate // MARK: - Public Properties @@ -146,6 +146,9 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// The channel updater to reuse actions from channel controller which is safe to use without DB. private let updater: ChannelUpdater + /// The app state observer for monitoring memory warnings and app state changes. + private let appStateObserver: AppStateObserving + /// The current user id. private var currentUserId: UserId? { client.currentUserId } @@ -175,6 +178,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E apiClient = client.apiClient paginationStateHandler = MessagesPaginationStateHandler() eventsController = client.eventsController() + appStateObserver = StreamAppStateObserver() updater = ChannelUpdater( channelRepository: client.channelRepository, messageRepository: client.messageRepository, @@ -183,6 +187,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E apiClient: client.apiClient ) eventsController.delegate = self + appStateObserver.subscribe(self) if let cid = channelQuery.cid { client.eventNotificationCenter.registerManualEventHandling(for: cid) @@ -193,6 +198,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E if let cid { client.eventNotificationCenter.unregisterManualEventHandling(for: cid) } + appStateObserver.unsubscribe(self) } // MARK: - Public Methods @@ -669,6 +675,13 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } } + // MARK: - AppStateObserverDelegate + + public func applicationDidReceiveMemoryWarning() { + // Reset the channel to free up memory by loading the first page + loadFirstPage() + } + // MARK: - Private Methods private func updateChannelData( From e993e0ee74c614f67afa7fdcec92e7173dd9f24f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 31 Jul 2025 20:20:04 +0100 Subject: [PATCH 53/85] Fix attachments not working --- DemoApp/Screens/DemoLivestreamChatChannelVC.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index 9ba72d550e..af4c4f0cb8 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -436,6 +436,21 @@ class DemoLivestreamComposerVC: ComposerVC { /// Reference to the livestream channel controller var livestreamChannelController: LivestreamChannelController? + override func addAttachmentToContent( + from url: URL, + type: AttachmentType, + info: [LocalAttachmentInfoKey: Any], + extraData: (any Encodable)? + ) throws { + guard let cid = livestreamChannelController?.channel?.cid else { + return + } + // We need to set the channel controller temporarily just to access the client config. + channelController = livestreamChannelController?.client.channelController(for: cid) + try super.addAttachmentToContent(from: url, type: type, info: info, extraData: extraData) + channelController = nil + } + /// Override message creation to use livestream controller override func createNewMessage(text: String) { guard let livestreamController = livestreamChannelController else { From 9eab99293f750265ebff216df3f69e585dc7eff9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 12:29:01 +0100 Subject: [PATCH 54/85] Explicitly disable commands --- DemoApp/Screens/DemoLivestreamChatChannelVC.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index af4c4f0cb8..bc12947544 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -486,6 +486,10 @@ class DemoLivestreamComposerVC: ComposerVC { override func setupVoiceRecordingView() { // Do not set up voice recording for livestream } + + override var isCommandsEnabled: Bool { + false + } } private extension UIView { From bcadecde05571e42804d5a93f77f7d3e5d15e681 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 12:57:21 +0100 Subject: [PATCH 55/85] Fix access control of livestream channel view --- .../Screens/DemoLivestreamChatChannelVC.swift | 84 ++++++++++--------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift index bc12947544..9bf04be400 100644 --- a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/DemoLivestreamChatChannelVC.swift @@ -6,7 +6,7 @@ import StreamChat import StreamChatUI import UIKit -open class DemoLivestreamChatChannelVC: _ViewController, +class DemoLivestreamChatChannelVC: _ViewController, ThemeProvider, ChatMessageListVCDataSource, ChatMessageListVCDelegate, @@ -14,49 +14,47 @@ open class DemoLivestreamChatChannelVC: _ViewController, EventsControllerDelegate { /// Controller for observing data changes within the channel. - open var livestreamChannelController: LivestreamChannelController! + var livestreamChannelController: LivestreamChannelController! /// User search controller for suggestion users when typing in the composer. - open lazy var userSuggestionSearchController: ChatUserSearchController = + lazy var userSuggestionSearchController: ChatUserSearchController = livestreamChannelController.client.userSearchController() /// A controller for observing web socket events. - public lazy var eventsController: EventsController = client.eventsController() + lazy var eventsController: EventsController = client.eventsController() /// The size of the channel avatar. - open var channelAvatarSize: CGSize { + var channelAvatarSize: CGSize { CGSize(width: 32, height: 32) } - public var client: ChatClient { + var client: ChatClient { livestreamChannelController.client } /// Component responsible for setting the correct offset when keyboard frame is changed. - open lazy var keyboardHandler: KeyboardHandler = ComposerKeyboardHandler( + lazy var keyboardHandler: KeyboardHandler = ComposerKeyboardHandler( composerParentVC: self, composerBottomConstraint: messageComposerBottomConstraint, messageListVC: messageListVC ) /// The message list component responsible to render the messages. - open lazy var messageListVC: ChatMessageListVC = components - .messageListVC - .init() + lazy var messageListVC: DemoLivestreamChatMessageListVC = DemoLivestreamChatMessageListVC() /// Controller that handles the composer view private(set) lazy var messageComposerVC = DemoLivestreamComposerVC() /// View for displaying the channel image in the navigation bar. - open private(set) lazy var channelAvatarView = components + private(set) lazy var channelAvatarView = components .channelAvatarView.init() .withoutAutoresizingMaskConstraints /// The message composer bottom constraint used for keyboard animation handling. - public var messageComposerBottomConstraint: NSLayoutConstraint? + var messageComposerBottomConstraint: NSLayoutConstraint? /// A boolean value indicating whether the last message is fully visible or not. - open var isLastMessageFullyVisible: Bool { + var isLastMessageFullyVisible: Bool { messageListVC.listView.isLastCellFullyVisible } @@ -93,7 +91,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, return banner }() - override open func setUp() { + override func setUp() { super.setUp() eventsController.delegate = self @@ -105,6 +103,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageComposerVC.userSearchController = userSuggestionSearchController setChannelControllerToComposerIfNeeded() + setChannelControllerToMessageListIfNeeded() livestreamChannelController.delegate = self livestreamChannelController.synchronize { [weak self] error in @@ -126,8 +125,12 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageComposerVC.channelController = nil messageComposerVC.livestreamChannelController = livestreamChannelController } + + private func setChannelControllerToMessageListIfNeeded() { + messageListVC.livestreamChannelController = livestreamChannelController + } - override open func setUpLayout() { + override func setUpLayout() { super.setUpLayout() view.backgroundColor = appearance.colorPalette.background @@ -174,13 +177,13 @@ open class DemoLivestreamChatChannelVC: _ViewController, pauseBannerView.isHidden = true } - override open func viewDidAppear(_ animated: Bool) { + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) keyboardHandler.start() } - override open func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if let draftMessage = livestreamChannelController.channel?.draftMessage { @@ -188,7 +191,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - override open func viewWillDisappear(_ animated: Bool) { + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) keyboardHandler.stop() @@ -198,12 +201,13 @@ open class DemoLivestreamChatChannelVC: _ViewController, /// Called when the syncing of the `channelController` is finished. /// - Parameter error: An `error` if the syncing failed; `nil` if it was successful. - open func didFinishSynchronizing(with error: Error?) { + func didFinishSynchronizing(with error: Error?) { if let error = error { log.error("Error when synchronizing ChannelController: \(error)") } setChannelControllerToComposerIfNeeded() + setChannelControllerToMessageListIfNeeded() messageComposerVC.updateContent() } @@ -219,7 +223,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, /// - id: The id of message which the message list should go to. /// - animated: `true` if you want to animate the change in position; `false` if it should be immediate. /// - shouldHighlight: Whether the message should be highlighted when jumping to it. By default it is highlighted. - public func jumpToMessage(id: MessageId, animated: Bool = true, shouldHighlight: Bool = true) { + func jumpToMessage(id: MessageId, animated: Bool = true, shouldHighlight: Bool = true) { if shouldHighlight { messageListVC.jumpToMessage(id: id, animated: animated) { [weak self] indexPath in self?.messageListVC.highlightCell(at: indexPath) @@ -232,29 +236,29 @@ open class DemoLivestreamChatChannelVC: _ViewController, // MARK: - ChatMessageListVCDataSource - public var messages: [ChatMessage] = [] + var messages: [ChatMessage] = [] - public var isFirstPageLoaded: Bool { + var isFirstPageLoaded: Bool { livestreamChannelController.hasLoadedAllNextMessages } - public var isLastPageLoaded: Bool { + var isLastPageLoaded: Bool { livestreamChannelController.hasLoadedAllPreviousMessages } - open func channel(for vc: ChatMessageListVC) -> ChatChannel? { + func channel(for vc: ChatMessageListVC) -> ChatChannel? { livestreamChannelController.channel } - open func numberOfMessages(in vc: ChatMessageListVC) -> Int { + func numberOfMessages(in vc: ChatMessageListVC) -> Int { messages.count } - open func chatMessageListVC(_ vc: ChatMessageListVC, messageAt indexPath: IndexPath) -> ChatMessage? { + func chatMessageListVC(_ vc: ChatMessageListVC, messageAt indexPath: IndexPath) -> ChatMessage? { messages[indexPath.item] } - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, messageLayoutOptionsAt indexPath: IndexPath ) -> ChatMessageLayoutOptions { @@ -268,7 +272,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, ) } - public func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessageId messageId: MessageId, _ completion: @escaping ((Error?) -> Void) @@ -278,7 +282,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - open func chatMessageListVCShouldLoadFirstPage( + func chatMessageListVCShouldLoadFirstPage( _ vc: ChatMessageListVC ) { livestreamChannelController.loadFirstPage() @@ -286,7 +290,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, // MARK: - ChatMessageListVCDelegate - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, scrollViewDidScroll scrollView: UIScrollView ) { @@ -295,7 +299,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, willDisplayMessageAt indexPath: IndexPath ) { @@ -316,7 +320,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, didTapOnAction actionItem: ChatMessageActionItem, for message: ChatMessage @@ -342,7 +346,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, } } - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, didTapOnMessageListView messageListView: ChatMessageListView, with gestureRecognizer: UITapGestureRecognizer @@ -350,7 +354,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageComposerVC.dismissSuggestions() } - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, headerViewForMessage message: ChatMessage, at indexPath: IndexPath @@ -358,7 +362,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, nil } - open func chatMessageListVC( + func chatMessageListVC( _ vc: ChatMessageListVC, footerViewForMessage message: ChatMessage, at indexPath: IndexPath @@ -368,7 +372,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, // MARK: - LivestreamChannelControllerDelegate - public func livestreamChannelController( + func livestreamChannelController( _ controller: LivestreamChannelController, didUpdateChannel channel: ChatChannel ) { @@ -376,7 +380,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, navigationItem.title = channel.name } - public func livestreamChannelController( + func livestreamChannelController( _ controller: LivestreamChannelController, didUpdateMessages messages: [ChatMessage] ) { @@ -396,14 +400,14 @@ open class DemoLivestreamChatChannelVC: _ViewController, messageListVC.updateMessages(with: changes) } - public func livestreamChannelController( + func livestreamChannelController( _ controller: LivestreamChannelController, didChangePauseState isPaused: Bool ) { showPauseBanner(isPaused) } - public func livestreamChannelController( + func livestreamChannelController( _ controller: LivestreamChannelController, didChangeSkippedMessagesAmount skippedMessagesAmount: Int ) { @@ -412,7 +416,7 @@ open class DemoLivestreamChatChannelVC: _ViewController, // MARK: - EventsControllerDelegate - open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { if let newMessagePendingEvent = event as? NewMessagePendingEvent { let newMessage = newMessagePendingEvent.message if !isFirstPageLoaded && newMessage.isSentByCurrentUser && !newMessage.isPartOfThread { From 0b12e2c67101ce4ef2b6259e6d67b3147df3b457 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 13:10:02 +0100 Subject: [PATCH 56/85] Use reactions from livestream controller in the demo app livestream UI --- .../DemoLivestreamChatChannelVC.swift | 0 .../DemoLivestreamChatMessageListVC.swift | 69 ++++ .../DemoLivestreamMessageActionsVC.swift | 338 ++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 18 +- 4 files changed, 424 insertions(+), 1 deletion(-) rename DemoApp/Screens/{ => Livestream}/DemoLivestreamChatChannelVC.swift (100%) create mode 100644 DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift create mode 100644 DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift diff --git a/DemoApp/Screens/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift similarity index 100% rename from DemoApp/Screens/DemoLivestreamChatChannelVC.swift rename to DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift new file mode 100644 index 0000000000..b3495afa72 --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift @@ -0,0 +1,69 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +/// A custom message list view controller for livestream channels that uses LivestreamChannelController +/// instead of MessageController and shows a custom bottom sheet for message actions. +class DemoLivestreamChatMessageListVC: ChatMessageListVC { + /// The livestream channel controller for managing channel operations + public weak var livestreamChannelController: LivestreamChannelController? + + override func didSelectMessageCell(at indexPath: IndexPath) { + guard + let cell = listView.cellForRow(at: indexPath) as? ChatMessageCell, + let messageContentView = cell.messageContentView, + let message = messageContentView.content, + message.isInteractionEnabled == true, + let livestreamChannelController = livestreamChannelController + else { return } + + if message.isBounced { + showActions(forDebouncedMessage: message) + return + } + + // Create the custom livestream actions view controller + let actionsController = DemoLivestreamMessageActionsVC() + actionsController.message = message + actionsController.livestreamChannelController = livestreamChannelController + actionsController.delegate = self + + // Present as bottom sheet + actionsController.modalPresentationStyle = .pageSheet + + if #available(iOS 16.0, *) { + if let sheetController = actionsController.sheetPresentationController { + sheetController.detents = [ + .custom { _ in + return 180 + } + ] + sheetController.prefersGrabberVisible = true + sheetController.preferredCornerRadius = 16 + } + } + + present(actionsController, animated: true) + } +} + +// MARK: - LivestreamMessageActionsVCDelegate + +extension DemoLivestreamChatMessageListVC: LivestreamMessageActionsVCDelegate { + public func livestreamMessageActionsVCDidFinish(_ vc: DemoLivestreamMessageActionsVC) { + dismiss(animated: true) + } + + func livestreamMessageActionsVC( + _ vc: DemoLivestreamMessageActionsVC, + message: ChatMessage, + didTapOnActionItem actionItem: ChatMessageActionItem + ) { + // Handle action items that need to be delegated to the parent + delegate?.chatMessageListVC(self, didTapOnAction: actionItem, for: message) + } +} diff --git a/DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift new file mode 100644 index 0000000000..1cb37947b4 --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift @@ -0,0 +1,338 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +/// Delegate protocol for `LivestreamMessageActionsVC` +protocol LivestreamMessageActionsVCDelegate: AnyObject { + func livestreamMessageActionsVC( + _ vc: DemoLivestreamMessageActionsVC, + message: ChatMessage, + didTapOnActionItem actionItem: ChatMessageActionItem + ) + func livestreamMessageActionsVCDidFinish(_ vc: DemoLivestreamMessageActionsVC) +} + +/// Custom bottom sheet view controller for livestream message actions +class DemoLivestreamMessageActionsVC: UIViewController { + // MARK: - Properties + + weak var delegate: LivestreamMessageActionsVCDelegate? + weak var livestreamChannelController: LivestreamChannelController? + var message: ChatMessage? + + // MARK: - UI Components + + private lazy var mainStackView = VContainer(spacing: 8) + + private lazy var reactionsStackView = HContainer( + spacing: 16, + distribution: .fillEqually, + alignment: .center + ).height(50) + + private lazy var actionsStackView = HContainer( + spacing: 16, + distribution: .fillEqually + ).height(80) + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupReactions() + setupActions() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor.systemBackground + + view.addSubview(mainStackView) + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10), + mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + mainStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10) + ]) + + mainStackView.views { + reactionsStackView + actionsStackView + } + } + + private func setupReactions() { + guard let channel = livestreamChannelController?.channel, + channel.canSendReaction else { return } + + // Use available reactions from the appearance system, ordered by raw value + let availableReactions = Appearance.default.images.availableReactions + + let reactionButtons = availableReactions + .sorted { $0.key.rawValue < $1.key.rawValue } + .map { (reactionType, reactionAppearance) in + createReactionButton(image: reactionAppearance.smallIcon, reactionType: reactionType) + } + + reactionButtons.forEach { + reactionsStackView.addArrangedSubview($0) + } + } + + private func setupActions() { + guard let message = message, + let livestreamChannelController = livestreamChannelController else { return } + + var actionButtons: [UIButton] = [] + + // Reply action + if let channel = livestreamChannelController.channel, channel.canQuoteMessage { + let replyButton = createSquareActionButton( + title: "Reply", + icon: UIImage(systemName: "arrowshape.turn.up.left") ?? UIImage(), + action: { [weak self] in + self?.handleReplyAction() + } + ) + actionButtons.append(replyButton) + } + + // Pin action + if let channel = livestreamChannelController.channel, channel.canPinMessage { + let isPinned = message.pinDetails != nil + let pinButton = createSquareActionButton( + title: isPinned ? "Unpin" : "Pin", + icon: UIImage(systemName: isPinned ? "pin.slash" : "pin") ?? UIImage(), + action: { [weak self] in + self?.handlePinAction() + } + ) + actionButtons.append(pinButton) + } + + // Copy action + if !message.text.isEmpty { + let copyActionItem = CopyActionItem { [weak self] actionItem in + guard let self = self, let message = self.message else { return } + self.delegate?.livestreamMessageActionsVC(self, message: message, didTapOnActionItem: actionItem) + } + let copyButton = createSquareActionButton( + title: copyActionItem.title, + icon: copyActionItem.icon, + action: { + copyActionItem.action(copyActionItem) + } + ) + actionButtons.append(copyButton) + } + + actionButtons.forEach { + actionsStackView.addArrangedSubview($0) + } + } + + private func createReactionButton(image: UIImage, reactionType: MessageReactionType) -> UIButton { + let button = UIButton(type: .system) + button.setImage(image, for: .normal) + button.contentMode = .scaleAspectFit + button.imageView?.contentMode = .scaleAspectFit + + // Add some padding around the image for better visual balance + button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + + // Check if current user has reacted with this type + let isSelected = message?.currentUserReactions.contains { $0.type == reactionType } ?? false + + // Configure appearance based on selection state + let colorPalette = Appearance.default.colorPalette + button.backgroundColor = isSelected ? colorPalette.accentPrimary.withAlphaComponent(0.5) : .systemGray6 + button.layer.borderWidth = isSelected ? 2 : 0.5 + button.layer.borderColor = isSelected ? colorPalette.accentPrimary.cgColor : UIColor.separator.cgColor + + // Apply styling and constraints + button.layer.cornerRadius = 20 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOffset = CGSize(width: 0, height: 1) + button.layer.shadowOpacity = 0.1 + button.layer.shadowRadius = 2 + + button.width(40).height(40) + + // Add press animation + button.addTarget(self, action: #selector(reactionButtonPressed(_:)), for: .touchDown) + button.addTarget(self, action: #selector(reactionButtonReleased(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + + button.addAction(UIAction { [weak self] _ in + self?.toggleReaction(reactionType) + self?.delegate?.livestreamMessageActionsVCDidFinish(self!) + }, for: .touchUpInside) + + return button + } + + @objc private func reactionButtonPressed(_ button: UIButton) { + UIView.animate(withDuration: 0.1) { + button.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + } + } + + @objc private func reactionButtonReleased(_ button: UIButton) { + UIView.animate(withDuration: 0.1) { + button.transform = .identity + } + } + + private func createSquareActionButton(title: String, icon: UIImage, action: @escaping () -> Void) -> UIButton { + let button = UIButton(type: .system) + button.backgroundColor = UIColor.systemGray6 + button.layer.cornerRadius = 12 + + // Create icon image view + let iconImageView = UIImageView(image: icon) + iconImageView.tintColor = .label + iconImageView.contentMode = .scaleAspectFit + iconImageView.width(24).height(24) + + // Create title label + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .label + titleLabel.textAlignment = .center + + // Create vertical container for icon and label + let contentStack = VContainer(spacing: 8, alignment: .center) { + iconImageView + titleLabel + } + contentStack.isUserInteractionEnabled = false + + button.addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.centerXAnchor.constraint(equalTo: button.centerXAnchor), + contentStack.centerYAnchor.constraint(equalTo: button.centerYAnchor) + ]) + + button.addAction(UIAction { [weak self] _ in + action() + self?.delegate?.livestreamMessageActionsVCDidFinish(self!) + }, for: .touchUpInside) + + return button + } + + // MARK: - Action Handlers + + private func toggleReaction(_ reactionType: MessageReactionType) { + guard let message = message else { return } + + // Check if current user has already reacted with this type + let hasReacted = message.currentUserReactions.contains { $0.type == reactionType } + + if hasReacted { + removeReaction(reactionType) + } else { + addReaction(reactionType) + } + } + + private func addReaction(_ reactionType: MessageReactionType) { + guard let message = message, + let controller = livestreamChannelController else { return } + + controller.addReaction(reactionType, to: message.id) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } + + private func removeReaction(_ reactionType: MessageReactionType) { + guard let message = message, + let controller = livestreamChannelController else { return } + + controller.deleteReaction(reactionType, from: message.id) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } + + private func handleReplyAction() { + guard let message = message else { return } + let actionItem = InlineReplyActionItem { _ in } + delegate?.livestreamMessageActionsVC(self, message: message, didTapOnActionItem: actionItem) + } + + private func handlePinAction() { + guard let message = message, + let controller = livestreamChannelController else { return } + + let isPinned = message.pinDetails != nil + + if isPinned { + controller.unpin(messageId: message.id) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } else { + controller.pin(messageId: message.id, pinning: .noExpiration) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } + } + + private func showErrorAlert(error: Error) { + let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + +// MARK: - Reactions Action Item + +/// Action item for adding reactions to a message +public struct ReactionsActionItem: ChatMessageActionItem { + public var title: String { "Add Reaction" } + public let icon: UIImage + public let action: (ChatMessageActionItem) -> Void + + public init(action: @escaping (ChatMessageActionItem) -> Void) { + self.action = action + icon = UIImage(systemName: "face.smiling") ?? UIImage() + } +} + +// MARK: - Pin Action Item + +/// Action item for pinning/unpinning a message +public struct PinActionItem: ChatMessageActionItem { + public var title: String + public let icon: UIImage + public let action: (ChatMessageActionItem) -> Void + public let isPinned: Bool + + public init(title: String? = nil, isPinned: Bool, action: @escaping (ChatMessageActionItem) -> Void) { + self.title = title ?? (isPinned ? "Unpin Message" : "Pin Message") + self.isPinned = isPinned + self.action = action + icon = UIImage(systemName: isPinned ? "pin.slash" : "pin") ?? UIImage() + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 39b9347526..b404029d92 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1613,6 +1613,8 @@ AD7BE1712C234798000A5756 /* ChatThreadListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE16F2C234798000A5756 /* ChatThreadListLoadingView.swift */; }; AD7BE1732C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */; }; AD7BE1742C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */; }; + AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */; }; + AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7EFDA52C77749300625FC5 /* PollCommentListVC.swift */; }; @@ -4409,6 +4411,8 @@ AD7BE16C2C20CC02000A5756 /* ThreadUpdaterMiddlware_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdaterMiddlware_Tests.swift; sourceTree = ""; }; AD7BE16F2C234798000A5756 /* ChatThreadListLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListLoadingView.swift; sourceTree = ""; }; AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListEmptyView.swift; sourceTree = ""; }; + AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatMessageListVC.swift; sourceTree = ""; }; + AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamMessageActionsVC.swift; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -6918,13 +6922,13 @@ A3227ECA284A607D00EBE6CC /* Screens */ = { isa = PBXGroup; children = ( - AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */, ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */, AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */, ADD328592C04DD8300BAD0E9 /* DemoAppTabBarController.swift */, C10B5C712A1F794A006A5BCB /* MembersViewController.swift */, C1CEF9062A1BC4E800414931 /* UserProfileViewController.swift */, AD7BE1672C1CB183000A5756 /* DebugObjectViewController.swift */, + AD7C76732E3CF0CD009250FB /* Livestream */, AD6BEFF42786474A00E184B4 /* AppConfigViewController */, A3227E5D284A494000EBE6CC /* Create Chat */, A3227E6A284A4B0D00EBE6CC /* LoginViewController */, @@ -8815,6 +8819,16 @@ path = ChatMessageReactionAuthorsVC; sourceTree = ""; }; + AD7C76732E3CF0CD009250FB /* Livestream */ = { + isa = PBXGroup; + children = ( + AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */, + AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */, + AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */, + ); + path = Livestream; + sourceTree = ""; + }; AD7EFDA42C776EB700625FC5 /* PollCommentListVC */ = { isa = PBXGroup; children = ( @@ -11233,6 +11247,8 @@ 8440860D28FBFE520027849C /* DemoAppCoordinator+DemoApp.swift in Sources */, 647F66D5261E22C200111B19 /* DemoConnectionBannerView.swift in Sources */, A31783DD285B79EB005009B9 /* Bundle+PushProvider.swift in Sources */, + AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */, + AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */, A3227E72284A4BF700EBE6CC /* HiddenChannelListVC.swift in Sources */, AD75CB6B27886746005F5FF7 /* OptionsSelectorViewController.swift in Sources */, AD053B9A2B335854003612B6 /* DemoComposerVC.swift in Sources */, From 38860580d703a247f3266f041dcfc9d5716d95a3 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 15:22:22 +0100 Subject: [PATCH 57/85] Display list of reaction authors when tapping the reactions in the message list --- .../DemoLivestreamChatMessageListVC.swift | 31 ++++ .../DemoLivestreamReactionsListView.swift | 170 ++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 4 + 3 files changed, 205 insertions(+) create mode 100644 DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift index b3495afa72..a61cbccbf1 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift @@ -4,6 +4,7 @@ import StreamChat import StreamChatUI +import SwiftUI import UIKit /// A custom message list view controller for livestream channels that uses LivestreamChannelController @@ -49,6 +50,36 @@ class DemoLivestreamChatMessageListVC: ChatMessageListVC { present(actionsController, animated: true) } + + override func messageContentViewDidTapOnReactionsView(_ indexPath: IndexPath?) { + guard + let indexPath = indexPath, + let cell = listView.cellForRow(at: indexPath) as? ChatMessageCell, + let messageContentView = cell.messageContentView, + let message = messageContentView.content, + let livestreamChannelController = livestreamChannelController + else { return } + + // Create SwiftUI reactions list view + let reactionsView = DemoLivestreamReactionsListView( + message: message, + controller: livestreamChannelController + ) + + // Present as a SwiftUI sheet + let hostingController = UIHostingController(rootView: reactionsView) + hostingController.modalPresentationStyle = .pageSheet + + if #available(iOS 16.0, *) { + if let sheetController = hostingController.sheetPresentationController { + sheetController.detents = [.medium(), .large()] + sheetController.prefersGrabberVisible = true + sheetController.preferredCornerRadius = 16 + } + } + + present(hostingController, animated: true) + } } // MARK: - LivestreamMessageActionsVCDelegate diff --git a/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift b/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift new file mode 100644 index 0000000000..d3e7706444 --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift @@ -0,0 +1,170 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import SwiftUI + +struct DemoLivestreamReactionsListView: View { + let message: ChatMessage + let controller: LivestreamChannelController + @Environment(\.presentationMode) private var presentationMode + + @State private var reactions: [ChatMessageReaction] = [] + @State private var isLoading = false + @State private var hasLoadedAll = false + @State private var errorMessage: String? + + private let pageSize = 25 + + var body: some View { + NavigationView { + VStack { + if reactions.isEmpty && isLoading { + ProgressView("Loading reactions...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if reactions.isEmpty { + VStack(spacing: 16) { + Image(systemName: "face.smiling") + .font(.system(size: 50)) + .foregroundColor(.secondary) + Text("No reactions yet") + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(reactions, id: \.self) { reaction in + ReactionRowView(reaction: reaction) + } + + if !hasLoadedAll { + HStack { + Spacer() + ProgressView() + .onAppear { + loadMoreReactions() + } + Spacer() + } + } + } + .listStyle(PlainListStyle()) + } + + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .padding() + } + } + .navigationTitle("Reactions") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + trailing: Button("Done") { + presentationMode.wrappedValue.dismiss() + } + ) + } + .onAppear { + loadInitialReactions() + } + } + + private func loadInitialReactions() { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + controller.loadReactions(for: message.id, limit: pageSize, offset: 0) { result in + DispatchQueue.main.async { + isLoading = false + switch result { + case .success(let newReactions): + reactions = newReactions + hasLoadedAll = newReactions.count < pageSize + case .failure(let error): + errorMessage = error.localizedDescription + } + } + } + } + + private func loadMoreReactions() { + guard !isLoading && !hasLoadedAll else { return } + isLoading = true + + controller.loadReactions(for: message.id, limit: pageSize, offset: reactions.count) { result in + DispatchQueue.main.async { + isLoading = false + switch result { + case .success(let newReactions): + reactions.append(contentsOf: newReactions) + hasLoadedAll = newReactions.count < pageSize + case .failure(let error): + errorMessage = error.localizedDescription + } + } + } + } +} + +fileprivate struct ReactionRowView: View { + let reaction: ChatMessageReaction + + var body: some View { + HStack(spacing: 12) { + // User avatar + if #available(iOS 15.0, *) { + AsyncImage(url: reaction.author.imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Circle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Text(reaction.author.name?.prefix(1).uppercased() ?? "?") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + + VStack(alignment: .leading, spacing: 2) { + Text(reaction.author.name ?? "Unknown User") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text(formatReactionDate(reaction.createdAt)) + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + + Spacer() + + // Reaction emoji/image + if let reactionAppearance = Appearance.default.images.availableReactions[reaction.type] { + if #available(iOS 15.0, *) { + Image(uiImage: reactionAppearance.largeIcon) + .frame(width: 24, height: 24) + .foregroundStyle(Color(Appearance.default.colorPalette.accentPrimary)) + } + } else { + Text(Appearance.default.images.availableReactionPushEmojis[reaction.type] ?? "👍") + .font(.system(size: 20)) + } + } + .padding(.vertical, 4) + } + + private func formatReactionDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b404029d92..8375965a65 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1615,6 +1615,7 @@ AD7BE1742C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */; }; AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */; }; AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */; }; + AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7EFDA52C77749300625FC5 /* PollCommentListVC.swift */; }; @@ -4413,6 +4414,7 @@ AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListEmptyView.swift; sourceTree = ""; }; AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatMessageListVC.swift; sourceTree = ""; }; AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamMessageActionsVC.swift; sourceTree = ""; }; + AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamReactionsListView.swift; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -8825,6 +8827,7 @@ AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */, AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */, AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */, + AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */, ); path = Livestream; sourceTree = ""; @@ -11234,6 +11237,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */, 6428DD5526201DCC0065DA1D /* BannerShowingConnectionDelegate.swift in Sources */, AD053BA52B335A63003612B6 /* DemoQuotedChatMessageView.swift in Sources */, AD053BAB2B33638B003612B6 /* LocationAttachmentSnapshotView.swift in Sources */, From 597ac21fc50ab12d33f983f2640bee8b369c0713 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 15:28:37 +0100 Subject: [PATCH 58/85] Change the thread to utility in the Manual Event Handler --- Sources/StreamChat/Workers/ManualEventHandler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift index 63657121a1..2c707ba800 100644 --- a/Sources/StreamChat/Workers/ManualEventHandler.swift +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -21,7 +21,7 @@ class ManualEventHandler { init( database: DatabaseContainer, - queue: DispatchQueue = DispatchQueue(label: "io.getstream.chat.manualEventHandler", qos: .background) + queue: DispatchQueue = DispatchQueue(label: "io.getstream.chat.manualEventHandler", qos: .utility) ) { self.database = database self.queue = queue From 45d6c2094aabd50b72aa709697abcda7015d0d0b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 15:29:01 +0100 Subject: [PATCH 59/85] Do not copy the current message everytime when doing in-memory message updates --- .../LivestreamChannelController.swift | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 4ef4768542..c3d43b7bbc 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -810,10 +810,8 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } private func handleNewMessage(_ message: ChatMessage) { - var currentMessages = messages - // If message already exists, update it instead - if currentMessages.contains(where: { $0.id == message.id }) { + if messages.contains(where: { $0.id == message.id }) { handleUpdatedMessage(message) return } @@ -830,8 +828,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E return } - currentMessages.insert(message, at: 0) - messages = currentMessages + messages.insert(message, at: 0) // Apply message limit only when not paused if !isPaused { @@ -846,19 +843,13 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { - var currentMessages = messages - - if let index = currentMessages.firstIndex(where: { $0.id == updatedMessage.id }) { - currentMessages[index] = updatedMessage - messages = currentMessages + if let index = messages.firstIndex(where: { $0.id == updatedMessage.id }) { + messages[index] = updatedMessage } } private func handleDeletedMessage(_ deletedMessage: ChatMessage) { - var currentMessages = messages - - currentMessages.removeAll { $0.id == deletedMessage.id } - messages = currentMessages + messages.removeAll { $0.id == deletedMessage.id } } private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { @@ -876,15 +867,9 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E private func updateMessage( _ updatedMessage: ChatMessage ) { - let messageId = updatedMessage.id - var currentMessages = messages - - guard let messageIndex = currentMessages.firstIndex(where: { $0.id == messageId }) else { - return + if let messageIndex = messages.firstIndex(where: { $0.id == updatedMessage.id }) { + messages[messageIndex] = updatedMessage } - - currentMessages[messageIndex] = updatedMessage - messages = currentMessages } private func handleChannelUpdated(_ event: ChannelUpdatedEvent) { From b5f01b35e1ee9b688c8068fb982d218a6cc6e2bc Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 15:33:31 +0100 Subject: [PATCH 60/85] Improve doc of livestream channel controller message capping --- .../ChannelController/LivestreamChannelController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index c3d43b7bbc..2cf173a6b2 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -120,7 +120,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// you will need to use `pause()` method before loading older messages. Otherwise the /// pagination will also be capped. Once the user scrolls back to the newest messages, you /// can call `resume()`. Whenever the user creates a new message, the controller will - /// automatically resume the controller. + /// automatically resume. public var maxMessageLimitOptions: MaxMessageLimitOptions? /// Set the delegate to observe the changes in the system. From 18251fb52e212cf7e0b5ea7fb64469b89cc7b21c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 15:38:03 +0100 Subject: [PATCH 61/85] Print whenever the livestream messages are updated --- DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift index 9bf04be400..c6a4190cd5 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift @@ -384,6 +384,8 @@ class DemoLivestreamChatChannelVC: _ViewController, _ controller: LivestreamChannelController, didUpdateMessages messages: [ChatMessage] ) { + debugPrint("[Livestream] didUpdateMessages.count: \(messages.count)") + messageListVC.setPreviousMessagesSnapshot(self.messages) messageListVC.setNewMessagesSnapshotArray(livestreamChannelController.messages) From a082865195c1bd3a75d93fb7f84fdd8515b23edf Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 15:55:05 +0100 Subject: [PATCH 62/85] Only count skipped messages if enabled --- .../Components/DemoChatChannelListRouter.swift | 1 + .../ChannelController/LivestreamChannelController.swift | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 5da976f481..b4442f9ac7 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -120,6 +120,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { let client = self.rootViewController.controller.client let livestreamController = client.livestreamChannelController(for: .init(cid: cid)) livestreamController.maxMessageLimitOptions = .recommended + livestreamController.countSkippedMessagesWhenPaused = true let vc = DemoLivestreamChatChannelVC() vc.livestreamChannelController = livestreamController vc.hidesBottomBarWhenPushed = true diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 2cf173a6b2..9df1af9558 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -109,6 +109,9 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Only the initial page will be loaded from cache, to avoid an initial blank screen. public var loadInitialMessagesFromCache: Bool = true + /// A boolean value indicating if the controller should count the number o skipped messages when in pause state. + public var countSkippedMessagesWhenPaused: Bool = false + /// Configuration for message limiting behaviour. /// /// Disabled by default. If enabled, older messages will be automatically discarded @@ -659,7 +662,9 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func resume() { guard isPaused else { return } isPaused = false - skippedMessagesAmount = 0 + if countSkippedMessagesWhenPaused { + skippedMessagesAmount = 0 + } loadFirstPage() } @@ -817,7 +822,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } // If paused and the message is not from the current user, skip processing - if isPaused && message.author.id != currentUserId { + if countSkippedMessagesWhenPaused, isPaused && message.author.id != currentUserId { skippedMessagesAmount += 1 return } From bfd3aca43563da48e02f97fde8b28db34959f5cf Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 16:10:32 +0100 Subject: [PATCH 63/85] Make the completion blocks and delegates as main actors --- .../LivestreamChannelController.swift | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 9df1af9558..a39f838d9f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -20,7 +20,7 @@ public extension ChatClient { /// - Read updates /// - Typing indicators /// - etc.. -public class LivestreamChannelController: DataStoreProvider, DelegateCallable, EventsControllerDelegate, AppStateObserverDelegate { +public class LivestreamChannelController: DataStoreProvider, EventsControllerDelegate, AppStateObserverDelegate { public typealias Delegate = LivestreamChannelControllerDelegate // MARK: - Public Properties @@ -208,7 +208,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Synchronizes the controller with the backend data. /// - Parameter completion: Called when the synchronization is finished. - public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { + public func synchronize(_ completion: (@MainActor (_ error: Error?) -> Void)? = nil) { // Populate the initial data with existing cache. if loadInitialMessagesFromCache, let cid = self.cid, let channel = dataStore.channel(cid: cid) { self.channel = channel @@ -229,10 +229,12 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func loadPreviousMessages( before messageId: MessageId? = nil, limit: Int? = nil, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { guard cid != nil else { - completion?(ClientError.ChannelNotCreatedYet()) + self.callback { + completion?(ClientError.ChannelNotCreatedYet()) + } return } @@ -241,12 +243,16 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E ?? messages.last?.id guard let messageId = messageId else { - completion?(ClientError.ChannelEmptyMessages()) + self.callback { + completion?(ClientError.ChannelEmptyMessages()) + } return } guard !hasLoadedAllPreviousMessages && !isLoadingPreviousMessages else { - completion?(nil) + self.callback { + completion?(nil) + } return } @@ -265,10 +271,12 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func loadNextMessages( after messageId: MessageId? = nil, limit: Int? = nil, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { guard cid != nil else { - completion?(ClientError.ChannelNotCreatedYet()) + self.callback { + completion?(ClientError.ChannelNotCreatedYet()) + } return } @@ -277,12 +285,16 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E ?? messages.first?.id guard let messageId = messageId else { - completion?(ClientError.ChannelEmptyMessages()) + self.callback { + completion?(ClientError.ChannelEmptyMessages()) + } return } guard !hasLoadedAllNextMessages && !isLoadingNextMessages else { - completion?(nil) + self.callback { + completion?(nil) + } return } @@ -301,10 +313,12 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func loadPageAroundMessageId( _ messageId: MessageId, limit: Int? = nil, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { guard !isLoadingMiddleMessages else { - completion?(nil) + self.callback { + completion?(nil) + } return } @@ -317,7 +331,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// Cleans the current state and loads the first page again. /// - Parameter completion: Callback when the API call is completed. - public func loadFirstPage(_ completion: ((_ error: Error?) -> Void)? = nil) { + public func loadFirstPage(_ completion: (@MainActor (_ error: Error?) -> Void)? = nil) { var query = channelQuery query.pagination = .init( pageSize: channelQuery.pagination?.pageSize ?? .messagesPageSize, @@ -360,7 +374,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E restrictedVisibility: [UserId] = [], location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], - completion: ((Result) -> Void)? = nil + completion: (@MainActor (Result) -> Void)? = nil ) { var transformableInfo = NewMessageTransformableInfo( text: text, @@ -398,7 +412,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func deleteMessage( messageId: MessageId, hard: Bool = false, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request( endpoint: .deleteMessage( @@ -453,7 +467,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E messageId: MessageId, reason: String? = nil, extraData: [String: RawJSON]? = nil, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request( endpoint: .flagMessage( @@ -476,7 +490,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// If request fails, the completion will be called with an error. public func unflag( messageId: MessageId, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request( endpoint: .flagMessage( @@ -510,7 +524,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E skipPush: Bool = false, pushEmojiCode: String? = nil, extraData: [String: RawJSON] = [:], - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request( endpoint: .addReaction( @@ -537,7 +551,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func deleteReaction( _ type: MessageReactionType, from messageId: MessageId, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request(endpoint: .deleteReaction(type, messageId: messageId)) { [weak self] result in self?.callback { @@ -554,7 +568,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E public func pin( messageId: MessageId, pinning: MessagePinning = .noExpiration, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request(endpoint: .pinMessage( messageId: messageId, @@ -572,7 +586,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. public func unpin( messageId: MessageId, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { apiClient.request( endpoint: .pinMessage( @@ -608,7 +622,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// - cooldownDuration: Duration of the time interval users have to wait between messages. /// Specified in seconds. Should be between 1-120. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. - public func enableSlowMode(cooldownDuration: Int, completion: ((Error?) -> Void)? = nil) { + public func enableSlowMode(cooldownDuration: Int, completion: (@MainActor (Error?) -> Void)? = nil) { guard let cid else { callback { completion?(ClientError.ChannelNotCreatedYet()) @@ -631,7 +645,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E /// /// - Parameters: /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. - public func disableSlowMode(completion: ((Error?) -> Void)? = nil) { + public func disableSlowMode(completion: (@MainActor (Error?) -> Void)? = nil) { guard let cid else { callback { completion?(ClientError.ChannelNotCreatedYet()) @@ -691,7 +705,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E private func updateChannelData( channelQuery: ChannelQuery, - completion: ((Error?) -> Void)? = nil + completion: (@MainActor (Error?) -> Void)? = nil ) { if let pagination = channelQuery.pagination { paginationStateHandler.begin(pagination: pagination) @@ -766,12 +780,18 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E } /// Helper method to execute the callbacks on the main thread. - func callback(_ action: @escaping () -> Void) { + private func callback(_ action: @MainActor @escaping () -> Void) { DispatchQueue.main.async { action() } } + private func delegateCallback(_ callback: @escaping @MainActor (Delegate) -> Void) { + self.callback { + self.multicastDelegate.invoke(callback) + } + } + private func handleChannelEvent(_ event: Event) { switch event { case let messageNewEvent as MessageNewEvent: @@ -895,12 +915,14 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], poll: PollPayload?, - completion: ((Result) -> Void)? = nil + completion: (@MainActor (Result) -> Void)? = nil ) { /// Perform action only if channel is already created on backend side and have a valid `cid`. guard let cid = cid else { let error = ClientError.ChannelNotCreatedYet() - completion?(.failure(error)) + self.callback { + completion?(.failure(error)) + } return } @@ -941,6 +963,7 @@ public class LivestreamChannelController: DataStoreProvider, DelegateCallable, E // MARK: - Delegate Protocol /// Delegate protocol for `LivestreamChannelController` +@MainActor public protocol LivestreamChannelControllerDelegate: AnyObject { /// Called when the channel data is updated. /// - Parameters: From 426b65da9a315bb19271156bdaeb251d38bdfeda Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 16:21:18 +0100 Subject: [PATCH 64/85] Fix loadReactions completion not in the MainActor --- .../DemoLivestreamReactionsListView.swift | 32 ++++++++----------- .../LivestreamChannelController.swift | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift b/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift index d3e7706444..6c3d124bc5 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift @@ -79,15 +79,13 @@ struct DemoLivestreamReactionsListView: View { errorMessage = nil controller.loadReactions(for: message.id, limit: pageSize, offset: 0) { result in - DispatchQueue.main.async { - isLoading = false - switch result { - case .success(let newReactions): - reactions = newReactions - hasLoadedAll = newReactions.count < pageSize - case .failure(let error): - errorMessage = error.localizedDescription - } + isLoading = false + switch result { + case .success(let newReactions): + reactions = newReactions + hasLoadedAll = newReactions.count < pageSize + case .failure(let error): + errorMessage = error.localizedDescription } } } @@ -97,15 +95,13 @@ struct DemoLivestreamReactionsListView: View { isLoading = true controller.loadReactions(for: message.id, limit: pageSize, offset: reactions.count) { result in - DispatchQueue.main.async { - isLoading = false - switch result { - case .success(let newReactions): - reactions.append(contentsOf: newReactions) - hasLoadedAll = newReactions.count < pageSize - case .failure(let error): - errorMessage = error.localizedDescription - } + isLoading = false + switch result { + case .success(let newReactions): + reactions.append(contentsOf: newReactions) + hasLoadedAll = newReactions.count < pageSize + case .failure(let error): + errorMessage = error.localizedDescription } } } diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index a39f838d9f..3696ad02be 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -436,7 +436,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel for messageId: MessageId, limit: Int = 25, offset: Int = 0, - completion: @escaping (Result<[ChatMessageReaction], Error>) -> Void + completion: @escaping @MainActor (Result<[ChatMessageReaction], Error>) -> Void ) { let pagination = Pagination(pageSize: limit, offset: offset) apiClient.request( From 20eacf6c1b79e53b4de9944431ffcd4e5f33d9d7 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 16:21:46 +0100 Subject: [PATCH 65/85] Fix thread-safeness when fetching cached channel in manual event handler --- .../StreamChat/Workers/ManualEventHandler.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift index 2c707ba800..e670c16e40 100644 --- a/Sources/StreamChat/Workers/ManualEventHandler.swift +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -243,12 +243,14 @@ class ManualEventHandler { // This is only needed because some events wrongly require the channel to create them. private func getLocalChannel(id: ChannelId) -> ChatChannel? { - if let cachedChannel = cachedChannels[id] { - return cachedChannel - } + queue.sync { + if let cachedChannel = cachedChannels[id] { + return cachedChannel + } - let channel = try? database.writableContext.channel(cid: id)?.asModel() - cachedChannels[id] = channel - return channel + let channel = try? database.writableContext.channel(cid: id)?.asModel() + cachedChannels[id] = channel + return channel + } } } From ec168d9cf5b99c1481d305b647e3b0b2ed9b7915 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 16:27:55 +0100 Subject: [PATCH 66/85] Remove cooldown protections --- Sources/StreamChat/StateLayer/Chat.swift | 3 --- Sources/StreamChat/Workers/ChannelUpdater.swift | 5 ----- 2 files changed, 8 deletions(-) diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index ccec50b9f1..1e0d3a83e0 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -1283,9 +1283,6 @@ public class Chat { /// /// - Throws: An error while communicating with the Stream API or when setting an invalid duration. public func enableSlowMode(cooldownDuration: Int) async throws { - guard cooldownDuration >= 1, cooldownDuration <= 120 else { - throw ClientError.InvalidCooldownDuration() - } try await channelUpdater.enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) } diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 46bcf4b1ae..bdcc6f0a3e 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -595,11 +595,6 @@ class ChannelUpdater: Worker { /// - cooldownDuration: Duration of the time interval users have to wait between messages. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. func enableSlowMode(cid: ChannelId, cooldownDuration: Int, completion: ((Error?) -> Void)? = nil) { - guard cooldownDuration >= 1, cooldownDuration <= 120 else { - completion?(ClientError.InvalidCooldownDuration()) - return - } - apiClient.request(endpoint: .enableSlowMode(cid: cid, cooldownDuration: cooldownDuration)) { completion?($0.error) } From 6289e5acb2c0373c1bd21a28a5a54925ffa954ee Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 16:51:11 +0100 Subject: [PATCH 67/85] Fix creating a new message not resuming the controller --- DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift | 4 ++++ .../ChannelController/LivestreamChannelController.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift index c6a4190cd5..fc5de057d1 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift @@ -297,6 +297,10 @@ class DemoLivestreamChatChannelVC: _ViewController, if isLastMessageFullyVisible && livestreamChannelController.isPaused { livestreamChannelController.resume() } + + if isLastMessageFullyVisible { + messageListVC.scrollToBottomButton.isHidden = true + } } func chatMessageListVC( diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 3696ad02be..ea18cf8598 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -863,7 +863,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel // If paused and the message is from the current user, load the first page // to go back to the latest messages if isPaused && message.author.id == currentUserId { - loadFirstPage() + resume() } } From cae85c77ffdaa99a949aca5730578304122d1a4e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 16:58:25 +0100 Subject: [PATCH 68/85] Save the channel to the DB on the first page fetch --- .../ChannelController/LivestreamChannelController.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index ea18cf8598..f3a3c57e9c 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -720,6 +720,13 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel switch result { case .success(let payload): + // If it is the first page, save channel to the DB to make sure manual event handling + // can fetch the channel from the DB. + if channelQuery.pagination == nil { + client.databaseContainer.write { session in + try session.saveChannel(payload: payload) + } + } self.handleChannelPayload(payload, channelQuery: channelQuery) completion?(nil) From 129255210864a12f279a3e08eea5450f2b1fae42 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 17:36:49 +0100 Subject: [PATCH 69/85] Remove unnecessary isBounce in live reactions view --- .../Screens/Livestream/DemoLivestreamChatMessageListVC.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift index a61cbccbf1..1603443a61 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift @@ -22,11 +22,6 @@ class DemoLivestreamChatMessageListVC: ChatMessageListVC { let livestreamChannelController = livestreamChannelController else { return } - if message.isBounced { - showActions(forDebouncedMessage: message) - return - } - // Create the custom livestream actions view controller let actionsController = DemoLivestreamMessageActionsVC() actionsController.message = message From dde3c21fec157ab31b824c91758389570ac28d44 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 1 Aug 2025 17:37:14 +0100 Subject: [PATCH 70/85] Add loadPinnedMessage to livestream controller --- .../LivestreamChannelController.swift | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index f3a3c57e9c..90898d2396 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -600,6 +600,52 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel } } + /// Loads the pinned messages of the current channel. + /// + /// - Parameters: + /// - pageSize: The number of pinned messages to load. Equals to `25` by default. + /// - sorting: The sorting options. By default, results are sorted descending by `pinned_at` field. + /// - pagination: The pagination parameter. If `nil` is provided, most recently pinned messages are fetched. + public func loadPinnedMessages( + pageSize: Int = .messagesPageSize, + sorting: [Sorting] = [], + pagination: PinnedMessagesPagination? = nil, + completion: @escaping @MainActor (Result<[ChatMessage], Error>) -> Void + ) { + guard let cid else { + callback { + completion(.failure(ClientError.ChannelNotCreatedYet())) + } + return + } + + let query = PinnedMessagesQuery( + pageSize: pageSize, + sorting: sorting, + pagination: pagination + ) + + apiClient.request(endpoint: .pinnedMessages(cid: cid, query: query)) { [weak self] result in + self?.callback { + switch result { + case .success(let payload): + let reads = self?.channel?.reads ?? [] + let currentUserId = self?.client.currentUserId + let messages = payload.messages.map { + $0.asModel( + cid: cid, + currentUserId: currentUserId, + channelReads: reads + ) + } + completion(.success(messages)) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + // Returns the current cooldown time for the channel. Returns 0 in case there is no cooldown active. public func currentCooldownTime() -> Int { guard let cooldownDuration = channel?.cooldownDuration, cooldownDuration > 0, From 19f96703039c3d17398915679faab256c89d6425 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 4 Aug 2025 14:05:15 +0100 Subject: [PATCH 71/85] Handle channel updates through the DB --- .../Workers/ManualEventHandler.swift | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift index e670c16e40..abdda327d5 100644 --- a/Sources/StreamChat/Workers/ManualEventHandler.swift +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -79,9 +79,6 @@ class ManualEventHandler { case .reactionDeleted: return createReactionDeletedEvent(from: eventPayload, cid: cid) - case .channelUpdated: - return createChannelUpdatedEvent(from: eventPayload, cid: cid) - default: return nil } @@ -224,23 +221,6 @@ class ManualEventHandler { ) } - private func createChannelUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ChannelUpdatedEvent? { - guard - let createdAt = payload.createdAt, - let channel = payload.channel?.asModel() - else { return nil } - - let currentUserId = database.writableContext.currentUser?.user.id - let channelReads = channel.reads - - return ChannelUpdatedEvent( - channel: channel, - user: payload.user?.asModel(), - message: payload.message?.asModel(cid: cid, currentUserId: currentUserId, channelReads: channelReads), - createdAt: createdAt - ) - } - // This is only needed because some events wrongly require the channel to create them. private func getLocalChannel(id: ChannelId) -> ChatChannel? { queue.sync { From 5d4dae14719d0e9ad5fa9153c984c094689d7d8f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 12:09:52 +0100 Subject: [PATCH 72/85] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf7a0a7eb..c4d353857b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ✅ Added - Add pending messages support [#3754](https://github.com/GetStream/stream-chat-swift/pull/3754) +- Add new lightweight `LivestreamChannelController` that improves performance for live chats [#3750](https://github.com/GetStream/stream-chat-swift/pull/3750) ### 🐞 Fixed - Fix `ChatClient.currentUserId` not removed instantly after calling `logout()` [#3766](https://github.com/GetStream/stream-chat-swift/pull/3766) From 6fb74a9df381e9f72d3664dd313d9af594d61834 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 12:47:41 +0100 Subject: [PATCH 73/85] Fix existing tests --- .../Workers/ChannelUpdater_Mock.swift | 17 +++++++- Tests/StreamChatTests/ChatClient_Tests.swift | 2 +- .../ChannelController_Tests.swift | 39 +++---------------- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift index 316da120c8..98e91c4617 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift @@ -112,6 +112,10 @@ final class ChannelUpdater_Mock: ChannelUpdater { @Atomic var enableSlowMode_completion: ((Error?) -> Void)? @Atomic var enableSlowMode_completion_result: Result? + @Atomic var disableSlowMode_cid: ChannelId? + @Atomic var disableSlowMode_completion: ((Error?) -> Void)? + @Atomic var disableSlowMode_completion_result: Result? + @Atomic var startWatching_cid: ChannelId? @Atomic var startWatching_completion: ((Error?) -> Void)? @Atomic var startWatching_completion_result: Result? @@ -249,6 +253,10 @@ final class ChannelUpdater_Mock: ChannelUpdater { enableSlowMode_completion = nil enableSlowMode_completion_result = nil + disableSlowMode_cid = nil + disableSlowMode_completion = nil + disableSlowMode_completion_result = nil + startWatching_cid = nil startWatching_completion = nil startWatching_completion_result = nil @@ -293,6 +301,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { override var paginationState: MessagesPaginationState { mockPaginationState } + override func update( channelQuery: ChannelQuery, isInRecoveryMode: Bool, @@ -428,7 +437,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { hideHistory: Bool, completion: ((Error?) -> Void)? = nil ) { - self.addMembers( + addMembers( currentUserId: currentUserId, cid: cid, members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, @@ -507,6 +516,12 @@ final class ChannelUpdater_Mock: ChannelUpdater { enableSlowMode_completion_result?.invoke(with: completion) } + override func disableSlowMode(cid: ChannelId, completion: @escaping (((any Error)?) -> Void)) { + disableSlowMode_cid = cid + disableSlowMode_completion = completion + disableSlowMode_completion_result?.invoke(with: completion) + } + override func startWatching(cid: ChannelId, isInRecoveryMode: Bool, completion: ((Error?) -> Void)? = nil) { startWatching_cid = cid startWatching_completion = completion diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 4a5e38ac2e..94896f0472 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -1129,7 +1129,7 @@ private class TestEnvironment { return self.eventDecoder! }, notificationCenterBuilder: { - self.notificationCenter = EventNotificationCenter_Mock(database: $0) + self.notificationCenter = EventNotificationCenter_Mock(database: $0, manualEventHandler: $1) return self.notificationCenter! }, internetConnection: { diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 6d7f62a015..9a517efc63 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -4422,33 +4422,6 @@ final class ChannelController_Tests: XCTestCase { XCTAssertNil(error) } - func test_enableSlowMode_failsForInvalidCooldown() throws { - // Create `ChannelController` for new channel - let query = ChannelQuery(channelPayload: .unique) - setupControllerForNewChannel(query: query) - - // Simulate successful backend channel creation - env.channelUpdater!.update_onChannelCreated?(query.cid!) - - // Simulate `enableSlowMode` call with invalid cooldown and assert error is returned - var error: Error? = try waitFor { [callbackQueueID] completion in - controller.enableSlowMode(cooldownDuration: .random(in: 130...250)) { error in - AssertTestQueue(withId: callbackQueueID) - completion(error) - } - } - XCTAssert(error is ClientError.InvalidCooldownDuration) - - // Simulate `enableSlowMode` call with another invalid cooldown and assert error is returned - error = try waitFor { [callbackQueueID] completion in - controller.enableSlowMode(cooldownDuration: .random(in: -100...0)) { error in - AssertTestQueue(withId: callbackQueueID) - completion(error) - } - } - XCTAssert(error is ClientError.InvalidCooldownDuration) - } - func test_enableSlowMode_callsChannelUpdater() { // Simulate `enableSlowMode` call and catch the completion var completionCalled = false @@ -4521,7 +4494,7 @@ final class ChannelController_Tests: XCTestCase { AssertTestQueue(withId: callbackQueueID) completion(error) } - env.channelUpdater!.enableSlowMode_completion?(nil) + env.channelUpdater!.disableSlowMode_completion?(nil) } XCTAssertNil(error) @@ -4544,15 +4517,13 @@ final class ChannelController_Tests: XCTestCase { controller = nil // Assert cid is passed to `channelUpdater`, completion is not called yet - XCTAssertEqual(env.channelUpdater!.enableSlowMode_cid, channelId) - // Assert that passed cooldown duration is 0 - XCTAssertEqual(env.channelUpdater!.enableSlowMode_cooldownDuration, 0) + XCTAssertEqual(env.channelUpdater!.disableSlowMode_cid, channelId) XCTAssertFalse(completionCalled) // Simulate successful update - env.channelUpdater!.enableSlowMode_completion?(nil) + env.channelUpdater!.disableSlowMode_completion?(nil) // Release reference of completion so we can deallocate stuff - env.channelUpdater!.enableSlowMode_completion = nil + env.channelUpdater!.disableSlowMode_completion = nil // Assert completion is called AssertAsync.willBeTrue(completionCalled) @@ -4570,7 +4541,7 @@ final class ChannelController_Tests: XCTestCase { // Simulate failed update let testError = TestError() - env.channelUpdater!.enableSlowMode_completion?(testError) + env.channelUpdater!.disableSlowMode_completion?(testError) // Completion should be called with the error AssertAsync.willBeEqual(completionCalledError as? TestError, testError) From 45bfbe7b5b39b09281a37ccaf0aaf824f2ab5747 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 14:04:27 +0100 Subject: [PATCH 74/85] Remove ChannelDetailPayload as model since it is not used --- .../ChannelPayload+asModel.swift | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift index 3be745ae26..8b251d40f1 100644 --- a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift @@ -72,47 +72,6 @@ extension ChannelPayload { } } -extension ChannelDetailPayload { - func asModel() -> ChatChannel { - ChatChannel( - cid: cid, - name: name, - imageURL: imageURL, - lastMessageAt: lastMessageAt, - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - truncatedAt: truncatedAt, - isHidden: false, - createdBy: createdBy?.asModel(), - config: config, - ownCapabilities: Set(ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), - isFrozen: isFrozen, - isDisabled: isDisabled, - isBlocked: isBlocked ?? false, - lastActiveMembers: members?.compactMap { $0.asModel(channelId: cid) } ?? [], - membership: nil, - currentlyTypingUsers: [], - lastActiveWatchers: [], - team: team, - unreadCount: ChannelUnreadCount(messages: 0, mentions: 0), - watcherCount: 0, - memberCount: memberCount, - reads: [], - cooldownDuration: cooldownDuration, - extraData: extraData, - latestMessages: [], - lastMessageFromCurrentUser: nil, - pinnedMessages: [], - pendingMessages: [], - muteDetails: nil, - previewMessage: nil, - draftMessage: nil, - activeLiveLocations: [] - ) - } -} - extension MemberPayload { /// Converts the MemberPayload to a ChatChannelMember model /// - Parameter channelId: The channel ID the member belongs to From 6460edac4037f7c9cf271334a5178536d49d008f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 15:12:46 +0100 Subject: [PATCH 75/85] Fix tests related to pending messages --- Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift index 38297c8450..072e2b828e 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -342,8 +342,9 @@ final class ChannelDTO_Tests: XCTestCase { // Pinned message should be older than `message` to ensure it's not returned first in `latestMessages` let pinnedMessage = dummyPinnedMessagePayload(createdAt: .unique(before: messageCreatedAt)) - - let pendingMessage = dummyMessagePayload(createdAt: messageCreatedAt) + + // Same of pending messages + let pendingMessage = dummyMessagePayload(createdAt: .unique(before: messageCreatedAt)) let payload = dummyPayload( with: channelId, From 82b4f85fcba8632941e1ebb3a76817d82440dc5d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 15:32:37 +0100 Subject: [PATCH 76/85] Add test coverage to user payload model conversion --- .../Payloads/UserPayloads_Tests.swift | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift index 102606006d..1e7dccb4f8 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift @@ -104,6 +104,116 @@ final class UserPayload_Tests: XCTestCase { XCTAssertEqual(payload.threads[0].lastReadMessageId, "6e75266e-c8e9-49f9-be87-f8e745e94821") XCTAssertEqual(payload.threads[0].parentMessageId, "6e75266e-c8e9-49f9-be87-f8e745e94821") } + + // MARK: - UserPayload.asModel() Tests + + func test_userPayload_asModel_convertsAllPropertiesCorrectly() { + // Given: UserPayload with all properties set + let userId = "test-user-id" + let name = "Test User" + let imageURL = URL(string: "https://example.com/avatar.png")! + let role = UserRole.admin + let teamsRole = ["ios": UserRole.guest, "android": UserRole.admin] + let createdAt = Date(timeIntervalSince1970: 1_609_459_200) // 2021-01-01 + let updatedAt = Date(timeIntervalSince1970: 1_609_545_600) // 2021-01-02 + let deactivatedAt = Date(timeIntervalSince1970: 1_609_632_000) // 2021-01-03 + let lastActiveAt = Date(timeIntervalSince1970: 1_609_718_400) // 2021-01-04 + let isOnline = true + let isBanned = false + let teams = ["team1", "team2", "team3"] + let language = "en" + let avgResponseTime = 30 + let extraData: [String: RawJSON] = ["custom_field": .string("custom_value")] + + let payload = UserPayload( + id: userId, + name: name, + imageURL: imageURL, + role: role, + teamsRole: teamsRole, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: deactivatedAt, + lastActiveAt: lastActiveAt, + isOnline: isOnline, + isInvisible: false, + isBanned: isBanned, + teams: teams, + language: language, + avgResponseTime: avgResponseTime, + extraData: extraData + ) + + // When: Converting to ChatUser model + let chatUser = payload.asModel() + + // Then: All properties are correctly mapped + XCTAssertEqual(chatUser.id, userId) + XCTAssertEqual(chatUser.name, name) + XCTAssertEqual(chatUser.imageURL, imageURL) + XCTAssertEqual(chatUser.isOnline, isOnline) + XCTAssertEqual(chatUser.isBanned, isBanned) + XCTAssertEqual(chatUser.isFlaggedByCurrentUser, false) // Always false in conversion + XCTAssertEqual(chatUser.userRole, role) + XCTAssertEqual(chatUser.teamsRole, teamsRole) + XCTAssertEqual(chatUser.userCreatedAt, createdAt) + XCTAssertEqual(chatUser.userUpdatedAt, updatedAt) + XCTAssertEqual(chatUser.userDeactivatedAt, deactivatedAt) + XCTAssertEqual(chatUser.lastActiveAt, lastActiveAt) + XCTAssertEqual(chatUser.teams, Set(teams)) + XCTAssertEqual(chatUser.language?.languageCode, language) + XCTAssertEqual(chatUser.avgResponseTime, avgResponseTime) + XCTAssertEqual(chatUser.extraData, extraData) + } + + func test_userPayload_asModel_withNilValues_handlesCorrectly() { + // Given: UserPayload with nil optional values + let userId = "test-user-id-nil" + let role = UserRole.user + let createdAt = Date() + let updatedAt = Date() + let extraData: [String: RawJSON] = [:] + + let payload = UserPayload( + id: userId, + name: nil, + imageURL: nil, + role: role, + teamsRole: nil, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: nil, + lastActiveAt: nil, + isOnline: false, + isInvisible: true, + isBanned: true, + teams: [], + language: nil, + avgResponseTime: nil, + extraData: extraData + ) + + // When: Converting to ChatUser model + let chatUser = payload.asModel() + + // Then: Nil values are correctly handled + XCTAssertEqual(chatUser.id, userId) + XCTAssertNil(chatUser.name) + XCTAssertNil(chatUser.imageURL) + XCTAssertEqual(chatUser.isOnline, false) + XCTAssertEqual(chatUser.isBanned, true) + XCTAssertEqual(chatUser.isFlaggedByCurrentUser, false) + XCTAssertEqual(chatUser.userRole, role) + XCTAssertNil(chatUser.teamsRole) + XCTAssertEqual(chatUser.userCreatedAt, createdAt) + XCTAssertEqual(chatUser.userUpdatedAt, updatedAt) + XCTAssertNil(chatUser.userDeactivatedAt) + XCTAssertNil(chatUser.lastActiveAt) + XCTAssertEqual(chatUser.teams, Set()) + XCTAssertNil(chatUser.language) + XCTAssertNil(chatUser.avgResponseTime) + XCTAssertEqual(chatUser.extraData, extraData) + } } final class UserRequestBody_Tests: XCTestCase { From 9b4bf9026742f41764d8ffaff3e3ee3f23fb0fea Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 16:07:20 +0100 Subject: [PATCH 77/85] Add test coverage to message payload model conversion --- .../Payloads/MessagePayloads_Tests.swift | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift index 65e35c8dc8..0eb7c0e012 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift @@ -137,6 +137,204 @@ final class MessagePayload_Tests: XCTestCase { XCTAssertEqual(payload.quotedMessageId, "4C0CC2DA-8AB5-421F-808E-50DC7E40653D") XCTAssertEqual(payload.translations, [.italian: "si sono qui", .dutch: "ja ik ben hier"]) } + + // MARK: - MessagePayload.asModel() Tests + + func test_messagePayload_asModel_convertsAllPropertiesCorrectly() { + let messageId = "test-message-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + let currentUserId = "current-user-id" + let userPayload = UserPayload.dummy(userId: "author-user-id", name: "Test Author") + let mentionedUserPayload = UserPayload.dummy(userId: "mentioned-user-id", name: "Mentioned User") + let threadParticipantPayload = UserPayload.dummy(userId: "participant-user-id", name: "Thread Participant") + let pinnedByPayload = UserPayload.dummy(userId: "pinned-by-user-id", name: "Pinned By User") + let quotedMessagePayload = MessagePayload.dummy(messageId: "quoted-message-id", text: "Quoted message text") + let reactionPayload = MessageReactionPayload( + type: MessageReactionType(rawValue: "love"), + score: 1, + messageId: "123", + createdAt: Date(timeIntervalSince1970: 1_609_459_300), + updatedAt: Date(timeIntervalSince1970: 1_609_459_300), + user: userPayload, + extraData: [:] + ) + + let payload = MessagePayload( + id: messageId, + type: .regular, + user: userPayload, + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + updatedAt: Date(timeIntervalSince1970: 1_609_459_250), + deletedAt: Date(timeIntervalSince1970: 1_609_459_300), + text: "Test message text", + command: "test-command", + args: "test-args", + parentId: "parent-message-id", + showReplyInChannel: true, + quotedMessageId: "quoted-message-id", + quotedMessage: quotedMessagePayload, + mentionedUsers: [mentionedUserPayload], + threadParticipants: [threadParticipantPayload], + replyCount: 5, + extraData: ["custom_field": .string("custom_value")], + latestReactions: [reactionPayload], + ownReactions: [reactionPayload], + reactionScores: ["love": 1], + reactionCounts: ["love": 1], + reactionGroups: [:], + isSilent: true, + isShadowed: true, + attachments: [], + channel: nil, + pinned: true, + pinnedBy: pinnedByPayload, + pinnedAt: Date(timeIntervalSince1970: 1_609_459_400), + pinExpires: Date(timeIntervalSince1970: 1_609_459_500), + translations: [.spanish: "Texto del mensaje de prueba"], + originalLanguage: "en", + moderation: nil, + moderationDetails: nil, + messageTextUpdatedAt: Date(timeIntervalSince1970: 1_609_459_350), poll: nil, + reminder: nil, + location: nil + ) + + let channelReads = [ + ChatChannelRead( + lastReadAt: Date(timeIntervalSince1970: 1_609_459_600), + lastReadMessageId: "read-message-id", + unreadMessagesCount: 0, + user: ChatUser.mock( + id: "reader-user-id", + name: "Reader User" + ) + ) + ] + + let chatMessage = payload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channelReads) + + XCTAssertEqual(chatMessage.id, messageId) + XCTAssertEqual(chatMessage.cid, cid) + XCTAssertEqual(chatMessage.text, "Test message text") + XCTAssertEqual(chatMessage.type, .regular) + XCTAssertEqual(chatMessage.command, "test-command") + XCTAssertEqual(chatMessage.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatMessage.updatedAt, Date(timeIntervalSince1970: 1_609_459_250)) + XCTAssertEqual(chatMessage.deletedAt, Date(timeIntervalSince1970: 1_609_459_300)) + XCTAssertEqual(chatMessage.arguments, "test-args") + XCTAssertEqual(chatMessage.parentMessageId, "parent-message-id") + XCTAssertEqual(chatMessage.showReplyInChannel, true) + XCTAssertEqual(chatMessage.replyCount, 5) + XCTAssertEqual(chatMessage.extraData, ["custom_field": .string("custom_value")]) + XCTAssertEqual(chatMessage.isSilent, true) + XCTAssertEqual(chatMessage.isShadowed, true) + XCTAssertEqual(chatMessage.reactionScores, ["love": 1]) + XCTAssertEqual(chatMessage.reactionCounts, ["love": 1]) + XCTAssertEqual(chatMessage.author.id, "author-user-id") + XCTAssertEqual(chatMessage.mentionedUsers.first?.id, "mentioned-user-id") + XCTAssertEqual(chatMessage.threadParticipants.first?.id, "participant-user-id") + XCTAssertEqual(chatMessage.isSentByCurrentUser, false) + XCTAssertNotNil(chatMessage.pinDetails) + XCTAssertEqual(chatMessage.pinDetails?.pinnedAt, Date(timeIntervalSince1970: 1_609_459_400)) + XCTAssertEqual(chatMessage.pinDetails?.expiresAt, Date(timeIntervalSince1970: 1_609_459_500)) + XCTAssertEqual(chatMessage.pinDetails?.pinnedBy.id, "pinned-by-user-id") + XCTAssertEqual(chatMessage.quotedMessage?.id, "quoted-message-id") + XCTAssertEqual(chatMessage.translations, [.spanish: "Texto del mensaje de prueba"]) + XCTAssertEqual(chatMessage.originalLanguage?.languageCode, "en") + XCTAssertEqual(chatMessage.textUpdatedAt, Date(timeIntervalSince1970: 1_609_459_350)) + XCTAssertEqual(chatMessage.latestReactions.count, 1) + XCTAssertEqual(chatMessage.currentUserReactions.count, 1) + XCTAssertFalse(chatMessage.isFlaggedByCurrentUser) + } + + func test_messagePayload_asModel_withMinimalData_handlesCorrectly() { + let messageId = "minimal-message-id" + let cid = ChannelId(type: .messaging, id: "minimal-channel") + let currentUserId = "current-user-id" + let userPayload = UserPayload.dummy(userId: currentUserId, name: "Current User") + let payload = MessagePayload( + id: messageId, + type: .regular, + user: userPayload, + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + updatedAt: Date(timeIntervalSince1970: 1_609_459_200), + deletedAt: nil, + text: "Minimal message", + command: nil, + args: nil, + parentId: nil, + showReplyInChannel: false, + quotedMessageId: nil, + quotedMessage: nil, + mentionedUsers: [], + threadParticipants: [], + replyCount: 0, + extraData: [:], + latestReactions: [], + ownReactions: [], + reactionScores: [:], + reactionCounts: [:], + reactionGroups: [:], + isSilent: false, + isShadowed: false, + attachments: [], + channel: nil, + pinned: false, + pinnedBy: nil, + pinnedAt: nil, + pinExpires: nil, + translations: nil, + originalLanguage: nil, + moderation: nil, + moderationDetails: nil, + messageTextUpdatedAt: nil, + poll: nil, + reminder: nil, + location: nil + ) + + let chatMessage = payload.asModel(cid: cid, currentUserId: currentUserId, channelReads: []) + + XCTAssertEqual(chatMessage.id, messageId) + XCTAssertEqual(chatMessage.cid, cid) + XCTAssertEqual(chatMessage.text, "Minimal message") + XCTAssertEqual(chatMessage.type, .regular) + XCTAssertNil(chatMessage.command) + XCTAssertEqual(chatMessage.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatMessage.updatedAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertNil(chatMessage.deletedAt) + XCTAssertNil(chatMessage.arguments) + XCTAssertNil(chatMessage.parentMessageId) + XCTAssertEqual(chatMessage.showReplyInChannel, false) + XCTAssertEqual(chatMessage.replyCount, 0) + XCTAssertEqual(chatMessage.extraData, [:]) + XCTAssertEqual(chatMessage.isSilent, false) + XCTAssertEqual(chatMessage.isShadowed, false) + XCTAssertEqual(chatMessage.reactionScores, [:]) + XCTAssertEqual(chatMessage.reactionCounts, [:]) + XCTAssertEqual(chatMessage.author.id, currentUserId) + XCTAssertTrue(chatMessage.mentionedUsers.isEmpty) + XCTAssertTrue(chatMessage.threadParticipants.isEmpty) + XCTAssertTrue(chatMessage.isSentByCurrentUser) + XCTAssertNil(chatMessage.pinDetails) + XCTAssertNil(chatMessage.quotedMessage) + XCTAssertNil(chatMessage.translations) + XCTAssertNil(chatMessage.originalLanguage) + XCTAssertNil(chatMessage.textUpdatedAt) + XCTAssertTrue(chatMessage.latestReactions.isEmpty) + XCTAssertTrue(chatMessage.currentUserReactions.isEmpty) + XCTAssertFalse(chatMessage.isFlaggedByCurrentUser) + XCTAssertTrue(chatMessage.readBy.isEmpty) + XCTAssertTrue(chatMessage.allAttachments.isEmpty) + XCTAssertTrue(chatMessage.latestReplies.isEmpty) + XCTAssertNil(chatMessage.localState) + XCTAssertNil(chatMessage.locallyCreatedAt) + XCTAssertFalse(chatMessage.isBounced) + XCTAssertNil(chatMessage.moderationDetails) + XCTAssertNil(chatMessage.poll) + XCTAssertNil(chatMessage.reminder) + XCTAssertNil(chatMessage.sharedLocation) + } } final class MessageRequestBody_Tests: XCTestCase { From 62102d8e339b59400138c58a5c8ad0adeb7047e5 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 17:08:37 +0100 Subject: [PATCH 78/85] Add test coverage to channel payload model conversion --- .../Payloads/ChannelListPayload_Tests.swift | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 62a4c445e2..060d151923 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -397,4 +397,201 @@ final class ChannelPayload_Tests: XCTestCase { // THEN XCTAssertEqual(payload.newestMessage?.id, laterMessage.id) } + + // MARK: - ChannelPayload.asModel() Tests + + func test_channelPayload_asModel_convertsAllPropertiesCorrectly() { + let currentUserId = "current-user-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + + let createdByPayload = UserPayload.dummy(userId: "creator-user-id", name: "Channel Creator") + let memberPayload = MemberPayload.dummy(user: UserPayload.dummy(userId: "member-user-id"), role: .member) + let watcherPayload = UserPayload.dummy(userId: "watcher-user-id", name: "Channel Watcher") + let messagePayload = MessagePayload.dummy(messageId: "message-id", authorUserId: "author-id") + let pinnedMessagePayload = MessagePayload.dummy(messageId: "pinned-message-id", authorUserId: "pinned-author-id") + let pendingMessagePayload = MessagePayload.dummy(messageId: "pending-message-id", authorUserId: "pending-author-id") + + let channelReadPayload = ChannelReadPayload( + user: UserPayload.dummy(userId: "reader-user-id", name: "Reader User"), + lastReadAt: Date(timeIntervalSince1970: 1_609_459_400), + lastReadMessageId: "last-read-message-id", + unreadMessagesCount: 5 + ) + + let membershipPayload = MemberPayload.dummy(user: .dummy(userId: currentUserId), role: .admin) + + let channel = ChannelDetailPayload( + cid: cid, + name: "Test Channel", + imageURL: URL(string: "https://example.com/channel.png"), + extraData: ["custom_field": .string("custom_value")], + typeRawValue: "messaging", + lastMessageAt: Date(timeIntervalSince1970: 1_609_459_500), + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + deletedAt: Date(timeIntervalSince1970: 1_609_459_600), + updatedAt: Date(timeIntervalSince1970: 1_609_459_300), + truncatedAt: Date(timeIntervalSince1970: 1_609_459_250), + createdBy: createdByPayload, + config: ChannelConfig(), + ownCapabilities: ["send-message", "upload-file"], + isDisabled: true, + isFrozen: true, + isBlocked: true, + isHidden: true, + members: [memberPayload], + memberCount: 10, + team: "team-id", + cooldownDuration: 30 + ) + + let typingUsers = Set([ChatUser.mock(id: "typing-user-id", name: "Typing User")]) + let unreadCount = ChannelUnreadCount(messages: 3, mentions: 1) + + let payload = ChannelPayload( + channel: channel, + watcherCount: 5, + watchers: [watcherPayload], + members: [memberPayload], + membership: membershipPayload, + messages: [messagePayload], + pendingMessages: [pendingMessagePayload], + pinnedMessages: [pinnedMessagePayload], + channelReads: [channelReadPayload], + isHidden: true, + draft: nil, + activeLiveLocations: [] + ) + + let chatChannel = payload.asModel( + currentUserId: currentUserId, + currentlyTypingUsers: typingUsers, + unreadCount: unreadCount + ) + + XCTAssertEqual(chatChannel.cid, cid) + XCTAssertEqual(chatChannel.name, "Test Channel") + XCTAssertEqual(chatChannel.imageURL, URL(string: "https://example.com/channel.png")) + XCTAssertEqual(chatChannel.lastMessageAt, Date(timeIntervalSince1970: 1_609_459_500)) + XCTAssertEqual(chatChannel.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatChannel.updatedAt, Date(timeIntervalSince1970: 1_609_459_300)) + XCTAssertEqual(chatChannel.deletedAt, Date(timeIntervalSince1970: 1_609_459_600)) + XCTAssertEqual(chatChannel.truncatedAt, Date(timeIntervalSince1970: 1_609_459_250)) + XCTAssertEqual(chatChannel.isHidden, true) + XCTAssertEqual(chatChannel.createdBy?.id, "creator-user-id") + XCTAssertNotNil(chatChannel.config) + XCTAssertTrue(chatChannel.ownCapabilities.contains(.sendMessage)) + XCTAssertTrue(chatChannel.ownCapabilities.contains(.uploadFile)) + XCTAssertEqual(chatChannel.isFrozen, true) + XCTAssertEqual(chatChannel.isDisabled, true) + XCTAssertEqual(chatChannel.isBlocked, true) + XCTAssertEqual(chatChannel.lastActiveMembers.count, 1) + XCTAssertEqual(chatChannel.lastActiveMembers.first?.id, "member-user-id") + XCTAssertEqual(chatChannel.membership?.id, currentUserId) + XCTAssertEqual(chatChannel.currentlyTypingUsers, typingUsers) + XCTAssertEqual(chatChannel.lastActiveWatchers.count, 1) + XCTAssertEqual(chatChannel.lastActiveWatchers.first?.id, "watcher-user-id") + XCTAssertEqual(chatChannel.team, "team-id") + XCTAssertEqual(chatChannel.unreadCount, unreadCount) + XCTAssertEqual(chatChannel.watcherCount, 5) + XCTAssertEqual(chatChannel.memberCount, 10) + XCTAssertEqual(chatChannel.reads.count, 1) + XCTAssertEqual(chatChannel.reads.first?.user.id, "reader-user-id") + XCTAssertEqual(chatChannel.cooldownDuration, 30) + XCTAssertEqual(chatChannel.extraData, ["custom_field": .string("custom_value")]) + XCTAssertEqual(chatChannel.latestMessages.count, 1) + XCTAssertEqual(chatChannel.latestMessages.first?.id, "message-id") + XCTAssertEqual(chatChannel.pinnedMessages.count, 1) + XCTAssertEqual(chatChannel.pinnedMessages.first?.id, "pinned-message-id") + XCTAssertEqual(chatChannel.pendingMessages.count, 1) + XCTAssertEqual(chatChannel.pendingMessages.first?.id, "pending-message-id") + XCTAssertNil(chatChannel.muteDetails) + XCTAssertNotNil(chatChannel.previewMessage) + XCTAssertEqual(chatChannel.previewMessage?.id, "message-id") + XCTAssertTrue(chatChannel.activeLiveLocations.isEmpty) + } + + func test_channelPayload_asModel_withMinimalData_handlesCorrectly() { + let currentUserId = "current-user-id" + let cid = ChannelId(type: .messaging, id: "minimal-channel") + + let channel = ChannelDetailPayload( + cid: cid, + name: "Minimal Channel", + imageURL: nil, + extraData: [:], + typeRawValue: "messaging", + lastMessageAt: Date(timeIntervalSince1970: 1_609_459_200), + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + deletedAt: nil, + updatedAt: Date(timeIntervalSince1970: 1_609_459_200), + truncatedAt: nil, + createdBy: nil, + config: ChannelConfig(), + ownCapabilities: nil, + isDisabled: false, + isFrozen: false, + isBlocked: nil, + isHidden: nil, + members: nil, + memberCount: 0, + team: nil, + cooldownDuration: 0 + ) + + let payload = ChannelPayload( + channel: channel, + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: nil, + activeLiveLocations: [] + ) + + let chatChannel = payload.asModel( + currentUserId: currentUserId, + currentlyTypingUsers: nil, + unreadCount: nil + ) + + XCTAssertEqual(chatChannel.cid, cid) + XCTAssertEqual(chatChannel.name, "Minimal Channel") + XCTAssertNil(chatChannel.imageURL) + XCTAssertEqual(chatChannel.lastMessageAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatChannel.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatChannel.updatedAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertNil(chatChannel.deletedAt) + XCTAssertNil(chatChannel.truncatedAt) + XCTAssertEqual(chatChannel.isHidden, false) + XCTAssertNil(chatChannel.createdBy) + XCTAssertNotNil(chatChannel.config) + XCTAssertTrue(chatChannel.ownCapabilities.isEmpty) + XCTAssertEqual(chatChannel.isFrozen, false) + XCTAssertEqual(chatChannel.isDisabled, false) + XCTAssertEqual(chatChannel.isBlocked, false) + XCTAssertTrue(chatChannel.lastActiveMembers.isEmpty) + XCTAssertNil(chatChannel.membership) + XCTAssertTrue(chatChannel.currentlyTypingUsers.isEmpty) + XCTAssertTrue(chatChannel.lastActiveWatchers.isEmpty) + XCTAssertNil(chatChannel.team) + XCTAssertEqual(chatChannel.unreadCount, .noUnread) + XCTAssertEqual(chatChannel.watcherCount, 0) + XCTAssertEqual(chatChannel.memberCount, 0) + XCTAssertTrue(chatChannel.reads.isEmpty) + XCTAssertEqual(chatChannel.cooldownDuration, 0) + XCTAssertEqual(chatChannel.extraData, [:]) + XCTAssertTrue(chatChannel.latestMessages.isEmpty) + XCTAssertTrue(chatChannel.pinnedMessages.isEmpty) + XCTAssertTrue(chatChannel.pendingMessages.isEmpty) + XCTAssertNil(chatChannel.muteDetails) + XCTAssertNil(chatChannel.previewMessage) + XCTAssertNil(chatChannel.lastMessageFromCurrentUser) + XCTAssertNil(chatChannel.draftMessage) + XCTAssertTrue(chatChannel.activeLiveLocations.isEmpty) + } } From 6c367ae38fc3d22e8ffc2e439168ace96b3776bb Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 17:16:08 +0100 Subject: [PATCH 79/85] Add missing tests to disable slow mode --- .../Workers/ChannelUpdater_Tests.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift index cf4d3448a0..f5c9783885 100644 --- a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift @@ -1770,6 +1770,42 @@ final class ChannelUpdater_Tests: XCTestCase { AssertAsync.willBeEqual(completionCalledError as? TestError, error) } + // MARK: - Disable slow mode + + func test_disableSlowMode_makesCorrectAPICall() { + let cid = ChannelId.unique + + channelUpdater.disableSlowMode(cid: cid) { _ in } + + // Assert that disableSlowMode calls enableSlowMode endpoint with cooldownDuration: 0 + let referenceEndpoint = Endpoint.enableSlowMode(cid: cid, cooldownDuration: 0) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + } + + func test_disableSlowMode_successfulResponse_isPropagatedToCompletion() { + var completionCalled = false + channelUpdater.disableSlowMode(cid: .unique) { error in + XCTAssertNil(error) + completionCalled = true + } + + XCTAssertFalse(completionCalled) + + apiClient.test_simulateResponse(Result.success(.init())) + + AssertAsync.willBeTrue(completionCalled) + } + + func test_disableSlowMode_errorResponse_isPropagatedToCompletion() { + var completionCalledError: Error? + channelUpdater.disableSlowMode(cid: .unique) { completionCalledError = $0 } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + AssertAsync.willBeEqual(completionCalledError as? TestError, error) + } + // MARK: - Start watching func test_startWatching_makesCorrectAPICall() { From 5d53af676dae5437c09d91b46e72754200ce2a2e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 20:09:21 +0100 Subject: [PATCH 80/85] Add test coverage to LivestreamChannelController --- .../LivestreamChannelController.swift | 68 +- StreamChat.xcodeproj/project.pbxproj | 4 + .../EventNotificationCenter_Mock.swift | 17 +- .../LivestreamChannelController_Tests.swift | 1823 +++++++++++++++++ 4 files changed, 1877 insertions(+), 35 deletions(-) create mode 100644 Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 90898d2396..7d15df635f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -174,15 +174,17 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// - client: The `Client` this controller belongs to. init( channelQuery: ChannelQuery, - client: ChatClient + client: ChatClient, + updater: ChannelUpdater? = nil, + paginationStateHandler: MessagesPaginationStateHandling = MessagesPaginationStateHandler() ) { self.channelQuery = channelQuery self.client = client apiClient = client.apiClient - paginationStateHandler = MessagesPaginationStateHandler() + self.paginationStateHandler = paginationStateHandler eventsController = client.eventsController() appStateObserver = StreamAppStateObserver() - updater = ChannelUpdater( + self.updater = updater ?? ChannelUpdater( channelRepository: client.channelRepository, messageRepository: client.messageRepository, paginationStateHandler: client.makeMessagesPaginationStateHandler(), @@ -208,7 +210,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// Synchronizes the controller with the backend data. /// - Parameter completion: Called when the synchronization is finished. - public func synchronize(_ completion: (@MainActor (_ error: Error?) -> Void)? = nil) { + public func synchronize(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil) { // Populate the initial data with existing cache. if loadInitialMessagesFromCache, let cid = self.cid, let channel = dataStore.channel(cid: cid) { self.channel = channel @@ -229,10 +231,10 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public func loadPreviousMessages( before messageId: MessageId? = nil, limit: Int? = nil, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { guard cid != nil else { - self.callback { + callback { completion?(ClientError.ChannelNotCreatedYet()) } return @@ -243,14 +245,14 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel ?? messages.last?.id guard let messageId = messageId else { - self.callback { + callback { completion?(ClientError.ChannelEmptyMessages()) } return } guard !hasLoadedAllPreviousMessages && !isLoadingPreviousMessages else { - self.callback { + callback { completion?(nil) } return @@ -271,10 +273,10 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public func loadNextMessages( after messageId: MessageId? = nil, limit: Int? = nil, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { guard cid != nil else { - self.callback { + callback { completion?(ClientError.ChannelNotCreatedYet()) } return @@ -285,14 +287,14 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel ?? messages.first?.id guard let messageId = messageId else { - self.callback { + callback { completion?(ClientError.ChannelEmptyMessages()) } return } guard !hasLoadedAllNextMessages && !isLoadingNextMessages else { - self.callback { + callback { completion?(nil) } return @@ -313,10 +315,10 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public func loadPageAroundMessageId( _ messageId: MessageId, limit: Int? = nil, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { guard !isLoadingMiddleMessages else { - self.callback { + callback { completion?(nil) } return @@ -331,7 +333,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// Cleans the current state and loads the first page again. /// - Parameter completion: Callback when the API call is completed. - public func loadFirstPage(_ completion: (@MainActor (_ error: Error?) -> Void)? = nil) { + public func loadFirstPage(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil) { var query = channelQuery query.pagination = .init( pageSize: channelQuery.pagination?.pageSize ?? .messagesPageSize, @@ -374,7 +376,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel restrictedVisibility: [UserId] = [], location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], - completion: (@MainActor (Result) -> Void)? = nil + completion: (@MainActor(Result) -> Void)? = nil ) { var transformableInfo = NewMessageTransformableInfo( text: text, @@ -412,7 +414,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public func deleteMessage( messageId: MessageId, hard: Bool = false, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request( endpoint: .deleteMessage( @@ -436,7 +438,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel for messageId: MessageId, limit: Int = 25, offset: Int = 0, - completion: @escaping @MainActor (Result<[ChatMessageReaction], Error>) -> Void + completion: @escaping @MainActor(Result<[ChatMessageReaction], Error>) -> Void ) { let pagination = Pagination(pageSize: limit, offset: offset) apiClient.request( @@ -467,7 +469,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel messageId: MessageId, reason: String? = nil, extraData: [String: RawJSON]? = nil, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request( endpoint: .flagMessage( @@ -490,7 +492,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// If request fails, the completion will be called with an error. public func unflag( messageId: MessageId, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request( endpoint: .flagMessage( @@ -524,7 +526,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel skipPush: Bool = false, pushEmojiCode: String? = nil, extraData: [String: RawJSON] = [:], - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request( endpoint: .addReaction( @@ -551,7 +553,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public func deleteReaction( _ type: MessageReactionType, from messageId: MessageId, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request(endpoint: .deleteReaction(type, messageId: messageId)) { [weak self] result in self?.callback { @@ -568,7 +570,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel public func pin( messageId: MessageId, pinning: MessagePinning = .noExpiration, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request(endpoint: .pinMessage( messageId: messageId, @@ -586,7 +588,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. public func unpin( messageId: MessageId, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { apiClient.request( endpoint: .pinMessage( @@ -610,7 +612,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel pageSize: Int = .messagesPageSize, sorting: [Sorting] = [], pagination: PinnedMessagesPagination? = nil, - completion: @escaping @MainActor (Result<[ChatMessage], Error>) -> Void + completion: @escaping @MainActor(Result<[ChatMessage], Error>) -> Void ) { guard let cid else { callback { @@ -668,7 +670,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// - cooldownDuration: Duration of the time interval users have to wait between messages. /// Specified in seconds. Should be between 1-120. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. - public func enableSlowMode(cooldownDuration: Int, completion: (@MainActor (Error?) -> Void)? = nil) { + public func enableSlowMode(cooldownDuration: Int, completion: (@MainActor(Error?) -> Void)? = nil) { guard let cid else { callback { completion?(ClientError.ChannelNotCreatedYet()) @@ -691,7 +693,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel /// /// - Parameters: /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. - public func disableSlowMode(completion: (@MainActor (Error?) -> Void)? = nil) { + public func disableSlowMode(completion: (@MainActor(Error?) -> Void)? = nil) { guard let cid else { callback { completion?(ClientError.ChannelNotCreatedYet()) @@ -735,9 +737,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel return } - callback { [weak self] in - self?.handleChannelEvent(event) - } + handleChannelEvent(event) } // MARK: - AppStateObserverDelegate @@ -751,7 +751,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel private func updateChannelData( channelQuery: ChannelQuery, - completion: (@MainActor (Error?) -> Void)? = nil + completion: (@MainActor(Error?) -> Void)? = nil ) { if let pagination = channelQuery.pagination { paginationStateHandler.begin(pagination: pagination) @@ -839,7 +839,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel } } - private func delegateCallback(_ callback: @escaping @MainActor (Delegate) -> Void) { + private func delegateCallback(_ callback: @escaping @MainActor(Delegate) -> Void) { self.callback { self.multicastDelegate.invoke(callback) } @@ -968,12 +968,12 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], poll: PollPayload?, - completion: (@MainActor (Result) -> Void)? = nil + completion: (@MainActor(Result) -> Void)? = nil ) { /// Perform action only if channel is already created on backend side and have a valid `cid`. guard let cid = cid else { let error = ClientError.ChannelNotCreatedYet() - self.callback { + callback { completion?(.failure(error)) } return diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 8375965a65..31bbd6576e 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1616,6 +1616,7 @@ AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */; }; AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */; }; AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */; }; + AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7EFDA52C77749300625FC5 /* PollCommentListVC.swift */; }; @@ -4415,6 +4416,7 @@ AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatMessageListVC.swift; sourceTree = ""; }; AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamMessageActionsVC.swift; sourceTree = ""; }; AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamReactionsListView.swift; sourceTree = ""; }; + AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController_Tests.swift; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -7522,6 +7524,7 @@ A364D0A827D128650029857A /* ChannelController */ = { isa = PBXGroup; children = ( + AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */, AD545E7C2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift */, 7952B3B224D314B100AC53D4 /* ChannelController_Tests.swift */, DA4AA3B32502719700FAAF6E /* ChannelController+Combine_Tests.swift */, @@ -12283,6 +12286,7 @@ 88DA577E2631D73800FA8C53 /* ChannelMuteDTO_Tests.swift in Sources */, 8459C9F42BFB929600F0D235 /* PollsRepository_Tests.swift in Sources */, 4F5151982BC407ED001B7152 /* UserList_Tests.swift in Sources */, + AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */, 84CC56EC267B3F6B00DF2784 /* AnyAttachmentPayload_Tests.swift in Sources */, AD545E812D5D0006008FD399 /* MessageController+Drafts_Tests.swift in Sources */, 88F7692B25837EE600BD36B0 /* AttachmentQueueUploader_Tests.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift index f2068ce8e9..6c05afdac0 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift @@ -7,7 +7,6 @@ import Foundation /// Mock implementation of `EventNotificationCenter` final class EventNotificationCenter_Mock: EventNotificationCenter, @unchecked Sendable { - override var newMessageIds: Set { newMessageIdsMock ?? super.newMessageIds } @@ -17,6 +16,22 @@ final class EventNotificationCenter_Mock: EventNotificationCenter, @unchecked Se lazy var mock_process = MockFunc<([Event], Bool, (() -> Void)?), Void>.mock(for: process) var mock_processCalledWithEvents: [Event] = [] + var registerManualEventHandling_calledWith: ChannelId? + var registerManualEventHandling_callCount = 0 + + var unregisterManualEventHandling_calledWith: ChannelId? + var unregisterManualEventHandling_callCount = 0 + + override func registerManualEventHandling(for cid: ChannelId) { + registerManualEventHandling_callCount += 1 + registerManualEventHandling_calledWith = cid + } + + override func unregisterManualEventHandling(for cid: ChannelId) { + unregisterManualEventHandling_callCount += 1 + unregisterManualEventHandling_calledWith = cid + } + override func process( _ events: [Event], postNotifications: Bool = true, diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift new file mode 100644 index 0000000000..e22b253ccb --- /dev/null +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -0,0 +1,1823 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class LivestreamChannelController_Tests: XCTestCase { + fileprivate var env: TestEnvironment! + + var client: ChatClient_Mock! + var channelQuery: ChannelQuery! + var controller: LivestreamChannelController! + + override func setUp() { + super.setUp() + + env = TestEnvironment() + client = ChatClient.mock(config: ChatClient_Mock.defaultMockedConfig) + channelQuery = ChannelQuery(cid: .unique) + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + } + + override func tearDown() { + client?.cleanUp() + env?.apiClient?.cleanUp() + env = nil + + AssertAsync { + Assert.canBeReleased(&controller) + Assert.canBeReleased(&client) + Assert.canBeReleased(&env) + } + + channelQuery = nil + + super.tearDown() + } +} + +// MARK: - TestEnvironment + +extension LivestreamChannelController_Tests { + fileprivate final class TestEnvironment { + var apiClient: APIClient_Spy? + var appStateObserver: MockAppStateObserver? + + init() { + apiClient = APIClient_Spy() + appStateObserver = MockAppStateObserver() + } + } +} + +// MARK: - Initialization Tests + +extension LivestreamChannelController_Tests { + func test_init_assignsValuesCorrectly() { + // Given + let channelQuery = ChannelQuery(cid: .unique) + let client = ChatClient.mock() + + // When + let controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + + // Then + XCTAssertEqual(controller.channelQuery.cid, channelQuery.cid) + XCTAssert(controller.client === client) + XCTAssertEqual(controller.cid, channelQuery.cid) + XCTAssertNil(controller.channel) + XCTAssertTrue(controller.messages.isEmpty) + XCTAssertFalse(controller.isPaused) + XCTAssertEqual(controller.skippedMessagesAmount, 0) + XCTAssertTrue(controller.loadInitialMessagesFromCache) + XCTAssertFalse(controller.countSkippedMessagesWhenPaused) + XCTAssertNil(controller.maxMessageLimitOptions) + } + + func test_init_registersForEventHandling() { + // Given + let cid = ChannelId.unique + let channelQuery = ChannelQuery(cid: cid) + let client = ChatClient_Mock.mock() + let eventNotificationCenter = EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + client.mockedEventNotificationCenter = eventNotificationCenter + + // When + _ = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + + // Then + XCTAssertEqual(eventNotificationCenter.registerManualEventHandling_callCount, 1) + XCTAssertEqual(eventNotificationCenter.registerManualEventHandling_calledWith, cid) + } + + func test_deinit_unregistersEventHandling() { + // Given + let cid = ChannelId.unique + let channelQuery = ChannelQuery(cid: cid) + let client = ChatClient_Mock.mock() + let eventNotificationCenter = EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + client.mockedEventNotificationCenter = eventNotificationCenter + + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + + // When + controller = nil + + // Then + XCTAssertEqual(eventNotificationCenter.unregisterManualEventHandling_callCount, 1) + XCTAssertEqual(eventNotificationCenter.unregisterManualEventHandling_calledWith, cid) + } +} + +// MARK: - Pagination Properties Tests + +extension LivestreamChannelController_Tests { + func test_hasLoadedAllNextMessages_whenMessagesArrayIsEmpty_thenReturnsTrue() { + // Given - messages array is empty by default + + // When + let result = controller.hasLoadedAllNextMessages + + // Then + XCTAssertTrue(result) + } +} + +// MARK: - Synchronize Tests + +extension LivestreamChannelController_Tests { + func test_synchronize_makesCorrectAPICall() { + // Given + let apiClient = client.mockAPIClient + + // When + controller.synchronize() + + // Then + let expectedEndpoint = Endpoint.updateChannel(query: channelQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_synchronize_withCache_loadsInitialDataFromCache() { + // Given + let cid = ChannelId.unique + controller = LivestreamChannelController( + channelQuery: ChannelQuery(cid: cid), + client: client + ) + controller.loadInitialMessagesFromCache = true + + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: [.dummy(), .dummy()] + ) + + // Save channel to cache + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: channelPayload) + } + + // When + controller.synchronize() + + // Then + XCTAssertNotNil(controller.channel) + XCTAssertEqual(controller.channel?.cid, cid) + XCTAssertEqual(controller.messages.count, 2) + } + + func test_synchronize_withoutCache_doesNotLoadFromCache() { + // Given + let cid = ChannelId.unique + controller = LivestreamChannelController( + channelQuery: ChannelQuery(cid: cid), + client: client + ) + controller.loadInitialMessagesFromCache = false + + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: [.dummy(), .dummy()] + ) + + // Save channel to cache + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: channelPayload) + } + + // When + controller.synchronize() + + // Then + XCTAssertNil(controller.channel) + XCTAssertTrue(controller.messages.isEmpty) + } + + func test_synchronize_successfulResponse_updatesChannelAndMessages() { + // Given + let expectation = self.expectation(description: "Synchronize completes") + var synchronizeError: Error? + + // When + controller.synchronize { error in + synchronizeError = error + expectation.fulfill() + } + + // Simulate successful API response + let cid = ChannelId.unique + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: [ + .dummy(messageId: "1", text: "Message 1"), + .dummy(messageId: "2", text: "Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(synchronizeError) + XCTAssertNotNil(controller.channel) + XCTAssertEqual(controller.channel?.cid, cid) + XCTAssertEqual(controller.messages.count, 2) + XCTAssertEqual(controller.messages.map(\.id), ["2", "1"]) // Reversed order + } + + func test_synchronize_failedResponse_callsCompletionWithError() { + // Given + let expectation = self.expectation(description: "Synchronize completes") + var synchronizeError: Error? + let testError = TestError() + + // When + controller.synchronize { error in + synchronizeError = error + expectation.fulfill() + } + + // Simulate failed API response + client.mockAPIClient.test_simulateResponse(Result.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertEqual(synchronizeError as? TestError, testError) + XCTAssertNil(controller.channel) + XCTAssertTrue(controller.messages.isEmpty) + } +} + +// MARK: - Message Loading Tests + +extension LivestreamChannelController_Tests { + func test_loadPreviousMessages_withNoMessages_callsCompletionWithError() { + // Given + let expectation = self.expectation(description: "Load previous messages completes") + var loadError: Error? + + // When + controller.loadPreviousMessages { error in + loadError = error + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssert(loadError is ClientError.ChannelEmptyMessages) + } + + func test_loadPreviousMessages_makesCorrectAPICall() throws { + // Given + // First load some messages so we have something to paginate from + controller.synchronize() + let channelPayload = ChannelPayload.dummy(messages: [.dummy(messageId: "message1")]) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + let apiClient = client.mockAPIClient + + // When + controller.loadPreviousMessages(before: "specific-message-id", limit: 50) + + // Then + let expectedPagination = MessagesPagination(pageSize: 50, parameter: .lessThan("specific-message-id")) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadPreviousMessages_usesDefaultLimit() throws { + // Given + // First load some messages so we have something to paginate from + controller.synchronize() + let channelPayload = ChannelPayload.dummy(messages: [.dummy(messageId: "message1")]) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + let apiClient = client.mockAPIClient + + // When + controller.loadPreviousMessages(before: "specific-message-id") + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: .lessThan("specific-message-id")) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadNextMessages_successfulResponse_prependsMessages() { + let mockPaginationStateHandler = MockPaginationStateHandler() + mockPaginationStateHandler.state = .init( + newestFetchedMessage: .dummy(), + hasLoadedAllNextMessages: false, + hasLoadedAllPreviousMessages: false, + isLoadingNextMessages: false, + isLoadingPreviousMessages: false, + isLoadingMiddleMessages: false + ) + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client, + paginationStateHandler: mockPaginationStateHandler + ) + + // Save initial messages to the DB + let initialChannelPayload = ChannelPayload.dummy( + channel: .dummy(cid: channelQuery.cid!), + messages: [ + .dummy(messageId: "old1"), + .dummy(messageId: "old2") + ] + ) + // Save channel to cache + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: initialChannelPayload) + } + controller.synchronize() + + let expectation = self.expectation(description: "Load next messages completes") + var loadError: Error? + + // When + controller.loadNextMessages(after: "old1") { error in + loadError = error + expectation.fulfill() + } + + // Simulate successful API response for next messages + let channelPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "new1", text: "New Message 1"), + .dummy(messageId: "new2", text: "New Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(loadError) + XCTAssertEqual(controller.messages.count, 4) + XCTAssertEqual(Set(controller.messages.map(\.id)), Set(["new2", "new1", "old2", "old1"])) // Next messages prepended + } + + func test_loadPageAroundMessageId_makesCorrectAPICall() throws { + // Given + let apiClient = client.mockAPIClient + + // When + controller.loadPageAroundMessageId("target-message-id", limit: 40) + + // Then + let expectedPagination = MessagesPagination(pageSize: 40, parameter: .around("target-message-id")) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadFirstPage_makesCorrectAPICall() throws { + // Given + let apiClient = client.mockAPIClient + + // When + controller.loadFirstPage() + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: nil) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadPreviousMessages_successfulResponse_appendsMessages() { + controller.synchronize() + let initialPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "new1", text: "New Message 1"), + .dummy(messageId: "new2", text: "New Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(initialPayload)) + + let expectation = self.expectation(description: "Load previous messages completes") + var loadError: Error? + + // When + controller.loadPreviousMessages(before: "new2") { error in + loadError = error + expectation.fulfill() + } + + // Simulate successful API response for previous messages + let channelPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "old1", text: "Old Message 1"), + .dummy(messageId: "old2", text: "Old Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(loadError) + XCTAssertEqual(controller.messages.count, 4) + XCTAssertEqual(controller.messages.map(\.id), ["new2", "new1", "old2", "old1"]) // Previous messages appended + } + + func test_loadPageAroundMessageId_successfulResponse_replacesMessages() { + controller.synchronize() + let initialPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "old1", text: "Old Message 1"), + .dummy(messageId: "old2", text: "Old Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(initialPayload)) + + let expectation = self.expectation(description: "Load page around message completes") + var loadError: Error? + + // When + controller.loadPageAroundMessageId("target-message-id") { error in + loadError = error + expectation.fulfill() + } + + // Simulate successful API response for page around message + let channelPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "around1", text: "Around Message 1"), + .dummy(messageId: "around2", text: "Around Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(loadError) + XCTAssertEqual(controller.messages.count, 2) + XCTAssertEqual(controller.messages.map(\.id), ["around2", "around1"]) // Replaced all messages + } +} + +// MARK: - Pause/Resume Tests + +extension LivestreamChannelController_Tests { + func test_pause_setsIsPausedToTrue() { + // Given + XCTAssertFalse(controller.isPaused) + + // When + controller.pause() + + // Then + XCTAssertTrue(controller.isPaused) + } + + func test_resume_setsIsPausedToFalse() { + // Given + controller.pause() + XCTAssertTrue(controller.isPaused) + + // When + controller.resume() + + // Then + XCTAssertFalse(controller.isPaused) + } + + func test_resume_resetsSkippedMessagesAmount() { + controller.countSkippedMessagesWhenPaused = true + + controller.pause() + + controller.eventsController( + EventsController( + notificationCenter: EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + ), + didReceiveEvent: MessageNewEvent( + user: .unique, + message: .unique, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.skippedMessagesAmount, 1) + + // When + controller.resume() + + // Then + XCTAssertEqual(controller.skippedMessagesAmount, 0) + } + + func test_resume_callsLoadFirstPage() throws { + // Given + let apiClient = client.mockAPIClient + controller.pause() + + // When + controller.resume() + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: nil) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_resume_whenNotPaused_doesNothing() { + // Given + XCTAssertFalse(controller.isPaused) + let apiClient = client.mockAPIClient + + // When + controller.resume() + + // Then + XCTAssertNil(apiClient.request_endpoint) + } +} + +// MARK: - Delegate Tests + +extension LivestreamChannelController_Tests { + @MainActor func test_delegate_isCalledWhenChannelUpdates() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: .unique) + ) + + // When + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // Then + AssertAsync.willBeTrue(delegate.didUpdateChannelCalled) + AssertAsync.willBeEqual(delegate.didUpdateChannelCalledWith?.cid, channelPayload.channel.cid) + } + + @MainActor func test_delegate_isCalledWhenMessagesUpdate() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + + let channelPayload = ChannelPayload.dummy( + messages: [.dummy(), .dummy()] + ) + + // When + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // Then + AssertAsync.willBeTrue(delegate.didUpdateMessagesCalled) + AssertAsync.willBeEqual(delegate.didUpdateMessagesCalledWith?.count, 2) + } + + @MainActor func test_delegate_isCalledWhenPauseStateChanges() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + + // When + controller.pause() + + // Then + AssertAsync.willBeTrue(delegate.didChangePauseStateCalled) + AssertAsync.willBeTrue(delegate.didChangePauseStateCalledWith ?? false) + } + + @MainActor func test_delegate_isCalledWhenSkippedMessagesAmountChanges() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + controller.countSkippedMessagesWhenPaused = true + + controller.pause() + + controller.eventsController( + EventsController( + notificationCenter: EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + ), + didReceiveEvent: MessageNewEvent( + user: .unique, + message: .unique, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + // When/Then + AssertAsync.willBeTrue(delegate.didChangeSkippedMessagesAmountCalled) + } +} + +// MARK: - Message Limiting Tests + +extension LivestreamChannelController_Tests { + func test_maxMessageLimitOptions_whenNil_doesNotLimitMessages() { + // Given + controller.maxMessageLimitOptions = nil + + // When/Then + XCTAssertNil(controller.maxMessageLimitOptions) + } + + func test_maxMessageLimitOptions_whenSet_configuresLimits() { + // Given + let options = MaxMessageLimitOptions(maxLimit: 100, discardAmount: 20) + + // When + controller.maxMessageLimitOptions = options + + // Then + XCTAssertEqual(controller.maxMessageLimitOptions?.maxLimit, 100) + XCTAssertEqual(controller.maxMessageLimitOptions?.discardAmount, 20) + } + + func test_maxMessageLimitOptions_recommendedConfiguration() { + // Given/When + let recommended = MaxMessageLimitOptions.recommended + + // Then + XCTAssertEqual(recommended.maxLimit, 200) + XCTAssertEqual(recommended.discardAmount, 50) + } +} + +// MARK: - Helper Mock Classes + +extension LivestreamChannelController_Tests { + class LivestreamChannelControllerDelegate_Mock: LivestreamChannelControllerDelegate { + var didUpdateChannelCalled = false + var didUpdateChannelCalledWith: ChatChannel? + + var didUpdateMessagesCalled = false + var didUpdateMessagesCalledWith: [ChatMessage]? + + var didChangePauseStateCalled = false + var didChangePauseStateCalledWith: Bool? + + var didChangeSkippedMessagesAmountCalled = false + var didChangeSkippedMessagesAmountCalledWith: Int? + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) { + didUpdateChannelCalled = true + didUpdateChannelCalledWith = channel + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + didUpdateMessagesCalled = true + didUpdateMessagesCalledWith = messages + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) { + didChangePauseStateCalled = true + didChangePauseStateCalledWith = isPaused + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) { + didChangeSkippedMessagesAmountCalled = true + didChangeSkippedMessagesAmountCalledWith = skippedMessagesAmount + } + } +} + +// MARK: - Message CRUD Tests + +extension LivestreamChannelController_Tests { + func test_createNewMessage_callsChannelUpdater() { + // Given + let messageText = "Test message" + let messageId = MessageId.unique + let mockUpdater = ChannelUpdater_Mock( + channelRepository: client.channelRepository, + messageRepository: client.messageRepository, + paginationStateHandler: client.makeMessagesPaginationStateHandler(), + database: client.databaseContainer, + apiClient: client.apiClient + ) + + // Create controller with mock updater + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client, + updater: mockUpdater + ) + + let expectation = self.expectation(description: "Create message completes") + var createResult: Result? + + // When + controller.createNewMessage( + messageId: messageId, + text: messageText, + pinning: .expirationTime(300), + isSilent: true, + attachments: [AnyAttachmentPayload.mockImage], + mentionedUserIds: [.unique], + quotedMessageId: .unique, + skipPush: true, + skipEnrichUrl: false, + extraData: ["test": .string("value")] + ) { result in + createResult = result + expectation.fulfill() + } + + // Simulate successful updater response + let mockMessage = ChatMessage.mock(id: messageId, cid: controller.cid!, text: messageText) + mockUpdater.createNewMessage_completion?(.success(mockMessage)) + + waitForExpectations(timeout: defaultTimeout) + + // Then - Verify the updater was called with correct parameters + XCTAssertEqual(mockUpdater.createNewMessage_cid, controller.cid) + XCTAssertEqual(mockUpdater.createNewMessage_text, messageText) + XCTAssertEqual(mockUpdater.createNewMessage_isSilent, true) + XCTAssertEqual(mockUpdater.createNewMessage_skipPush, true) + XCTAssertEqual(mockUpdater.createNewMessage_skipEnrichUrl, false) + XCTAssertEqual(mockUpdater.createNewMessage_attachments?.count, 1) + XCTAssertEqual(mockUpdater.createNewMessage_mentionedUserIds?.count, 1) + XCTAssertNotNil(mockUpdater.createNewMessage_quotedMessageId) + XCTAssertNotNil(mockUpdater.createNewMessage_pinning) + XCTAssertEqual(mockUpdater.createNewMessage_extraData?["test"], .string("value")) + + // Verify completion was called with correct result + XCTAssertNotNil(createResult) + if case .success(let resultMessageId) = createResult { + XCTAssertEqual(resultMessageId, messageId) + } else { + XCTFail("Expected success result") + } + + mockUpdater.cleanUp() + } + + func test_createNewMessage_updaterFailure_callsCompletionWithError() { + // Given + let messageText = "Test message" + let messageId = MessageId.unique + let mockUpdater = ChannelUpdater_Mock( + channelRepository: client.channelRepository, + messageRepository: client.messageRepository, + paginationStateHandler: client.makeMessagesPaginationStateHandler(), + database: client.databaseContainer, + apiClient: client.apiClient + ) + + // Create controller with mock updater + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client, + updater: mockUpdater + ) + + let expectation = self.expectation(description: "Create message completes") + var createResult: Result? + let testError = TestError() + + // When + controller.createNewMessage( + messageId: messageId, + text: messageText + ) { result in + createResult = result + expectation.fulfill() + } + + // Simulate updater failure + mockUpdater.createNewMessage_completion?(.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then - Verify the updater was called + XCTAssertEqual(mockUpdater.createNewMessage_cid, controller.cid) + XCTAssertEqual(mockUpdater.createNewMessage_text, messageText) + + // Verify completion was called with error + XCTAssertNotNil(createResult) + if case .failure(let error) = createResult { + XCTAssert(error is TestError) + } else { + XCTFail("Expected failure result") + } + + mockUpdater.cleanUp() + } +} + +// MARK: - Event Handling Tests + +extension LivestreamChannelController_Tests { + func test_applicationDidReceiveMemoryWarning_callsLoadFirstPage() { + // Given + let apiClient = client.mockAPIClient + + // When + controller.applicationDidReceiveMemoryWarning() + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: nil) + var expectedQuery = channelQuery! + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_didReceiveEvent_messageNewEvent_addsMessageToArray() { + let newMessage = ChatMessage.mock(id: "new", cid: controller.cid!, text: "New message") + let event = MessageNewEvent( + user: .mock(id: .unique), + message: newMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "new") + } + + func test_didReceiveEvent_newMessagePendingEvent_addsMessageToArray() { + let pendingMessage = ChatMessage.mock(id: "pending", cid: controller.cid!, text: "Pending message") + let event = NewMessagePendingEvent( + message: pendingMessage, + cid: controller.cid! + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "pending") + } + + func test_didReceiveEvent_messageUpdatedEvent_updatesExistingMessage() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "update-me"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + let updatedMessage = ChatMessage.mock(id: "update-me", cid: controller.cid!, text: "Updated text") + let event = MessageUpdatedEvent( + user: .mock(id: .unique), + channel: .mock(cid: controller.cid!), + message: updatedMessage, + createdAt: .unique + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "update-me") + XCTAssertEqual(controller.messages.first?.text, "Updated text") + } + + func test_didReceiveEvent_messageDeletedEvent_hardDelete_removesMessage() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "delete-me"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.count, 1) + + let messageToDelete = ChatMessage.mock(id: "delete-me", cid: controller.cid!, text: "Delete me") + let event = MessageDeletedEvent( + user: .mock(id: .unique), + channel: .mock(cid: controller.cid!), + message: messageToDelete, + createdAt: .unique, + isHardDelete: true + ) + + // When + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Then + XCTAssertEqual(controller.messages.count, 0) + } + + func test_didReceiveEvent_messageDeletedEvent_softDelete_updatesMessage() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "delete-me"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.count, 1) + + let deletedMessage = ChatMessage.mock( + id: "delete-me", + cid: controller.cid!, + text: "Delete me", + deletedAt: .unique + ) + let event = MessageDeletedEvent( + user: .mock(id: .unique), + channel: .mock(cid: controller.cid!), + message: deletedMessage, + createdAt: .unique, + isHardDelete: false + ) + + // When + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "delete-me") + XCTAssertNotNil(controller.messages.first?.deletedAt) + } + + func test_didReceiveEvent_newMessageErrorEvent_updatesMessageState() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "failed-message"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + // When + let event = NewMessageErrorEvent( + messageId: "failed-message", + cid: controller.cid!, + error: ClientError.Unknown() + ) + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "failed-message") + XCTAssertEqual(controller.messages.first?.localState, .sendingFailed) + } + + func test_didReceiveEvent_reactionNewEvent_updatesMessage() { + let message = ChatMessage.mock( + id: "message-with-reaction", + cid: controller.cid!, + text: "React to me", + reactionScores: [:] + ) + let messageWithReaction = ChatMessage.mock( + id: "message-with-reaction", + cid: controller.cid!, + text: "React to me", + reactionScores: ["like": 1] + ) + let event = ReactionNewEvent( + user: .mock(id: .unique), + cid: controller.cid!, + message: messageWithReaction, + reaction: .mock( + id: "message-with-reaction", + type: .init(rawValue: "like") + ), + createdAt: .unique + ) + + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: message, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.first?.id, "message-with-reaction") + XCTAssertEqual(controller.messages.first?.reactionScores["like"], nil) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "message-with-reaction") + XCTAssertEqual(controller.messages.first?.reactionScores["like"], 1) + } + + func test_didReceiveEvent_channelUpdatedEvent_updatesChannel() { + let updatedChannel = ChatChannel.mock(cid: controller.cid!, name: "Updated Name") + let event = ChannelUpdatedEvent( + channel: updatedChannel, + user: .mock(id: .unique), + message: nil, + createdAt: .unique + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.channel?.name, "Updated Name") + } + + func test_didReceiveEvent_differentChannelEvent_isIgnored() { + let otherChannelId = ChannelId.unique + let messageFromOtherChannel = ChatMessage.mock(id: "other", cid: otherChannelId, text: "Other message") + let event = MessageNewEvent( + user: .mock(id: .unique), + message: messageFromOtherChannel, + channel: .mock(cid: otherChannelId), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then - Message array should not change + XCTAssertEqual(controller.messages.count, 0) + } + + func test_didReceiveEvent_whenPaused_newMessageFromOtherUser_incrementsSkippedCount() { + // Given + controller.countSkippedMessagesWhenPaused = true + controller.pause() + XCTAssertEqual(controller.skippedMessagesAmount, 0) + + let otherUserId = UserId.unique + let newMessage = ChatMessage.mock( + id: "new", + cid: controller.cid!, + text: "New message", + author: .unique + ) + let event = MessageNewEvent( + user: .mock(id: otherUserId), + message: newMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // When + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Then + XCTAssertEqual(controller.skippedMessagesAmount, 1) + XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused + } +} + +// MARK: - Message CRUD Tests + +extension LivestreamChannelController_Tests { + func test_deleteMessage_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Delete message completes") + var deleteError: Error? + + // When + controller.deleteMessage(messageId: messageId, hard: false) { error in + deleteError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse( + Result.success(.init(message: .dummy())) + ) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.deleteMessage(messageId: messageId, hard: false) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(deleteError) + } + + func test_deleteMessage_withHardDelete_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.deleteMessage(messageId: messageId, hard: true) { _ in } + + // Then + let expectedEndpoint = Endpoint.deleteMessage(messageId: messageId, hard: true) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_deleteMessage_failedResponse_callsCompletionWithError() { + // Given + let messageId = MessageId.unique + let testError = TestError() + let expectation = self.expectation(description: "Delete message completes") + var deleteError: Error? + + // When + controller.deleteMessage(messageId: messageId) { error in + deleteError = error + expectation.fulfill() + } + + // Simulate failed response + client.mockAPIClient.test_simulateResponse(Result.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssert(deleteError is TestError) + } +} + +// MARK: - Reactions Tests + +extension LivestreamChannelController_Tests { + func test_addReaction_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reactionType = MessageReactionType(rawValue: "like") + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Add reaction completes") + var reactionError: Error? + + // When + controller.addReaction( + reactionType, + to: messageId, + score: 5, + enforceUnique: true, + skipPush: true, + pushEmojiCode: "👍", + extraData: ["key": .string("value")] + ) { error in + reactionError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.addReaction( + reactionType, + score: 5, + enforceUnique: true, + extraData: ["key": .string("value")], + skipPush: true, + emojiCode: "👍", + messageId: messageId + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(reactionError) + } + + func test_addReaction_withDefaultParameters_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reactionType = MessageReactionType(rawValue: "heart") + let apiClient = client.mockAPIClient + + // When + controller.addReaction(reactionType, to: messageId) { _ in } + + // Then + let expectedEndpoint = Endpoint.addReaction( + reactionType, + score: 1, + enforceUnique: false, + extraData: [:], + skipPush: false, + emojiCode: nil, + messageId: messageId + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_deleteReaction_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reactionType = MessageReactionType(rawValue: "like") + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Delete reaction completes") + var reactionError: Error? + + // When + controller.deleteReaction(reactionType, from: messageId) { error in + reactionError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.deleteReaction(reactionType, messageId: messageId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(reactionError) + } + + func test_loadReactions_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Load reactions completes") + var loadResult: Result<[ChatMessageReaction], Error>? + + // When + controller.loadReactions(for: messageId, limit: 50, offset: 10) { result in + loadResult = result + expectation.fulfill() + } + + // Simulate successful response + let mockReactions = [MessageReactionPayload.dummy( + messageId: messageId, + user: UserPayload.dummy(userId: .unique) + )] + let reactionsPayload = MessageReactionsPayload(reactions: mockReactions) + client.mockAPIClient.test_simulateResponse(Result.success(reactionsPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedPagination = Pagination(pageSize: 50, offset: 10) + let expectedEndpoint = Endpoint.loadReactions(messageId: messageId, pagination: expectedPagination) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNotNil(loadResult) + if case .success = loadResult { + // Test passes + } else { + XCTFail("Expected success result") + } + } + + func test_loadReactions_withDefaultParameters_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.loadReactions(for: messageId) { _ in } + + // Then + let expectedPagination = Pagination(pageSize: 25, offset: 0) + let expectedEndpoint = Endpoint.loadReactions(messageId: messageId, pagination: expectedPagination) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadReactions_failedResponse_callsCompletionWithError() { + // Given + let messageId = MessageId.unique + let testError = TestError() + let expectation = self.expectation(description: "Load reactions completes") + var loadResult: Result<[ChatMessageReaction], Error>? + + // When + controller.loadReactions(for: messageId) { result in + loadResult = result + expectation.fulfill() + } + + // Simulate failed response + client.mockAPIClient.test_simulateResponse(Result.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNotNil(loadResult) + if case .failure(let error) = loadResult { + XCTAssert(error is TestError) + } else { + XCTFail("Expected failure result") + } + } +} + +// MARK: - Message Actions Tests + +extension LivestreamChannelController_Tests { + func test_flag_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reason = "spam" + let extraData: [String: RawJSON] = ["key": .string("value")] + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Flag message completes") + var flagError: Error? + + // When + controller.flag(messageId: messageId, reason: reason, extraData: extraData) { error in + flagError = error + expectation.fulfill() + } + + // Simulate successful response + let flagPayload = FlagMessagePayload( + currentUser: CurrentUserPayload.dummy(userId: .unique, role: .user), + flaggedMessageId: messageId + ) + client.mockAPIClient.test_simulateResponse(Result.success(flagPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.flagMessage( + true, + with: messageId, + reason: reason, + extraData: extraData + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(flagError) + } + + func test_flag_withDefaultParameters_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.flag(messageId: messageId) { _ in } + + // Then + let expectedEndpoint = Endpoint.flagMessage( + true, + with: messageId, + reason: nil, + extraData: nil + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_unflag_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Unflag message completes") + var unflagError: Error? + + // When + controller.unflag(messageId: messageId) { error in + unflagError = error + expectation.fulfill() + } + + // Simulate successful response + let flagPayload = FlagMessagePayload( + currentUser: CurrentUserPayload.dummy(userId: .unique, role: .user), + flaggedMessageId: messageId + ) + client.mockAPIClient.test_simulateResponse(Result.success(flagPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.flagMessage( + false, + with: messageId, + reason: nil, + extraData: nil + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(unflagError) + } + + func test_pin_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let pinning = MessagePinning.expirationTime(20) + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Pin message completes") + var pinError: Error? + + // When + controller.pin(messageId: messageId, pinning: pinning) { error in + pinError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: true)) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(pinError) + } + + func test_pin_withDefaultPinning_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.pin(messageId: messageId) { _ in } + + // Then + let expectedEndpoint = Endpoint.pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: true)) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_unpin_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Unpin message completes") + var unpinError: Error? + + // When + controller.unpin(messageId: messageId) { error in + unpinError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: false)) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(unpinError) + } + + func test_loadPinnedMessages_makesCorrectAPICall() { + // Given + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Load pinned messages completes") + var loadResult: Result<[ChatMessage], Error>? + let sorting: [Sorting] = [.init(key: .pinnedAt, isAscending: false)] + let pagination = PinnedMessagesPagination.after(.unique, inclusive: false) + + // When + controller.loadPinnedMessages( + pageSize: 50, + sorting: sorting, + pagination: pagination + ) { result in + loadResult = result + expectation.fulfill() + } + + // Simulate successful response + let pinnedMessagesPayload = PinnedMessagesPayload(messages: [.dummy()]) + client.mockAPIClient.test_simulateResponse(Result.success(pinnedMessagesPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedQuery = PinnedMessagesQuery( + pageSize: 50, + sorting: sorting, + pagination: pagination + ) + let expectedEndpoint = Endpoint.pinnedMessages(cid: controller.cid!, query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNotNil(loadResult) + if case .success = loadResult { + // Test passes + } else { + XCTFail("Expected success result") + } + } + + func test_loadPinnedMessages_withDefaultParameters_makesCorrectAPICall() { + // Given + let apiClient = client.mockAPIClient + + // When + controller.loadPinnedMessages { _ in } + + // Then + let expectedQuery = PinnedMessagesQuery( + pageSize: 25, + sorting: [], + pagination: nil + ) + let expectedEndpoint = Endpoint.pinnedMessages(cid: controller.cid!, query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } +} + +// MARK: - Slow Mode Tests + +extension LivestreamChannelController_Tests { + func test_enableSlowMode_makesCorrectAPICall() { + // Given + let cooldownDuration = 30 + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Enable slow mode completes") + var slowModeError: Error? + + // When + controller.enableSlowMode(cooldownDuration: cooldownDuration) { error in + slowModeError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.enableSlowMode(cid: controller.cid!, cooldownDuration: cooldownDuration) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(slowModeError) + } + + func test_disableSlowMode_makesCorrectCall() { + // Given + let expectation = self.expectation(description: "Disable slow mode completes") + var slowModeError: Error? + + // When + controller.disableSlowMode { error in + slowModeError = error + expectation.fulfill() + } + + // Simulate successful response - this goes through the updater + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(slowModeError) + } + + func test_currentCooldownTime_withNoCooldown_returnsZero() { + // Given + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: controller.cid!, cooldownDuration: 0) + ) + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // When + let cooldownTime = controller.currentCooldownTime() + + // Then + XCTAssertEqual(cooldownTime, 0) + } + + func test_currentCooldownTime_withNoChannel_returnsZero() { + // Given + // Use a fresh controller with no channel data loaded + let freshController = LivestreamChannelController( + channelQuery: ChannelQuery(cid: .unique), + client: client + ) + + // When + let cooldownTime = freshController.currentCooldownTime() + + // Then + XCTAssertEqual(cooldownTime, 0) + } + + func test_currentCooldownTime_withActiveSlowMode_returnsCorrectTime() { + // Given + let currentUserId = UserId.unique + client.mockAuthenticationRepository.mockedCurrentUserId = currentUserId + let currentDate = Date() + let messageDate = currentDate.addingTimeInterval(-10) // 10 seconds ago + let cooldownDuration = 30 + + // Create a mock channel payload with cooldown + let channelPayload = ChannelPayload.dummy( + channel: .dummy( + cid: controller.cid!, + ownCapabilities: [], + cooldownDuration: cooldownDuration + ), + messages: [ + .dummy( + messageId: .unique, + authorUserId: currentUserId, + createdAt: messageDate + ) + ] + ) + + // Load the channel data through normal API flow + let exp = expectation(description: "sync completion") + controller.synchronize { _ in + exp.fulfill() + } + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // When + let cooldownTime = controller.currentCooldownTime() + + // Then + // Should be approximately 20 seconds (30 - 10) + XCTAssertGreaterThan(cooldownTime, 18) + XCTAssertLessThan(cooldownTime, 22) + } + + func test_currentCooldownTime_withSkipSlowModeCapability_returnsZero() { + // Given + let currentUserId = UserId.unique + client.setToken(token: .unique(userId: currentUserId)) + let currentDate = Date() + let messageDate = currentDate.addingTimeInterval(-10) + + // Create a mock channel payload with skip slow mode capability + let channelPayload = ChannelPayload.dummy( + channel: .dummy( + cid: controller.cid!, + ownCapabilities: [ChannelCapability.skipSlowMode.rawValue], + cooldownDuration: 30 + ), + messages: [ + .dummy( + messageId: .unique, + authorUserId: currentUserId, + createdAt: messageDate + ) + ] + ) + + // Load the channel data through normal API flow + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // When + let cooldownTime = controller.currentCooldownTime() + + // Then + XCTAssertEqual(cooldownTime, 0) + } +} + +class MockPaginationStateHandler: MessagesPaginationStateHandling { + init() { + state = .initial + } + + var state: MessagesPaginationState + + var beginCallCount = 0 + var endCallCount = 0 + + func begin(pagination: MessagesPagination) { + beginCallCount += 1 + } + + func end(pagination: MessagesPagination, with result: Result<[MessagePayload], any Error>) { + endCallCount += 1 + } +} From c78a4757c4622f2d75d83d257a8a1ea84103bd12 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 23:42:40 +0100 Subject: [PATCH 81/85] Fix UI Tests not compiling --- .../SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift index 5f6ca55d7d..3e856b29b8 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift @@ -718,7 +718,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: true ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 1) @@ -731,7 +731,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: true ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 0) @@ -744,7 +744,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: false ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 0) @@ -757,7 +757,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: true ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 0) From e48080fb1378db8a11c0f70eeaad2b9593c90804 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 5 Aug 2025 23:49:25 +0100 Subject: [PATCH 82/85] Add combine test coverage --- StreamChat.xcodeproj/project.pbxproj | 4 + ...treamChannelController+Combine_Tests.swift | 273 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 31bbd6576e..a20b148482 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1617,6 +1617,7 @@ AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */; }; AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */; }; AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */; }; + AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7EFDA52C77749300625FC5 /* PollCommentListVC.swift */; }; @@ -4417,6 +4418,7 @@ AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamMessageActionsVC.swift; sourceTree = ""; }; AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamReactionsListView.swift; sourceTree = ""; }; AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController_Tests.swift; sourceTree = ""; }; + AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LivestreamChannelController+Combine_Tests.swift"; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -7524,6 +7526,7 @@ A364D0A827D128650029857A /* ChannelController */ = { isa = PBXGroup; children = ( + AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */, AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */, AD545E7C2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift */, 7952B3B224D314B100AC53D4 /* ChannelController_Tests.swift */, @@ -12270,6 +12273,7 @@ 84EB4E78276A03DE00E47E73 /* ErrorPayload_Tests.swift in Sources */, DA4EE5B5252B680700CB26D4 /* UserListController+SwiftUI_Tests.swift in Sources */, 8A0D649824E579AB0017A3C0 /* GuestEndpoints_Tests.swift in Sources */, + AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */, DAF1BED92506612F003CEDC0 /* MessageController+SwiftUI_Tests.swift in Sources */, A3A52B6627EB61FC00311DFC /* EventPayload_Tests.swift in Sources */, A32B6D9E2869DABD002B1312 /* GiphyAttachmentPayload_Tests.swift in Sources */, diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift new file mode 100644 index 0000000000..753b13e514 --- /dev/null +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift @@ -0,0 +1,273 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class LivestreamChannelController_Combine_Tests: iOS13TestCase { + var livestreamChannelController: LivestreamChannelController! + var client: ChatClient_Mock! + var channelQuery: ChannelQuery! + var cancellables: Set! + + override func setUp() { + super.setUp() + + client = ChatClient.mock(config: ChatClient_Mock.defaultMockedConfig) + channelQuery = ChannelQuery(cid: .unique) + livestreamChannelController = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + cancellables = [] + } + + override func tearDown() { + // Release existing subscriptions and make sure the controller gets released, too + cancellables = nil + AssertAsync.canBeReleased(&livestreamChannelController) + livestreamChannelController = nil + client?.cleanUp() + client = nil + channelQuery = nil + super.tearDown() + } + + // MARK: - Channel Change Publisher + + func test_channelChangePublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Setup the chain without additional receive(on:) to avoid double async dispatch + livestreamChannelController + .channelChangePublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Verify initial state + XCTAssertEqual(recording.output, [nil]) + + // Don't keep weak reference - use the controller directly for easier debugging + let newChannel: ChatChannel = .mock(cid: channelQuery.cid!, name: .unique, imageURL: .unique(), extraData: [:]) + let event = ChannelUpdatedEvent( + channel: newChannel, + user: .mock(id: .unique), + message: nil, + createdAt: .unique + ) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Simulate channel update event + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Use AssertAsync to wait for the async update (delegate callback happens on main queue) + AssertAsync { + Assert.willBeEqual(recording.output.count, 2) + Assert.willBeEqual(recording.output.last, newChannel) + } + } + + func test_channelChangePublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .channelChangePublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } + + // MARK: - Messages Changes Publisher + + func test_messagesChangesPublisher() { + // Setup Recording publishers + var recording = Record<[ChatMessage], Never>.Recording() + + // Setup the chain + livestreamChannelController + .messagesChangesPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + let newMessage1: ChatMessage = .mock(id: .unique, cid: channelQuery.cid!, text: "Message 1", author: .mock(id: .unique)) + let newMessage2: ChatMessage = .mock(id: .unique, cid: channelQuery.cid!, text: "Message 2", author: .mock(id: .unique)) + + // Simulate new message events + let event1 = MessageNewEvent( + user: .mock(id: .unique), + message: newMessage1, + channel: .mock(cid: channelQuery.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + let event2 = MessageNewEvent( + user: .mock(id: .unique), + message: newMessage2, + channel: .mock(cid: channelQuery.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // Send the events + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event1 + ) + + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event2 + ) + + // Use AssertAsync to wait for the async updates + AssertAsync { + Assert.willBeEqual(recording.output.count, 3) + } + } + + func test_messagesChangesPublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .messagesChangesPublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } + + // MARK: - Is Paused Publisher + + func test_isPausedPublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Setup the chain + livestreamChannelController + .isPausedPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Test initial state + XCTAssertEqual(recording.output, [false]) + + // Test pausing + controller?.pause() + + // Use AssertAsync to wait for the async update + AssertAsync { + Assert.willBeEqual(recording.output, [false, true]) + Assert.willBeEqual(controller?.isPaused, true) + } + } + + func test_isPausedPublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .isPausedPublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } + + // MARK: - Skipped Messages Amount Publisher + + func test_skippedMessagesAmountPublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Enable counting skipped messages when paused + livestreamChannelController.countSkippedMessagesWhenPaused = true + + // Setup the chain + livestreamChannelController + .skippedMessagesAmountPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Test initial state + XCTAssertEqual(recording.output, [0]) + + // Pause the controller to enable skipped message counting + controller?.pause() + + // Simulate new messages from other users while paused + let otherUserId = UserId.unique + let messageFromOtherUser = ChatMessage.mock(id: .unique, cid: channelQuery.cid!, text: "Skipped message", author: .mock(id: otherUserId)) + + let event = MessageNewEvent( + user: .mock(id: otherUserId), + message: messageFromOtherUser, + channel: .mock(cid: channelQuery.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Use AssertAsync to wait for the async update + AssertAsync { + Assert.willBeEqual(recording.output, [0, 1]) + } + } + + func test_skippedMessagesAmountPublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .skippedMessagesAmountPublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } +} From b4d1c1a52899ab72314cac00bcaf6caed1860bb5 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 6 Aug 2025 00:35:43 +0100 Subject: [PATCH 83/85] Add memory warning test coverage --- .../Audio/StreamAppStateObserver_Tests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift b/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift index 5f25d2970d..fe77a0940a 100644 --- a/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift +++ b/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift @@ -77,6 +77,52 @@ final class StreamAppStateObserver_Tests: XCTestCase { ]) } + // MARK: - Memory Warning + + func test_memoryWarning_allSubscribersWillBeNotifiedWhenMemoryWarningOccurs() { + let subscriberA = SpyAppStateObserverDelegate() + let subscriberB = SpyAppStateObserverDelegate() + + appStateObserver.subscribe(subscriberA) + appStateObserver.subscribe(subscriberB) + + simulateAppDidReceiveMemoryWarning() + + [subscriberA, subscriberB].forEach { subscriber in + XCTAssertEqual(subscriber.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()" + ]) + } + } + + func test_memoryWarning_onlyRemainingSubscribersWillBeNotifiedAfterUnsubscribe() { + let subscriberA = SpyAppStateObserverDelegate() + let subscriberB = SpyAppStateObserverDelegate() + + appStateObserver.subscribe(subscriberA) + appStateObserver.subscribe(subscriberB) + + simulateAppDidReceiveMemoryWarning() + + [subscriberA, subscriberB].forEach { subscriber in + XCTAssertEqual(subscriber.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()" + ]) + } + + appStateObserver.unsubscribe(subscriberA) + + simulateAppDidReceiveMemoryWarning() + + XCTAssertEqual(subscriberA.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()" + ]) + XCTAssertEqual(subscriberB.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()", + "applicationDidReceiveMemoryWarning()" + ]) + } + // MARK: - Private Helpers private func simulateAppDidMoveToBackground() { @@ -88,6 +134,11 @@ final class StreamAppStateObserver_Tests: XCTestCase { notificationCenter.observersMap[UIApplication.didBecomeActiveNotification]? .forEach { $0.execute() } } + + private func simulateAppDidReceiveMemoryWarning() { + notificationCenter.observersMap[UIApplication.didReceiveMemoryWarningNotification]? + .forEach { $0.execute() } + } } private final class StubNotificationCenter: NotificationCenter, @unchecked Sendable { @@ -129,4 +180,8 @@ private final class SpyAppStateObserverDelegate: AppStateObserverDelegate, Spy { func applicationDidMoveToForeground() { record() } + + func applicationDidReceiveMemoryWarning() { + record() + } } From b83ac17d763bc019ba8505815d756c9f4d01557d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 6 Aug 2025 00:44:49 +0100 Subject: [PATCH 84/85] Add missing test coverage to the event notification centre --- .../Workers/EventNotificationCenter.swift | 4 - StreamChat.xcodeproj/project.pbxproj | 4 + .../Workers/ManualEventHandler_Mock.swift | 45 +++++++++++ .../EventNotificationCenter_Tests.swift | 75 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 49673c377d..51ca8a004a 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -66,10 +66,6 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { database.write({ session in events.forEach { event in - guard let eventDTO = event as? EventDTO else { - middlewareEvents.append(event) - return - } if let manualEvent = self.manualEventHandler.handle(event) { manualHandlingEvents.append(manualEvent) } else { diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index a20b148482..6001b95f62 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1618,6 +1618,7 @@ AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */; }; AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */; }; AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */; }; + AD7C76852E42CDF6009250FB /* ManualEventHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7EFDA52C77749300625FC5 /* PollCommentListVC.swift */; }; @@ -4419,6 +4420,7 @@ AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamReactionsListView.swift; sourceTree = ""; }; AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController_Tests.swift; sourceTree = ""; }; AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LivestreamChannelController+Combine_Tests.swift"; sourceTree = ""; }; + AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler_Mock.swift; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -7320,6 +7322,7 @@ A364D09727D0C5940029857A /* Workers */ = { isa = PBXGroup; children = ( + AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */, 882C5762252C7F6500E60C44 /* ChannelMemberListUpdater_Mock.swift */, 88F6DF96252C88BB009A8AF0 /* ChannelMemberUpdater_Mock.swift */, F62D143D24DD70190081D241 /* ChannelUpdater_Mock.swift */, @@ -11432,6 +11435,7 @@ A3C3BC3F27E87F5C00224761 /* ChannelListUpdater_Spy.swift in Sources */, A3C3BC6427E8AA0A00224761 /* ChannelId+Unique.swift in Sources */, 82F714A12B077F3300442A74 /* XCTestCase+iOS13.swift in Sources */, + AD7C76852E42CDF6009250FB /* ManualEventHandler_Mock.swift in Sources */, A3C3BC7127E8AA4300224761 /* TestFetchedResultsController.swift in Sources */, 82E655392B06775D00D64906 /* MockFunc.swift in Sources */, A344078927D753530044F150 /* UserPayload.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift new file mode 100644 index 0000000000..ff371bdd01 --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift @@ -0,0 +1,45 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ManualEventHandler_Mock: ManualEventHandler { + init() { + super.init( + database: DatabaseContainer_Spy() + ) + } + + static func mock() -> Self { + Self() + } + + var registerCallCount = 0 + var registerCalledWith: [ChannelId] = [] + + override func register(channelId: ChannelId) { + registerCallCount += 1 + registerCalledWith.append(channelId) + } + + var unregisterCallCount = 0 + var unregisterCalledWith: [ChannelId] = [] + + override func unregister(channelId: ChannelId) { + unregisterCallCount += 1 + unregisterCalledWith.append(channelId) + } + + var handleCallCount = 0 + var handleCalledWith: [Event] = [] + var handleReturnValue: Event? + + override func handle(_ event: Event) -> Event? { + handleCallCount += 1 + handleCalledWith.append(event) + return handleReturnValue + } +} \ No newline at end of file diff --git a/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift b/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift index c41467811d..d8bc5ce106 100644 --- a/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift +++ b/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift @@ -275,4 +275,79 @@ final class EventNotificationCenter_Tests: XCTestCase { center.process(events) } } + + // MARK: - Manual Event Handling Tests + + func test_registerManualEventHandling_callsManualEventHandler() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let cid: ChannelId = .unique + + center.registerManualEventHandling(for: cid) + + XCTAssertEqual(mockHandler.registerCallCount, 1) + XCTAssertEqual(mockHandler.registerCalledWith, [cid]) + } + + func test_unregisterManualEventHandling_callsManualEventHandler() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let cid: ChannelId = .unique + + center.unregisterManualEventHandling(for: cid) + + XCTAssertEqual(mockHandler.unregisterCallCount, 1) + XCTAssertEqual(mockHandler.unregisterCalledWith, [cid]) + } + + func test_process_whenManualEventHandlerReturnsEvent_eventIsAddedToEventsToPost() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + + // Create event logger to check published events + let eventLogger = EventLogger(center) + + // Create test event and mock return value + let originalEvent = TestEvent() + let manualEvent = TestEvent() + mockHandler.handleReturnValue = manualEvent + + // Process event + center.process(originalEvent) + + // Verify manual handler was called with original event + AssertAsync { + Assert.willBeEqual(mockHandler.handleCallCount, 1) + Assert.willBeEqual(mockHandler.handleCalledWith as? [TestEvent], [originalEvent]) + // Verify manual event was posted + Assert.willBeEqual(eventLogger.events as? [TestEvent], [manualEvent]) + } + } + + func test_process_whenManualEventHandlerReturnsNil_eventIsProcessedByMiddlewares() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + + // Create event logger to check published events + let eventLogger = EventLogger(center) + + // Create test event + let originalEvent = TestEvent() + mockHandler.handleReturnValue = nil // Manual handler doesn't handle this event + + // Add a middleware that will process the event + let middleware = EventMiddleware_Mock { event, _ in event } + center.add(middleware: middleware) + + // Process event + center.process(originalEvent) + + // Verify manual handler was called with original event + AssertAsync { + Assert.willBeEqual(mockHandler.handleCallCount, 1) + Assert.willBeEqual(mockHandler.handleCalledWith as? [TestEvent], [originalEvent]) + // Verify original event was posted (processed by middleware) + Assert.willBeEqual(eventLogger.events as? [TestEvent], [originalEvent]) + } + } } From 5153c46fe2874a3fd880c5177339a077945f46b5 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 6 Aug 2025 00:55:03 +0100 Subject: [PATCH 85/85] Add test coverage to ManualEventHandler --- .../Workers/ManualEventHandler.swift | 2 + StreamChat.xcodeproj/project.pbxproj | 4 + .../Workers/ManualEventHandler_Tests.swift | 326 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift index abdda327d5..f99cfcc2cd 100644 --- a/Sources/StreamChat/Workers/ManualEventHandler.swift +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -21,9 +21,11 @@ class ManualEventHandler { init( database: DatabaseContainer, + cachedChannels: [ChannelId: ChatChannel] = [:], queue: DispatchQueue = DispatchQueue(label: "io.getstream.chat.manualEventHandler", qos: .utility) ) { self.database = database + self.cachedChannels = cachedChannels self.queue = queue } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 6001b95f62..eaa0dd7424 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1616,6 +1616,7 @@ AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */; }; AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */; }; AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */; }; + AD7C767F2E426B34009250FB /* ManualEventHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C767E2E426B34009250FB /* ManualEventHandler_Tests.swift */; }; AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */; }; AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */; }; AD7C76852E42CDF6009250FB /* ManualEventHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */; }; @@ -4418,6 +4419,7 @@ AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatMessageListVC.swift; sourceTree = ""; }; AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamMessageActionsVC.swift; sourceTree = ""; }; AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamReactionsListView.swift; sourceTree = ""; }; + AD7C767E2E426B34009250FB /* ManualEventHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler_Tests.swift; sourceTree = ""; }; AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController_Tests.swift; sourceTree = ""; }; AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LivestreamChannelController+Combine_Tests.swift"; sourceTree = ""; }; AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler_Mock.swift; sourceTree = ""; }; @@ -7300,6 +7302,7 @@ A364D09627D0C56C0029857A /* Workers */ = { isa = PBXGroup; children = ( + AD7C767E2E426B34009250FB /* ManualEventHandler_Tests.swift */, AD9490582BF5701D00E69224 /* ThreadsRepository_Tests.swift */, 792921C424C0479700116BBB /* ChannelListUpdater_Tests.swift */, 882C5765252C7F7000E60C44 /* ChannelMemberListUpdater_Tests.swift */, @@ -12037,6 +12040,7 @@ A30C3F22276B4F8800DA5968 /* UnknownUserEvent_Tests.swift in Sources */, DA84074025260CA3005A0F62 /* UserListController_Tests.swift in Sources */, AD545E852D5D7591008FD399 /* DraftListQuery_Tests.swift in Sources */, + AD7C767F2E426B34009250FB /* ManualEventHandler_Tests.swift in Sources */, C1EE53A727BA53F300B1A6CA /* Endpoint_Tests.swift in Sources */, 84A1D2F426AB221E00014712 /* ChannelEventsController_Tests.swift in Sources */, 88381E6E258259310047A6A3 /* FileUploadPayload_Tests.swift in Sources */, diff --git a/Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift b/Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift new file mode 100644 index 0000000000..c7dee2865d --- /dev/null +++ b/Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift @@ -0,0 +1,326 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ManualEventHandler_Tests: XCTestCase { + var database: DatabaseContainer_Spy! + var handler: ManualEventHandler! + var cid: ChannelId! + var cachedChannel: ChatChannel! + + override func setUp() { + super.setUp() + + database = DatabaseContainer_Spy() + cid = .unique + + // Setup database with channel and current user + try! database.createChannel(cid: cid, withMessages: false) + try! database.createCurrentUser() + + // Get the channel from database to use as cached channel + cachedChannel = .mock(cid: cid) + + // Create handler with pre-cached channel to avoid registration requirements + handler = ManualEventHandler( + database: database, + cachedChannels: [cid: cachedChannel] + ) + + // Register the channel so events are processed + handler.register(channelId: cid) + } + + override func tearDown() { + handler = nil + database = nil + cachedChannel = nil + cid = nil + super.tearDown() + } + + // MARK: - Event Handling - Non-EventDTO + + func test_handle_nonEventDTO_returnsNil() { + struct NonEventDTO: Event {} + let event = NonEventDTO() + + let result = handler.handle(event) + XCTAssertNil(result) + } + + // MARK: - Event Handling - Missing CID + + func test_handle_eventWithoutCid_returnsNil() throws { + // Create a simple event DTO that has no cid + struct TestEventDTO: EventDTO { + let payload: EventPayload = EventPayload( + eventType: .healthCheck, + connectionId: .unique + ) + } + + let eventDTO = TestEventDTO() + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + XCTAssertNil(result, "Events without cid should return nil") + } + + // MARK: - Event Handling - Unregistered Channel + + func test_handle_unregisteredChannel_returnsNil() throws { + let unregisteredCid: ChannelId = .unique + let eventPayload = EventPayload( + eventType: .messageNew, + cid: unregisteredCid, + user: .dummy(userId: .unique), + message: .dummy(messageId: .unique, authorUserId: .unique), + createdAt: .unique + ) + let eventDTO = try! MessageNewEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + XCTAssertNil(result) + } + + // MARK: - Event Handling - Unsupported Event Type + + func test_handle_unsupportedEventType_returnsNil() throws { + // Use a typing event which is not handled by ManualEventHandler + let eventPayload = EventPayload( + eventType: .userStartTyping, + cid: cid, + user: .dummy(userId: .unique), + createdAt: .unique + ) + let eventDTO = try! TypingEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + XCTAssertNil(result, "Unsupported event types should return nil") + } + + // MARK: - Message New Event + + func test_handle_messageNewEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageNew, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + watcherCount: 10, + unreadCount: .init(channels: 1, messages: 2, threads: 0), + createdAt: createdAt + ) + let eventDTO = try! MessageNewEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageNewEvent = try XCTUnwrap(result as? MessageNewEvent) + XCTAssertEqual(messageNewEvent.user.id, userId) + XCTAssertEqual(messageNewEvent.message.id, messageId) + XCTAssertEqual(messageNewEvent.cid, cid) + XCTAssertEqual(messageNewEvent.watcherCount, 10) + XCTAssertEqual(messageNewEvent.unreadCount?.messages, 2) + XCTAssertEqual(messageNewEvent.createdAt, createdAt) + } + + // MARK: - Message Updated Event + + func test_handle_messageUpdatedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageUpdated, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + createdAt: createdAt + ) + let eventDTO = try! MessageUpdatedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageUpdatedEvent = try XCTUnwrap(result as? MessageUpdatedEvent) + XCTAssertEqual(messageUpdatedEvent.user.id, userId) + XCTAssertEqual(messageUpdatedEvent.message.id, messageId) + XCTAssertEqual(messageUpdatedEvent.cid, cid) + XCTAssertEqual(messageUpdatedEvent.createdAt, createdAt) + } + + // MARK: - Message Deleted Event + + func test_handle_messageDeletedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageDeleted, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + createdAt: createdAt, + hardDelete: true + ) + let eventDTO = try! MessageDeletedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageDeletedEvent = try XCTUnwrap(result as? MessageDeletedEvent) + XCTAssertEqual(messageDeletedEvent.user?.id, userId) + XCTAssertEqual(messageDeletedEvent.message.id, messageId) + XCTAssertEqual(messageDeletedEvent.cid, cid) + XCTAssertEqual(messageDeletedEvent.isHardDelete, true) + XCTAssertEqual(messageDeletedEvent.createdAt, createdAt) + } + + func test_handle_messageDeletedEvent_withoutUser_returnsEvent() throws { + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageDeleted, + cid: cid, + user: nil, + message: .dummy(messageId: messageId, authorUserId: .unique), + createdAt: createdAt, + hardDelete: false + ) + let eventDTO = try! MessageDeletedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageDeletedEvent = try XCTUnwrap(result as? MessageDeletedEvent) + XCTAssertNil(messageDeletedEvent.user) + XCTAssertEqual(messageDeletedEvent.message.id, messageId) + XCTAssertEqual(messageDeletedEvent.cid, cid) + XCTAssertEqual(messageDeletedEvent.isHardDelete, false) + XCTAssertEqual(messageDeletedEvent.createdAt, createdAt) + } + + // MARK: - Reaction New Event + + func test_handle_reactionNewEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let reactionType: MessageReactionType = "like" + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .reactionNew, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + reaction: .dummy(type: reactionType, messageId: messageId, user: .dummy(userId: userId)), + createdAt: createdAt + ) + let eventDTO = try! ReactionNewEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let reactionNewEvent = try XCTUnwrap(result as? ReactionNewEvent) + XCTAssertEqual(reactionNewEvent.user.id, userId) + XCTAssertEqual(reactionNewEvent.message.id, messageId) + XCTAssertEqual(reactionNewEvent.cid, cid) + XCTAssertEqual(reactionNewEvent.reaction.type, reactionType) + XCTAssertEqual(reactionNewEvent.createdAt, createdAt) + } + + // MARK: - Reaction Updated Event + + func test_handle_reactionUpdatedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let reactionType: MessageReactionType = "love" + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .reactionUpdated, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + reaction: .dummy(type: reactionType, messageId: messageId, user: .dummy(userId: userId)), + createdAt: createdAt + ) + let eventDTO = try! ReactionUpdatedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let reactionUpdatedEvent = try XCTUnwrap(result as? ReactionUpdatedEvent) + XCTAssertEqual(reactionUpdatedEvent.user.id, userId) + XCTAssertEqual(reactionUpdatedEvent.message.id, messageId) + XCTAssertEqual(reactionUpdatedEvent.cid, cid) + XCTAssertEqual(reactionUpdatedEvent.reaction.type, reactionType) + XCTAssertEqual(reactionUpdatedEvent.createdAt, createdAt) + } + + // MARK: - Reaction Deleted Event + + func test_handle_reactionDeletedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let reactionType: MessageReactionType = "angry" + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .reactionDeleted, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + reaction: .dummy(type: reactionType, messageId: messageId, user: .dummy(userId: userId)), + createdAt: createdAt + ) + let eventDTO = try! ReactionDeletedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let reactionDeletedEvent = try XCTUnwrap(result as? ReactionDeletedEvent) + XCTAssertEqual(reactionDeletedEvent.user.id, userId) + XCTAssertEqual(reactionDeletedEvent.message.id, messageId) + XCTAssertEqual(reactionDeletedEvent.cid, cid) + XCTAssertEqual(reactionDeletedEvent.reaction.type, reactionType) + XCTAssertEqual(reactionDeletedEvent.createdAt, createdAt) + } +}