From 1079c92e7c21f7c03e2908d69b4a6a3f9e3071bf Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 11 Aug 2025 14:46:53 +0100 Subject: [PATCH 1/6] Handle Member Update Events in the Livestream channel controller --- .../LivestreamChannelController.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 7d15df635f..d527861cb1 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -882,6 +882,16 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel case let channelUpdatedEvent as ChannelUpdatedEvent: handleChannelUpdated(channelUpdatedEvent) + case is MemberAddedEvent, + is MemberRemovedEvent, + is MemberUpdatedEvent, + is NotificationAddedToChannelEvent, + is NotificationRemovedFromChannelEvent, + is NotificationInvitedEvent, + is NotificationInviteAcceptedEvent, + is NotificationInviteRejectedEvent: + updateChannelFromDataStore() + default: break } @@ -954,6 +964,15 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel channel = event.channel } + // For events that do not have the channel data, and still + // go through the middleware, lets fetch it from DB and update it. + private func updateChannelFromDataStore() { + guard let cid = cid, let updatedChannel = dataStore.channel(cid: cid) else { + return + } + channel = updatedChannel + } + private func createNewMessage( messageId: MessageId? = nil, text: String, From bb96728967facf5f6f8f4ef7668ff5dd5f818eee Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 11 Aug 2025 14:47:54 +0100 Subject: [PATCH 2/6] Handle channel truncation updated event in livestream channel controller --- .../ChannelController/LivestreamChannelController.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index d527861cb1..53ad795e50 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -892,6 +892,14 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel is NotificationInviteRejectedEvent: updateChannelFromDataStore() + case let channelTruncatedEvent as ChannelTruncatedEvent: + channel = channelTruncatedEvent.channel + if let message = channelTruncatedEvent.message { + messages = [message] + } else { + messages = [] + } + default: break } From 95f1583c12d378cec39cc313e57fb9c036dc38c8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 11 Aug 2025 14:48:23 +0100 Subject: [PATCH 3/6] Update demo app livestream UI to show number of members and watchers --- .../DemoLivestreamChatChannelVC.swift | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift index fc5de057d1..02df525bc9 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift @@ -50,6 +50,35 @@ class DemoLivestreamChatChannelVC: _ViewController, .channelAvatarView.init() .withoutAutoresizingMaskConstraints + /// Title label shown in the navigation bar. + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = appearance.fonts.subheadlineBold + label.textColor = appearance.colorPalette.text + label.textAlignment = .center + label.setContentHuggingPriority(.required, for: .vertical) + return label + }() + + /// Subtitle label showing members and online watchers in the navigation bar. + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.font = appearance.fonts.footnote + label.textColor = appearance.colorPalette.subtitleText + label.textAlignment = .center + label.setContentCompressionResistancePriority(.required, for: .vertical) + return label + }() + + /// Stack view containing title and subtitle for the navigation bar. + private lazy var titleStackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 0 + return stack + }() + /// The message composer bottom constraint used for keyboard animation handling. var messageComposerBottomConstraint: NSLayoutConstraint? @@ -160,6 +189,10 @@ class DemoLivestreamChatChannelVC: _ViewController, navigationItem.rightBarButtonItem = UIBarButtonItem(customView: channelAvatarView) channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) + // Set up custom title view with channel name and member/online counts + navigationItem.titleView = titleStackView + updateNavigationTitle() + // Add pause banner view.addSubview(pauseBannerView) NSLayoutConstraint.activate([ @@ -381,7 +414,7 @@ class DemoLivestreamChatChannelVC: _ViewController, didUpdateChannel channel: ChatChannel ) { channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) - navigationItem.title = channel.name + updateNavigationTitle() } func livestreamChannelController( @@ -438,6 +471,19 @@ class DemoLivestreamChatChannelVC: _ViewController, self.pauseBannerView.alpha = show ? 1.0 : 0.0 }) } + + /// Updates the navigation title and subtitle with channel name, member count and online watcher count. + private func updateNavigationTitle() { + let channel = livestreamChannelController.channel + titleLabel.text = channel?.name ?? "" + + let memberCount = channel?.memberCount ?? 0 + let watcherCount = channel?.watcherCount ?? 0 + + let membersText = memberCount == 1 ? "1 member" : "\(memberCount) members" + let onlineText = watcherCount == 1 ? "1 online" : "\(watcherCount) online" + subtitleLabel.text = "\(membersText), \(onlineText)" + } } /// A custom composer view controller for livestream channels that uses LivestreamChannelController From 32914e80843476120656c7d957f119fd7191505c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 11 Aug 2025 15:23:44 +0100 Subject: [PATCH 4/6] Add test coverage to member related events --- .../DummyData/ChannelDetailPayload.swift | 3 +- .../LivestreamChannelController_Tests.swift | 130 +++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift index 6e24f3e173..6854c79329 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift @@ -26,6 +26,7 @@ extension ChannelDetailPayload { isDisabled: Bool = false, isHidden: Bool? = nil, members: [MemberPayload] = [], + memberCount: Int? = nil, team: String? = nil, cooldownDuration: Int = 0 ) -> Self { @@ -48,7 +49,7 @@ extension ChannelDetailPayload { isBlocked: isBlocked, isHidden: isHidden, members: members, - memberCount: members.count, + memberCount: memberCount ?? members.count, team: team, cooldownDuration: cooldownDuration ) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift index e22b253ccb..75fc913ace 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -1138,11 +1138,137 @@ extension LivestreamChannelController_Tests { ), didReceiveEvent: event ) - + // Then XCTAssertEqual(controller.channel?.name, "Updated Name") } - + + // MARK: - Member Update Events Tests + + func test_didReceiveEvent_memberRelatedEvents_updateChannelFromDataStore() { + let cid = controller.cid! + + // Test data for all member-related events + let memberEvents: [(event: Event, description: String)] = [ + ( + MemberAddedEvent( + user: .mock(id: .unique), + cid: cid, + member: .mock(id: .unique), + createdAt: .unique + ), + "MemberAddedEvent" + ), + ( + MemberRemovedEvent( + user: .mock(id: .unique), + cid: cid, + createdAt: .unique + ), + "MemberRemovedEvent" + ), + ( + MemberUpdatedEvent( + user: .mock(id: .unique), + cid: cid, + member: .mock(id: .unique), + createdAt: .unique + ), + "MemberUpdatedEvent" + ), + ( + NotificationAddedToChannelEvent( + channel: .mock(cid: cid), + unreadCount: nil, + member: .mock(id: .unique), + createdAt: .unique + ), + "NotificationAddedToChannelEvent" + ), + ( + NotificationRemovedFromChannelEvent( + user: .mock(id: .unique), + cid: cid, + member: .mock(id: .unique), + createdAt: .unique + ), + "NotificationRemovedFromChannelEvent" + ), + ( + NotificationInvitedEvent( + user: .mock(id: .unique), + cid: cid, + member: .mock(id: .unique), + createdAt: .unique + ), + "NotificationInvitedEvent" + ), + ( + NotificationInviteAcceptedEvent( + user: .mock(id: .unique), + channel: .mock(cid: cid), + member: .mock(id: .unique), + createdAt: .unique + ), + "NotificationInviteAcceptedEvent" + ), + ( + NotificationInviteRejectedEvent( + user: .mock(id: .unique), + channel: .mock(cid: cid), + member: .mock(id: .unique), + createdAt: .unique + ), + "NotificationInviteRejectedEvent" + ) + ] + + // Test each member-related event + for (index, (event, description)) in memberEvents.enumerated() { + let initialMemberCount = 10 + index + let updatedMemberCount = 20 + index + + // Given - Set up initial channel in database + let initialChannelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid, memberCount: initialMemberCount) + ) + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: initialChannelPayload) + } + + // Load initial data + let exp = expectation(description: "sync completes") + controller.synchronize { _ in + exp.fulfill() + } + client.mockAPIClient.test_simulateResponse(.success(initialChannelPayload)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(controller.channel?.memberCount, initialMemberCount, "Initial setup failed for \(description)") + + // Update channel in database with new member count + let updatedChannelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid, memberCount: updatedMemberCount) + ) + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: updatedChannelPayload) + } + + // When + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual( + controller.channel?.memberCount, + updatedMemberCount, + "\(description) should update channel from data store" + ) + } + } + func test_didReceiveEvent_differentChannelEvent_isIgnored() { let otherChannelId = ChannelId.unique let messageFromOtherChannel = ChatMessage.mock(id: "other", cid: otherChannelId, text: "Other message") From 4316037490a5efe24c1587297689577ba53005b5 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 11 Aug 2025 15:35:50 +0100 Subject: [PATCH 5/6] Add test coverage to truncated channel even --- .../LivestreamChannelController_Tests.swift | 97 ++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift index 75fc913ace..e5d3917b51 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -1268,7 +1268,102 @@ extension LivestreamChannelController_Tests { ) } } - + + func test_didReceiveEvent_channelTruncatedEvent_updatesChannelAndMessages() { + // Given - Set up initial channel with messages + let cid = controller.cid! + let initialMessage1 = ChatMessage.mock(id: "message1", cid: cid, text: "Message 1") + let initialMessage2 = ChatMessage.mock(id: "message2", cid: cid, text: "Message 2") + + // Load initial messages + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: initialMessage1, + channel: .mock(cid: cid), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: initialMessage2, + channel: .mock(cid: cid), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.count, 2) + + // Create truncated channel and truncation message + let truncatedChannel = ChatChannel.mock(cid: cid, name: "Truncated Channel") + let truncationMessage = ChatMessage.mock(id: "truncation", cid: cid, text: "Channel was truncated") + + let event = ChannelTruncatedEvent( + channel: truncatedChannel, + user: .mock(id: .unique), + message: truncationMessage, + createdAt: .unique + ) + + // When + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.channel?.name, "Truncated Channel") + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "truncation") + XCTAssertEqual(controller.messages.first?.text, "Channel was truncated") + } + + func test_didReceiveEvent_channelTruncatedEventWithoutMessage_clearsMessages() { + // Given - Set up initial channel with messages + let cid = controller.cid! + let initialMessage = ChatMessage.mock(id: "message1", cid: cid, text: "Message 1") + + // Load initial message + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: initialMessage, + channel: .mock(cid: cid), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.count, 1) + + // Create truncated channel without truncation message + let truncatedChannel = ChatChannel.mock(cid: cid, name: "Truncated Channel") + + let event = ChannelTruncatedEvent( + channel: truncatedChannel, + user: .mock(id: .unique), + message: nil, // No truncation message + createdAt: .unique + ) + + // When + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.channel?.name, "Truncated Channel") + XCTAssertTrue(controller.messages.isEmpty) + } + func test_didReceiveEvent_differentChannelEvent_isIgnored() { let otherChannelId = ChannelId.unique let messageFromOtherChannel = ChatMessage.mock(id: "other", cid: otherChannelId, text: "Other message") From 50b3bd3974edf884f262926aa5c6cb3a58bdb392 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 11 Aug 2025 15:43:29 +0100 Subject: [PATCH 6/6] Update CHANGELOG.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b5a671ec..01ff585990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +## StreamChat +### ✅ Added +- Handle member-related events in `LivestreamChannelController` [#3775](https://github.com/GetStream/stream-chat-swift/pull/3775) +- Handle channel truncation events in `LivestreamChannelController` [#3775](https://github.com/GetStream/stream-chat-swift/pull/3775) # [4.84.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.84.0) _August 06, 2025_