Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

## StreamChat
### ✅ Added
- 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)
### 🔄 Changed
- The `LivestreamChannelController.resume()` should be manually called, previously, it was automatically called on a new message [#3774](https://github.com/GetStream/stream-chat-swift/pull/3774)

# [4.84.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.84.0)
_August 06, 2025_
Expand Down
117 changes: 26 additions & 91 deletions DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ class DemoLivestreamChatChannelVC: _ViewController,
/// Controller for observing data changes within the channel.
var livestreamChannelController: LivestreamChannelController!

/// Controller to observe web socket events.
lazy var eventsController = livestreamChannelController.client.eventsController()

/// User search controller for suggestion users when typing in the composer.
lazy var userSuggestionSearchController: ChatUserSearchController =
livestreamChannelController.client.userSearchController()

/// A controller for observing web socket events.
lazy var eventsController: EventsController = client.eventsController()

/// The size of the channel avatar.
var channelAvatarSize: CGSize {
CGSize(width: 32, height: 32)
Expand Down Expand Up @@ -58,38 +58,8 @@ class DemoLivestreamChatChannelVC: _ViewController,
messageListVC.listView.isLastCellFullyVisible
}

private var isLastMessageVisibleOrSeen: Bool {
isLastMessageFullyVisible
}

/// Banner view to show when chat is paused due to scrolling
private lazy var pauseBannerView: UIView = {
let banner = UIView()
banner.backgroundColor = appearance.colorPalette.background2
banner.layer.cornerRadius = 12
banner.layer.shadowColor = UIColor.black.cgColor
banner.layer.shadowOffset = CGSize(width: 0, height: 2)
banner.layer.shadowOpacity = 0.1
banner.layer.shadowRadius = 4
banner.translatesAutoresizingMaskIntoConstraints = false

let label = UILabel()
label.text = "Chat paused due to scroll"
label.font = appearance.fonts.footnote
label.textColor = appearance.colorPalette.text
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false

banner.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -16),
label.topAnchor.constraint(equalTo: banner.topAnchor, constant: 8),
label.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -8)
])

return banner
}()
private lazy var pauseBannerView = LivestreamPauseBannerView()

override func setUp() {
super.setUp()
Expand All @@ -113,12 +83,6 @@ class DemoLivestreamChatChannelVC: _ViewController,
messageListVC.swipeToReplyGestureHandler.onReply = { [weak self] message in
self?.messageComposerVC.content.quoteMessage(message)
}

// Initialize messages from controller
messages = livestreamChannelController.messages

// Initialize pause banner state
pauseBannerView.alpha = 0.0
}

private func setChannelControllerToComposerIfNeeded() {
Expand Down Expand Up @@ -172,9 +136,6 @@ class DemoLivestreamChatChannelVC: _ViewController,
constant: -16
)
])

// Initially hide the banner
pauseBannerView.isHidden = true
}

override func viewDidAppear(_ animated: Bool) {
Expand All @@ -183,14 +144,6 @@ class DemoLivestreamChatChannelVC: _ViewController,
keyboardHandler.start()
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

if let draftMessage = livestreamChannelController.channel?.draftMessage {
messageComposerVC.content.draftMessage(draftMessage)
}
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)

Expand All @@ -211,29 +164,6 @@ class DemoLivestreamChatChannelVC: _ViewController,
messageComposerVC.updateContent()
}

// MARK: - Actions

/// Jump to a given message.
/// In case the message is already loaded, it directly goes to it.
/// If not, it will load the messages around it and go to that page.
///
/// This function is an high-level abstraction of `messageListVC.jumpToMessage(id:onHighlight:)`.
///
/// - Parameters:
/// - id: The id of message which the message list should go to.
/// - animated: `true` if you want to animate the change in position; `false` if it should be immediate.
/// - shouldHighlight: Whether the message should be highlighted when jumping to it. By default it is highlighted.
func jumpToMessage(id: MessageId, animated: Bool = true, shouldHighlight: Bool = true) {
if shouldHighlight {
messageListVC.jumpToMessage(id: id, animated: animated) { [weak self] indexPath in
self?.messageListVC.highlightCell(at: indexPath)
}
return
}

messageListVC.jumpToMessage(id: id, animated: animated)
}

// MARK: - ChatMessageListVCDataSource

var messages: [ChatMessage] = []
Expand Down Expand Up @@ -298,8 +228,8 @@ class DemoLivestreamChatChannelVC: _ViewController,
livestreamChannelController.resume()
}

if isLastMessageFullyVisible {
messageListVC.scrollToBottomButton.isHidden = true
if !isLastMessageFullyVisible && !livestreamChannelController.isPaused && scrollView.isDragging {
livestreamChannelController.pause()
}
}

Expand All @@ -317,9 +247,6 @@ class DemoLivestreamChatChannelVC: _ViewController,

// Load older messages when displaying messages near the end of the array
if indexPath.item >= messageCount - 10 {
if messageListVC.listView.isDragging && !messageListVC.listView.isLastCellFullyVisible {
livestreamChannelController.pause()
}
livestreamChannelController.loadPreviousMessages()
}
}
Expand All @@ -345,6 +272,11 @@ class DemoLivestreamChatChannelVC: _ViewController,
}
case is MarkUnreadActionItem:
dismiss(animated: true)
case is CopyActionItem:
UIPasteboard.general.string = message.text
dismiss(animated: true) { [weak self] in
self?.presentAlert(title: "Message copied to clipboard")
}
default:
return
}
Expand Down Expand Up @@ -420,30 +352,33 @@ class DemoLivestreamChatChannelVC: _ViewController,
messageListVC.scrollToBottomButton.content = .init(messages: skippedMessagesAmount, mentions: 0)
}

// MARK: - EventsControllerDelegate
func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) {
if event is NewMessagePendingEvent {
if livestreamChannelController.isPaused {
pauseBannerView.setState(.resuming)
}
}

func eventsController(_ controller: EventsController, didReceiveEvent event: Event) {
if let newMessagePendingEvent = event as? NewMessagePendingEvent {
let newMessage = newMessagePendingEvent.message
if !isFirstPageLoaded && newMessage.isSentByCurrentUser && !newMessage.isPartOfThread {
livestreamChannelController.loadFirstPage()
if let newMessageEvent = event as? MessageNewEvent, newMessageEvent.message.isSentByCurrentUser {
if livestreamChannelController.isPaused {
pauseBannerView.setState(.resuming)
livestreamChannelController.resume()
}
}
}

/// Shows or hides the pause banner with animation
/// Shows or hides the pause banner.
private func showPauseBanner(_ show: Bool) {
UIView.animate(withDuration: 0.3, animations: {
self.pauseBannerView.isHidden = !show
self.pauseBannerView.alpha = show ? 1.0 : 0.0
})
if show {
pauseBannerView.setState(.paused)
}
pauseBannerView.setVisible(show, animated: true)
}
}

/// A custom composer view controller for livestream channels that uses LivestreamChannelController
/// and disables voice recording functionality.
class DemoLivestreamComposerVC: ComposerVC {
/// Reference to the livestream channel controller
var livestreamChannelController: LivestreamChannelController?

override func addAttachmentToContent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import UIKit
/// A custom message list view controller for livestream channels that uses LivestreamChannelController
/// instead of MessageController and shows a custom bottom sheet for message actions.
class DemoLivestreamChatMessageListVC: ChatMessageListVC {
/// The livestream channel controller for managing channel operations
public weak var livestreamChannelController: LivestreamChannelController?

override func didSelectMessageCell(at indexPath: IndexPath) {
Expand All @@ -22,13 +21,11 @@ class DemoLivestreamChatMessageListVC: ChatMessageListVC {
let livestreamChannelController = livestreamChannelController
else { return }

// Create the custom livestream actions view controller
let actionsController = DemoLivestreamMessageActionsVC()
actionsController.message = message
actionsController.livestreamChannelController = livestreamChannelController
actionsController.delegate = self

// Present as bottom sheet

actionsController.modalPresentationStyle = .pageSheet

if #available(iOS 16.0, *) {
Expand All @@ -54,14 +51,12 @@ class DemoLivestreamChatMessageListVC: ChatMessageListVC {
let message = messageContentView.content,
let livestreamChannelController = livestreamChannelController
else { return }

// Create SwiftUI reactions list view

let reactionsView = DemoLivestreamReactionsListView(
message: message,
controller: livestreamChannelController
)

// Present as a SwiftUI sheet

let hostingController = UIHostingController(rootView: reactionsView)
hostingController.modalPresentationStyle = .pageSheet

Expand Down Expand Up @@ -89,7 +84,6 @@ extension DemoLivestreamChatMessageListVC: LivestreamMessageActionsVCDelegate {
message: ChatMessage,
didTapOnActionItem actionItem: ChatMessageActionItem
) {
// Handle action items that need to be delegated to the parent
delegate?.chatMessageListVC(self, didTapOnAction: actionItem, for: message)
}
}
20 changes: 5 additions & 15 deletions DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ protocol LivestreamMessageActionsVCDelegate: AnyObject {
func livestreamMessageActionsVCDidFinish(_ vc: DemoLivestreamMessageActionsVC)
}

/// Custom bottom sheet view controller for livestream message actions
/// Custom bottom sheet view controller for livestream message actions.
class DemoLivestreamMessageActionsVC: UIViewController {
// MARK: - Properties

Expand Down Expand Up @@ -51,7 +51,7 @@ class DemoLivestreamMessageActionsVC: UIViewController {
// MARK: - Setup

private func setupUI() {
view.backgroundColor = UIColor.systemBackground
view.backgroundColor = Appearance.default.colorPalette.background

view.addSubview(mainStackView)
NSLayoutConstraint.activate([
Expand All @@ -70,8 +70,7 @@ class DemoLivestreamMessageActionsVC: UIViewController {
private func setupReactions() {
guard let channel = livestreamChannelController?.channel,
channel.canSendReaction else { return }

// Use available reactions from the appearance system, ordered by raw value

let availableReactions = Appearance.default.images.availableReactions

let reactionButtons = availableReactions
Expand Down Expand Up @@ -142,29 +141,20 @@ class DemoLivestreamMessageActionsVC: UIViewController {
button.setImage(image, for: .normal)
button.contentMode = .scaleAspectFit
button.imageView?.contentMode = .scaleAspectFit

// Add some padding around the image for better visual balance
button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)

// Check if current user has reacted with this type

let isSelected = message?.currentUserReactions.contains { $0.type == reactionType } ?? false

// Configure appearance based on selection state
let colorPalette = Appearance.default.colorPalette
button.backgroundColor = isSelected ? colorPalette.accentPrimary.withAlphaComponent(0.5) : .systemGray6
button.layer.borderWidth = isSelected ? 2 : 0.5
button.layer.borderColor = isSelected ? colorPalette.accentPrimary.cgColor : UIColor.separator.cgColor

// Apply styling and constraints
button.layer.cornerRadius = 20
button.layer.shadowColor = UIColor.black.cgColor
button.layer.shadowOffset = CGSize(width: 0, height: 1)
button.layer.shadowOpacity = 0.1
button.layer.shadowRadius = 2

button.width(40).height(40)

// Add press animation

button.addTarget(self, action: #selector(reactionButtonPressed(_:)), for: .touchDown)
button.addTarget(self, action: #selector(reactionButtonReleased(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ private struct ReactionRowView: View {

var body: some View {
HStack(spacing: 12) {
// User avatar
if #available(iOS 15.0, *) {
AsyncImage(url: reaction.author.imageURL) { image in
image
Expand Down Expand Up @@ -143,7 +142,6 @@ private struct ReactionRowView: View {

Spacer()

// Reaction emoji/image
if let reactionAppearance = Appearance.default.images.availableReactions[reaction.type] {
if #available(iOS 15.0, *) {
Image(uiImage: reactionAppearance.largeIcon)
Expand Down
Loading
Loading