Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation
import StreamChat

/// Data source providing the chat messages.
Expand All @@ -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<ChatChannel>,
channelController: ChatChannelController
didUpdateChannel channel: EntityChange<ChatChannel>
)
}

Expand Down Expand Up @@ -126,8 +125,7 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
) {
delegate?.dataSource(
channelDataSource: self,
didUpdateChannel: channel,
channelController: channelController
didUpdateChannel: channel
)
}

Expand Down Expand Up @@ -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<ChatMessage> = []

var messages: LazyCachedMapCollection<ChatMessage> {
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<ChatMessage>] = 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)
}
}
10 changes: 10 additions & 0 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ public struct ChatChannelView<Factory: ViewFactory>: 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 {
Expand Down
101 changes: 85 additions & 16 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
private var lastRefreshThreshold = 200
private let refreshThreshold = 200
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -496,8 +499,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {

func dataSource(
channelDataSource: ChannelDataSource,
didUpdateChannel channel: EntityChange<ChatChannel>,
channelController: ChatChannelController
didUpdateChannel channel: EntityChange<ChatChannel>
) {
self.channel = channel.item
checkReadIndicators(for: channel)
Expand Down Expand Up @@ -633,7 +635,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}

private func checkReadIndicators(for channel: EntityChange<ChatChannel>) {
internal func checkReadIndicators(for channel: EntityChange<ChatChannel>) {
switch channel {
case let .update(chatChannel):
let newReadsString = chatChannel.readsString
Expand Down Expand Up @@ -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
Expand All @@ -686,7 +688,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}

private func checkHeaderType() {
internal func checkHeaderType() {
guard let channel = channel else {
return
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -765,7 +767,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}

private func shouldAnimate(changes: [ListChange<ChatMessage>]) -> Bool {
internal func shouldAnimate(changes: [ListChange<ChatMessage>]) -> Bool {
if !utils.messageListConfig.messageDisplayOptions.animateChanges {
return false
}
Expand Down Expand Up @@ -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
Expand All @@ -817,7 +819,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}

private func updateScrolledIdToNewestMessage() {
internal func updateScrolledIdToNewestMessage() {
if scrolledId != nil {
scrolledId = nil
}
Expand Down Expand Up @@ -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<ChatMessage>]) -> Bool {
false
}

override func dataSource(
channelDataSource: any ChannelDataSource,
didUpdateChannel channel: EntityChange<ChatChannel>
) {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
withAnimation {
if messages.first?.id == scrolledId {
scrollView.scrollTo(scrolledId, anchor: .top)
} else {
} else if showScrollToLatestButton {
scrollView.scrollTo(scrolledId, anchor: messageListConfig.scrollingAnchor)
}
}
Expand Down
Loading