diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift index 988887bb..9e65fefc 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Foundation import StreamChat /// Data source providing the chat messages. @@ -21,11 +22,9 @@ protocol MessagesDataSource: AnyObject { /// - Parameters: /// - channelDataSource: the channel's data source. /// - channel: the updated channel. - /// - channelController: the channel's controller. func dataSource( channelDataSource: ChannelDataSource, - didUpdateChannel channel: EntityChange, - channelController: ChatChannelController + didUpdateChannel channel: EntityChange ) } @@ -126,8 +125,7 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate { ) { delegate?.dataSource( channelDataSource: self, - didUpdateChannel: channel, - channelController: channelController + didUpdateChannel: channel ) } @@ -248,3 +246,85 @@ class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate messageController.loadFirstPage(completion) } } + +/// Implementation of `ChannelDataSource`. Loads the messages of a livestream channel. +class LivestreamChannelDataSource: ChannelDataSource, LivestreamChannelControllerDelegate { + let controller: LivestreamChannelController + weak var delegate: MessagesDataSource? + + // Cache to convert array to LazyCachedMapCollection + private var cachedMessages: LazyCachedMapCollection = [] + + var messages: LazyCachedMapCollection { + cachedMessages + } + + var hasLoadedAllNextMessages: Bool { + controller.hasLoadedAllNextMessages + } + + var firstUnreadMessageId: String? { + // Livestream channels don't support read receipts + nil + } + + init(controller: LivestreamChannelController) { + self.controller = controller + self.controller.delegate = self + self.cachedMessages = LazyCachedMapCollection(source: controller.messages, map: { $0 }) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + // Convert array to LazyCachedMapCollection + cachedMessages = LazyCachedMapCollection(source: messages, map: { $0 }) + + // Create changes array - for simplicity, we'll treat all updates as insertions + // This is a limitation since LivestreamChannelController doesn't provide ListChange details + let changes: [ListChange] = messages.enumerated().map { index, message in + .insert(message, index: IndexPath(row: index, section: 0)) + } + + delegate?.dataSource( + channelDataSource: self, + didUpdateMessages: cachedMessages, + changes: changes + ) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) { + delegate?.dataSource(channelDataSource: self, didUpdateChannel: .update(channel)) + } + + func loadPreviousMessages( + before messageId: MessageId?, + limit: Int, + completion: ((Error?) -> Void)? + ) { + controller.loadPreviousMessages( + before: messageId, + limit: limit, + completion: completion + ) + } + + func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) { + controller.loadNextMessages(limit: limit, completion: completion) + } + + func loadPageAroundMessageId( + _ messageId: MessageId, + completion: ((Error?) -> Void)? + ) { + controller.loadPageAroundMessageId(messageId, completion: completion) + } + + func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) { + controller.loadFirstPage(completion) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift index 1e4ee9c3..80ba2c6d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift @@ -38,6 +38,16 @@ public struct ChatChannelView: View, KeyboardReadable { factory = viewFactory } + public init( + viewFactory: Factory = DefaultViewFactory.shared, + livestreamViewModel: LivestreamChannelViewModel + ) { + _viewModel = StateObject( + wrappedValue: livestreamViewModel + ) + factory = viewFactory + } + public var body: some View { ZStack { if let channel = viewModel.channel { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index 5e5d90c5..4c94a66d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -12,7 +12,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { @Injected(\.utils) private var utils @Injected(\.images) private var images - private var channelDataSource: ChannelDataSource + internal var channelDataSource: ChannelDataSource private var cancellables = Set() private var lastRefreshThreshold = 200 private let refreshThreshold = 200 @@ -43,8 +43,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { private lazy var messagesDateFormatter = utils.dateFormatter private lazy var messageCachingUtils = utils.messageCachingUtils - private var loadingPreviousMessages: Bool = false - private var loadingMessagesAround: Bool = false + internal var loadingPreviousMessages: Bool = false + internal var loadingMessagesAround: Bool = false private var scrollsToUnreadAfterJumpToMessage = false private var disableDateIndicator = false private var channelName = "" @@ -138,7 +138,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { // it should not call markRead() in any scenario. public var currentUserMarkedMessageUnread: Bool = false - @Published public private(set) var channel: ChatChannel? + @Published public internal(set) var channel: ChatChannel? public var isMessageThread: Bool { messageController != nil @@ -147,16 +147,19 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { public init( channelController: ChatChannelController, messageController: ChatMessageController? = nil, - scrollToMessage: ChatMessage? = nil + scrollToMessage: ChatMessage? = nil, + syncOnAppear: Bool = true ) { self.channelController = channelController if InjectedValues[\.utils].shouldSyncChannelControllerOnAppear(channelController) - && messageController == nil { + && messageController == nil && syncOnAppear { channelController.synchronize() } if let messageController = messageController { self.messageController = messageController - messageController.synchronize() + if syncOnAppear { + messageController.synchronize() + } channelDataSource = MessageThreadDataSource( channelController: channelController, messageController: messageController @@ -496,8 +499,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { func dataSource( channelDataSource: ChannelDataSource, - didUpdateChannel channel: EntityChange, - channelController: ChatChannelController + didUpdateChannel channel: EntityChange ) { self.channel = channel.item checkReadIndicators(for: channel) @@ -633,7 +635,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func checkReadIndicators(for channel: EntityChange) { + internal func checkReadIndicators(for channel: EntityChange) { switch channel { case let .update(chatChannel): let newReadsString = chatChannel.readsString @@ -664,7 +666,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func checkOnlineIndicator() { + internal func checkOnlineIndicator() { guard let channel else { return } let updated = !channel.lastActiveMembers.filter { member in member.id != chatClient.currentUserId && member.isOnline @@ -686,7 +688,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func checkHeaderType() { + internal func checkHeaderType() { guard let channel = channel else { return } @@ -716,7 +718,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func checkUnreadCount() { + internal func checkUnreadCount() { guard !isMessageThread else { return } guard let channel = channelController.channel else { return } @@ -765,7 +767,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func shouldAnimate(changes: [ListChange]) -> Bool { + internal func shouldAnimate(changes: [ListChange]) -> Bool { if !utils.messageListConfig.messageDisplayOptions.animateChanges { return false } @@ -807,7 +809,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func checkTypingIndicator() { + internal func checkTypingIndicator() { guard let channel = channel else { return } let shouldShow = !channel.currentlyTypingUsersFiltered(currentUserId: chatClient.currentUserId).isEmpty && utils.messageListConfig.typingIndicatorPlacement == .bottomOverlay @@ -817,7 +819,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - private func updateScrolledIdToNewestMessage() { + internal func updateScrolledIdToNewestMessage() { if scrolledId != nil { scrolledId = nil } @@ -920,3 +922,70 @@ extension Notification.Name { /// When a sheet is presented, the message cell is not reloaded. static let messageSheetShownNotification = Notification.Name("messageSheetShownNotification") } + +/// View model for livestream channels using `LivestreamChannelController`. +open class LivestreamChannelViewModel: ChatChannelViewModel { + @Injected(\.chatClient) private var livestreamChatClient + + /// The livestream channel controller. + public let livestreamChannelController: LivestreamChannelController + + public init( + livestreamChannelController: LivestreamChannelController + ) { + self.livestreamChannelController = livestreamChannelController + + // Create a temporary ChatChannelController to satisfy the parent initializer + // The parent class requires ChatChannelController, but we'll use LivestreamChannelController + // for all operations. We need a valid controller to pass to super.init. + let tempController: ChatChannelController + if let cid = livestreamChannelController.cid { + tempController = livestreamChannelController.client.channelController(for: cid) + } else { + tempController = livestreamChannelController.client.channelController(for: ChannelId(type: .livestream, id: UUID().uuidString)) + } + + super.init( + channelController: tempController, + messageController: nil, + scrollToMessage: nil, + syncOnAppear: false + ) + + livestreamChannelController.synchronize() + + Task { @MainActor in + let dataSource = LivestreamChannelDataSource(controller: livestreamChannelController) + channelDataSource = dataSource + channelDataSource.delegate = self + self.messages = self.channelDataSource.messages + } + + // Update channel from livestream controller + channel = livestreamChannelController.channel + } + + override public func scrollToLastMessage() { + updateScrolledIdToNewestMessage() + } + + override func shouldAnimate(changes: [ListChange]) -> Bool { + false + } + + override func dataSource( + channelDataSource: any ChannelDataSource, + didUpdateChannel channel: EntityChange + ) { + self.channel = channel.item + checkHeaderType() + checkOnlineIndicator() + } + + override public func checkUnreadCount() { + // Livestream channels don't support read receipts, so skip unread count logic + guard !isMessageThread else { return } + guard livestreamChannelController.channel != nil else { return } + // Skip read receipt logic for livestream + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index c2c0e6d0..53becbca 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -303,7 +303,7 @@ public struct MessageListView: View, KeyboardReadable { withAnimation { if messages.first?.id == scrolledId { scrollView.scrollTo(scrolledId, anchor: .top) - } else { + } else if showScrollToLatestButton { scrollView.scrollTo(scrolledId, anchor: messageListConfig.scrollingAnchor) } }