Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 47 additions & 1 deletion DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -313,7 +346,7 @@ class DemoLivestreamChatChannelVC: _ViewController,
didUpdateChannel channel: ChatChannel
) {
channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId)
navigationItem.title = channel.name
updateNavigationTitle()
}

func livestreamChannelController(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ extension ChannelDetailPayload {
isDisabled: Bool = false,
isHidden: Bool? = nil,
members: [MemberPayload] = [],
memberCount: Int? = nil,
team: String? = nil,
cooldownDuration: Int = 0
) -> Self {
Expand All @@ -48,7 +49,7 @@ extension ChannelDetailPayload {
isBlocked: isBlocked,
isHidden: isHidden,
members: members,
memberCount: members.count,
memberCount: memberCount ?? members.count,
team: team,
cooldownDuration: cooldownDuration
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading