diff --git a/CHANGELOG.md b/CHANGELOG.md index 097a735f58..7d7a997225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## 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) - Add a completion block to `LivestreamChannelController.resume()` to observe possible errors [#3774](https://github.com/GetStream/stream-chat-swift/pull/3774) ### 🐞 Fixed - Fix pending message being added to `LivestreamChannelController.messages` when in paused state [#3774](https://github.com/GetStream/stream-chat-swift/pull/3774) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift index 5cdc60238d..0ff3c85913 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? @@ -124,6 +153,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([ @@ -313,7 +346,7 @@ class DemoLivestreamChatChannelVC: _ViewController, didUpdateChannel channel: ChatChannel ) { channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) - navigationItem.title = channel.name + updateNavigationTitle() } func livestreamChannelController( @@ -374,6 +407,19 @@ class DemoLivestreamChatChannelVC: _ViewController, } pauseBannerView.setVisible(show, animated: true) } + + /// 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 diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index f4059da731..b02f2035d1 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -904,6 +904,24 @@ 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() + + case let channelTruncatedEvent as ChannelTruncatedEvent: + channel = channelTruncatedEvent.channel + if let message = channelTruncatedEvent.message { + messages = [message] + } else { + messages = [] + } + default: break } @@ -965,6 +983,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, 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 eca3fed734..e85ccb0221 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -1226,10 +1226,231 @@ 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_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