diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf7a0a7ebb..c4d353857b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ✅ Added - Add pending messages support [#3754](https://github.com/GetStream/stream-chat-swift/pull/3754) +- Add new lightweight `LivestreamChannelController` that improves performance for live chats [#3750](https://github.com/GetStream/stream-chat-swift/pull/3750) ### 🐞 Fixed - Fix `ChatClient.currentUserId` not removed instantly after calling `logout()` [#3766](https://github.com/GetStream/stream-chat-swift/pull/3766) diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift new file mode 100644 index 00000000000..fc5de057d10 --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift @@ -0,0 +1,519 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +class DemoLivestreamChatChannelVC: _ViewController, + ThemeProvider, + ChatMessageListVCDataSource, + ChatMessageListVCDelegate, + LivestreamChannelControllerDelegate, + EventsControllerDelegate +{ + /// Controller for observing data changes within the channel. + var livestreamChannelController: LivestreamChannelController! + + /// 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) + } + + var client: ChatClient { + livestreamChannelController.client + } + + /// Component responsible for setting the correct offset when keyboard frame is changed. + lazy var keyboardHandler: KeyboardHandler = ComposerKeyboardHandler( + composerParentVC: self, + composerBottomConstraint: messageComposerBottomConstraint, + messageListVC: messageListVC + ) + + /// The message list component responsible to render the messages. + lazy var messageListVC: DemoLivestreamChatMessageListVC = DemoLivestreamChatMessageListVC() + + /// Controller that handles the composer view + private(set) lazy var messageComposerVC = DemoLivestreamComposerVC() + + /// View for displaying the channel image in the navigation bar. + private(set) lazy var channelAvatarView = components + .channelAvatarView.init() + .withoutAutoresizingMaskConstraints + + /// The message composer bottom constraint used for keyboard animation handling. + var messageComposerBottomConstraint: NSLayoutConstraint? + + /// A boolean value indicating whether the last message is fully visible or not. + var isLastMessageFullyVisible: Bool { + 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 + }() + + override func setUp() { + super.setUp() + + eventsController.delegate = self + + messageListVC.delegate = self + messageListVC.dataSource = self + messageListVC.client = client + + messageComposerVC.userSearchController = userSuggestionSearchController + + setChannelControllerToComposerIfNeeded() + setChannelControllerToMessageListIfNeeded() + + livestreamChannelController.delegate = self + livestreamChannelController.synchronize { [weak self] error in + self?.didFinishSynchronizing(with: error) + } + + 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() { + messageComposerVC.channelController = nil + messageComposerVC.livestreamChannelController = livestreamChannelController + } + + private func setChannelControllerToMessageListIfNeeded() { + messageListVC.livestreamChannelController = livestreamChannelController + } + + override func setUpLayout() { + super.setUpLayout() + + view.backgroundColor = appearance.colorPalette.background + + addChildViewController(messageListVC, targetView: view) + NSLayoutConstraint.activate([ + messageListVC.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + messageListVC.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + messageListVC.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) + ]) + + addChildViewController(messageComposerVC, targetView: view) + NSLayoutConstraint.activate([ + messageComposerVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + messageComposerVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + messageComposerVC.view.topAnchor.constraint(equalTo: messageListVC.view.bottomAnchor) + ]) + messageComposerBottomConstraint = messageComposerVC.view.bottomAnchor + .constraint(equalTo: view.bottomAnchor) + messageComposerBottomConstraint?.isActive = true + + NSLayoutConstraint.activate([ + channelAvatarView.widthAnchor.constraint(equalToConstant: channelAvatarSize.width), + channelAvatarView.heightAnchor.constraint(equalToConstant: channelAvatarSize.height) + ]) + + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: channelAvatarView) + channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) + + // Add pause banner + view.addSubview(pauseBannerView) + NSLayoutConstraint.activate([ + pauseBannerView.widthAnchor.constraint(equalToConstant: 200), + pauseBannerView.centerXAnchor.constraint( + equalTo: view.centerXAnchor + ), + pauseBannerView.bottomAnchor.constraint( + equalTo: messageComposerVC.view.topAnchor, + constant: -16 + ) + ]) + + // Initially hide the banner + pauseBannerView.isHidden = true + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + 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) + + keyboardHandler.stop() + + resignFirstResponder() + } + + /// Called when the syncing of the `channelController` is finished. + /// - Parameter error: An `error` if the syncing failed; `nil` if it was successful. + func didFinishSynchronizing(with error: Error?) { + if let error = error { + log.error("Error when synchronizing ChannelController: \(error)") + } + + setChannelControllerToComposerIfNeeded() + setChannelControllerToMessageListIfNeeded() + 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] = [] + + var isFirstPageLoaded: Bool { + livestreamChannelController.hasLoadedAllNextMessages + } + + var isLastPageLoaded: Bool { + livestreamChannelController.hasLoadedAllPreviousMessages + } + + func channel(for vc: ChatMessageListVC) -> ChatChannel? { + livestreamChannelController.channel + } + + func numberOfMessages(in vc: ChatMessageListVC) -> Int { + messages.count + } + + func chatMessageListVC(_ vc: ChatMessageListVC, messageAt indexPath: IndexPath) -> ChatMessage? { + messages[indexPath.item] + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + messageLayoutOptionsAt indexPath: IndexPath + ) -> ChatMessageLayoutOptions { + guard let channel = livestreamChannelController.channel else { return [] } + + return components.messageLayoutOptionsResolver.optionsForMessage( + at: indexPath, + in: channel, + with: AnyRandomAccessCollection(messages), + appearance: appearance + ) + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + shouldLoadPageAroundMessageId messageId: MessageId, + _ completion: @escaping ((Error?) -> Void) + ) { + livestreamChannelController.loadPageAroundMessageId(messageId) { error in + completion(error) + } + } + + func chatMessageListVCShouldLoadFirstPage( + _ vc: ChatMessageListVC + ) { + livestreamChannelController.loadFirstPage() + } + + // MARK: - ChatMessageListVCDelegate + + func chatMessageListVC( + _ vc: ChatMessageListVC, + scrollViewDidScroll scrollView: UIScrollView + ) { + if isLastMessageFullyVisible && livestreamChannelController.isPaused { + livestreamChannelController.resume() + } + + if isLastMessageFullyVisible { + messageListVC.scrollToBottomButton.isHidden = true + } + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + willDisplayMessageAt indexPath: IndexPath + ) { + let messageCount = messages.count + guard messageCount > 0 else { return } + + // Load newer messages when displaying messages near index 0 + if indexPath.item < 10 && !isFirstPageLoaded { + livestreamChannelController.loadNextMessages() + } + + // 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() + } + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + didTapOnAction actionItem: ChatMessageActionItem, + for message: ChatMessage + ) { + switch actionItem { + case is EditActionItem: + dismiss(animated: true) { [weak self] in + self?.messageComposerVC.content.editMessage(message) + self?.messageComposerVC.composerView.inputMessageView.textView.becomeFirstResponder() + } + case is InlineReplyActionItem: + dismiss(animated: true) { [weak self] in + self?.messageComposerVC.content.quoteMessage(message) + } + case is ThreadReplyActionItem: + dismiss(animated: true) { [weak self] in + self?.messageListVC.showThread(messageId: message.id) + } + case is MarkUnreadActionItem: + dismiss(animated: true) + default: + return + } + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + didTapOnMessageListView messageListView: ChatMessageListView, + with gestureRecognizer: UITapGestureRecognizer + ) { + messageComposerVC.dismissSuggestions() + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + headerViewForMessage message: ChatMessage, + at indexPath: IndexPath + ) -> ChatMessageDecorationView? { + nil + } + + func chatMessageListVC( + _ vc: ChatMessageListVC, + footerViewForMessage message: ChatMessage, + at indexPath: IndexPath + ) -> ChatMessageDecorationView? { + nil + } + + // MARK: - LivestreamChannelControllerDelegate + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) { + channelAvatarView.content = (livestreamChannelController.channel, client.currentUserId) + navigationItem.title = channel.name + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + debugPrint("[Livestream] didUpdateMessages.count: \(messages.count)") + + messageListVC.setPreviousMessagesSnapshot(self.messages) + messageListVC.setNewMessagesSnapshotArray(livestreamChannelController.messages) + + let diff = livestreamChannelController.messages.difference(from: self.messages) + let changes = diff.map { change in + switch change { + case let .insert(offset, element, _): + return ListChange.insert(element, index: IndexPath(row: offset, section: 0)) + case let .remove(offset, element, _): + return ListChange.remove(element, index: IndexPath(row: offset, section: 0)) + } + } + + messageListVC.updateMessages(with: changes) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) { + showPauseBanner(isPaused) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) { + messageListVC.scrollToBottomButton.content = .init(messages: skippedMessagesAmount, mentions: 0) + } + + // MARK: - EventsControllerDelegate + + 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() + } + } + } + + /// Shows or hides the pause banner with animation + private func showPauseBanner(_ show: Bool) { + UIView.animate(withDuration: 0.3, animations: { + self.pauseBannerView.isHidden = !show + self.pauseBannerView.alpha = show ? 1.0 : 0.0 + }) + } +} + +/// 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( + from url: URL, + type: AttachmentType, + info: [LocalAttachmentInfoKey: Any], + extraData: (any Encodable)? + ) throws { + guard let cid = livestreamChannelController?.channel?.cid else { + return + } + // We need to set the channel controller temporarily just to access the client config. + channelController = livestreamChannelController?.client.channelController(for: cid) + try super.addAttachmentToContent(from: url, type: type, info: info, extraData: extraData) + channelController = nil + } + + /// Override message creation to use livestream controller + override func createNewMessage(text: String) { + guard let livestreamController = livestreamChannelController else { + // Fallback to the regular implementation if livestream controller is not available + super.createNewMessage(text: text) + return + } + + if content.threadMessage?.id != nil { + // For thread replies, we still need to use the regular channel controller + // since LivestreamChannelController doesn't support thread operations + super.createNewMessage(text: text) + return + } + + livestreamController.createNewMessage( + text: text, + pinning: nil, + attachments: content.attachments, + mentionedUserIds: content.mentionedUsers.map(\.id), + quotedMessageId: content.quotingMessage?.id, + skipEnrichUrl: content.skipEnrichUrl, + extraData: content.extraData + ) + } + + /// Override to hide the record button for livestream + override func updateRecordButtonVisibility() { + composerView.recordButton.isHidden = true + } + + /// Override to ensure voice recording is disabled + override func setupVoiceRecordingView() { + // Do not set up voice recording for livestream + } + + override var isCommandsEnabled: Bool { + false + } +} + +private extension UIView { + var withoutAutoresizingMaskConstraints: Self { + translatesAutoresizingMaskIntoConstraints = false + return self + } +} + +private extension UIViewController { + func addChildViewController(_ child: UIViewController, targetView superview: UIView) { + addChild(child) + child.view.translatesAutoresizingMaskIntoConstraints = false + superview.addSubview(child.view) + child.didMove(toParent: self) + } +} diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift new file mode 100644 index 00000000000..1603443a61c --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatMessageListVC.swift @@ -0,0 +1,95 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import SwiftUI +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) { + guard + let cell = listView.cellForRow(at: indexPath) as? ChatMessageCell, + let messageContentView = cell.messageContentView, + let message = messageContentView.content, + message.isInteractionEnabled == true, + 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, *) { + if let sheetController = actionsController.sheetPresentationController { + sheetController.detents = [ + .custom { _ in + return 180 + } + ] + sheetController.prefersGrabberVisible = true + sheetController.preferredCornerRadius = 16 + } + } + + present(actionsController, animated: true) + } + + override func messageContentViewDidTapOnReactionsView(_ indexPath: IndexPath?) { + guard + let indexPath = indexPath, + let cell = listView.cellForRow(at: indexPath) as? ChatMessageCell, + let messageContentView = cell.messageContentView, + 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 + + if #available(iOS 16.0, *) { + if let sheetController = hostingController.sheetPresentationController { + sheetController.detents = [.medium(), .large()] + sheetController.prefersGrabberVisible = true + sheetController.preferredCornerRadius = 16 + } + } + + present(hostingController, animated: true) + } +} + +// MARK: - LivestreamMessageActionsVCDelegate + +extension DemoLivestreamChatMessageListVC: LivestreamMessageActionsVCDelegate { + public func livestreamMessageActionsVCDidFinish(_ vc: DemoLivestreamMessageActionsVC) { + dismiss(animated: true) + } + + func livestreamMessageActionsVC( + _ vc: DemoLivestreamMessageActionsVC, + message: ChatMessage, + didTapOnActionItem actionItem: ChatMessageActionItem + ) { + // Handle action items that need to be delegated to the parent + delegate?.chatMessageListVC(self, didTapOnAction: actionItem, for: message) + } +} diff --git a/DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift new file mode 100644 index 00000000000..1cb37947b4e --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamMessageActionsVC.swift @@ -0,0 +1,338 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +/// Delegate protocol for `LivestreamMessageActionsVC` +protocol LivestreamMessageActionsVCDelegate: AnyObject { + func livestreamMessageActionsVC( + _ vc: DemoLivestreamMessageActionsVC, + message: ChatMessage, + didTapOnActionItem actionItem: ChatMessageActionItem + ) + func livestreamMessageActionsVCDidFinish(_ vc: DemoLivestreamMessageActionsVC) +} + +/// Custom bottom sheet view controller for livestream message actions +class DemoLivestreamMessageActionsVC: UIViewController { + // MARK: - Properties + + weak var delegate: LivestreamMessageActionsVCDelegate? + weak var livestreamChannelController: LivestreamChannelController? + var message: ChatMessage? + + // MARK: - UI Components + + private lazy var mainStackView = VContainer(spacing: 8) + + private lazy var reactionsStackView = HContainer( + spacing: 16, + distribution: .fillEqually, + alignment: .center + ).height(50) + + private lazy var actionsStackView = HContainer( + spacing: 16, + distribution: .fillEqually + ).height(80) + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupReactions() + setupActions() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor.systemBackground + + view.addSubview(mainStackView) + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10), + mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + mainStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10) + ]) + + mainStackView.views { + reactionsStackView + actionsStackView + } + } + + 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 + .sorted { $0.key.rawValue < $1.key.rawValue } + .map { (reactionType, reactionAppearance) in + createReactionButton(image: reactionAppearance.smallIcon, reactionType: reactionType) + } + + reactionButtons.forEach { + reactionsStackView.addArrangedSubview($0) + } + } + + private func setupActions() { + guard let message = message, + let livestreamChannelController = livestreamChannelController else { return } + + var actionButtons: [UIButton] = [] + + // Reply action + if let channel = livestreamChannelController.channel, channel.canQuoteMessage { + let replyButton = createSquareActionButton( + title: "Reply", + icon: UIImage(systemName: "arrowshape.turn.up.left") ?? UIImage(), + action: { [weak self] in + self?.handleReplyAction() + } + ) + actionButtons.append(replyButton) + } + + // Pin action + if let channel = livestreamChannelController.channel, channel.canPinMessage { + let isPinned = message.pinDetails != nil + let pinButton = createSquareActionButton( + title: isPinned ? "Unpin" : "Pin", + icon: UIImage(systemName: isPinned ? "pin.slash" : "pin") ?? UIImage(), + action: { [weak self] in + self?.handlePinAction() + } + ) + actionButtons.append(pinButton) + } + + // Copy action + if !message.text.isEmpty { + let copyActionItem = CopyActionItem { [weak self] actionItem in + guard let self = self, let message = self.message else { return } + self.delegate?.livestreamMessageActionsVC(self, message: message, didTapOnActionItem: actionItem) + } + let copyButton = createSquareActionButton( + title: copyActionItem.title, + icon: copyActionItem.icon, + action: { + copyActionItem.action(copyActionItem) + } + ) + actionButtons.append(copyButton) + } + + actionButtons.forEach { + actionsStackView.addArrangedSubview($0) + } + } + + private func createReactionButton(image: UIImage, reactionType: MessageReactionType) -> UIButton { + let button = UIButton(type: .system) + 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]) + + button.addAction(UIAction { [weak self] _ in + self?.toggleReaction(reactionType) + self?.delegate?.livestreamMessageActionsVCDidFinish(self!) + }, for: .touchUpInside) + + return button + } + + @objc private func reactionButtonPressed(_ button: UIButton) { + UIView.animate(withDuration: 0.1) { + button.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + } + } + + @objc private func reactionButtonReleased(_ button: UIButton) { + UIView.animate(withDuration: 0.1) { + button.transform = .identity + } + } + + private func createSquareActionButton(title: String, icon: UIImage, action: @escaping () -> Void) -> UIButton { + let button = UIButton(type: .system) + button.backgroundColor = UIColor.systemGray6 + button.layer.cornerRadius = 12 + + // Create icon image view + let iconImageView = UIImageView(image: icon) + iconImageView.tintColor = .label + iconImageView.contentMode = .scaleAspectFit + iconImageView.width(24).height(24) + + // Create title label + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .label + titleLabel.textAlignment = .center + + // Create vertical container for icon and label + let contentStack = VContainer(spacing: 8, alignment: .center) { + iconImageView + titleLabel + } + contentStack.isUserInteractionEnabled = false + + button.addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.centerXAnchor.constraint(equalTo: button.centerXAnchor), + contentStack.centerYAnchor.constraint(equalTo: button.centerYAnchor) + ]) + + button.addAction(UIAction { [weak self] _ in + action() + self?.delegate?.livestreamMessageActionsVCDidFinish(self!) + }, for: .touchUpInside) + + return button + } + + // MARK: - Action Handlers + + private func toggleReaction(_ reactionType: MessageReactionType) { + guard let message = message else { return } + + // Check if current user has already reacted with this type + let hasReacted = message.currentUserReactions.contains { $0.type == reactionType } + + if hasReacted { + removeReaction(reactionType) + } else { + addReaction(reactionType) + } + } + + private func addReaction(_ reactionType: MessageReactionType) { + guard let message = message, + let controller = livestreamChannelController else { return } + + controller.addReaction(reactionType, to: message.id) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } + + private func removeReaction(_ reactionType: MessageReactionType) { + guard let message = message, + let controller = livestreamChannelController else { return } + + controller.deleteReaction(reactionType, from: message.id) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } + + private func handleReplyAction() { + guard let message = message else { return } + let actionItem = InlineReplyActionItem { _ in } + delegate?.livestreamMessageActionsVC(self, message: message, didTapOnActionItem: actionItem) + } + + private func handlePinAction() { + guard let message = message, + let controller = livestreamChannelController else { return } + + let isPinned = message.pinDetails != nil + + if isPinned { + controller.unpin(messageId: message.id) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } else { + controller.pin(messageId: message.id, pinning: .noExpiration) { [weak self] error in + if let error = error { + DispatchQueue.main.async { + self?.showErrorAlert(error: error) + } + } + } + } + } + + private func showErrorAlert(error: Error) { + let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + +// MARK: - Reactions Action Item + +/// Action item for adding reactions to a message +public struct ReactionsActionItem: ChatMessageActionItem { + public var title: String { "Add Reaction" } + public let icon: UIImage + public let action: (ChatMessageActionItem) -> Void + + public init(action: @escaping (ChatMessageActionItem) -> Void) { + self.action = action + icon = UIImage(systemName: "face.smiling") ?? UIImage() + } +} + +// MARK: - Pin Action Item + +/// Action item for pinning/unpinning a message +public struct PinActionItem: ChatMessageActionItem { + public var title: String + public let icon: UIImage + public let action: (ChatMessageActionItem) -> Void + public let isPinned: Bool + + public init(title: String? = nil, isPinned: Bool, action: @escaping (ChatMessageActionItem) -> Void) { + self.title = title ?? (isPinned ? "Unpin Message" : "Pin Message") + self.isPinned = isPinned + self.action = action + icon = UIImage(systemName: isPinned ? "pin.slash" : "pin") ?? UIImage() + } +} diff --git a/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift b/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift new file mode 100644 index 00000000000..6c3d124bc52 --- /dev/null +++ b/DemoApp/Screens/Livestream/DemoLivestreamReactionsListView.swift @@ -0,0 +1,166 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import SwiftUI + +struct DemoLivestreamReactionsListView: View { + let message: ChatMessage + let controller: LivestreamChannelController + @Environment(\.presentationMode) private var presentationMode + + @State private var reactions: [ChatMessageReaction] = [] + @State private var isLoading = false + @State private var hasLoadedAll = false + @State private var errorMessage: String? + + private let pageSize = 25 + + var body: some View { + NavigationView { + VStack { + if reactions.isEmpty && isLoading { + ProgressView("Loading reactions...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if reactions.isEmpty { + VStack(spacing: 16) { + Image(systemName: "face.smiling") + .font(.system(size: 50)) + .foregroundColor(.secondary) + Text("No reactions yet") + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(reactions, id: \.self) { reaction in + ReactionRowView(reaction: reaction) + } + + if !hasLoadedAll { + HStack { + Spacer() + ProgressView() + .onAppear { + loadMoreReactions() + } + Spacer() + } + } + } + .listStyle(PlainListStyle()) + } + + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .padding() + } + } + .navigationTitle("Reactions") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + trailing: Button("Done") { + presentationMode.wrappedValue.dismiss() + } + ) + } + .onAppear { + loadInitialReactions() + } + } + + private func loadInitialReactions() { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + controller.loadReactions(for: message.id, limit: pageSize, offset: 0) { result in + isLoading = false + switch result { + case .success(let newReactions): + reactions = newReactions + hasLoadedAll = newReactions.count < pageSize + case .failure(let error): + errorMessage = error.localizedDescription + } + } + } + + private func loadMoreReactions() { + guard !isLoading && !hasLoadedAll else { return } + isLoading = true + + controller.loadReactions(for: message.id, limit: pageSize, offset: reactions.count) { result in + isLoading = false + switch result { + case .success(let newReactions): + reactions.append(contentsOf: newReactions) + hasLoadedAll = newReactions.count < pageSize + case .failure(let error): + errorMessage = error.localizedDescription + } + } + } +} + +fileprivate struct ReactionRowView: View { + let reaction: ChatMessageReaction + + var body: some View { + HStack(spacing: 12) { + // User avatar + if #available(iOS 15.0, *) { + AsyncImage(url: reaction.author.imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Circle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Text(reaction.author.name?.prefix(1).uppercased() ?? "?") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + + VStack(alignment: .leading, spacing: 2) { + Text(reaction.author.name ?? "Unknown User") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text(formatReactionDate(reaction.createdAt)) + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + + Spacer() + + // Reaction emoji/image + if let reactionAppearance = Appearance.default.images.availableReactions[reaction.type] { + if #available(iOS 15.0, *) { + Image(uiImage: reactionAppearance.largeIcon) + .frame(width: 24, height: 24) + .foregroundStyle(Color(Appearance.default.colorPalette.accentPrimary)) + } + } else { + Text(Appearance.default.images.availableReactionPushEmojis[reaction.type] ?? "👍") + .font(.system(size: 20)) + } + } + .padding(.vertical, 4) + } + + private func formatReactionDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 655ff167198..b4442f9ac70 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -116,6 +116,16 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { ) self.channelPresentingStyle = .embeddedInTabBar }), + .init(title: "Show as Livestream Controller", handler: { [unowned self] _ in + let client = self.rootViewController.controller.client + let livestreamController = client.livestreamChannelController(for: .init(cid: cid)) + livestreamController.maxMessageLimitOptions = .recommended + livestreamController.countSkippedMessagesWhenPaused = true + let vc = DemoLivestreamChatChannelVC() + vc.livestreamChannelController = livestreamController + vc.hidesBottomBarWhenPushed = true + self.rootViewController.navigationController?.pushViewController(vc, animated: true) + }), .init(title: "Update channel name", isEnabled: canUpdateChannel, handler: { [unowned self] _ in self.rootViewController.presentAlert(title: "Enter channel name", textFieldPlaceholder: "Channel name") { name in guard let name = name, !name.isEmpty else { diff --git a/Sources/StreamChat/Audio/AppStateObserving.swift b/Sources/StreamChat/Audio/AppStateObserving.swift index 287947f9b59..9537a807996 100644 --- a/Sources/StreamChat/Audio/AppStateObserving.swift +++ b/Sources/StreamChat/Audio/AppStateObserving.swift @@ -15,6 +15,20 @@ protocol AppStateObserverDelegate: AnyObject { /// Will be triggered when the app moves to the foreground func applicationDidMoveToForeground() + + /// Will be triggered when the app receives a memory warning + func applicationDidReceiveMemoryWarning() +} + +extension AppStateObserverDelegate { + /// Default implementation of `applicationDidMoveToBackground` that does nothing. + public func applicationDidMoveToBackground() {} + + /// Default implementation of `applicationDidMoveToForeground` that does nothing. + public func applicationDidMoveToForeground() {} + + /// Default implementation of `applicationDidReceiveMemoryWarning` that does nothing. + public func applicationDidReceiveMemoryWarning() {} } /// This protocol describes an object that observes the state of an App and provides related information @@ -37,6 +51,7 @@ final class StreamAppStateObserver: AppStateObserving { /// The observation tokens that are used to retain the notification subscription on the NotificationCenter private var didMoveToBackgroundObservationToken: Any? private var didMoveToForegroundObservationToken: Any? + private var didReceiveMemoryWarningObservationToken: Any? /// A multicastDelegate instance that is being used as subscribers handler. Manages the following operations: /// - Subscribe @@ -88,6 +103,13 @@ final class StreamAppStateObserver: AppStateObserving { object: nil ) + notificationCenter.addObserver( + self, + selector: #selector(handleAppDidReceiveMemoryWarning), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + #endif } @@ -100,4 +122,9 @@ final class StreamAppStateObserver: AppStateObserving { @objc private func handleAppDidMoveToForeground() { delegate.invoke { $0.applicationDidMoveToForeground() } } + + /// Handles the app receiving memory warning notification by invoking the delegate method. + @objc private func handleAppDidReceiveMemoryWarning() { + delegate.invoke { $0.applicationDidReceiveMemoryWarning() } + } } diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift index 7c2697d129c..73ce9239706 100644 --- a/Sources/StreamChat/ChatClientFactory.swift +++ b/Sources/StreamChat/ChatClientFactory.swift @@ -115,7 +115,7 @@ class ChatClientFactory { databaseContainer: DatabaseContainer, currentUserId: @escaping () -> UserId? ) -> EventNotificationCenter { - let center = environment.notificationCenterBuilder(databaseContainer) + let center = environment.notificationCenterBuilder(databaseContainer, nil) let middlewares: [EventMiddleware] = [ EventDataProcessorMiddleware(), TypingStartCleanupMiddleware( diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 5c700c38586..94f25e5ed97 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -836,7 +836,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -891,7 +896,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -962,7 +972,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -1376,12 +1391,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP channelModificationFailed(completion) return } - guard cooldownDuration >= 1, cooldownDuration <= 120 else { - callback { - completion?(ClientError.InvalidCooldownDuration()) - } - return - } updater.enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) { error in self.callback { completion?(error) @@ -1401,7 +1410,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP channelModificationFailed(completion) return } - updater.enableSlowMode(cid: cid, cooldownDuration: 0) { error in + updater.disableSlowMode(cid: cid) { error in self.callback { completion?(error) } @@ -1740,7 +1749,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) } self.callback { completion?(result.map(\.id)) @@ -1989,7 +2003,7 @@ private extension ChatChannelController { /// ie. VCs should use the `are{FEATURE_NAME}Enabled` props (ie. `areReadEventsEnabled`) before using any feature private func channelFeatureDisabled(feature: String, completion: ((Error?) -> Void)?) { let error = ClientError.ChannelFeatureDisabled("Channel feature: \(feature) is disabled for this channel.") - log.error(error.localizedDescription) + log.warning(error.localizedDescription) callback { completion?(error) } diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift new file mode 100644 index 00000000000..4a7799d7595 --- /dev/null +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController+Combine.swift @@ -0,0 +1,87 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +extension LivestreamChannelController { + /// A publisher emitting a new value every time the channel changes. + public var channelChangePublisher: AnyPublisher { + basePublishers.channelChange.keepAlive(self) + } + + /// A publisher emitting a new value every time the list of messages changes. + public var messagesChangesPublisher: AnyPublisher<[ChatMessage], Never> { + basePublishers.messagesChanges.keepAlive(self) + } + + /// A publisher emitting a new value every time the pause state changes. + public var isPausedPublisher: AnyPublisher { + basePublishers.isPaused.keepAlive(self) + } + + /// A publisher emitting a new value every time the skipped messages amount changes. + public var skippedMessagesAmountPublisher: AnyPublisher { + basePublishers.skippedMessagesAmount.keepAlive(self) + } + + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose + /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, + /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + class BasePublishers { + /// The wrapper controller + unowned let controller: LivestreamChannelController + + /// A backing subject for `channelChangePublisher`. + let channelChange: CurrentValueSubject + + /// A backing subject for `messagesChangesPublisher`. + let messagesChanges: CurrentValueSubject<[ChatMessage], Never> + + /// A backing subject for `isPausedPublisher`. + let isPaused: CurrentValueSubject + + // A backing subject for `skippedMessagesAmountPublisher`. + let skippedMessagesAmount: CurrentValueSubject + + init(controller: LivestreamChannelController) { + self.controller = controller + channelChange = .init(controller.channel) + messagesChanges = .init(controller.messages) + skippedMessagesAmount = .init(controller.skippedMessagesAmount) + isPaused = .init(controller.isPaused) + controller.multicastDelegate.add(additionalDelegate: self) + } + } +} + +extension LivestreamChannelController.BasePublishers: LivestreamChannelControllerDelegate { + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) { + channelChange.send(channel) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + messagesChanges.send(messages) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) { + self.isPaused.send(isPaused) + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) { + self.skippedMessagesAmount.send(skippedMessagesAmount) + } +} diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift new file mode 100644 index 00000000000..7d15df635fc --- /dev/null +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -0,0 +1,1100 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +public extension ChatClient { + /// Creates a new `LivestreamChannelController` for the given channel query. + /// - Parameter channelQuery: The query to observe the channel. + /// - Returns: A new `LivestreamChannelController` instance. + func livestreamChannelController(for channelQuery: ChannelQuery) -> LivestreamChannelController { + LivestreamChannelController(channelQuery: channelQuery, client: self) + } +} + +/// A controller for managing livestream channels that operates without local database persistence. +/// +/// Unlike `ChatChannelController`, this controller manages all data in memory and communicates directly with the API. +/// It is more performant than `ChatChannelController` but is more simpler and it has less features, like for example: +/// - Read updates +/// - Typing indicators +/// - etc.. +public class LivestreamChannelController: DataStoreProvider, EventsControllerDelegate, AppStateObserverDelegate { + public typealias Delegate = LivestreamChannelControllerDelegate + + // MARK: - Public Properties + + /// The ChannelQuery this controller observes. + public private(set) var channelQuery: ChannelQuery + + /// The identifier of a channel this controller observes. + public var cid: ChannelId? { channelQuery.cid } + + /// The `ChatClient` instance this controller belongs to. + public let client: ChatClient + + /// The channel the controller represents. + public private(set) var channel: ChatChannel? { + didSet { + guard let channel else { return } + delegateCallback { + $0.livestreamChannelController(self, didUpdateChannel: channel) + } + } + } + + /// The messages of the channel the controller represents. + public private(set) var messages: [ChatMessage] = [] { + didSet { + delegateCallback { + $0.livestreamChannelController(self, didUpdateMessages: self.messages) + } + } + } + + /// A Boolean value that indicates whether message processing is paused. + /// + /// When paused, new messages from other users will not be added to the messages array. + /// This is useful when loading previous messages to prevent the array from being modified. + public private(set) var isPaused: Bool = false { + didSet { + delegateCallback { + $0.livestreamChannelController(self, didChangePauseState: self.isPaused) + } + } + } + + /// The amount of messages that were skipped during the pause state. + public private(set) var skippedMessagesAmount: Int = 0 { + didSet { + delegateCallback { + $0.livestreamChannelController(self, didChangeSkippedMessagesAmount: self.skippedMessagesAmount) + } + } + } + + /// A Boolean value that returns whether the oldest messages have all been loaded or not. + public var hasLoadedAllPreviousMessages: Bool { + paginationStateHandler.state.hasLoadedAllPreviousMessages + } + + /// A Boolean value that returns whether the newest messages have all been loaded or not. + public var hasLoadedAllNextMessages: Bool { + paginationStateHandler.state.hasLoadedAllNextMessages || messages.isEmpty + } + + /// A Boolean value that returns whether the channel is currently loading previous (old) messages. + public var isLoadingPreviousMessages: Bool { + paginationStateHandler.state.isLoadingPreviousMessages + } + + /// A Boolean value that returns whether the channel is currently loading next (new) messages. + public var isLoadingNextMessages: Bool { + paginationStateHandler.state.isLoadingNextMessages + } + + /// A Boolean value that returns whether the channel is currently loading a page around a message. + public var isLoadingMiddleMessages: Bool { + paginationStateHandler.state.isLoadingMiddleMessages + } + + /// A Boolean value that returns whether the channel is currently in a mid-page. + public var isJumpingToMessage: Bool { + paginationStateHandler.state.isJumpingToMessage + } + + /// A Boolean value that indicates whether to load initial messages from the cache. + /// + /// Only the initial page will be loaded from cache, to avoid an initial blank screen. + public var loadInitialMessagesFromCache: Bool = true + + /// A boolean value indicating if the controller should count the number o skipped messages when in pause state. + public var countSkippedMessagesWhenPaused: Bool = false + + /// Configuration for message limiting behaviour. + /// + /// Disabled by default. If enabled, older messages will be automatically discarded + /// once the limit is reached. The `MaxMessageLimitOptions.recommended` is the recommended + /// configuration which uses 200 max messages with 50 discard amount. + /// This can be used to further improve the memory usage of the controller. + /// + /// - Note: In order to use this, if you want to support loading previous messages, + /// you will need to use `pause()` method before loading older messages. Otherwise the + /// pagination will also be capped. Once the user scrolls back to the newest messages, you + /// can call `resume()`. Whenever the user creates a new message, the controller will + /// automatically resume. + public var maxMessageLimitOptions: MaxMessageLimitOptions? + + /// Set the delegate to observe the changes in the system. + public var delegate: LivestreamChannelControllerDelegate? { + get { multicastDelegate.mainDelegate } + set { multicastDelegate.set(mainDelegate: newValue) } + } + + /// A type-erased multicast delegate. + internal var multicastDelegate: MulticastDelegate = .init() + + // MARK: - Private Properties + + /// The API client for making direct API calls. + private let apiClient: APIClient + + /// Pagination state handler for managing message pagination. + private let paginationStateHandler: MessagesPaginationStateHandling + + /// Events controller for listening to real-time events. + private let eventsController: EventsController + + /// The channel updater to reuse actions from channel controller which is safe to use without DB. + private let updater: ChannelUpdater + + /// The app state observer for monitoring memory warnings and app state changes. + private let appStateObserver: AppStateObserving + + /// The current user id. + private var currentUserId: UserId? { client.currentUserId } + + /// An internal backing object for all publicly available Combine publishers. + var basePublishers: BasePublishers { + if let value = _basePublishers as? BasePublishers { + return value + } + _basePublishers = BasePublishers(controller: self) + return _basePublishers as? BasePublishers ?? .init(controller: self) + } + + var _basePublishers: Any? + + // MARK: - Initialization + + /// Creates a new `LivestreamChannelController` + /// - Parameters: + /// - channelQuery: channel query for observing changes + /// - client: The `Client` this controller belongs to. + init( + channelQuery: ChannelQuery, + client: ChatClient, + updater: ChannelUpdater? = nil, + paginationStateHandler: MessagesPaginationStateHandling = MessagesPaginationStateHandler() + ) { + self.channelQuery = channelQuery + self.client = client + apiClient = client.apiClient + self.paginationStateHandler = paginationStateHandler + eventsController = client.eventsController() + appStateObserver = StreamAppStateObserver() + self.updater = updater ?? ChannelUpdater( + channelRepository: client.channelRepository, + messageRepository: client.messageRepository, + paginationStateHandler: client.makeMessagesPaginationStateHandler(), + database: client.databaseContainer, + apiClient: client.apiClient + ) + eventsController.delegate = self + appStateObserver.subscribe(self) + + if let cid = channelQuery.cid { + client.eventNotificationCenter.registerManualEventHandling(for: cid) + } + } + + deinit { + if let cid { + client.eventNotificationCenter.unregisterManualEventHandling(for: cid) + } + appStateObserver.unsubscribe(self) + } + + // MARK: - Public Methods + + /// Synchronizes the controller with the backend data. + /// - Parameter completion: Called when the synchronization is finished. + public func synchronize(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil) { + // Populate the initial data with existing cache. + if loadInitialMessagesFromCache, let cid = self.cid, let channel = dataStore.channel(cid: cid) { + self.channel = channel + messages = channel.latestMessages + } + + updateChannelData( + channelQuery: channelQuery, + completion: completion + ) + } + + /// Loads previous (older) messages from backend. + /// - Parameters: + /// - messageId: ID of the last fetched message. You will get messages `older` than the provided ID. + /// - limit: Limit for page size. By default it is 25. + /// - completion: Called when the network request is finished. + public func loadPreviousMessages( + before messageId: MessageId? = nil, + limit: Int? = nil, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + guard cid != nil else { + callback { + completion?(ClientError.ChannelNotCreatedYet()) + } + return + } + + let messageId = messageId + ?? paginationStateHandler.state.oldestFetchedMessage?.id + ?? messages.last?.id + + guard let messageId = messageId else { + callback { + completion?(ClientError.ChannelEmptyMessages()) + } + return + } + + guard !hasLoadedAllPreviousMessages && !isLoadingPreviousMessages else { + callback { + completion?(nil) + } + return + } + + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize + var query = channelQuery + query.pagination = MessagesPagination(pageSize: limit, parameter: .lessThan(messageId)) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Loads next messages from backend. + /// - Parameters: + /// - messageId: ID of the current first message. You will get messages `newer` than the provided ID. + /// - limit: Limit for page size. By default it is 25. + /// - completion: Called when the network request is finished. + public func loadNextMessages( + after messageId: MessageId? = nil, + limit: Int? = nil, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + guard cid != nil else { + callback { + completion?(ClientError.ChannelNotCreatedYet()) + } + return + } + + let messageId = messageId + ?? paginationStateHandler.state.newestFetchedMessage?.id + ?? messages.first?.id + + guard let messageId = messageId else { + callback { + completion?(ClientError.ChannelEmptyMessages()) + } + return + } + + guard !hasLoadedAllNextMessages && !isLoadingNextMessages else { + callback { + completion?(nil) + } + return + } + + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize + var query = channelQuery + query.pagination = MessagesPagination(pageSize: limit, parameter: .greaterThan(messageId)) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Load messages around the given message id. + /// - Parameters: + /// - messageId: The message id of the message to jump to. + /// - limit: The number of messages to load in total, including the message to jump to. + /// - completion: Callback when the API call is completed. + public func loadPageAroundMessageId( + _ messageId: MessageId, + limit: Int? = nil, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + guard !isLoadingMiddleMessages else { + callback { + completion?(nil) + } + return + } + + let limit = limit ?? channelQuery.pagination?.pageSize ?? .messagesPageSize + var query = channelQuery + query.pagination = MessagesPagination(pageSize: limit, parameter: .around(messageId)) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Cleans the current state and loads the first page again. + /// - Parameter completion: Callback when the API call is completed. + public func loadFirstPage(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil) { + var query = channelQuery + query.pagination = .init( + pageSize: channelQuery.pagination?.pageSize ?? .messagesPageSize, + parameter: nil + ) + + updateChannelData(channelQuery: query, completion: completion) + } + + /// Creates a new message and schedules it for send. + /// + /// This is the only method that still uses the DB to create data. + /// This is mostly to reuse the complex logic of the Message Sender. + /// + /// - Parameters: + /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. + /// - text: Text of the message. + /// - pinning: Pins the new message. `nil` if should not be pinned. + /// - isSilent: A flag indicating whether the message is a silent message. Silent messages are special messages that don't increase the unread messages count nor mark a channel as unread. + /// - attachments: An array of the attachments for the message. + /// `Note`: can be built-in types, custom attachment types conforming to `AttachmentEnvelope` protocol + /// and `ChatMessageAttachmentSeed`s. + /// - quotedMessageId: An id of the message new message quotes. (inline reply) + /// - skipPush: If true, skips sending push notification to channel members. + /// - skipEnrichUrl: If true, the url preview won't be attached to the message. + /// - restrictedVisibility: The list of user ids that should be able to see the message. + /// - location: The new location information of the message. + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes. + public func createNewMessage( + messageId: MessageId? = nil, + text: String, + pinning: MessagePinning? = nil, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + skipPush: Bool = false, + skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, + extraData: [String: RawJSON] = [:], + completion: (@MainActor(Result) -> Void)? = nil + ) { + var transformableInfo = NewMessageTransformableInfo( + text: text, + attachments: attachments, + extraData: extraData + ) + if let transformer = client.config.modelsTransformer { + transformableInfo = transformer.transform(newMessageInfo: transformableInfo) + } + + createNewMessage( + messageId: messageId, + text: transformableInfo.text, + pinning: pinning, + isSilent: isSilent, + attachments: transformableInfo.attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, + location: location, + extraData: transformableInfo.extraData, + poll: nil, + completion: completion + ) + } + + /// Deletes a message from the channel. + /// - Parameters: + /// - messageId: The message identifier to delete. + /// - hard: A Boolean value to determine if the message will be delete permanently on the backend. By default it is `false`. + /// - completion: Called when the network request is finished. + /// If request fails, the completion will be called with an error. + public func deleteMessage( + messageId: MessageId, + hard: Bool = false, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request( + endpoint: .deleteMessage( + messageId: messageId, + hard: hard + ) + ) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Loads reactions for a specific message. + /// - Parameters: + /// - messageId: The message identifier to load reactions for. + /// - limit: The number of reactions to load. Default is 25. + /// - offset: The starting position from the desired range to be fetched. Default is 0. + /// - completion: Called when the network request is finished. Returns reactions array or error. + public func loadReactions( + for messageId: MessageId, + limit: Int = 25, + offset: Int = 0, + completion: @escaping @MainActor(Result<[ChatMessageReaction], Error>) -> Void + ) { + let pagination = Pagination(pageSize: limit, offset: offset) + apiClient.request( + endpoint: .loadReactions(messageId: messageId, pagination: pagination) + ) { [weak self] result in + self?.callback { + switch result { + case .success(let payload): + let reactions = payload.reactions.compactMap { + $0.asModel(messageId: messageId) + } + completion(.success(reactions)) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /// Flags a message. + /// - Parameters: + /// - messageId: The message identifier to flag. + /// - reason: The flag reason. + /// - extraData: Additional data associated with the flag request. + /// - completion: Called when the network request is finished. + /// If request fails, the completion will be called with an error. + public func flag( + messageId: MessageId, + reason: String? = nil, + extraData: [String: RawJSON]? = nil, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request( + endpoint: .flagMessage( + true, + with: messageId, + reason: reason, + extraData: extraData + ) + ) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Unflags a message. + /// - Parameters: + /// - messageId: The message identifier to unflag. + /// - completion: Called when the network request is finished. + /// If request fails, the completion will be called with an error. + public func unflag( + messageId: MessageId, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request( + endpoint: .flagMessage( + false, + with: messageId, + reason: nil, + extraData: nil + ) + ) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Adds a new reaction to a message. + /// - Parameters: + /// - type: The reaction type. + /// - messageId: The message identifier to add the reaction to. + /// - score: The reaction score. + /// - enforceUnique: If set to `true`, new reaction will replace all reactions the user has (if any) on this message. + /// - skipPush: If set to `true`, skips sending push notification when reacting a message. + /// - pushEmojiCode: The emoji code when receiving a reaction push notification. + /// - extraData: The reaction extra data. + /// - completion: The completion. Will be called when the network request is finished. + public func addReaction( + _ type: MessageReactionType, + to messageId: MessageId, + score: Int = 1, + enforceUnique: Bool = false, + skipPush: Bool = false, + pushEmojiCode: String? = nil, + extraData: [String: RawJSON] = [:], + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request( + endpoint: .addReaction( + type, + score: score, + enforceUnique: enforceUnique, + extraData: extraData, + skipPush: skipPush, + emojiCode: pushEmojiCode, + messageId: messageId + ) + ) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Deletes a reaction from a message. + /// - Parameters: + /// - type: The reaction type to delete. + /// - messageId: The message identifier to delete the reaction from. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func deleteReaction( + _ type: MessageReactionType, + from messageId: MessageId, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .deleteReaction(type, messageId: messageId)) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Pins a message. + /// - Parameters: + /// - messageId: The message identifier to pin. + /// - pinning: The pinning expiration information. It supports setting an infinite expiration, setting a date, or the amount of time a message is pinned. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func pin( + messageId: MessageId, + pinning: MessagePinning = .noExpiration, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request(endpoint: .pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: true)) + )) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Unpins a message. + /// - Parameters: + /// - messageId: The message identifier to unpin. + /// - completion: Called when the network request is finished. If request fails, the completion will be called with an error. + public func unpin( + messageId: MessageId, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + apiClient.request( + endpoint: .pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: false)) + ) + ) { [weak self] result in + self?.callback { + completion?(result.error) + } + } + } + + /// Loads the pinned messages of the current channel. + /// + /// - Parameters: + /// - pageSize: The number of pinned messages to load. Equals to `25` by default. + /// - sorting: The sorting options. By default, results are sorted descending by `pinned_at` field. + /// - pagination: The pagination parameter. If `nil` is provided, most recently pinned messages are fetched. + public func loadPinnedMessages( + pageSize: Int = .messagesPageSize, + sorting: [Sorting] = [], + pagination: PinnedMessagesPagination? = nil, + completion: @escaping @MainActor(Result<[ChatMessage], Error>) -> Void + ) { + guard let cid else { + callback { + completion(.failure(ClientError.ChannelNotCreatedYet())) + } + return + } + + let query = PinnedMessagesQuery( + pageSize: pageSize, + sorting: sorting, + pagination: pagination + ) + + apiClient.request(endpoint: .pinnedMessages(cid: cid, query: query)) { [weak self] result in + self?.callback { + switch result { + case .success(let payload): + let reads = self?.channel?.reads ?? [] + let currentUserId = self?.client.currentUserId + let messages = payload.messages.map { + $0.asModel( + cid: cid, + currentUserId: currentUserId, + channelReads: reads + ) + } + completion(.success(messages)) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + // Returns the current cooldown time for the channel. Returns 0 in case there is no cooldown active. + public func currentCooldownTime() -> Int { + guard let cooldownDuration = channel?.cooldownDuration, cooldownDuration > 0, + let currentUserLatestMessage = messages.first(where: { $0.author.id == currentUserId }), + channel?.ownCapabilities.contains(.skipSlowMode) == false else { + return 0 + } + + let currentTime = Date().timeIntervalSince(currentUserLatestMessage.createdAt) + return max(0, cooldownDuration - Int(currentTime)) + } + + /// Enables slow mode for the channel + /// + /// When slow mode is enabled, users can only send a message every `cooldownDuration` time interval. + /// `cooldownDuration` is specified in seconds, and should be between 1-120. + /// For more information, please check [documentation](https://getstream.io/chat/docs/javascript/slow_mode/?language=swift). + /// + /// - Parameters: + /// - cooldownDuration: Duration of the time interval users have to wait between messages. + /// Specified in seconds. Should be between 1-120. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + public func enableSlowMode(cooldownDuration: Int, completion: (@MainActor(Error?) -> Void)? = nil) { + guard let cid else { + callback { + completion?(ClientError.ChannelNotCreatedYet()) + } + return + } + + apiClient.request( + endpoint: .enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) + ) { result in + self.callback { + completion?(result.error) + } + } + } + + /// Disables slow mode for the channel + /// + /// For more information, please check [documentation](https://getstream.io/chat/docs/javascript/slow_mode/?language=swift). + /// + /// - Parameters: + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + public func disableSlowMode(completion: (@MainActor(Error?) -> Void)? = nil) { + guard let cid else { + callback { + completion?(ClientError.ChannelNotCreatedYet()) + } + return + } + + updater.disableSlowMode(cid: cid) { error in + self.callback { + completion?(error) + } + } + } + + /// Pauses the collecting of new messages. + /// + /// When paused, new messages from other users will not be added to the messages array. + /// This is useful for the loading of previous message to not conflict with the max limit of the messages array. + public func pause() { + guard !isPaused else { return } + isPaused = true + } + + /// Resumes the collecting of new messages. + /// + /// This will load the first page, reseting the current messages and returning to the latest messages. + /// After resuming, new messages will be added to the messages array again. + public func resume() { + guard isPaused else { return } + isPaused = false + if countSkippedMessagesWhenPaused { + skippedMessagesAmount = 0 + } + loadFirstPage() + } + + // MARK: - EventsControllerDelegate + + public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { + return + } + + handleChannelEvent(event) + } + + // MARK: - AppStateObserverDelegate + + public func applicationDidReceiveMemoryWarning() { + // Reset the channel to free up memory by loading the first page + loadFirstPage() + } + + // MARK: - Private Methods + + private func updateChannelData( + channelQuery: ChannelQuery, + completion: (@MainActor(Error?) -> Void)? = nil + ) { + if let pagination = channelQuery.pagination { + paginationStateHandler.begin(pagination: pagination) + } + + let endpoint: Endpoint = + .updateChannel(query: channelQuery) + + let requestCompletion: (Result) -> Void = { [weak self] result in + self?.callback { [weak self] in + guard let self = self else { return } + + switch result { + case .success(let payload): + // If it is the first page, save channel to the DB to make sure manual event handling + // can fetch the channel from the DB. + if channelQuery.pagination == nil { + client.databaseContainer.write { session in + try session.saveChannel(payload: payload) + } + } + self.handleChannelPayload(payload, channelQuery: channelQuery) + completion?(nil) + + case .failure(let error): + if let pagination = channelQuery.pagination { + self.paginationStateHandler.end(pagination: pagination, with: .failure(error)) + } + completion?(error) + } + } + } + + apiClient.request(endpoint: endpoint, completion: requestCompletion) + } + + private func handleChannelPayload(_ payload: ChannelPayload, channelQuery: ChannelQuery) { + if let pagination = channelQuery.pagination { + paginationStateHandler.end(pagination: pagination, with: .success(payload.messages)) + } + + let newChannel = payload.asModel( + currentUserId: currentUserId, + currentlyTypingUsers: channel?.currentlyTypingUsers, + unreadCount: channel?.unreadCount + ) + + channel = newChannel + + let newMessages = payload.messages.compactMap { + $0.asModel(cid: payload.channel.cid, currentUserId: currentUserId, channelReads: newChannel.reads) + } + + updateMessagesArray(with: newMessages, pagination: channelQuery.pagination) + } + + private func updateMessagesArray(with newMessages: [ChatMessage], pagination: MessagesPagination?) { + let newMessages = Array(newMessages.reversed()) + switch pagination?.parameter { + case .lessThan, .lessThanOrEqual: + messages.append(contentsOf: newMessages) + + case .greaterThan, .greaterThanOrEqual: + messages.insert(contentsOf: newMessages, at: 0) + + case .around, .none: + messages = newMessages + } + } + + private func applyMessageLimit() { + guard let options = maxMessageLimitOptions, + messages.count > options.maxLimit else { + return + } + + let newCount = options.maxLimit - options.discardAmount + messages = Array(messages.prefix(newCount)) + } + + /// Helper method to execute the callbacks on the main thread. + private func callback(_ action: @MainActor @escaping () -> Void) { + DispatchQueue.main.async { + action() + } + } + + private func delegateCallback(_ callback: @escaping @MainActor(Delegate) -> Void) { + self.callback { + self.multicastDelegate.invoke(callback) + } + } + + private func handleChannelEvent(_ event: Event) { + switch event { + case let messageNewEvent as MessageNewEvent: + handleNewMessage(messageNewEvent.message) + + case let localMessageNewEvent as NewMessagePendingEvent: + handleNewMessage(localMessageNewEvent.message) + + case let messageUpdatedEvent as MessageUpdatedEvent: + handleUpdatedMessage(messageUpdatedEvent.message) + + case let messageDeletedEvent as MessageDeletedEvent: + if messageDeletedEvent.isHardDelete { + handleDeletedMessage(messageDeletedEvent.message) + return + } + handleUpdatedMessage(messageDeletedEvent.message) + + case let newMessageErrorEvent as NewMessageErrorEvent: + guard let message = messages.first(where: { $0.id == newMessageErrorEvent.messageId }) else { + return + } + let errorMessage = message.changing(state: .sendingFailed) + handleUpdatedMessage(errorMessage) + + case let reactionNewEvent as ReactionNewEvent: + handleNewReaction(reactionNewEvent) + + case let reactionUpdatedEvent as ReactionUpdatedEvent: + handleUpdatedReaction(reactionUpdatedEvent) + + case let reactionDeletedEvent as ReactionDeletedEvent: + handleDeletedReaction(reactionDeletedEvent) + + case let channelUpdatedEvent as ChannelUpdatedEvent: + handleChannelUpdated(channelUpdatedEvent) + + default: + break + } + } + + private func handleNewMessage(_ message: ChatMessage) { + // If message already exists, update it instead + if messages.contains(where: { $0.id == message.id }) { + handleUpdatedMessage(message) + return + } + + // If paused and the message is not from the current user, skip processing + if countSkippedMessagesWhenPaused, isPaused && message.author.id != currentUserId { + skippedMessagesAmount += 1 + return + } + + // If we don't have the first page loaded, do not insert new messages + // they will be inserted once we load the first page again. + if !hasLoadedAllNextMessages { + return + } + + messages.insert(message, at: 0) + + // Apply message limit only when not paused + if !isPaused { + applyMessageLimit() + } + + // If paused and the message is from the current user, load the first page + // to go back to the latest messages + if isPaused && message.author.id == currentUserId { + resume() + } + } + + private func handleUpdatedMessage(_ updatedMessage: ChatMessage) { + if let index = messages.firstIndex(where: { $0.id == updatedMessage.id }) { + messages[index] = updatedMessage + } + } + + private func handleDeletedMessage(_ deletedMessage: ChatMessage) { + messages.removeAll { $0.id == deletedMessage.id } + } + + private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { + updateMessage(reactionEvent.message) + } + + private func handleUpdatedReaction(_ reactionEvent: ReactionUpdatedEvent) { + updateMessage(reactionEvent.message) + } + + private func handleDeletedReaction(_ reactionEvent: ReactionDeletedEvent) { + updateMessage(reactionEvent.message) + } + + private func updateMessage( + _ updatedMessage: ChatMessage + ) { + if let messageIndex = messages.firstIndex(where: { $0.id == updatedMessage.id }) { + messages[messageIndex] = updatedMessage + } + } + + private func handleChannelUpdated(_ event: ChannelUpdatedEvent) { + channel = event.channel + } + + private func createNewMessage( + messageId: MessageId? = nil, + text: String, + pinning: MessagePinning? = nil, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + skipPush: Bool = false, + skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, + extraData: [String: RawJSON] = [:], + poll: PollPayload?, + completion: (@MainActor(Result) -> Void)? = nil + ) { + /// Perform action only if channel is already created on backend side and have a valid `cid`. + guard let cid = cid else { + let error = ClientError.ChannelNotCreatedYet() + callback { + completion?(.failure(error)) + } + return + } + + updater.createNewMessage( + in: cid, + messageId: messageId, + text: text, + pinning: pinning, + isSilent: isSilent, + isSystem: false, + command: nil, + arguments: nil, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, + poll: poll, + location: location, + extraData: extraData + ) { result in + if let newMessage = try? result.get() { + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: cid + ) + ) + } + self.callback { + completion?(result.map(\.id)) + } + } + } +} + +// MARK: - Delegate Protocol + +/// Delegate protocol for `LivestreamChannelController` +@MainActor +public protocol LivestreamChannelControllerDelegate: AnyObject { + /// Called when the channel data is updated. + /// - Parameters: + /// - controller: The controller that updated. + /// - channel: The updated channel the controller manages. + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) + + /// Called when the messages are updated. + /// - Parameters: + /// - controller: The controller that updated. + /// - messages: The current messages array. + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) + + /// Called when the pause state changes. + /// - Parameters: + /// - controller: The controller that updated. + /// - isPaused: The new pause state. + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) + + /// Called when the skipped messages amount changes. + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) +} + +// MARK: - Default Implementations + +public extension LivestreamChannelControllerDelegate { + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) {} + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) {} + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) {} + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) {} +} + +/// Configuration options for message limiting in LivestreamChannelController. +public struct MaxMessageLimitOptions { + /// The maximum number of messages to keep in memory. + /// When this limit is reached, older messages will be discarded. + public let maxLimit: Int + + /// The number of messages to discard when the maximum limit is reached. + /// This should be less than maxLimit to avoid discarding all messages. + public let discardAmount: Int + + /// Creates a new MaxMessageLimitOptions configuration. + /// - Parameters: + /// - maxLimit: The maximum number of messages to keep. Default is 200. + /// - discardAmount: The number of messages to discard when limit is reached. Default is 50. + public init(maxLimit: Int = 200, discardAmount: Int = 50) { + self.maxLimit = maxLimit + self.discardAmount = discardAmount + } + + /// The recommended configuration with 200 max messages and 50 discard amount. + public static let recommended = MaxMessageLimitOptions() +} diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 0d653a15a0f..ea44356c8df 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -417,7 +417,12 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP extraData: transformableInfo.extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self.client.eventNotificationCenter.process( + NewMessagePendingEvent( + message: newMessage, + cid: self.cid + ) + ) } self.callback { completion?(result.map(\.id)) diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 87bea296d08..0e5cc07febd 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -288,6 +288,51 @@ public struct ChatChannel { activeLiveLocations: activeLiveLocations ) } + + /// Returns a new `ChatChannel` with the provided data changed. + public func changing( + name: String? = nil, + imageURL: URL? = nil, + reads: [ChatChannelRead]? = nil, + extraData: [String: RawJSON]? = nil + ) -> ChatChannel { + .init( + cid: cid, + name: name ?? self.name, + imageURL: imageURL ?? self.imageURL, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + truncatedAt: truncatedAt, + isHidden: isHidden, + createdBy: createdBy, + config: config, + ownCapabilities: ownCapabilities, + isFrozen: isFrozen, + isDisabled: isDisabled, + isBlocked: isBlocked, + lastActiveMembers: lastActiveMembers, + membership: membership, + currentlyTypingUsers: currentlyTypingUsers, + lastActiveWatchers: lastActiveWatchers, + team: team, + unreadCount: unreadCount, + watcherCount: watcherCount, + memberCount: memberCount, + reads: reads ?? self.reads, + cooldownDuration: cooldownDuration, + extraData: extraData ?? [:], + latestMessages: latestMessages, + lastMessageFromCurrentUser: lastMessageFromCurrentUser, + pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, + muteDetails: muteDetails, + previewMessage: previewMessage, + draftMessage: draftMessage, + activeLiveLocations: activeLiveLocations + ) + } } extension ChatChannel { diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index dc3af467a22..7bb764a2c90 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -284,6 +284,7 @@ public struct ChatMessage { translations: [TranslationLanguage: String]? = nil, originalLanguage: TranslationLanguage? = nil, moderationDetails: MessageModerationDetails? = nil, + readBy: Set? = nil, extraData: [String: RawJSON]? = nil ) -> ChatMessage { .init( @@ -322,7 +323,7 @@ public struct ChatMessage { translations: translations ?? self.translations, originalLanguage: originalLanguage ?? self.originalLanguage, moderationDetails: moderationDetails ?? self.moderationDetails, - readBy: readBy, + readBy: readBy ?? self.readBy, poll: poll, textUpdatedAt: textUpdatedAt, draftReply: draftReply, diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift new file mode 100644 index 00000000000..8b251d40f19 --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift @@ -0,0 +1,128 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension ChannelPayload { + /// Converts the ChannelPayload to a ChatChannel model + /// - Returns: A ChatChannel instance + func asModel( + currentUserId: UserId?, + currentlyTypingUsers: Set?, + unreadCount: ChannelUnreadCount? + ) -> ChatChannel { + let channelPayload = channel + + // Map members + let mappedMembers = members.compactMap { $0.asModel(channelId: channelPayload.cid) } + + // Map latest messages + let reads = channelReads.map { $0.asModel() } + let latestMessages = messages.compactMap { + $0.asModel(cid: channel.cid, currentUserId: currentUserId, channelReads: reads) + } + + // Map reads + let mappedReads = channelReads.map { $0.asModel() } + + // Map watchers + let mappedWatchers = watchers?.map { $0.asModel() } ?? [] + + return ChatChannel( + cid: channelPayload.cid, + name: channelPayload.name, + imageURL: channelPayload.imageURL, + lastMessageAt: channelPayload.lastMessageAt, + createdAt: channelPayload.createdAt, + updatedAt: channelPayload.updatedAt, + deletedAt: channelPayload.deletedAt, + truncatedAt: channelPayload.truncatedAt, + isHidden: isHidden ?? false, + createdBy: channelPayload.createdBy?.asModel(), + config: channelPayload.config, + ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []), + isFrozen: channelPayload.isFrozen, + isDisabled: channelPayload.isDisabled, + isBlocked: channelPayload.isBlocked ?? false, + lastActiveMembers: Array(mappedMembers), + membership: membership?.asModel(channelId: channelPayload.cid), + currentlyTypingUsers: currentlyTypingUsers ?? [], + lastActiveWatchers: Array(mappedWatchers), + team: channelPayload.team, + unreadCount: unreadCount ?? .noUnread, + watcherCount: watcherCount ?? 0, + memberCount: channelPayload.memberCount, + reads: mappedReads, + cooldownDuration: channelPayload.cooldownDuration, + extraData: channelPayload.extraData, + latestMessages: latestMessages, + lastMessageFromCurrentUser: latestMessages.first { $0.isSentByCurrentUser }, + pinnedMessages: pinnedMessages.compactMap { + $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + }, + pendingMessages: (pendingMessages ?? []).compactMap { + $0.asModel(cid: channelPayload.cid, currentUserId: currentUserId, channelReads: reads) + }, + muteDetails: nil, + previewMessage: latestMessages.first, + draftMessage: nil, + activeLiveLocations: [] + ) + } +} + +extension MemberPayload { + /// Converts the MemberPayload to a ChatChannelMember model + /// - Parameter channelId: The channel ID the member belongs to + /// - Returns: A ChatChannelMember instance, or nil if user is missing + func asModel(channelId: ChannelId) -> ChatChannelMember? { + guard let userPayload = user else { return nil } + let user = userPayload.asModel() + + return ChatChannelMember( + id: user.id, + name: user.name, + imageURL: user.imageURL, + isOnline: user.isOnline, + isBanned: user.isBanned, + isFlaggedByCurrentUser: user.isFlaggedByCurrentUser, + userRole: user.userRole, + teamsRole: user.teamsRole, + userCreatedAt: user.userCreatedAt, + userUpdatedAt: user.userUpdatedAt, + deactivatedAt: user.userDeactivatedAt, + lastActiveAt: user.lastActiveAt, + teams: user.teams, + language: user.language, + extraData: user.extraData, + memberRole: MemberRole(rawValue: role?.rawValue ?? "member"), + memberCreatedAt: createdAt, + memberUpdatedAt: updatedAt, + isInvited: isInvited ?? false, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + archivedAt: archivedAt, + pinnedAt: pinnedAt, + isBannedFromChannel: isBanned ?? false, + banExpiresAt: banExpiresAt, + isShadowBannedFromChannel: isShadowBanned ?? false, + notificationsMuted: notificationsMuted, + avgResponseTime: user.avgResponseTime, + memberExtraData: extraData ?? [:] + ) + } +} + +extension ChannelReadPayload { + /// Converts the ChannelReadPayload to a ChatChannelRead model + /// - Returns: A ChatChannelRead instance + func asModel() -> ChatChannelRead { + ChatChannelRead( + lastReadAt: lastReadAt, + lastReadMessageId: lastReadMessageId, + unreadMessagesCount: unreadMessagesCount, + user: user.asModel() + ) + } +} diff --git a/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift new file mode 100644 index 00000000000..7bba9ebd111 --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/MessagePayload+asModel.swift @@ -0,0 +1,158 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension MessagePayload { + /// Converts the MessagePayload to a ChatMessage model. + /// - Parameters: + /// - cid: The channel ID the message belongs to. + /// - currentUserId: The current user's ID for determining sent status. + /// - channelReads: Channel reads for determining readBy status. + /// - Returns: A ChatMessage instance. + func asModel( + cid: ChannelId, + currentUserId: UserId?, + channelReads: [ChatChannelRead] + ) -> ChatMessage { + let author = user.asModel() + let mentionedUsers = Set(mentionedUsers.compactMap { $0.asModel() }) + let threadParticipants = threadParticipants.compactMap { $0.asModel() } + + let quotedMessage = quotedMessage?.asModel( + cid: cid, + currentUserId: currentUserId, + channelReads: channelReads + ) + + let latestReactions = Set(latestReactions.compactMap { $0.asModel(messageId: id) }) + + let currentUserReactions: Set + if ownReactions.isEmpty { + currentUserReactions = latestReactions.filter { $0.author.id == currentUserId } + } else { + currentUserReactions = Set(ownReactions.compactMap { $0.asModel(messageId: id) }) + } + + let attachments: [AnyChatMessageAttachment] = attachments + .enumerated() + .compactMap { offset, attachmentPayload in + guard let payloadData = try? JSONEncoder.stream.encode(attachmentPayload.payload) else { + log.error("Failed to encode attachment payload at index \(offset) for message \(id)") + return nil + } + return AnyChatMessageAttachment( + id: .init(cid: cid, messageId: id, index: offset), + type: attachmentPayload.type, + payload: payloadData, + downloadingState: nil, + uploadingState: nil + ) + } + + let createdAtInterval = createdAt.timeIntervalSince1970 + let messageUserId = user.id + let readBy = channelReads.filter { read in + read.user.id != messageUserId && read.lastReadAt.timeIntervalSince1970 >= createdAtInterval + } + + return ChatMessage( + id: id, + cid: cid, + text: text, + type: type, + command: command, + createdAt: createdAt, + locallyCreatedAt: nil, + updatedAt: updatedAt, + deletedAt: deletedAt, + arguments: args, + parentMessageId: parentId, + showReplyInChannel: showReplyInChannel, + replyCount: replyCount, + extraData: extraData, + quotedMessage: quotedMessage, + isBounced: moderationDetails?.action == MessageModerationAction.bounce.rawValue, + isSilent: isSilent, + isShadowed: isShadowed, + reactionScores: reactionScores, + reactionCounts: reactionCounts, + reactionGroups: reactionGroups.reduce(into: [:]) { acc, element in + acc[element.key] = ChatMessageReactionGroup( + type: element.key, + sumScores: element.value.sumScores, + count: element.value.count, + firstReactionAt: element.value.firstReactionAt, + lastReactionAt: element.value.lastReactionAt + ) + }, + author: author, + mentionedUsers: mentionedUsers, + threadParticipants: threadParticipants, + attachments: attachments, + latestReplies: [], + localState: nil, + isFlaggedByCurrentUser: false, + latestReactions: latestReactions, + currentUserReactions: currentUserReactions, + isSentByCurrentUser: user.id == currentUserId, + pinDetails: pinned ? MessagePinDetails( + pinnedAt: pinnedAt ?? createdAt, + pinnedBy: pinnedBy?.asModel() ?? author, + expiresAt: pinExpires + ) : nil, + translations: translations, + originalLanguage: originalLanguage.flatMap { TranslationLanguage(languageCode: $0) }, + moderationDetails: moderationDetails.map { .init( + originalText: $0.originalText, + action: .init(rawValue: $0.action), + textHarms: $0.textHarms, + imageHarms: $0.imageHarms, + blocklistMatched: $0.blocklistMatched, + semanticFilterMatched: $0.semanticFilterMatched, + platformCircumvented: $0.platformCircumvented + ) }, + readBy: Set(readBy.map(\.user)), + poll: nil, + textUpdatedAt: messageTextUpdatedAt, + draftReply: nil, + reminder: reminder.map { + .init( + remindAt: $0.remindAt, + createdAt: $0.createdAt, + updatedAt: $0.updatedAt + ) + }, + sharedLocation: location.map { + .init( + messageId: $0.messageId, + channelId: cid, + userId: $0.userId, + createdByDeviceId: $0.createdByDeviceId, + latitude: $0.latitude, + longitude: $0.longitude, + updatedAt: $0.updatedAt, + createdAt: $0.createdAt, + endAt: $0.endAt + ) + } + ) + } +} + +extension MessageReactionPayload { + /// Converts the MessageReactionPayload to a ChatMessageReaction model. + /// - Returns: A ChatMessageReaction instance. + func asModel(messageId: MessageId) -> ChatMessageReaction { + ChatMessageReaction( + id: [user.id, messageId, type.rawValue].joined(separator: "/"), + type: type, + score: score, + createdAt: createdAt, + updatedAt: updatedAt, + author: user.asModel(), + extraData: extraData + ) + } +} diff --git a/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift new file mode 100644 index 00000000000..a7f1ea11a9c --- /dev/null +++ b/Sources/StreamChat/Models/Payload+asModel/UserPayload+asModel.swift @@ -0,0 +1,30 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension UserPayload { + /// Converts the UserPayload to a ChatUser model. + /// - Returns: A ChatUser instance. + func asModel() -> ChatUser { + ChatUser( + id: id, + name: name, + imageURL: imageURL, + isOnline: isOnline, + isBanned: isBanned, + isFlaggedByCurrentUser: false, + userRole: role, + teamsRole: teamsRole, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: deactivatedAt, + lastActiveAt: lastActiveAt, + teams: Set(teams), + language: language.flatMap { TranslationLanguage(languageCode: $0) }, + avgResponseTime: avgResponseTime, + extraData: extraData + ) + } +} diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 60dc9c480af..1e0d3a83e07 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -508,6 +508,7 @@ public class Chat { restrictedVisibility: [UserId] = [] ) async throws -> ChatMessage { Task { try await stopTyping() } // errors explicitly ignored + let cid = try await self.cid let localMessage = try await channelUpdater.createNewMessage( in: cid, messageId: messageId, @@ -527,7 +528,7 @@ public class Chat { ) // Important to set up the waiter immediately async let sentMessage = try await waitForAPIRequest(localMessage: localMessage) - eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage)) + eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage, cid: cid)) return try await sentMessage } @@ -545,6 +546,7 @@ public class Chat { restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:] ) async throws -> ChatMessage { + let cid = try await self.cid let localMessage = try await channelUpdater.createNewMessage( in: cid, messageId: messageId, @@ -564,7 +566,7 @@ public class Chat { ) // Important to set up the waiter immediately async let sentMessage = try await waitForAPIRequest(localMessage: localMessage) - eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage)) + eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage, cid: cid)) return try await sentMessage } @@ -979,6 +981,7 @@ public class Chat { messageId: MessageId? = nil ) async throws -> ChatMessage { Task { try await stopTyping() } // errors explicitly ignored + let cid = try await self.cid let localMessage = try await messageUpdater.createNewReply( in: cid, messageId: messageId, @@ -997,7 +1000,7 @@ public class Chat { extraData: extraData ) async let sentMessage = try await waitForAPIRequest(localMessage: localMessage) - eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage)) + eventNotificationCenter.process(NewMessagePendingEvent(message: localMessage, cid: cid)) return try await sentMessage } @@ -1280,9 +1283,6 @@ public class Chat { /// /// - Throws: An error while communicating with the Stream API or when setting an invalid duration. public func enableSlowMode(cooldownDuration: Int) async throws { - guard cooldownDuration >= 1, cooldownDuration <= 120 else { - throw ClientError.InvalidCooldownDuration() - } try await channelUpdater.enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) } diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index b3bb16c9914..fc4c0280d98 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -159,11 +159,15 @@ class MessageDeletedEventDTO: EventDTO { } let userDTO = user.flatMap { session.user(id: $0.id) } - let messageDTO = session.message(id: message.id) // If the message is hard deleted, it is not available as DTO. // So we map the Payload Directly to the Model. - let message = (try? messageDTO?.asModel()) ?? message.asModel(currentUser: session.currentUser) + let channelReads = (try? channelDTO.asModel().reads) ?? [] + let message = message.asModel( + cid: cid, + currentUserId: session.currentUser?.user.id, + channelReads: channelReads + ) return try? MessageDeletedEvent( user: userDTO?.asModel(), @@ -237,89 +241,14 @@ class MessageReadEventDTO: EventDTO { } // Triggered when the current user creates a new message and is pending to be sent. -public struct NewMessagePendingEvent: Event { +public struct NewMessagePendingEvent: ChannelSpecificEvent { public var message: ChatMessage + public var cid: ChannelId } // Triggered when a message failed being sent. -public struct NewMessageErrorEvent: Event { +public struct NewMessageErrorEvent: ChannelSpecificEvent { public let messageId: MessageId + public let cid: ChannelId public let error: Error } - -// MARK: - Workaround to map a deleted message to Model. - -// At the moment our SDK does not support mapping Payload -> Model -// So this is just a workaround for `MessageDeletedEvent` to have the `message` non-optional. -// So some of the data will be incorrect, but for this is use case is more than enough. - -private extension MessagePayload { - func asModel(currentUser: CurrentUserDTO?) -> ChatMessage { - .init( - id: id, - cid: cid, - text: text, - type: type, - command: command, - createdAt: createdAt, - locallyCreatedAt: nil, - updatedAt: updatedAt, - deletedAt: deletedAt, - arguments: args, - parentMessageId: parentId, - showReplyInChannel: showReplyInChannel, - replyCount: replyCount, - extraData: extraData, - quotedMessage: quotedMessage?.asModel(currentUser: currentUser), - isBounced: false, - isSilent: isSilent, - isShadowed: isShadowed, - reactionScores: reactionScores, - reactionCounts: reactionCounts, - reactionGroups: [:], - author: user.asModel(), - mentionedUsers: Set(mentionedUsers.map { $0.asModel() }), - threadParticipants: threadParticipants.map { $0.asModel() }, - attachments: [], - latestReplies: [], - localState: nil, - isFlaggedByCurrentUser: false, - latestReactions: [], - currentUserReactions: [], - isSentByCurrentUser: user.id == currentUser?.user.id, - pinDetails: nil, - translations: nil, - originalLanguage: originalLanguage.map { TranslationLanguage(languageCode: $0) }, - moderationDetails: nil, - readBy: [], - poll: nil, - textUpdatedAt: messageTextUpdatedAt, - draftReply: nil, - reminder: nil, - sharedLocation: nil - ) - } -} - -private extension UserPayload { - func asModel() -> ChatUser { - .init( - id: id, - name: name, - imageURL: imageURL, - isOnline: isOnline, - isBanned: isBanned, - isFlaggedByCurrentUser: false, - userRole: role, - teamsRole: teamsRole, - createdAt: createdAt, - updatedAt: updatedAt, - deactivatedAt: deactivatedAt, - lastActiveAt: lastActiveAt, - teams: Set(teams), - language: language.map { TranslationLanguage(languageCode: $0) }, - avgResponseTime: avgResponseTime, - extraData: extraData - ) - } -} diff --git a/Sources/StreamChat/Workers/Background/MessageSender.swift b/Sources/StreamChat/Workers/Background/MessageSender.swift index f12a1290828..b58d817cf2c 100644 --- a/Sources/StreamChat/Workers/Background/MessageSender.swift +++ b/Sources/StreamChat/Workers/Background/MessageSender.swift @@ -87,6 +87,7 @@ class MessageSender: Worker { newRequests[cid] = newRequests[cid] ?? [] newRequests[cid]!.append(.init( messageId: dto.id, + cid: cid, createdLocallyAt: (dto.locallyCreatedAt ?? dto.createdAt).bridgeDate )) } @@ -229,10 +230,18 @@ private class MessageSendingQueue { if let repositoryError = result.error { switch repositoryError { case .messageDoesNotExist, .messageNotPendingSend, .messageDoesNotHaveValidChannel: - let event = NewMessageErrorEvent(messageId: request.messageId, error: repositoryError) + let event = NewMessageErrorEvent( + messageId: request.messageId, + cid: request.cid, + error: repositoryError + ) eventsNotificationCenter.process(event) case .failedToSendMessage(let clientError): - let event = NewMessageErrorEvent(messageId: request.messageId, error: clientError) + let event = NewMessageErrorEvent( + messageId: request.messageId, + cid: request.cid, + error: clientError + ) eventsNotificationCenter.process(event) if ClientError.isEphemeral(error: clientError) { @@ -250,6 +259,7 @@ private class MessageSendingQueue { extension MessageSendingQueue { struct SendRequest: Hashable { let messageId: MessageId + let cid: ChannelId let createdLocallyAt: Date static func == (lhs: Self, rhs: Self) -> Bool { diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 7dc2eb6d0e9..bdcc6f0a3ec 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -50,7 +50,7 @@ class ChannelUpdater: Worker { if let pagination = channelQuery.pagination { paginationStateHandler.begin(pagination: pagination) } - + let didLoadFirstPage = channelQuery.pagination?.parameter == nil let didJumpToMessage: Bool = channelQuery.pagination?.parameter?.isJumpingToMessage == true let resetMembersAndReads = didLoadFirstPage @@ -73,7 +73,7 @@ class ChannelUpdater: Worker { // Fetching channel data should prepopulate it. Then we can save an API call // for providing member data. let memberListQuery = ChannelMemberListQuery(cid: payload.channel.cid, sort: actions?.updateMemberList ?? []) - + if let channelDTO = session.channel(cid: payload.channel.cid) { if resetMessages { channelDTO.cleanAllMessagesExcludingLocalOnly() @@ -89,11 +89,11 @@ class ChannelUpdater: Worker { channelDTO.watchers.removeAll() } } - + let updatedChannel = try session.saveChannel(payload: payload) updatedChannel.oldestMessageAt = self.paginationState.oldestMessageAt?.bridgeDate updatedChannel.newestMessageAt = self.paginationState.newestMessageAt?.bridgeDate - + // Share member data with member list query without any filters (requres ChannelDTO to be saved first) let memberListQueryDTO: ChannelMemberListQueryDTO = try { if let dto = session.channelMemberListQuery(queryHash: memberListQuery.queryHash) { @@ -148,7 +148,7 @@ class ChannelUpdater: Worker { completion?($0.error) } } - + /// Loads channel members and reads for these members using channel query endpoint. /// /// - Note: Use it only if we would like to paginate channel reads (reads pagination can only be done through paginating members using the channel query endpoint). @@ -180,7 +180,7 @@ class ChannelUpdater: Worker { // In addition to this, we want to save channel data because reads are // stored and returned through channel data. let memberListQuery = ChannelMemberListQuery(cid: cid, sort: memberListSorting) - + // Keep the default logic where loading the first page, resets the pagination state. if membersPagination.offset == 0 { let channelDTO = session.channel(cid: cid) @@ -191,7 +191,7 @@ class ChannelUpdater: Worker { let updatedChannel = try session.saveChannel(payload: payload) let memberListQueryDTO = try session.saveQuery(memberListQuery) memberListQueryDTO.members.formUnion(updatedChannel.members) - + paginatedMembers = payload.members.compactMapLoggingError { try session.member(userId: $0.userId, cid: cid)?.asModel() } } completion: { error in if let paginatedMembers { @@ -593,7 +593,6 @@ class ChannelUpdater: Worker { /// - Parameters: /// - cid: Channel id of the channel to be marked as read /// - cooldownDuration: Duration of the time interval users have to wait between messages. - /// Specified in seconds. Should be between 0-120. Pass 0 to disable slow mode. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. func enableSlowMode(cid: ChannelId, cooldownDuration: Int, completion: ((Error?) -> Void)? = nil) { apiClient.request(endpoint: .enableSlowMode(cid: cid, cooldownDuration: cooldownDuration)) { @@ -601,6 +600,13 @@ class ChannelUpdater: Worker { } } + /// Disables slow mode for the channel. + func disableSlowMode(cid: ChannelId, completion: @escaping ((Error?) -> Void)) { + apiClient.request(endpoint: .enableSlowMode(cid: cid, cooldownDuration: 0)) { + completion($0.error) + } + } + /// Start watching a channel /// /// Watching a channel is defined as observing notifications about this channel. @@ -756,21 +762,21 @@ class ChannelUpdater: Worker { } } } - + func deleteFile(in cid: ChannelId, url: String, completion: ((Error?) -> Void)? = nil) { apiClient.request(endpoint: .deleteFile(cid: cid, url: url), completion: { completion?($0.error) }) } - + func deleteImage(in cid: ChannelId, url: String, completion: ((Error?) -> Void)? = nil) { apiClient.request(endpoint: .deleteImage(cid: cid, url: url), completion: { completion?($0.error) }) } - + // MARK: - private - + private func messagePayload(text: String?, currentUserId: UserId?) -> MessageRequestBody? { var messagePayload: MessageRequestBody? if let text = text, let currentUserId = currentUserId { @@ -803,7 +809,7 @@ extension ChannelUpdater { } } } - + func addMembers( currentUserId: UserId? = nil, cid: ChannelId, @@ -823,7 +829,7 @@ extension ChannelUpdater { } } } - + func channelWatchers(for query: ChannelWatcherListQuery) async throws -> [ChatUser] { let payload = try await withCheckedThrowingContinuation { continuation in channelWatchers(query: query) { result in @@ -835,7 +841,7 @@ extension ChannelUpdater { try ids.compactMap { try session.user(id: $0)?.asModel() } } } - + func createNewMessage( in cid: ChannelId, messageId: MessageId?, @@ -875,7 +881,7 @@ extension ChannelUpdater { } } } - + func deleteChannel(cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in deleteChannel(cid: cid) { error in @@ -883,7 +889,7 @@ extension ChannelUpdater { } } } - + func deleteFile(in cid: ChannelId, url: String) async throws { try await withCheckedThrowingContinuation { continuation in deleteFile(in: cid, url: url) { error in @@ -891,7 +897,7 @@ extension ChannelUpdater { } } } - + func deleteImage(in cid: ChannelId, url: String) async throws { try await withCheckedThrowingContinuation { continuation in deleteImage(in: cid, url: url) { error in @@ -899,7 +905,7 @@ extension ChannelUpdater { } } } - + func enableSlowMode(cid: ChannelId, cooldownDuration: Int) async throws { try await withCheckedThrowingContinuation { continuation in enableSlowMode(cid: cid, cooldownDuration: cooldownDuration) { error in @@ -907,7 +913,7 @@ extension ChannelUpdater { } } } - + func enrichUrl(_ url: URL) async throws -> LinkAttachmentPayload { try await withCheckedThrowingContinuation { continuation in enrichUrl(url) { result in @@ -915,7 +921,7 @@ extension ChannelUpdater { } } } - + func freezeChannel(_ freeze: Bool, cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in freezeChannel(freeze, cid: cid) { error in @@ -923,7 +929,7 @@ extension ChannelUpdater { } } } - + func hideChannel(cid: ChannelId, clearHistory: Bool) async throws { try await withCheckedThrowingContinuation { continuation in hideChannel(cid: cid, clearHistory: clearHistory) { error in @@ -931,7 +937,7 @@ extension ChannelUpdater { } } } - + func inviteMembers(cid: ChannelId, userIds: Set) async throws { try await withCheckedThrowingContinuation { continuation in inviteMembers(cid: cid, userIds: userIds) { error in @@ -939,7 +945,7 @@ extension ChannelUpdater { } } } - + func loadMembersWithReads( in cid: ChannelId, membersPagination: Pagination, @@ -951,7 +957,7 @@ extension ChannelUpdater { } } } - + func loadPinnedMessages(in cid: ChannelId, query: PinnedMessagesQuery) async throws -> [ChatMessage] { try await withCheckedThrowingContinuation { continuation in loadPinnedMessages(in: cid, query: query) { result in @@ -959,7 +965,7 @@ extension ChannelUpdater { } } } - + func muteChannel(cid: ChannelId, expiration: Int? = nil) async throws { try await withCheckedThrowingContinuation { continuation in muteChannel(cid: cid, expiration: expiration) { error in @@ -983,7 +989,7 @@ extension ChannelUpdater { } } } - + func removeMembers( currentUserId: UserId? = nil, cid: ChannelId, @@ -1001,7 +1007,7 @@ extension ChannelUpdater { } } } - + func showChannel(cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in showChannel(cid: cid) { error in @@ -1009,7 +1015,7 @@ extension ChannelUpdater { } } } - + func startWatching(cid: ChannelId, isInRecoveryMode: Bool) async throws { try await withCheckedThrowingContinuation { continuation in startWatching(cid: cid, isInRecoveryMode: isInRecoveryMode) { error in @@ -1017,7 +1023,7 @@ extension ChannelUpdater { } } } - + func stopWatching(cid: ChannelId) async throws { try await withCheckedThrowingContinuation { continuation in stopWatching(cid: cid) { error in @@ -1025,7 +1031,7 @@ extension ChannelUpdater { } } } - + func truncateChannel( cid: ChannelId, skipPush: Bool, @@ -1060,7 +1066,7 @@ extension ChannelUpdater { ) } } - + func update(channelPayload: ChannelEditDetailPayload) async throws { try await withCheckedThrowingContinuation { continuation in updateChannel(channelPayload: channelPayload) { error in @@ -1068,7 +1074,7 @@ extension ChannelUpdater { } } } - + func updatePartial(channelPayload: ChannelEditDetailPayload, unsetProperties: [String]) async throws { try await withCheckedThrowingContinuation { continuation in partialChannelUpdate(updates: channelPayload, unsetProperties: unsetProperties) { error in @@ -1076,7 +1082,7 @@ extension ChannelUpdater { } } } - + func uploadFile( type: AttachmentType, localFileURL: URL, @@ -1094,9 +1100,7 @@ extension ChannelUpdater { } } } - - // MARK: - - + func loadMessages(with channelQuery: ChannelQuery, pagination: MessagesPagination) async throws -> [ChatMessage] { let payload = try await update(channelQuery: channelQuery.withPagination(pagination)) guard let cid = channelQuery.cid else { return [] } @@ -1104,7 +1108,7 @@ extension ChannelUpdater { guard let toDate = payload.messages.last?.createdAt else { return [] } return try await messageRepository.messages(from: fromDate, to: toDate, in: cid) } - + func loadMessages( before messageId: MessageId?, limit: Int?, @@ -1137,7 +1141,7 @@ extension ChannelUpdater { let pagination = MessagesPagination(pageSize: limit, parameter: .greaterThan(messageId)) try await update(channelQuery: channelQuery.withPagination(pagination)) } - + func loadMessages( around messageId: MessageId, limit: Int?, @@ -1179,7 +1183,7 @@ extension ChannelQuery { result.pagination = pagination return result } - + func withOptions(forWatching watch: Bool) -> Self { var result = self result.options = watch ? .all : .state diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 9cda5c7c0c5..51ca8a004a5 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -17,11 +17,30 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { // Contains the ids of the new messages that are going to be added during the ongoing process private(set) var newMessageIds: Set = Set() - init(database: DatabaseContainer) { + /// Handles manual event processing for channels that opt out of middleware processing. + private let manualEventHandler: ManualEventHandler + + init( + database: DatabaseContainer, + manualEventHandler: ManualEventHandler? = nil + ) { self.database = database + self.manualEventHandler = manualEventHandler ?? ManualEventHandler(database: database) super.init() } + /// Registers a channel for manual event handling. + /// + /// The middleware's will not process events for this channel. + func registerManualEventHandling(for cid: ChannelId) { + manualEventHandler.register(channelId: cid) + } + + /// Unregister a channel for manual event handling. + func unregisterManualEventHandling(for cid: ChannelId) { + manualEventHandler.unregister(channelId: cid) + } + func add(middlewares: [EventMiddleware]) { self.middlewares.append(contentsOf: middlewares) } @@ -42,14 +61,26 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } var eventsToPost = [Event]() + var middlewareEvents = [Event]() + var manualHandlingEvents = [Event]() + database.write({ session in + events.forEach { event in + if let manualEvent = self.manualEventHandler.handle(event) { + manualHandlingEvents.append(manualEvent) + } else { + middlewareEvents.append(event) + } + } + self.newMessageIds = Set(messageIds.compactMap { !session.messageExists(id: $0) ? $0 : nil }) - eventsToPost = events.compactMap { + eventsToPost.append(contentsOf: manualHandlingEvents) + eventsToPost.append(contentsOf: middlewareEvents.compactMap { self.middlewares.process(event: $0, session: session) - } + }) self.newMessageIds = [] }, completion: { _ in @@ -84,7 +115,7 @@ extension EventNotificationCenter { .receive(on: DispatchQueue.main) .sink(receiveValue: handler) } - + func subscribe( filter: @escaping (Event) -> Bool = { _ in true }, handler: @escaping (Event) -> Void @@ -95,7 +126,7 @@ extension EventNotificationCenter { .receive(on: DispatchQueue.main) .sink(receiveValue: handler) } - + static func channelFilter(cid: ChannelId, event: Event) -> Bool { switch event { case let channelEvent as ChannelSpecificEvent: diff --git a/Sources/StreamChat/Workers/ManualEventHandler.swift b/Sources/StreamChat/Workers/ManualEventHandler.swift new file mode 100644 index 00000000000..f99cfcc2cdd --- /dev/null +++ b/Sources/StreamChat/Workers/ManualEventHandler.swift @@ -0,0 +1,238 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Handles manual event processing for channels that opt out of middleware processing. +class ManualEventHandler { + /// The database used when evaluating events. + private let database: DatabaseContainer + + /// The queue for thread-safe operations. + private let queue: DispatchQueue + + // The channels for which events will not be processed by the middlewares. + private var channelIds: Set = [] + + // Some events require the chat channel data, so we need to fetch it from local DB. + // We try to only do this once, to avoid unnecessary DB fetches. + private var cachedChannels: [ChannelId: ChatChannel] = [:] + + init( + database: DatabaseContainer, + cachedChannels: [ChannelId: ChatChannel] = [:], + queue: DispatchQueue = DispatchQueue(label: "io.getstream.chat.manualEventHandler", qos: .utility) + ) { + self.database = database + self.cachedChannels = cachedChannels + self.queue = queue + } + + /// Registers a channel for manual event handling. + /// + /// The middleware's will not process events for this channel. + func register(channelId: ChannelId) { + queue.async { [weak self] in + self?.channelIds.insert(channelId) + } + } + + /// Unregister a channel for manual event handling. + func unregister(channelId: ChannelId) { + queue.async { [weak self] in + self?.channelIds.remove(channelId) + self?.cachedChannels.removeValue(forKey: channelId) + } + } + + /// Converts a manual event to its domain representation. + func handle(_ event: Event) -> Event? { + guard let eventDTO = event as? EventDTO else { + return nil + } + + let eventPayload = eventDTO.payload + + guard let cid = eventPayload.cid else { + return nil + } + + guard isRegistered(channelId: cid) else { + return nil + } + + switch eventPayload.eventType { + case .messageNew: + return createMessageNewEvent(from: eventPayload, cid: cid) + + case .messageUpdated: + return createMessageUpdatedEvent(from: eventPayload, cid: cid) + + case .messageDeleted: + return createMessageDeletedEvent(from: eventPayload, cid: cid) + + case .reactionNew: + return createReactionNewEvent(from: eventPayload, cid: cid) + + case .reactionUpdated: + return createReactionUpdatedEvent(from: eventPayload, cid: cid) + + case .reactionDeleted: + return createReactionDeletedEvent(from: eventPayload, cid: cid) + + default: + return nil + } + } + + private func isRegistered(channelId: ChannelId) -> Bool { + queue.sync { channelIds.contains(channelId) } + } + + // MARK: - Event Creation Helpers + + private func createMessageNewEvent(from payload: EventPayload, cid: ChannelId) -> MessageNewEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let channel = getLocalChannel(id: cid), + let currentUserId = database.writableContext.currentUser?.user.id + else { + return nil + } + + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + + return MessageNewEvent( + user: userPayload.asModel(), + message: message, + channel: channel, + createdAt: createdAt, + watcherCount: payload.watcherCount, + unreadCount: payload.unreadCount.map { + .init( + channels: $0.channels ?? 0, + messages: $0.messages ?? 0, + threads: $0.threads ?? 0 + ) + } + ) + } + + private func createMessageUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> MessageUpdatedEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid) + else { return nil } + + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + + return MessageUpdatedEvent( + user: userPayload.asModel(), + channel: channel, + message: message, + createdAt: createdAt + ) + } + + private func createMessageDeletedEvent(from payload: EventPayload, cid: ChannelId) -> MessageDeletedEvent? { + guard + let messagePayload = payload.message, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid) + else { return nil } + + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + let userPayload = payload.user + + return MessageDeletedEvent( + user: userPayload?.asModel(), + channel: channel, + message: message, + createdAt: createdAt, + isHardDelete: payload.hardDelete + ) + } + + private func createReactionNewEvent(from payload: EventPayload, cid: ChannelId) -> ReactionNewEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid) + else { return nil } + + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + + return ReactionNewEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(messageId: messagePayload.id), + createdAt: createdAt + ) + } + + private func createReactionUpdatedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionUpdatedEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid) + else { return nil } + + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + + return ReactionUpdatedEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(messageId: messagePayload.id), + createdAt: createdAt + ) + } + + private func createReactionDeletedEvent(from payload: EventPayload, cid: ChannelId) -> ReactionDeletedEvent? { + guard + let userPayload = payload.user, + let messagePayload = payload.message, + let reactionPayload = payload.reaction, + let createdAt = payload.createdAt, + let currentUserId = database.writableContext.currentUser?.user.id, + let channel = getLocalChannel(id: cid) + else { return nil } + + let message = messagePayload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channel.reads) + + return ReactionDeletedEvent( + user: userPayload.asModel(), + cid: cid, + message: message, + reaction: reactionPayload.asModel(messageId: messagePayload.id), + createdAt: createdAt + ) + } + + // This is only needed because some events wrongly require the channel to create them. + private func getLocalChannel(id: ChannelId) -> ChatChannel? { + queue.sync { + if let cachedChannel = cachedChannels[id] { + return cachedChannel + } + + let channel = try? database.writableContext.channel(cid: id)?.asModel() + cachedChannels[id] = channel + return channel + } + } +} diff --git a/Sources/StreamChat/Workers/UserListUpdater.swift b/Sources/StreamChat/Workers/UserListUpdater.swift index d38fa6e40ce..2c0bd1d8223 100644 --- a/Sources/StreamChat/Workers/UserListUpdater.swift +++ b/Sources/StreamChat/Workers/UserListUpdater.swift @@ -121,26 +121,3 @@ extension UserListQuery { return query } } - -private extension UserPayload { - func asModel() -> ChatUser { - ChatUser( - id: id, - name: name, - imageURL: imageURL, - isOnline: isOnline, - isBanned: isBanned, - isFlaggedByCurrentUser: false, - userRole: role, - teamsRole: teamsRole, - createdAt: createdAt, - updatedAt: updatedAt, - deactivatedAt: deactivatedAt, - lastActiveAt: lastActiveAt, - teams: Set(teams), - language: language.map(TranslationLanguage.init), - avgResponseTime: avgResponseTime, - extraData: extraData - ) - } -} diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift index 0c05b9fc2ba..65ee6ab34e7 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC+DiffKit.swift @@ -5,15 +5,21 @@ import Foundation import StreamChat -extension ChatMessageListVC { +public extension ChatMessageListVC { /// Set the previous message snapshot before the data controller reports new messages. - internal func setPreviousMessagesSnapshot(_ messages: [ChatMessage]) { + func setPreviousMessagesSnapshot(_ messages: [ChatMessage]) { listView.previousMessagesSnapshot = messages } /// Set the new message snapshot reported by the data controller. - internal func setNewMessagesSnapshot(_ messages: LazyCachedMapCollection) { + func setNewMessagesSnapshot(_ messages: LazyCachedMapCollection) { listView.currentMessagesFromDataSource = messages listView.newMessagesSnapshot = messages } + + /// Set the new message snapshot reported by the data controller as an Array. + func setNewMessagesSnapshotArray(_ messages: [ChatMessage]) { + listView.currentMessagesFromDataSourceArray = messages + listView.newMessagesSnapshotArray = messages + } } diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift index 075b6ddad40..6fc65f2b5dd 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListView.swift @@ -25,10 +25,18 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { /// we update the messages data with the one originally reported by the data controller. internal var currentMessagesFromDataSource: LazyCachedMapCollection = [] + /// The current messages from the data source, including skipped messages as an array. + /// Used mostly for the Livestream version of the message list. + internal var currentMessagesFromDataSourceArray: [ChatMessage]? + /// The new messages snapshot reported by the channel or message controller. /// If messages are being skipped, this snapshot doesn't include skipped messages. internal var newMessagesSnapshot: LazyCachedMapCollection = [] + /// The new messages snapshot reported by the channel or message controller as an array. + /// Used mostly for the Livestream version of the message list. + internal var newMessagesSnapshotArray: [ChatMessage]? + /// When inserting messages at the bottom, if the user is scrolled up, /// we skip adding the message to the UI until the user scrolls back /// to the bottom. This is to avoid message list jumps. @@ -212,8 +220,15 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { completion: (() -> Void)? = nil ) { let previousMessagesSnapshot = self.previousMessagesSnapshot - let newMessagesWithoutSkipped = newMessagesSnapshot.filter { - !self.skippedMessages.contains($0.id) + let newMessagesWithoutSkipped: [ChatMessage] + if let newMessagesSnapshotArray = newMessagesSnapshotArray { + newMessagesWithoutSkipped = newMessagesSnapshotArray.filter { + !self.skippedMessages.contains($0.id) + } + } else { + newMessagesWithoutSkipped = newMessagesSnapshot.filter { + !self.skippedMessages.contains($0.id) + } } adjustContentInsetToPositionMessagesAtTheTop() @@ -243,7 +258,12 @@ open class ChatMessageListView: UITableView, Customizable, ComponentsProvider { internal func reloadSkippedMessages() { skippedMessages = [] newMessagesSnapshot = currentMessagesFromDataSource - onNewDataSource?(Array(newMessagesSnapshot)) + newMessagesSnapshotArray = currentMessagesFromDataSourceArray + if let newMessagesSnapshotArray { + onNewDataSource?(newMessagesSnapshotArray) + } else { + onNewDataSource?(Array(newMessagesSnapshot)) + } reloadData() scrollToBottom() } diff --git a/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift b/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift index df6920977db..c981d4a09f2 100644 --- a/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift +++ b/Sources/StreamChatUI/ChatMessageList/ScrollToBottomButton.swift @@ -11,7 +11,7 @@ public typealias ScrollToLatestMessageButton = ScrollToBottomButton /// A Button that is used to indicate unread messages in the Message list. open class ScrollToBottomButton: _Button, ThemeProvider { /// The unread count that will be shown on the button as a badge icon. - var content: ChannelUnreadCount = .noUnread { + public var content: ChannelUnreadCount = .noUnread { didSet { updateContentIfNeeded() } diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index 46e22c6128e..4bf539653e5 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -1767,7 +1767,7 @@ open class ComposerVC: _ViewController, present(alert, animated: true) } - private func removeMentionUserIfNotIncluded(in currentText: String) { + func removeMentionUserIfNotIncluded(in currentText: String) { // If the user included some mentions via suggestions, // but then removed them from text, we should remove them from // the content we'll send diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 89b2dd1d9f3..eaa0dd74244 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1420,9 +1420,14 @@ AD17E1212E00985B001AF308 /* SharedLocationPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1202E009853001AF308 /* SharedLocationPayload.swift */; }; AD17E1232E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; + AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; + AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */; }; + AD1BA40B2E3A2D180092D602 /* ManualEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */; }; + AD1BA40C2E3A2D180092D602 /* ManualEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; + AD26CB772E3ACAB9002FC1A7 /* DemoLivestreamChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */; }; AD29395D2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD29395C2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift */; }; AD29395E2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD29395C2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift */; }; AD2C94DC29CB8CC40096DCA1 /* PartiallyFailingChannelListPayload.json in Sources */ = {isa = PBXBuildFile; fileRef = AD2C94DB29CB8CC40096DCA1 /* PartiallyFailingChannelListPayload.json */; }; @@ -1475,6 +1480,14 @@ AD4C8C232C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */; }; AD4CDD85296499160057BC8A /* ScrollViewPaginationHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */; }; AD4CDD862964991A0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CDD83296498EB0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift */; }; + AD4E87972E37947300223A1C /* ChannelPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */; }; + AD4E87982E37947300223A1C /* UserPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87962E37947300223A1C /* UserPayload+asModel.swift */; }; + AD4E87992E37947300223A1C /* MessagePayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */; }; + AD4E879B2E37947300223A1C /* ChannelPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */; }; + AD4E879C2E37947300223A1C /* UserPayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87962E37947300223A1C /* UserPayload+asModel.swift */; }; + AD4E879D2E37947300223A1C /* MessagePayload+asModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */; }; + AD4E87A12E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */; }; + AD4E87A22E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */; }; AD4F89D02C666471006DF7E5 /* PollResultsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */; }; AD4F89D12C666471006DF7E5 /* PollResultsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */; }; AD4F89D42C666471006DF7E5 /* PollResultsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4F89CC2C666471006DF7E5 /* PollResultsVC.swift */; }; @@ -1600,6 +1613,13 @@ AD7BE1712C234798000A5756 /* ChatThreadListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE16F2C234798000A5756 /* ChatThreadListLoadingView.swift */; }; AD7BE1732C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */; }; AD7BE1742C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */; }; + AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */; }; + AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */; }; + AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */; }; + AD7C767F2E426B34009250FB /* ManualEventHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C767E2E426B34009250FB /* ManualEventHandler_Tests.swift */; }; + AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */; }; + AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */; }; + AD7C76852E42CDF6009250FB /* ManualEventHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7EFDA52C77749300625FC5 /* PollCommentListVC.swift */; }; @@ -4270,9 +4290,12 @@ AD17CDF827E4DB2700E0D092 /* PushProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushProvider.swift; sourceTree = ""; }; AD17E1202E009853001AF308 /* SharedLocationPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationPayload.swift; sourceTree = ""; }; AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; + AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController.swift; sourceTree = ""; }; + AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; + AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatChannelVC.swift; sourceTree = ""; }; AD29395C2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyGestureHandler.swift; sourceTree = ""; }; AD2C94DB29CB8CC40096DCA1 /* PartiallyFailingChannelListPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = PartiallyFailingChannelListPayload.json; sourceTree = ""; }; AD2C94DE29CB93C40096DCA1 /* FailingChannelListPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FailingChannelListPayload.json; sourceTree = ""; }; @@ -4309,6 +4332,10 @@ AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedUserAvatarsView.swift; sourceTree = ""; }; AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; AD4CDD83296498EB0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; + AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPayload+asModel.swift"; sourceTree = ""; }; + AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagePayload+asModel.swift"; sourceTree = ""; }; + AD4E87962E37947300223A1C /* UserPayload+asModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserPayload+asModel.swift"; sourceTree = ""; }; + AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LivestreamChannelController+Combine.swift"; sourceTree = ""; }; AD4F89CA2C666471006DF7E5 /* PollResultsSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsSectionHeaderView.swift; sourceTree = ""; }; AD4F89CC2C666471006DF7E5 /* PollResultsVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsVC.swift; sourceTree = ""; }; AD4F89CD2C666471006DF7E5 /* PollResultsVoteItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollResultsVoteItemCell.swift; sourceTree = ""; }; @@ -4389,6 +4416,13 @@ AD7BE16C2C20CC02000A5756 /* ThreadUpdaterMiddlware_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdaterMiddlware_Tests.swift; sourceTree = ""; }; AD7BE16F2C234798000A5756 /* ChatThreadListLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListLoadingView.swift; sourceTree = ""; }; AD7BE1722C2347A3000A5756 /* ChatThreadListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListEmptyView.swift; sourceTree = ""; }; + AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamChatMessageListVC.swift; sourceTree = ""; }; + AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamMessageActionsVC.swift; sourceTree = ""; }; + AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoLivestreamReactionsListView.swift; sourceTree = ""; }; + AD7C767E2E426B34009250FB /* ManualEventHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler_Tests.swift; sourceTree = ""; }; + AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamChannelController_Tests.swift; sourceTree = ""; }; + AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LivestreamChannelController+Combine_Tests.swift"; sourceTree = ""; }; + AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEventHandler_Mock.swift; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -5991,6 +6025,7 @@ 799C9427247D2FB9001F1104 /* Workers */ = { isa = PBXGroup; children = ( + AD1BA40A2E3A2D180092D602 /* ManualEventHandler.swift */, 4F45802D2BEE0B4B0099F540 /* ChannelListLinker.swift */, 792A4F1A247FE84900EAF71D /* ChannelListUpdater.swift */, 882C5755252C791400E60C44 /* ChannelMemberListUpdater.swift */, @@ -6029,6 +6064,7 @@ children = ( 225D807625D316B10094E555 /* Attachments */, ADFCA5B52D121EE9000F515F /* Location */, + AD4E879F2E37967200223A1C /* Payload+asModel */, AD8C7C5C2BA3BE1E00260715 /* AppSettings.swift */, 8A62706D24BF45360040BFD6 /* BanEnabling.swift */, 82BE0ACC2C009A17008DA9DC /* BlockedUserDetails.swift */, @@ -6039,10 +6075,10 @@ 79896D5D25065E6900BA8F1C /* ChannelRead.swift */, 79877A042498E4BB00015F8B /* ChannelType.swift */, ADA03A212D64EFE900DFE048 /* DraftMessage.swift */, - 799C9431247D2FB9001F1104 /* ChatMessage.swift */, 79877A052498E4BC00015F8B /* CurrentUser.swift */, 79877A022498E4BB00015F8B /* Device.swift */, 79877A032498E4BB00015F8B /* Member.swift */, + 799C9431247D2FB9001F1104 /* ChatMessage.swift */, AD70DC3B2ADEF09C00CFC3B7 /* MessageModerationDetails.swift */, ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */, AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */, @@ -6902,6 +6938,7 @@ C10B5C712A1F794A006A5BCB /* MembersViewController.swift */, C1CEF9062A1BC4E800414931 /* UserProfileViewController.swift */, AD7BE1672C1CB183000A5756 /* DebugObjectViewController.swift */, + AD7C76732E3CF0CD009250FB /* Livestream */, AD6BEFF42786474A00E184B4 /* AppConfigViewController */, A3227E5D284A494000EBE6CC /* Create Chat */, A3227E6A284A4B0D00EBE6CC /* LoginViewController */, @@ -7265,6 +7302,7 @@ A364D09627D0C56C0029857A /* Workers */ = { isa = PBXGroup; children = ( + AD7C767E2E426B34009250FB /* ManualEventHandler_Tests.swift */, AD9490582BF5701D00E69224 /* ThreadsRepository_Tests.swift */, 792921C424C0479700116BBB /* ChannelListUpdater_Tests.swift */, 882C5765252C7F7000E60C44 /* ChannelMemberListUpdater_Tests.swift */, @@ -7287,6 +7325,7 @@ A364D09727D0C5940029857A /* Workers */ = { isa = PBXGroup; children = ( + AD7C76842E42CDF6009250FB /* ManualEventHandler_Mock.swift */, 882C5762252C7F6500E60C44 /* ChannelMemberListUpdater_Mock.swift */, 88F6DF96252C88BB009A8AF0 /* ChannelMemberUpdater_Mock.swift */, F62D143D24DD70190081D241 /* ChannelUpdater_Mock.swift */, @@ -7493,6 +7532,8 @@ A364D0A827D128650029857A /* ChannelController */ = { isa = PBXGroup; children = ( + AD7C76822E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift */, + AD7C76802E4275B3009250FB /* LivestreamChannelController_Tests.swift */, AD545E7C2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift */, 7952B3B224D314B100AC53D4 /* ChannelController_Tests.swift */, DA4AA3B32502719700FAAF6E /* ChannelController+Combine_Tests.swift */, @@ -8668,6 +8709,16 @@ path = ViewPaginationHandling; sourceTree = ""; }; + AD4E879F2E37967200223A1C /* Payload+asModel */ = { + isa = PBXGroup; + children = ( + AD4E87932E37947300223A1C /* ChannelPayload+asModel.swift */, + AD4E87942E37947300223A1C /* MessagePayload+asModel.swift */, + AD4E87962E37947300223A1C /* UserPayload+asModel.swift */, + ); + path = "Payload+asModel"; + sourceTree = ""; + }; AD4EA229264ADE0100DF8EE2 /* Composer */ = { isa = PBXGroup; children = ( @@ -8782,6 +8833,17 @@ path = ChatMessageReactionAuthorsVC; sourceTree = ""; }; + AD7C76732E3CF0CD009250FB /* Livestream */ = { + isa = PBXGroup; + children = ( + AD26CB762E3ACAA0002FC1A7 /* DemoLivestreamChatChannelVC.swift */, + AD7C766D2E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift */, + AD7C766F2E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift */, + AD7C76742E3D047E009250FB /* DemoLivestreamReactionsListView.swift */, + ); + path = Livestream; + sourceTree = ""; + }; AD7EFDA42C776EB700625FC5 /* PollCommentListVC */ = { isa = PBXGroup; children = ( @@ -9524,6 +9586,8 @@ AD78568B298B268F00C2FEAD /* ChannelControllerDelegate.swift */, DAE566E624FFD22300E39431 /* ChannelController+SwiftUI.swift */, DA4AA3B12502718600FAAF6E /* ChannelController+Combine.swift */, + AD1B9F412E30F7850091A37A /* LivestreamChannelController.swift */, + AD4E87A02E39167C00223A1C /* LivestreamChannelController+Combine.swift */, ); path = ChannelController; sourceTree = ""; @@ -11185,6 +11249,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AD7C76752E3D0486009250FB /* DemoLivestreamReactionsListView.swift in Sources */, 6428DD5526201DCC0065DA1D /* BannerShowingConnectionDelegate.swift in Sources */, AD053BA52B335A63003612B6 /* DemoQuotedChatMessageView.swift in Sources */, AD053BAB2B33638B003612B6 /* LocationAttachmentSnapshotView.swift in Sources */, @@ -11198,6 +11263,8 @@ 8440860D28FBFE520027849C /* DemoAppCoordinator+DemoApp.swift in Sources */, 647F66D5261E22C200111B19 /* DemoConnectionBannerView.swift in Sources */, A31783DD285B79EB005009B9 /* Bundle+PushProvider.swift in Sources */, + AD7C76712E3CE1E0009250FB /* DemoLivestreamChatMessageListVC.swift in Sources */, + AD7C76722E3CE1E0009250FB /* DemoLivestreamMessageActionsVC.swift in Sources */, A3227E72284A4BF700EBE6CC /* HiddenChannelListVC.swift in Sources */, AD75CB6B27886746005F5FF7 /* OptionsSelectorViewController.swift in Sources */, AD053B9A2B335854003612B6 /* DemoComposerVC.swift in Sources */, @@ -11235,6 +11302,7 @@ ADA2D64A2C46B66E001D2B44 /* DemoChatChannelListErrorView.swift in Sources */, A3227E5B284A489000EBE6CC /* UIViewController+Alert.swift in Sources */, A3227E6D284A4B6A00EBE6CC /* UserCredentialsCell.swift in Sources */, + AD26CB772E3ACAB9002FC1A7 /* DemoLivestreamChatChannelVC.swift in Sources */, 84A33ABA28F86B8500CEC8FD /* StreamChatWrapper+DemoApp.swift in Sources */, AD053BA92B336331003612B6 /* LocationDetailViewController.swift in Sources */, ADFCA5B72D1232B3000F515F /* LocationProvider.swift in Sources */, @@ -11370,6 +11438,7 @@ A3C3BC3F27E87F5C00224761 /* ChannelListUpdater_Spy.swift in Sources */, A3C3BC6427E8AA0A00224761 /* ChannelId+Unique.swift in Sources */, 82F714A12B077F3300442A74 /* XCTestCase+iOS13.swift in Sources */, + AD7C76852E42CDF6009250FB /* ManualEventHandler_Mock.swift in Sources */, A3C3BC7127E8AA4300224761 /* TestFetchedResultsController.swift in Sources */, 82E655392B06775D00D64906 /* MockFunc.swift in Sources */, A344078927D753530044F150 /* UserPayload.swift in Sources */, @@ -11671,6 +11740,7 @@ DA4AA3B8250271BD00FAAF6E /* CurrentUserController+Combine.swift in Sources */, 79280F712487CD2B00CDEB89 /* Atomic.swift in Sources */, AD7AC99B260A9572004AADA5 /* MessagePinning.swift in Sources */, + AD4E87A22E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */, ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */, 88DA57642631CF1F00FA8C53 /* MuteDetails.swift in Sources */, 7978FBBA26E15A58002CA2DF /* MessageSearchQuery.swift in Sources */, @@ -11844,6 +11914,9 @@ C1EE53A927BA662B00B1A6CA /* QueuedRequestDTO.swift in Sources */, 790A4C42252DD377001F4A23 /* DeviceEndpoints.swift in Sources */, C1CEF9092A1CDF7600414931 /* UserUpdateMiddleware.swift in Sources */, + AD4E879B2E37947300223A1C /* ChannelPayload+asModel.swift in Sources */, + AD4E879C2E37947300223A1C /* UserPayload+asModel.swift in Sources */, + AD4E879D2E37947300223A1C /* MessagePayload+asModel.swift in Sources */, 797A756424814E7A003CF16D /* WebSocketConnectPayload.swift in Sources */, F6FF1DA624FD17B400151735 /* MessageController.swift in Sources */, E7AD954F25D536AA00076DC3 /* SystemEnvironment+Version.swift in Sources */, @@ -11861,6 +11934,7 @@ 7991D83D24F7E93900D21BA3 /* ChannelListController+SwiftUI.swift in Sources */, 225D7FE225D191400094E555 /* ChatMessageImageAttachment.swift in Sources */, 8A0D64A724E57A520017A3C0 /* GuestUserTokenRequestPayload.swift in Sources */, + AD1B9F432E30F7860091A37A /* LivestreamChannelController.swift in Sources */, AD6E32A42BBC502D0073831B /* ThreadQuery.swift in Sources */, 888E8C39252B2ABB00195E03 /* UserController+Combine.swift in Sources */, 4F1BEE762BE384ED00B6685C /* ReactionList.swift in Sources */, @@ -11892,6 +11966,7 @@ AD37D7CA2BC98A5300800D8C /* ThreadReadDTO.swift in Sources */, ADE40043291B1A510000C98B /* AttachmentUploader.swift in Sources */, AD37D7D32BC9938E00800D8C /* ThreadRead.swift in Sources */, + AD1BA40C2E3A2D180092D602 /* ManualEventHandler.swift in Sources */, 841BAA542BD26136000C73E4 /* PollOption.swift in Sources */, 8836FFBB2540741D009FDF73 /* FlagUserPayload.swift in Sources */, ADF2BBEB2B9B622B0069D467 /* AppSettingsPayload.swift in Sources */, @@ -11965,6 +12040,7 @@ A30C3F22276B4F8800DA5968 /* UnknownUserEvent_Tests.swift in Sources */, DA84074025260CA3005A0F62 /* UserListController_Tests.swift in Sources */, AD545E852D5D7591008FD399 /* DraftListQuery_Tests.swift in Sources */, + AD7C767F2E426B34009250FB /* ManualEventHandler_Tests.swift in Sources */, C1EE53A727BA53F300B1A6CA /* Endpoint_Tests.swift in Sources */, 84A1D2F426AB221E00014712 /* ChannelEventsController_Tests.swift in Sources */, 88381E6E258259310047A6A3 /* FileUploadPayload_Tests.swift in Sources */, @@ -12205,6 +12281,7 @@ 84EB4E78276A03DE00E47E73 /* ErrorPayload_Tests.swift in Sources */, DA4EE5B5252B680700CB26D4 /* UserListController+SwiftUI_Tests.swift in Sources */, 8A0D649824E579AB0017A3C0 /* GuestEndpoints_Tests.swift in Sources */, + AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */, DAF1BED92506612F003CEDC0 /* MessageController+SwiftUI_Tests.swift in Sources */, A3A52B6627EB61FC00311DFC /* EventPayload_Tests.swift in Sources */, A32B6D9E2869DABD002B1312 /* GiphyAttachmentPayload_Tests.swift in Sources */, @@ -12221,6 +12298,7 @@ 88DA577E2631D73800FA8C53 /* ChannelMuteDTO_Tests.swift in Sources */, 8459C9F42BFB929600F0D235 /* PollsRepository_Tests.swift in Sources */, 4F5151982BC407ED001B7152 /* UserList_Tests.swift in Sources */, + AD7C76812E4275B3009250FB /* LivestreamChannelController_Tests.swift in Sources */, 84CC56EC267B3F6B00DF2784 /* AnyAttachmentPayload_Tests.swift in Sources */, AD545E812D5D0006008FD399 /* MessageController+Drafts_Tests.swift in Sources */, 88F7692B25837EE600BD36B0 /* AttachmentQueueUploader_Tests.swift in Sources */, @@ -12657,6 +12735,10 @@ AD0F7F1A2B613EDC00914C4C /* TextLinkDetector.swift in Sources */, C121E884274544AF00023E4C /* ChatMessageFileAttachment.swift in Sources */, C121E885274544AF00023E4C /* ChatMessageVideoAttachment.swift in Sources */, + AD4E87972E37947300223A1C /* ChannelPayload+asModel.swift in Sources */, + AD4E87982E37947300223A1C /* UserPayload+asModel.swift in Sources */, + AD4E87992E37947300223A1C /* MessagePayload+asModel.swift in Sources */, + AD4E87A12E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */, 4F9494BC2C41086F00B5C9CE /* BackgroundEntityDatabaseObserver.swift in Sources */, C121E886274544AF00023E4C /* ChatMessageImageAttachment.swift in Sources */, 43D3F0FD28410A0200B74921 /* CreateCallRequestBody.swift in Sources */, @@ -12729,6 +12811,7 @@ C121E8AE274544B000023E4C /* ChannelController+SwiftUI.swift in Sources */, 4FE56B8E2D5DFE4600589F9A /* MarkdownParser.swift in Sources */, C121E8AF274544B000023E4C /* ChannelController+Combine.swift in Sources */, + AD1B9F422E30F7850091A37A /* LivestreamChannelController.swift in Sources */, 82C18FDD2C10C8E600C5283C /* BlockedUserPayload.swift in Sources */, C121E8B0274544B000023E4C /* ChannelListController.swift in Sources */, C121E8B1274544B000023E4C /* ChannelListController+SwiftUI.swift in Sources */, @@ -12821,6 +12904,7 @@ C121E8E1274544B100023E4C /* OptionalDecodable.swift in Sources */, C1CEF90A2A1CDF7600414931 /* UserUpdateMiddleware.swift in Sources */, C121E8E2274544B200023E4C /* Codable+Extensions.swift in Sources */, + AD1BA40B2E3A2D180092D602 /* ManualEventHandler.swift in Sources */, C121E8E3274544B200023E4C /* Data+Gzip.swift in Sources */, C121E8E4274544B200023E4C /* LazyCachedMapCollection.swift in Sources */, 40789D3D29F6AD9C0018C2BB /* Debouncer.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift index 316da120c82..98e91c4617d 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift @@ -112,6 +112,10 @@ final class ChannelUpdater_Mock: ChannelUpdater { @Atomic var enableSlowMode_completion: ((Error?) -> Void)? @Atomic var enableSlowMode_completion_result: Result? + @Atomic var disableSlowMode_cid: ChannelId? + @Atomic var disableSlowMode_completion: ((Error?) -> Void)? + @Atomic var disableSlowMode_completion_result: Result? + @Atomic var startWatching_cid: ChannelId? @Atomic var startWatching_completion: ((Error?) -> Void)? @Atomic var startWatching_completion_result: Result? @@ -249,6 +253,10 @@ final class ChannelUpdater_Mock: ChannelUpdater { enableSlowMode_completion = nil enableSlowMode_completion_result = nil + disableSlowMode_cid = nil + disableSlowMode_completion = nil + disableSlowMode_completion_result = nil + startWatching_cid = nil startWatching_completion = nil startWatching_completion_result = nil @@ -293,6 +301,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { override var paginationState: MessagesPaginationState { mockPaginationState } + override func update( channelQuery: ChannelQuery, isInRecoveryMode: Bool, @@ -428,7 +437,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { hideHistory: Bool, completion: ((Error?) -> Void)? = nil ) { - self.addMembers( + addMembers( currentUserId: currentUserId, cid: cid, members: userIds.map { MemberInfo(userId: $0, extraData: nil) }, @@ -507,6 +516,12 @@ final class ChannelUpdater_Mock: ChannelUpdater { enableSlowMode_completion_result?.invoke(with: completion) } + override func disableSlowMode(cid: ChannelId, completion: @escaping (((any Error)?) -> Void)) { + disableSlowMode_cid = cid + disableSlowMode_completion = completion + disableSlowMode_completion_result?.invoke(with: completion) + } + override func startWatching(cid: ChannelId, isInRecoveryMode: Bool, completion: ((Error?) -> Void)? = nil) { startWatching_cid = cid startWatching_completion = completion diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift index f2068ce8e98..6c05afdac09 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift @@ -7,7 +7,6 @@ import Foundation /// Mock implementation of `EventNotificationCenter` final class EventNotificationCenter_Mock: EventNotificationCenter, @unchecked Sendable { - override var newMessageIds: Set { newMessageIdsMock ?? super.newMessageIds } @@ -17,6 +16,22 @@ final class EventNotificationCenter_Mock: EventNotificationCenter, @unchecked Se lazy var mock_process = MockFunc<([Event], Bool, (() -> Void)?), Void>.mock(for: process) var mock_processCalledWithEvents: [Event] = [] + var registerManualEventHandling_calledWith: ChannelId? + var registerManualEventHandling_callCount = 0 + + var unregisterManualEventHandling_calledWith: ChannelId? + var unregisterManualEventHandling_callCount = 0 + + override func registerManualEventHandling(for cid: ChannelId) { + registerManualEventHandling_callCount += 1 + registerManualEventHandling_calledWith = cid + } + + override func unregisterManualEventHandling(for cid: ChannelId) { + unregisterManualEventHandling_callCount += 1 + unregisterManualEventHandling_calledWith = cid + } + override func process( _ events: [Event], postNotifications: Bool = true, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift new file mode 100644 index 00000000000..ff371bdd01f --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ManualEventHandler_Mock.swift @@ -0,0 +1,45 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ManualEventHandler_Mock: ManualEventHandler { + init() { + super.init( + database: DatabaseContainer_Spy() + ) + } + + static func mock() -> Self { + Self() + } + + var registerCallCount = 0 + var registerCalledWith: [ChannelId] = [] + + override func register(channelId: ChannelId) { + registerCallCount += 1 + registerCalledWith.append(channelId) + } + + var unregisterCallCount = 0 + var unregisterCalledWith: [ChannelId] = [] + + override func unregister(channelId: ChannelId) { + unregisterCallCount += 1 + unregisterCalledWith.append(channelId) + } + + var handleCallCount = 0 + var handleCalledWith: [Event] = [] + var handleReturnValue: Event? + + override func handle(_ event: Event) -> Event? { + handleCallCount += 1 + handleCalledWith.append(event) + return handleReturnValue + } +} \ No newline at end of file diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 62a4c445e22..060d1519239 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -397,4 +397,201 @@ final class ChannelPayload_Tests: XCTestCase { // THEN XCTAssertEqual(payload.newestMessage?.id, laterMessage.id) } + + // MARK: - ChannelPayload.asModel() Tests + + func test_channelPayload_asModel_convertsAllPropertiesCorrectly() { + let currentUserId = "current-user-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + + let createdByPayload = UserPayload.dummy(userId: "creator-user-id", name: "Channel Creator") + let memberPayload = MemberPayload.dummy(user: UserPayload.dummy(userId: "member-user-id"), role: .member) + let watcherPayload = UserPayload.dummy(userId: "watcher-user-id", name: "Channel Watcher") + let messagePayload = MessagePayload.dummy(messageId: "message-id", authorUserId: "author-id") + let pinnedMessagePayload = MessagePayload.dummy(messageId: "pinned-message-id", authorUserId: "pinned-author-id") + let pendingMessagePayload = MessagePayload.dummy(messageId: "pending-message-id", authorUserId: "pending-author-id") + + let channelReadPayload = ChannelReadPayload( + user: UserPayload.dummy(userId: "reader-user-id", name: "Reader User"), + lastReadAt: Date(timeIntervalSince1970: 1_609_459_400), + lastReadMessageId: "last-read-message-id", + unreadMessagesCount: 5 + ) + + let membershipPayload = MemberPayload.dummy(user: .dummy(userId: currentUserId), role: .admin) + + let channel = ChannelDetailPayload( + cid: cid, + name: "Test Channel", + imageURL: URL(string: "https://example.com/channel.png"), + extraData: ["custom_field": .string("custom_value")], + typeRawValue: "messaging", + lastMessageAt: Date(timeIntervalSince1970: 1_609_459_500), + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + deletedAt: Date(timeIntervalSince1970: 1_609_459_600), + updatedAt: Date(timeIntervalSince1970: 1_609_459_300), + truncatedAt: Date(timeIntervalSince1970: 1_609_459_250), + createdBy: createdByPayload, + config: ChannelConfig(), + ownCapabilities: ["send-message", "upload-file"], + isDisabled: true, + isFrozen: true, + isBlocked: true, + isHidden: true, + members: [memberPayload], + memberCount: 10, + team: "team-id", + cooldownDuration: 30 + ) + + let typingUsers = Set([ChatUser.mock(id: "typing-user-id", name: "Typing User")]) + let unreadCount = ChannelUnreadCount(messages: 3, mentions: 1) + + let payload = ChannelPayload( + channel: channel, + watcherCount: 5, + watchers: [watcherPayload], + members: [memberPayload], + membership: membershipPayload, + messages: [messagePayload], + pendingMessages: [pendingMessagePayload], + pinnedMessages: [pinnedMessagePayload], + channelReads: [channelReadPayload], + isHidden: true, + draft: nil, + activeLiveLocations: [] + ) + + let chatChannel = payload.asModel( + currentUserId: currentUserId, + currentlyTypingUsers: typingUsers, + unreadCount: unreadCount + ) + + XCTAssertEqual(chatChannel.cid, cid) + XCTAssertEqual(chatChannel.name, "Test Channel") + XCTAssertEqual(chatChannel.imageURL, URL(string: "https://example.com/channel.png")) + XCTAssertEqual(chatChannel.lastMessageAt, Date(timeIntervalSince1970: 1_609_459_500)) + XCTAssertEqual(chatChannel.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatChannel.updatedAt, Date(timeIntervalSince1970: 1_609_459_300)) + XCTAssertEqual(chatChannel.deletedAt, Date(timeIntervalSince1970: 1_609_459_600)) + XCTAssertEqual(chatChannel.truncatedAt, Date(timeIntervalSince1970: 1_609_459_250)) + XCTAssertEqual(chatChannel.isHidden, true) + XCTAssertEqual(chatChannel.createdBy?.id, "creator-user-id") + XCTAssertNotNil(chatChannel.config) + XCTAssertTrue(chatChannel.ownCapabilities.contains(.sendMessage)) + XCTAssertTrue(chatChannel.ownCapabilities.contains(.uploadFile)) + XCTAssertEqual(chatChannel.isFrozen, true) + XCTAssertEqual(chatChannel.isDisabled, true) + XCTAssertEqual(chatChannel.isBlocked, true) + XCTAssertEqual(chatChannel.lastActiveMembers.count, 1) + XCTAssertEqual(chatChannel.lastActiveMembers.first?.id, "member-user-id") + XCTAssertEqual(chatChannel.membership?.id, currentUserId) + XCTAssertEqual(chatChannel.currentlyTypingUsers, typingUsers) + XCTAssertEqual(chatChannel.lastActiveWatchers.count, 1) + XCTAssertEqual(chatChannel.lastActiveWatchers.first?.id, "watcher-user-id") + XCTAssertEqual(chatChannel.team, "team-id") + XCTAssertEqual(chatChannel.unreadCount, unreadCount) + XCTAssertEqual(chatChannel.watcherCount, 5) + XCTAssertEqual(chatChannel.memberCount, 10) + XCTAssertEqual(chatChannel.reads.count, 1) + XCTAssertEqual(chatChannel.reads.first?.user.id, "reader-user-id") + XCTAssertEqual(chatChannel.cooldownDuration, 30) + XCTAssertEqual(chatChannel.extraData, ["custom_field": .string("custom_value")]) + XCTAssertEqual(chatChannel.latestMessages.count, 1) + XCTAssertEqual(chatChannel.latestMessages.first?.id, "message-id") + XCTAssertEqual(chatChannel.pinnedMessages.count, 1) + XCTAssertEqual(chatChannel.pinnedMessages.first?.id, "pinned-message-id") + XCTAssertEqual(chatChannel.pendingMessages.count, 1) + XCTAssertEqual(chatChannel.pendingMessages.first?.id, "pending-message-id") + XCTAssertNil(chatChannel.muteDetails) + XCTAssertNotNil(chatChannel.previewMessage) + XCTAssertEqual(chatChannel.previewMessage?.id, "message-id") + XCTAssertTrue(chatChannel.activeLiveLocations.isEmpty) + } + + func test_channelPayload_asModel_withMinimalData_handlesCorrectly() { + let currentUserId = "current-user-id" + let cid = ChannelId(type: .messaging, id: "minimal-channel") + + let channel = ChannelDetailPayload( + cid: cid, + name: "Minimal Channel", + imageURL: nil, + extraData: [:], + typeRawValue: "messaging", + lastMessageAt: Date(timeIntervalSince1970: 1_609_459_200), + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + deletedAt: nil, + updatedAt: Date(timeIntervalSince1970: 1_609_459_200), + truncatedAt: nil, + createdBy: nil, + config: ChannelConfig(), + ownCapabilities: nil, + isDisabled: false, + isFrozen: false, + isBlocked: nil, + isHidden: nil, + members: nil, + memberCount: 0, + team: nil, + cooldownDuration: 0 + ) + + let payload = ChannelPayload( + channel: channel, + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: nil, + activeLiveLocations: [] + ) + + let chatChannel = payload.asModel( + currentUserId: currentUserId, + currentlyTypingUsers: nil, + unreadCount: nil + ) + + XCTAssertEqual(chatChannel.cid, cid) + XCTAssertEqual(chatChannel.name, "Minimal Channel") + XCTAssertNil(chatChannel.imageURL) + XCTAssertEqual(chatChannel.lastMessageAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatChannel.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatChannel.updatedAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertNil(chatChannel.deletedAt) + XCTAssertNil(chatChannel.truncatedAt) + XCTAssertEqual(chatChannel.isHidden, false) + XCTAssertNil(chatChannel.createdBy) + XCTAssertNotNil(chatChannel.config) + XCTAssertTrue(chatChannel.ownCapabilities.isEmpty) + XCTAssertEqual(chatChannel.isFrozen, false) + XCTAssertEqual(chatChannel.isDisabled, false) + XCTAssertEqual(chatChannel.isBlocked, false) + XCTAssertTrue(chatChannel.lastActiveMembers.isEmpty) + XCTAssertNil(chatChannel.membership) + XCTAssertTrue(chatChannel.currentlyTypingUsers.isEmpty) + XCTAssertTrue(chatChannel.lastActiveWatchers.isEmpty) + XCTAssertNil(chatChannel.team) + XCTAssertEqual(chatChannel.unreadCount, .noUnread) + XCTAssertEqual(chatChannel.watcherCount, 0) + XCTAssertEqual(chatChannel.memberCount, 0) + XCTAssertTrue(chatChannel.reads.isEmpty) + XCTAssertEqual(chatChannel.cooldownDuration, 0) + XCTAssertEqual(chatChannel.extraData, [:]) + XCTAssertTrue(chatChannel.latestMessages.isEmpty) + XCTAssertTrue(chatChannel.pinnedMessages.isEmpty) + XCTAssertTrue(chatChannel.pendingMessages.isEmpty) + XCTAssertNil(chatChannel.muteDetails) + XCTAssertNil(chatChannel.previewMessage) + XCTAssertNil(chatChannel.lastMessageFromCurrentUser) + XCTAssertNil(chatChannel.draftMessage) + XCTAssertTrue(chatChannel.activeLiveLocations.isEmpty) + } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift index 65e35c8dc8a..0eb7c0e012b 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift @@ -137,6 +137,204 @@ final class MessagePayload_Tests: XCTestCase { XCTAssertEqual(payload.quotedMessageId, "4C0CC2DA-8AB5-421F-808E-50DC7E40653D") XCTAssertEqual(payload.translations, [.italian: "si sono qui", .dutch: "ja ik ben hier"]) } + + // MARK: - MessagePayload.asModel() Tests + + func test_messagePayload_asModel_convertsAllPropertiesCorrectly() { + let messageId = "test-message-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + let currentUserId = "current-user-id" + let userPayload = UserPayload.dummy(userId: "author-user-id", name: "Test Author") + let mentionedUserPayload = UserPayload.dummy(userId: "mentioned-user-id", name: "Mentioned User") + let threadParticipantPayload = UserPayload.dummy(userId: "participant-user-id", name: "Thread Participant") + let pinnedByPayload = UserPayload.dummy(userId: "pinned-by-user-id", name: "Pinned By User") + let quotedMessagePayload = MessagePayload.dummy(messageId: "quoted-message-id", text: "Quoted message text") + let reactionPayload = MessageReactionPayload( + type: MessageReactionType(rawValue: "love"), + score: 1, + messageId: "123", + createdAt: Date(timeIntervalSince1970: 1_609_459_300), + updatedAt: Date(timeIntervalSince1970: 1_609_459_300), + user: userPayload, + extraData: [:] + ) + + let payload = MessagePayload( + id: messageId, + type: .regular, + user: userPayload, + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + updatedAt: Date(timeIntervalSince1970: 1_609_459_250), + deletedAt: Date(timeIntervalSince1970: 1_609_459_300), + text: "Test message text", + command: "test-command", + args: "test-args", + parentId: "parent-message-id", + showReplyInChannel: true, + quotedMessageId: "quoted-message-id", + quotedMessage: quotedMessagePayload, + mentionedUsers: [mentionedUserPayload], + threadParticipants: [threadParticipantPayload], + replyCount: 5, + extraData: ["custom_field": .string("custom_value")], + latestReactions: [reactionPayload], + ownReactions: [reactionPayload], + reactionScores: ["love": 1], + reactionCounts: ["love": 1], + reactionGroups: [:], + isSilent: true, + isShadowed: true, + attachments: [], + channel: nil, + pinned: true, + pinnedBy: pinnedByPayload, + pinnedAt: Date(timeIntervalSince1970: 1_609_459_400), + pinExpires: Date(timeIntervalSince1970: 1_609_459_500), + translations: [.spanish: "Texto del mensaje de prueba"], + originalLanguage: "en", + moderation: nil, + moderationDetails: nil, + messageTextUpdatedAt: Date(timeIntervalSince1970: 1_609_459_350), poll: nil, + reminder: nil, + location: nil + ) + + let channelReads = [ + ChatChannelRead( + lastReadAt: Date(timeIntervalSince1970: 1_609_459_600), + lastReadMessageId: "read-message-id", + unreadMessagesCount: 0, + user: ChatUser.mock( + id: "reader-user-id", + name: "Reader User" + ) + ) + ] + + let chatMessage = payload.asModel(cid: cid, currentUserId: currentUserId, channelReads: channelReads) + + XCTAssertEqual(chatMessage.id, messageId) + XCTAssertEqual(chatMessage.cid, cid) + XCTAssertEqual(chatMessage.text, "Test message text") + XCTAssertEqual(chatMessage.type, .regular) + XCTAssertEqual(chatMessage.command, "test-command") + XCTAssertEqual(chatMessage.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatMessage.updatedAt, Date(timeIntervalSince1970: 1_609_459_250)) + XCTAssertEqual(chatMessage.deletedAt, Date(timeIntervalSince1970: 1_609_459_300)) + XCTAssertEqual(chatMessage.arguments, "test-args") + XCTAssertEqual(chatMessage.parentMessageId, "parent-message-id") + XCTAssertEqual(chatMessage.showReplyInChannel, true) + XCTAssertEqual(chatMessage.replyCount, 5) + XCTAssertEqual(chatMessage.extraData, ["custom_field": .string("custom_value")]) + XCTAssertEqual(chatMessage.isSilent, true) + XCTAssertEqual(chatMessage.isShadowed, true) + XCTAssertEqual(chatMessage.reactionScores, ["love": 1]) + XCTAssertEqual(chatMessage.reactionCounts, ["love": 1]) + XCTAssertEqual(chatMessage.author.id, "author-user-id") + XCTAssertEqual(chatMessage.mentionedUsers.first?.id, "mentioned-user-id") + XCTAssertEqual(chatMessage.threadParticipants.first?.id, "participant-user-id") + XCTAssertEqual(chatMessage.isSentByCurrentUser, false) + XCTAssertNotNil(chatMessage.pinDetails) + XCTAssertEqual(chatMessage.pinDetails?.pinnedAt, Date(timeIntervalSince1970: 1_609_459_400)) + XCTAssertEqual(chatMessage.pinDetails?.expiresAt, Date(timeIntervalSince1970: 1_609_459_500)) + XCTAssertEqual(chatMessage.pinDetails?.pinnedBy.id, "pinned-by-user-id") + XCTAssertEqual(chatMessage.quotedMessage?.id, "quoted-message-id") + XCTAssertEqual(chatMessage.translations, [.spanish: "Texto del mensaje de prueba"]) + XCTAssertEqual(chatMessage.originalLanguage?.languageCode, "en") + XCTAssertEqual(chatMessage.textUpdatedAt, Date(timeIntervalSince1970: 1_609_459_350)) + XCTAssertEqual(chatMessage.latestReactions.count, 1) + XCTAssertEqual(chatMessage.currentUserReactions.count, 1) + XCTAssertFalse(chatMessage.isFlaggedByCurrentUser) + } + + func test_messagePayload_asModel_withMinimalData_handlesCorrectly() { + let messageId = "minimal-message-id" + let cid = ChannelId(type: .messaging, id: "minimal-channel") + let currentUserId = "current-user-id" + let userPayload = UserPayload.dummy(userId: currentUserId, name: "Current User") + let payload = MessagePayload( + id: messageId, + type: .regular, + user: userPayload, + createdAt: Date(timeIntervalSince1970: 1_609_459_200), + updatedAt: Date(timeIntervalSince1970: 1_609_459_200), + deletedAt: nil, + text: "Minimal message", + command: nil, + args: nil, + parentId: nil, + showReplyInChannel: false, + quotedMessageId: nil, + quotedMessage: nil, + mentionedUsers: [], + threadParticipants: [], + replyCount: 0, + extraData: [:], + latestReactions: [], + ownReactions: [], + reactionScores: [:], + reactionCounts: [:], + reactionGroups: [:], + isSilent: false, + isShadowed: false, + attachments: [], + channel: nil, + pinned: false, + pinnedBy: nil, + pinnedAt: nil, + pinExpires: nil, + translations: nil, + originalLanguage: nil, + moderation: nil, + moderationDetails: nil, + messageTextUpdatedAt: nil, + poll: nil, + reminder: nil, + location: nil + ) + + let chatMessage = payload.asModel(cid: cid, currentUserId: currentUserId, channelReads: []) + + XCTAssertEqual(chatMessage.id, messageId) + XCTAssertEqual(chatMessage.cid, cid) + XCTAssertEqual(chatMessage.text, "Minimal message") + XCTAssertEqual(chatMessage.type, .regular) + XCTAssertNil(chatMessage.command) + XCTAssertEqual(chatMessage.createdAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertEqual(chatMessage.updatedAt, Date(timeIntervalSince1970: 1_609_459_200)) + XCTAssertNil(chatMessage.deletedAt) + XCTAssertNil(chatMessage.arguments) + XCTAssertNil(chatMessage.parentMessageId) + XCTAssertEqual(chatMessage.showReplyInChannel, false) + XCTAssertEqual(chatMessage.replyCount, 0) + XCTAssertEqual(chatMessage.extraData, [:]) + XCTAssertEqual(chatMessage.isSilent, false) + XCTAssertEqual(chatMessage.isShadowed, false) + XCTAssertEqual(chatMessage.reactionScores, [:]) + XCTAssertEqual(chatMessage.reactionCounts, [:]) + XCTAssertEqual(chatMessage.author.id, currentUserId) + XCTAssertTrue(chatMessage.mentionedUsers.isEmpty) + XCTAssertTrue(chatMessage.threadParticipants.isEmpty) + XCTAssertTrue(chatMessage.isSentByCurrentUser) + XCTAssertNil(chatMessage.pinDetails) + XCTAssertNil(chatMessage.quotedMessage) + XCTAssertNil(chatMessage.translations) + XCTAssertNil(chatMessage.originalLanguage) + XCTAssertNil(chatMessage.textUpdatedAt) + XCTAssertTrue(chatMessage.latestReactions.isEmpty) + XCTAssertTrue(chatMessage.currentUserReactions.isEmpty) + XCTAssertFalse(chatMessage.isFlaggedByCurrentUser) + XCTAssertTrue(chatMessage.readBy.isEmpty) + XCTAssertTrue(chatMessage.allAttachments.isEmpty) + XCTAssertTrue(chatMessage.latestReplies.isEmpty) + XCTAssertNil(chatMessage.localState) + XCTAssertNil(chatMessage.locallyCreatedAt) + XCTAssertFalse(chatMessage.isBounced) + XCTAssertNil(chatMessage.moderationDetails) + XCTAssertNil(chatMessage.poll) + XCTAssertNil(chatMessage.reminder) + XCTAssertNil(chatMessage.sharedLocation) + } } final class MessageRequestBody_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift index 102606006dc..1e7dccb4f8c 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/UserPayloads_Tests.swift @@ -104,6 +104,116 @@ final class UserPayload_Tests: XCTestCase { XCTAssertEqual(payload.threads[0].lastReadMessageId, "6e75266e-c8e9-49f9-be87-f8e745e94821") XCTAssertEqual(payload.threads[0].parentMessageId, "6e75266e-c8e9-49f9-be87-f8e745e94821") } + + // MARK: - UserPayload.asModel() Tests + + func test_userPayload_asModel_convertsAllPropertiesCorrectly() { + // Given: UserPayload with all properties set + let userId = "test-user-id" + let name = "Test User" + let imageURL = URL(string: "https://example.com/avatar.png")! + let role = UserRole.admin + let teamsRole = ["ios": UserRole.guest, "android": UserRole.admin] + let createdAt = Date(timeIntervalSince1970: 1_609_459_200) // 2021-01-01 + let updatedAt = Date(timeIntervalSince1970: 1_609_545_600) // 2021-01-02 + let deactivatedAt = Date(timeIntervalSince1970: 1_609_632_000) // 2021-01-03 + let lastActiveAt = Date(timeIntervalSince1970: 1_609_718_400) // 2021-01-04 + let isOnline = true + let isBanned = false + let teams = ["team1", "team2", "team3"] + let language = "en" + let avgResponseTime = 30 + let extraData: [String: RawJSON] = ["custom_field": .string("custom_value")] + + let payload = UserPayload( + id: userId, + name: name, + imageURL: imageURL, + role: role, + teamsRole: teamsRole, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: deactivatedAt, + lastActiveAt: lastActiveAt, + isOnline: isOnline, + isInvisible: false, + isBanned: isBanned, + teams: teams, + language: language, + avgResponseTime: avgResponseTime, + extraData: extraData + ) + + // When: Converting to ChatUser model + let chatUser = payload.asModel() + + // Then: All properties are correctly mapped + XCTAssertEqual(chatUser.id, userId) + XCTAssertEqual(chatUser.name, name) + XCTAssertEqual(chatUser.imageURL, imageURL) + XCTAssertEqual(chatUser.isOnline, isOnline) + XCTAssertEqual(chatUser.isBanned, isBanned) + XCTAssertEqual(chatUser.isFlaggedByCurrentUser, false) // Always false in conversion + XCTAssertEqual(chatUser.userRole, role) + XCTAssertEqual(chatUser.teamsRole, teamsRole) + XCTAssertEqual(chatUser.userCreatedAt, createdAt) + XCTAssertEqual(chatUser.userUpdatedAt, updatedAt) + XCTAssertEqual(chatUser.userDeactivatedAt, deactivatedAt) + XCTAssertEqual(chatUser.lastActiveAt, lastActiveAt) + XCTAssertEqual(chatUser.teams, Set(teams)) + XCTAssertEqual(chatUser.language?.languageCode, language) + XCTAssertEqual(chatUser.avgResponseTime, avgResponseTime) + XCTAssertEqual(chatUser.extraData, extraData) + } + + func test_userPayload_asModel_withNilValues_handlesCorrectly() { + // Given: UserPayload with nil optional values + let userId = "test-user-id-nil" + let role = UserRole.user + let createdAt = Date() + let updatedAt = Date() + let extraData: [String: RawJSON] = [:] + + let payload = UserPayload( + id: userId, + name: nil, + imageURL: nil, + role: role, + teamsRole: nil, + createdAt: createdAt, + updatedAt: updatedAt, + deactivatedAt: nil, + lastActiveAt: nil, + isOnline: false, + isInvisible: true, + isBanned: true, + teams: [], + language: nil, + avgResponseTime: nil, + extraData: extraData + ) + + // When: Converting to ChatUser model + let chatUser = payload.asModel() + + // Then: Nil values are correctly handled + XCTAssertEqual(chatUser.id, userId) + XCTAssertNil(chatUser.name) + XCTAssertNil(chatUser.imageURL) + XCTAssertEqual(chatUser.isOnline, false) + XCTAssertEqual(chatUser.isBanned, true) + XCTAssertEqual(chatUser.isFlaggedByCurrentUser, false) + XCTAssertEqual(chatUser.userRole, role) + XCTAssertNil(chatUser.teamsRole) + XCTAssertEqual(chatUser.userCreatedAt, createdAt) + XCTAssertEqual(chatUser.userUpdatedAt, updatedAt) + XCTAssertNil(chatUser.userDeactivatedAt) + XCTAssertNil(chatUser.lastActiveAt) + XCTAssertEqual(chatUser.teams, Set()) + XCTAssertNil(chatUser.language) + XCTAssertNil(chatUser.avgResponseTime) + XCTAssertEqual(chatUser.extraData, extraData) + } } final class UserRequestBody_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift b/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift index 5f25d2970d6..fe77a0940a3 100644 --- a/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift +++ b/Tests/StreamChatTests/Audio/StreamAppStateObserver_Tests.swift @@ -77,6 +77,52 @@ final class StreamAppStateObserver_Tests: XCTestCase { ]) } + // MARK: - Memory Warning + + func test_memoryWarning_allSubscribersWillBeNotifiedWhenMemoryWarningOccurs() { + let subscriberA = SpyAppStateObserverDelegate() + let subscriberB = SpyAppStateObserverDelegate() + + appStateObserver.subscribe(subscriberA) + appStateObserver.subscribe(subscriberB) + + simulateAppDidReceiveMemoryWarning() + + [subscriberA, subscriberB].forEach { subscriber in + XCTAssertEqual(subscriber.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()" + ]) + } + } + + func test_memoryWarning_onlyRemainingSubscribersWillBeNotifiedAfterUnsubscribe() { + let subscriberA = SpyAppStateObserverDelegate() + let subscriberB = SpyAppStateObserverDelegate() + + appStateObserver.subscribe(subscriberA) + appStateObserver.subscribe(subscriberB) + + simulateAppDidReceiveMemoryWarning() + + [subscriberA, subscriberB].forEach { subscriber in + XCTAssertEqual(subscriber.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()" + ]) + } + + appStateObserver.unsubscribe(subscriberA) + + simulateAppDidReceiveMemoryWarning() + + XCTAssertEqual(subscriberA.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()" + ]) + XCTAssertEqual(subscriberB.recordedFunctions, [ + "applicationDidReceiveMemoryWarning()", + "applicationDidReceiveMemoryWarning()" + ]) + } + // MARK: - Private Helpers private func simulateAppDidMoveToBackground() { @@ -88,6 +134,11 @@ final class StreamAppStateObserver_Tests: XCTestCase { notificationCenter.observersMap[UIApplication.didBecomeActiveNotification]? .forEach { $0.execute() } } + + private func simulateAppDidReceiveMemoryWarning() { + notificationCenter.observersMap[UIApplication.didReceiveMemoryWarningNotification]? + .forEach { $0.execute() } + } } private final class StubNotificationCenter: NotificationCenter, @unchecked Sendable { @@ -129,4 +180,8 @@ private final class SpyAppStateObserverDelegate: AppStateObserverDelegate, Spy { func applicationDidMoveToForeground() { record() } + + func applicationDidReceiveMemoryWarning() { + record() + } } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 4a5e38ac2ee..94896f0472e 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -1129,7 +1129,7 @@ private class TestEnvironment { return self.eventDecoder! }, notificationCenterBuilder: { - self.notificationCenter = EventNotificationCenter_Mock(database: $0) + self.notificationCenter = EventNotificationCenter_Mock(database: $0, manualEventHandler: $1) return self.notificationCenter! }, internetConnection: { diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 6d7f62a0154..9a517efc632 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -4422,33 +4422,6 @@ final class ChannelController_Tests: XCTestCase { XCTAssertNil(error) } - func test_enableSlowMode_failsForInvalidCooldown() throws { - // Create `ChannelController` for new channel - let query = ChannelQuery(channelPayload: .unique) - setupControllerForNewChannel(query: query) - - // Simulate successful backend channel creation - env.channelUpdater!.update_onChannelCreated?(query.cid!) - - // Simulate `enableSlowMode` call with invalid cooldown and assert error is returned - var error: Error? = try waitFor { [callbackQueueID] completion in - controller.enableSlowMode(cooldownDuration: .random(in: 130...250)) { error in - AssertTestQueue(withId: callbackQueueID) - completion(error) - } - } - XCTAssert(error is ClientError.InvalidCooldownDuration) - - // Simulate `enableSlowMode` call with another invalid cooldown and assert error is returned - error = try waitFor { [callbackQueueID] completion in - controller.enableSlowMode(cooldownDuration: .random(in: -100...0)) { error in - AssertTestQueue(withId: callbackQueueID) - completion(error) - } - } - XCTAssert(error is ClientError.InvalidCooldownDuration) - } - func test_enableSlowMode_callsChannelUpdater() { // Simulate `enableSlowMode` call and catch the completion var completionCalled = false @@ -4521,7 +4494,7 @@ final class ChannelController_Tests: XCTestCase { AssertTestQueue(withId: callbackQueueID) completion(error) } - env.channelUpdater!.enableSlowMode_completion?(nil) + env.channelUpdater!.disableSlowMode_completion?(nil) } XCTAssertNil(error) @@ -4544,15 +4517,13 @@ final class ChannelController_Tests: XCTestCase { controller = nil // Assert cid is passed to `channelUpdater`, completion is not called yet - XCTAssertEqual(env.channelUpdater!.enableSlowMode_cid, channelId) - // Assert that passed cooldown duration is 0 - XCTAssertEqual(env.channelUpdater!.enableSlowMode_cooldownDuration, 0) + XCTAssertEqual(env.channelUpdater!.disableSlowMode_cid, channelId) XCTAssertFalse(completionCalled) // Simulate successful update - env.channelUpdater!.enableSlowMode_completion?(nil) + env.channelUpdater!.disableSlowMode_completion?(nil) // Release reference of completion so we can deallocate stuff - env.channelUpdater!.enableSlowMode_completion = nil + env.channelUpdater!.disableSlowMode_completion = nil // Assert completion is called AssertAsync.willBeTrue(completionCalled) @@ -4570,7 +4541,7 @@ final class ChannelController_Tests: XCTestCase { // Simulate failed update let testError = TestError() - env.channelUpdater!.enableSlowMode_completion?(testError) + env.channelUpdater!.disableSlowMode_completion?(testError) // Completion should be called with the error AssertAsync.willBeEqual(completionCalledError as? TestError, testError) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift new file mode 100644 index 00000000000..753b13e514d --- /dev/null +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController+Combine_Tests.swift @@ -0,0 +1,273 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class LivestreamChannelController_Combine_Tests: iOS13TestCase { + var livestreamChannelController: LivestreamChannelController! + var client: ChatClient_Mock! + var channelQuery: ChannelQuery! + var cancellables: Set! + + override func setUp() { + super.setUp() + + client = ChatClient.mock(config: ChatClient_Mock.defaultMockedConfig) + channelQuery = ChannelQuery(cid: .unique) + livestreamChannelController = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + cancellables = [] + } + + override func tearDown() { + // Release existing subscriptions and make sure the controller gets released, too + cancellables = nil + AssertAsync.canBeReleased(&livestreamChannelController) + livestreamChannelController = nil + client?.cleanUp() + client = nil + channelQuery = nil + super.tearDown() + } + + // MARK: - Channel Change Publisher + + func test_channelChangePublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Setup the chain without additional receive(on:) to avoid double async dispatch + livestreamChannelController + .channelChangePublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Verify initial state + XCTAssertEqual(recording.output, [nil]) + + // Don't keep weak reference - use the controller directly for easier debugging + let newChannel: ChatChannel = .mock(cid: channelQuery.cid!, name: .unique, imageURL: .unique(), extraData: [:]) + let event = ChannelUpdatedEvent( + channel: newChannel, + user: .mock(id: .unique), + message: nil, + createdAt: .unique + ) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Simulate channel update event + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Use AssertAsync to wait for the async update (delegate callback happens on main queue) + AssertAsync { + Assert.willBeEqual(recording.output.count, 2) + Assert.willBeEqual(recording.output.last, newChannel) + } + } + + func test_channelChangePublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .channelChangePublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } + + // MARK: - Messages Changes Publisher + + func test_messagesChangesPublisher() { + // Setup Recording publishers + var recording = Record<[ChatMessage], Never>.Recording() + + // Setup the chain + livestreamChannelController + .messagesChangesPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + let newMessage1: ChatMessage = .mock(id: .unique, cid: channelQuery.cid!, text: "Message 1", author: .mock(id: .unique)) + let newMessage2: ChatMessage = .mock(id: .unique, cid: channelQuery.cid!, text: "Message 2", author: .mock(id: .unique)) + + // Simulate new message events + let event1 = MessageNewEvent( + user: .mock(id: .unique), + message: newMessage1, + channel: .mock(cid: channelQuery.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + let event2 = MessageNewEvent( + user: .mock(id: .unique), + message: newMessage2, + channel: .mock(cid: channelQuery.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // Send the events + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event1 + ) + + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event2 + ) + + // Use AssertAsync to wait for the async updates + AssertAsync { + Assert.willBeEqual(recording.output.count, 3) + } + } + + func test_messagesChangesPublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .messagesChangesPublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } + + // MARK: - Is Paused Publisher + + func test_isPausedPublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Setup the chain + livestreamChannelController + .isPausedPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Test initial state + XCTAssertEqual(recording.output, [false]) + + // Test pausing + controller?.pause() + + // Use AssertAsync to wait for the async update + AssertAsync { + Assert.willBeEqual(recording.output, [false, true]) + Assert.willBeEqual(controller?.isPaused, true) + } + } + + func test_isPausedPublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .isPausedPublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } + + // MARK: - Skipped Messages Amount Publisher + + func test_skippedMessagesAmountPublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Enable counting skipped messages when paused + livestreamChannelController.countSkippedMessagesWhenPaused = true + + // Setup the chain + livestreamChannelController + .skippedMessagesAmountPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Test initial state + XCTAssertEqual(recording.output, [0]) + + // Pause the controller to enable skipped message counting + controller?.pause() + + // Simulate new messages from other users while paused + let otherUserId = UserId.unique + let messageFromOtherUser = ChatMessage.mock(id: .unique, cid: channelQuery.cid!, text: "Skipped message", author: .mock(id: otherUserId)) + + let event = MessageNewEvent( + user: .mock(id: otherUserId), + message: messageFromOtherUser, + channel: .mock(cid: channelQuery.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + controller?.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: event + ) + + // Use AssertAsync to wait for the async update + AssertAsync { + Assert.willBeEqual(recording.output, [0, 1]) + } + } + + func test_skippedMessagesAmountPublisher_keepsControllerAlive() { + // Setup the chain + livestreamChannelController + .skippedMessagesAmountPublisher + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: LivestreamChannelController? = livestreamChannelController + livestreamChannelController = nil + + // Assert controller is kept alive by the publisher. + AssertAsync.staysTrue(controller != nil) + } +} diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift new file mode 100644 index 00000000000..e22b253ccbb --- /dev/null +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -0,0 +1,1823 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class LivestreamChannelController_Tests: XCTestCase { + fileprivate var env: TestEnvironment! + + var client: ChatClient_Mock! + var channelQuery: ChannelQuery! + var controller: LivestreamChannelController! + + override func setUp() { + super.setUp() + + env = TestEnvironment() + client = ChatClient.mock(config: ChatClient_Mock.defaultMockedConfig) + channelQuery = ChannelQuery(cid: .unique) + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + } + + override func tearDown() { + client?.cleanUp() + env?.apiClient?.cleanUp() + env = nil + + AssertAsync { + Assert.canBeReleased(&controller) + Assert.canBeReleased(&client) + Assert.canBeReleased(&env) + } + + channelQuery = nil + + super.tearDown() + } +} + +// MARK: - TestEnvironment + +extension LivestreamChannelController_Tests { + fileprivate final class TestEnvironment { + var apiClient: APIClient_Spy? + var appStateObserver: MockAppStateObserver? + + init() { + apiClient = APIClient_Spy() + appStateObserver = MockAppStateObserver() + } + } +} + +// MARK: - Initialization Tests + +extension LivestreamChannelController_Tests { + func test_init_assignsValuesCorrectly() { + // Given + let channelQuery = ChannelQuery(cid: .unique) + let client = ChatClient.mock() + + // When + let controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + + // Then + XCTAssertEqual(controller.channelQuery.cid, channelQuery.cid) + XCTAssert(controller.client === client) + XCTAssertEqual(controller.cid, channelQuery.cid) + XCTAssertNil(controller.channel) + XCTAssertTrue(controller.messages.isEmpty) + XCTAssertFalse(controller.isPaused) + XCTAssertEqual(controller.skippedMessagesAmount, 0) + XCTAssertTrue(controller.loadInitialMessagesFromCache) + XCTAssertFalse(controller.countSkippedMessagesWhenPaused) + XCTAssertNil(controller.maxMessageLimitOptions) + } + + func test_init_registersForEventHandling() { + // Given + let cid = ChannelId.unique + let channelQuery = ChannelQuery(cid: cid) + let client = ChatClient_Mock.mock() + let eventNotificationCenter = EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + client.mockedEventNotificationCenter = eventNotificationCenter + + // When + _ = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + + // Then + XCTAssertEqual(eventNotificationCenter.registerManualEventHandling_callCount, 1) + XCTAssertEqual(eventNotificationCenter.registerManualEventHandling_calledWith, cid) + } + + func test_deinit_unregistersEventHandling() { + // Given + let cid = ChannelId.unique + let channelQuery = ChannelQuery(cid: cid) + let client = ChatClient_Mock.mock() + let eventNotificationCenter = EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + client.mockedEventNotificationCenter = eventNotificationCenter + + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client + ) + + // When + controller = nil + + // Then + XCTAssertEqual(eventNotificationCenter.unregisterManualEventHandling_callCount, 1) + XCTAssertEqual(eventNotificationCenter.unregisterManualEventHandling_calledWith, cid) + } +} + +// MARK: - Pagination Properties Tests + +extension LivestreamChannelController_Tests { + func test_hasLoadedAllNextMessages_whenMessagesArrayIsEmpty_thenReturnsTrue() { + // Given - messages array is empty by default + + // When + let result = controller.hasLoadedAllNextMessages + + // Then + XCTAssertTrue(result) + } +} + +// MARK: - Synchronize Tests + +extension LivestreamChannelController_Tests { + func test_synchronize_makesCorrectAPICall() { + // Given + let apiClient = client.mockAPIClient + + // When + controller.synchronize() + + // Then + let expectedEndpoint = Endpoint.updateChannel(query: channelQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_synchronize_withCache_loadsInitialDataFromCache() { + // Given + let cid = ChannelId.unique + controller = LivestreamChannelController( + channelQuery: ChannelQuery(cid: cid), + client: client + ) + controller.loadInitialMessagesFromCache = true + + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: [.dummy(), .dummy()] + ) + + // Save channel to cache + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: channelPayload) + } + + // When + controller.synchronize() + + // Then + XCTAssertNotNil(controller.channel) + XCTAssertEqual(controller.channel?.cid, cid) + XCTAssertEqual(controller.messages.count, 2) + } + + func test_synchronize_withoutCache_doesNotLoadFromCache() { + // Given + let cid = ChannelId.unique + controller = LivestreamChannelController( + channelQuery: ChannelQuery(cid: cid), + client: client + ) + controller.loadInitialMessagesFromCache = false + + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: [.dummy(), .dummy()] + ) + + // Save channel to cache + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: channelPayload) + } + + // When + controller.synchronize() + + // Then + XCTAssertNil(controller.channel) + XCTAssertTrue(controller.messages.isEmpty) + } + + func test_synchronize_successfulResponse_updatesChannelAndMessages() { + // Given + let expectation = self.expectation(description: "Synchronize completes") + var synchronizeError: Error? + + // When + controller.synchronize { error in + synchronizeError = error + expectation.fulfill() + } + + // Simulate successful API response + let cid = ChannelId.unique + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: [ + .dummy(messageId: "1", text: "Message 1"), + .dummy(messageId: "2", text: "Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(synchronizeError) + XCTAssertNotNil(controller.channel) + XCTAssertEqual(controller.channel?.cid, cid) + XCTAssertEqual(controller.messages.count, 2) + XCTAssertEqual(controller.messages.map(\.id), ["2", "1"]) // Reversed order + } + + func test_synchronize_failedResponse_callsCompletionWithError() { + // Given + let expectation = self.expectation(description: "Synchronize completes") + var synchronizeError: Error? + let testError = TestError() + + // When + controller.synchronize { error in + synchronizeError = error + expectation.fulfill() + } + + // Simulate failed API response + client.mockAPIClient.test_simulateResponse(Result.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertEqual(synchronizeError as? TestError, testError) + XCTAssertNil(controller.channel) + XCTAssertTrue(controller.messages.isEmpty) + } +} + +// MARK: - Message Loading Tests + +extension LivestreamChannelController_Tests { + func test_loadPreviousMessages_withNoMessages_callsCompletionWithError() { + // Given + let expectation = self.expectation(description: "Load previous messages completes") + var loadError: Error? + + // When + controller.loadPreviousMessages { error in + loadError = error + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssert(loadError is ClientError.ChannelEmptyMessages) + } + + func test_loadPreviousMessages_makesCorrectAPICall() throws { + // Given + // First load some messages so we have something to paginate from + controller.synchronize() + let channelPayload = ChannelPayload.dummy(messages: [.dummy(messageId: "message1")]) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + let apiClient = client.mockAPIClient + + // When + controller.loadPreviousMessages(before: "specific-message-id", limit: 50) + + // Then + let expectedPagination = MessagesPagination(pageSize: 50, parameter: .lessThan("specific-message-id")) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadPreviousMessages_usesDefaultLimit() throws { + // Given + // First load some messages so we have something to paginate from + controller.synchronize() + let channelPayload = ChannelPayload.dummy(messages: [.dummy(messageId: "message1")]) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + let apiClient = client.mockAPIClient + + // When + controller.loadPreviousMessages(before: "specific-message-id") + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: .lessThan("specific-message-id")) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadNextMessages_successfulResponse_prependsMessages() { + let mockPaginationStateHandler = MockPaginationStateHandler() + mockPaginationStateHandler.state = .init( + newestFetchedMessage: .dummy(), + hasLoadedAllNextMessages: false, + hasLoadedAllPreviousMessages: false, + isLoadingNextMessages: false, + isLoadingPreviousMessages: false, + isLoadingMiddleMessages: false + ) + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client, + paginationStateHandler: mockPaginationStateHandler + ) + + // Save initial messages to the DB + let initialChannelPayload = ChannelPayload.dummy( + channel: .dummy(cid: channelQuery.cid!), + messages: [ + .dummy(messageId: "old1"), + .dummy(messageId: "old2") + ] + ) + // Save channel to cache + try! client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: initialChannelPayload) + } + controller.synchronize() + + let expectation = self.expectation(description: "Load next messages completes") + var loadError: Error? + + // When + controller.loadNextMessages(after: "old1") { error in + loadError = error + expectation.fulfill() + } + + // Simulate successful API response for next messages + let channelPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "new1", text: "New Message 1"), + .dummy(messageId: "new2", text: "New Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(loadError) + XCTAssertEqual(controller.messages.count, 4) + XCTAssertEqual(Set(controller.messages.map(\.id)), Set(["new2", "new1", "old2", "old1"])) // Next messages prepended + } + + func test_loadPageAroundMessageId_makesCorrectAPICall() throws { + // Given + let apiClient = client.mockAPIClient + + // When + controller.loadPageAroundMessageId("target-message-id", limit: 40) + + // Then + let expectedPagination = MessagesPagination(pageSize: 40, parameter: .around("target-message-id")) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadFirstPage_makesCorrectAPICall() throws { + // Given + let apiClient = client.mockAPIClient + + // When + controller.loadFirstPage() + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: nil) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadPreviousMessages_successfulResponse_appendsMessages() { + controller.synchronize() + let initialPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "new1", text: "New Message 1"), + .dummy(messageId: "new2", text: "New Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(initialPayload)) + + let expectation = self.expectation(description: "Load previous messages completes") + var loadError: Error? + + // When + controller.loadPreviousMessages(before: "new2") { error in + loadError = error + expectation.fulfill() + } + + // Simulate successful API response for previous messages + let channelPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "old1", text: "Old Message 1"), + .dummy(messageId: "old2", text: "Old Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(loadError) + XCTAssertEqual(controller.messages.count, 4) + XCTAssertEqual(controller.messages.map(\.id), ["new2", "new1", "old2", "old1"]) // Previous messages appended + } + + func test_loadPageAroundMessageId_successfulResponse_replacesMessages() { + controller.synchronize() + let initialPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "old1", text: "Old Message 1"), + .dummy(messageId: "old2", text: "Old Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(initialPayload)) + + let expectation = self.expectation(description: "Load page around message completes") + var loadError: Error? + + // When + controller.loadPageAroundMessageId("target-message-id") { error in + loadError = error + expectation.fulfill() + } + + // Simulate successful API response for page around message + let channelPayload = ChannelPayload.dummy( + messages: [ + .dummy(messageId: "around1", text: "Around Message 1"), + .dummy(messageId: "around2", text: "Around Message 2") + ] + ) + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(loadError) + XCTAssertEqual(controller.messages.count, 2) + XCTAssertEqual(controller.messages.map(\.id), ["around2", "around1"]) // Replaced all messages + } +} + +// MARK: - Pause/Resume Tests + +extension LivestreamChannelController_Tests { + func test_pause_setsIsPausedToTrue() { + // Given + XCTAssertFalse(controller.isPaused) + + // When + controller.pause() + + // Then + XCTAssertTrue(controller.isPaused) + } + + func test_resume_setsIsPausedToFalse() { + // Given + controller.pause() + XCTAssertTrue(controller.isPaused) + + // When + controller.resume() + + // Then + XCTAssertFalse(controller.isPaused) + } + + func test_resume_resetsSkippedMessagesAmount() { + controller.countSkippedMessagesWhenPaused = true + + controller.pause() + + controller.eventsController( + EventsController( + notificationCenter: EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + ), + didReceiveEvent: MessageNewEvent( + user: .unique, + message: .unique, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.skippedMessagesAmount, 1) + + // When + controller.resume() + + // Then + XCTAssertEqual(controller.skippedMessagesAmount, 0) + } + + func test_resume_callsLoadFirstPage() throws { + // Given + let apiClient = client.mockAPIClient + controller.pause() + + // When + controller.resume() + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: nil) + var expectedQuery = try XCTUnwrap(channelQuery) + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_resume_whenNotPaused_doesNothing() { + // Given + XCTAssertFalse(controller.isPaused) + let apiClient = client.mockAPIClient + + // When + controller.resume() + + // Then + XCTAssertNil(apiClient.request_endpoint) + } +} + +// MARK: - Delegate Tests + +extension LivestreamChannelController_Tests { + @MainActor func test_delegate_isCalledWhenChannelUpdates() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: .unique) + ) + + // When + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // Then + AssertAsync.willBeTrue(delegate.didUpdateChannelCalled) + AssertAsync.willBeEqual(delegate.didUpdateChannelCalledWith?.cid, channelPayload.channel.cid) + } + + @MainActor func test_delegate_isCalledWhenMessagesUpdate() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + + let channelPayload = ChannelPayload.dummy( + messages: [.dummy(), .dummy()] + ) + + // When + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // Then + AssertAsync.willBeTrue(delegate.didUpdateMessagesCalled) + AssertAsync.willBeEqual(delegate.didUpdateMessagesCalledWith?.count, 2) + } + + @MainActor func test_delegate_isCalledWhenPauseStateChanges() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + + // When + controller.pause() + + // Then + AssertAsync.willBeTrue(delegate.didChangePauseStateCalled) + AssertAsync.willBeTrue(delegate.didChangePauseStateCalledWith ?? false) + } + + @MainActor func test_delegate_isCalledWhenSkippedMessagesAmountChanges() { + // Given + let delegate = LivestreamChannelControllerDelegate_Mock() + controller.delegate = delegate + controller.countSkippedMessagesWhenPaused = true + + controller.pause() + + controller.eventsController( + EventsController( + notificationCenter: EventNotificationCenter_Mock( + database: DatabaseContainer_Spy() + ) + ), + didReceiveEvent: MessageNewEvent( + user: .unique, + message: .unique, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + // When/Then + AssertAsync.willBeTrue(delegate.didChangeSkippedMessagesAmountCalled) + } +} + +// MARK: - Message Limiting Tests + +extension LivestreamChannelController_Tests { + func test_maxMessageLimitOptions_whenNil_doesNotLimitMessages() { + // Given + controller.maxMessageLimitOptions = nil + + // When/Then + XCTAssertNil(controller.maxMessageLimitOptions) + } + + func test_maxMessageLimitOptions_whenSet_configuresLimits() { + // Given + let options = MaxMessageLimitOptions(maxLimit: 100, discardAmount: 20) + + // When + controller.maxMessageLimitOptions = options + + // Then + XCTAssertEqual(controller.maxMessageLimitOptions?.maxLimit, 100) + XCTAssertEqual(controller.maxMessageLimitOptions?.discardAmount, 20) + } + + func test_maxMessageLimitOptions_recommendedConfiguration() { + // Given/When + let recommended = MaxMessageLimitOptions.recommended + + // Then + XCTAssertEqual(recommended.maxLimit, 200) + XCTAssertEqual(recommended.discardAmount, 50) + } +} + +// MARK: - Helper Mock Classes + +extension LivestreamChannelController_Tests { + class LivestreamChannelControllerDelegate_Mock: LivestreamChannelControllerDelegate { + var didUpdateChannelCalled = false + var didUpdateChannelCalledWith: ChatChannel? + + var didUpdateMessagesCalled = false + var didUpdateMessagesCalledWith: [ChatMessage]? + + var didChangePauseStateCalled = false + var didChangePauseStateCalledWith: Bool? + + var didChangeSkippedMessagesAmountCalled = false + var didChangeSkippedMessagesAmountCalledWith: Int? + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateChannel channel: ChatChannel + ) { + didUpdateChannelCalled = true + didUpdateChannelCalledWith = channel + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didUpdateMessages messages: [ChatMessage] + ) { + didUpdateMessagesCalled = true + didUpdateMessagesCalledWith = messages + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangePauseState isPaused: Bool + ) { + didChangePauseStateCalled = true + didChangePauseStateCalledWith = isPaused + } + + func livestreamChannelController( + _ controller: LivestreamChannelController, + didChangeSkippedMessagesAmount skippedMessagesAmount: Int + ) { + didChangeSkippedMessagesAmountCalled = true + didChangeSkippedMessagesAmountCalledWith = skippedMessagesAmount + } + } +} + +// MARK: - Message CRUD Tests + +extension LivestreamChannelController_Tests { + func test_createNewMessage_callsChannelUpdater() { + // Given + let messageText = "Test message" + let messageId = MessageId.unique + let mockUpdater = ChannelUpdater_Mock( + channelRepository: client.channelRepository, + messageRepository: client.messageRepository, + paginationStateHandler: client.makeMessagesPaginationStateHandler(), + database: client.databaseContainer, + apiClient: client.apiClient + ) + + // Create controller with mock updater + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client, + updater: mockUpdater + ) + + let expectation = self.expectation(description: "Create message completes") + var createResult: Result? + + // When + controller.createNewMessage( + messageId: messageId, + text: messageText, + pinning: .expirationTime(300), + isSilent: true, + attachments: [AnyAttachmentPayload.mockImage], + mentionedUserIds: [.unique], + quotedMessageId: .unique, + skipPush: true, + skipEnrichUrl: false, + extraData: ["test": .string("value")] + ) { result in + createResult = result + expectation.fulfill() + } + + // Simulate successful updater response + let mockMessage = ChatMessage.mock(id: messageId, cid: controller.cid!, text: messageText) + mockUpdater.createNewMessage_completion?(.success(mockMessage)) + + waitForExpectations(timeout: defaultTimeout) + + // Then - Verify the updater was called with correct parameters + XCTAssertEqual(mockUpdater.createNewMessage_cid, controller.cid) + XCTAssertEqual(mockUpdater.createNewMessage_text, messageText) + XCTAssertEqual(mockUpdater.createNewMessage_isSilent, true) + XCTAssertEqual(mockUpdater.createNewMessage_skipPush, true) + XCTAssertEqual(mockUpdater.createNewMessage_skipEnrichUrl, false) + XCTAssertEqual(mockUpdater.createNewMessage_attachments?.count, 1) + XCTAssertEqual(mockUpdater.createNewMessage_mentionedUserIds?.count, 1) + XCTAssertNotNil(mockUpdater.createNewMessage_quotedMessageId) + XCTAssertNotNil(mockUpdater.createNewMessage_pinning) + XCTAssertEqual(mockUpdater.createNewMessage_extraData?["test"], .string("value")) + + // Verify completion was called with correct result + XCTAssertNotNil(createResult) + if case .success(let resultMessageId) = createResult { + XCTAssertEqual(resultMessageId, messageId) + } else { + XCTFail("Expected success result") + } + + mockUpdater.cleanUp() + } + + func test_createNewMessage_updaterFailure_callsCompletionWithError() { + // Given + let messageText = "Test message" + let messageId = MessageId.unique + let mockUpdater = ChannelUpdater_Mock( + channelRepository: client.channelRepository, + messageRepository: client.messageRepository, + paginationStateHandler: client.makeMessagesPaginationStateHandler(), + database: client.databaseContainer, + apiClient: client.apiClient + ) + + // Create controller with mock updater + controller = LivestreamChannelController( + channelQuery: channelQuery, + client: client, + updater: mockUpdater + ) + + let expectation = self.expectation(description: "Create message completes") + var createResult: Result? + let testError = TestError() + + // When + controller.createNewMessage( + messageId: messageId, + text: messageText + ) { result in + createResult = result + expectation.fulfill() + } + + // Simulate updater failure + mockUpdater.createNewMessage_completion?(.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then - Verify the updater was called + XCTAssertEqual(mockUpdater.createNewMessage_cid, controller.cid) + XCTAssertEqual(mockUpdater.createNewMessage_text, messageText) + + // Verify completion was called with error + XCTAssertNotNil(createResult) + if case .failure(let error) = createResult { + XCTAssert(error is TestError) + } else { + XCTFail("Expected failure result") + } + + mockUpdater.cleanUp() + } +} + +// MARK: - Event Handling Tests + +extension LivestreamChannelController_Tests { + func test_applicationDidReceiveMemoryWarning_callsLoadFirstPage() { + // Given + let apiClient = client.mockAPIClient + + // When + controller.applicationDidReceiveMemoryWarning() + + // Then + let expectedPagination = MessagesPagination(pageSize: 25, parameter: nil) + var expectedQuery = channelQuery! + expectedQuery.pagination = expectedPagination + let expectedEndpoint = Endpoint.updateChannel(query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_didReceiveEvent_messageNewEvent_addsMessageToArray() { + let newMessage = ChatMessage.mock(id: "new", cid: controller.cid!, text: "New message") + let event = MessageNewEvent( + user: .mock(id: .unique), + message: newMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "new") + } + + func test_didReceiveEvent_newMessagePendingEvent_addsMessageToArray() { + let pendingMessage = ChatMessage.mock(id: "pending", cid: controller.cid!, text: "Pending message") + let event = NewMessagePendingEvent( + message: pendingMessage, + cid: controller.cid! + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "pending") + } + + func test_didReceiveEvent_messageUpdatedEvent_updatesExistingMessage() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "update-me"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + let updatedMessage = ChatMessage.mock(id: "update-me", cid: controller.cid!, text: "Updated text") + let event = MessageUpdatedEvent( + user: .mock(id: .unique), + channel: .mock(cid: controller.cid!), + message: updatedMessage, + createdAt: .unique + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "update-me") + XCTAssertEqual(controller.messages.first?.text, "Updated text") + } + + func test_didReceiveEvent_messageDeletedEvent_hardDelete_removesMessage() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "delete-me"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.count, 1) + + let messageToDelete = ChatMessage.mock(id: "delete-me", cid: controller.cid!, text: "Delete me") + let event = MessageDeletedEvent( + user: .mock(id: .unique), + channel: .mock(cid: controller.cid!), + message: messageToDelete, + createdAt: .unique, + isHardDelete: true + ) + + // When + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Then + XCTAssertEqual(controller.messages.count, 0) + } + + func test_didReceiveEvent_messageDeletedEvent_softDelete_updatesMessage() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "delete-me"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.count, 1) + + let deletedMessage = ChatMessage.mock( + id: "delete-me", + cid: controller.cid!, + text: "Delete me", + deletedAt: .unique + ) + let event = MessageDeletedEvent( + user: .mock(id: .unique), + channel: .mock(cid: controller.cid!), + message: deletedMessage, + createdAt: .unique, + isHardDelete: false + ) + + // When + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "delete-me") + XCTAssertNotNil(controller.messages.first?.deletedAt) + } + + func test_didReceiveEvent_newMessageErrorEvent_updatesMessageState() { + // Add a message + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: .mock(id: "failed-message"), + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + // When + let event = NewMessageErrorEvent( + messageId: "failed-message", + cid: controller.cid!, + error: ClientError.Unknown() + ) + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "failed-message") + XCTAssertEqual(controller.messages.first?.localState, .sendingFailed) + } + + func test_didReceiveEvent_reactionNewEvent_updatesMessage() { + let message = ChatMessage.mock( + id: "message-with-reaction", + cid: controller.cid!, + text: "React to me", + reactionScores: [:] + ) + let messageWithReaction = ChatMessage.mock( + id: "message-with-reaction", + cid: controller.cid!, + text: "React to me", + reactionScores: ["like": 1] + ) + let event = ReactionNewEvent( + user: .mock(id: .unique), + cid: controller.cid!, + message: messageWithReaction, + reaction: .mock( + id: "message-with-reaction", + type: .init(rawValue: "like") + ), + createdAt: .unique + ) + + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: MessageNewEvent( + user: .mock(id: .unique), + message: message, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + XCTAssertEqual(controller.messages.first?.id, "message-with-reaction") + XCTAssertEqual(controller.messages.first?.reactionScores["like"], nil) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertEqual(controller.messages.first?.id, "message-with-reaction") + XCTAssertEqual(controller.messages.first?.reactionScores["like"], 1) + } + + func test_didReceiveEvent_channelUpdatedEvent_updatesChannel() { + let updatedChannel = ChatChannel.mock(cid: controller.cid!, name: "Updated Name") + let event = ChannelUpdatedEvent( + channel: updatedChannel, + user: .mock(id: .unique), + message: nil, + createdAt: .unique + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then + XCTAssertEqual(controller.channel?.name, "Updated Name") + } + + func test_didReceiveEvent_differentChannelEvent_isIgnored() { + let otherChannelId = ChannelId.unique + let messageFromOtherChannel = ChatMessage.mock(id: "other", cid: otherChannelId, text: "Other message") + let event = MessageNewEvent( + user: .mock(id: .unique), + message: messageFromOtherChannel, + channel: .mock(cid: otherChannelId), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // When + controller.eventsController( + EventsController( + notificationCenter: client.eventNotificationCenter + ), + didReceiveEvent: event + ) + + // Then - Message array should not change + XCTAssertEqual(controller.messages.count, 0) + } + + func test_didReceiveEvent_whenPaused_newMessageFromOtherUser_incrementsSkippedCount() { + // Given + controller.countSkippedMessagesWhenPaused = true + controller.pause() + XCTAssertEqual(controller.skippedMessagesAmount, 0) + + let otherUserId = UserId.unique + let newMessage = ChatMessage.mock( + id: "new", + cid: controller.cid!, + text: "New message", + author: .unique + ) + let event = MessageNewEvent( + user: .mock(id: otherUserId), + message: newMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + + // When + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Then + XCTAssertEqual(controller.skippedMessagesAmount, 1) + XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused + } +} + +// MARK: - Message CRUD Tests + +extension LivestreamChannelController_Tests { + func test_deleteMessage_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Delete message completes") + var deleteError: Error? + + // When + controller.deleteMessage(messageId: messageId, hard: false) { error in + deleteError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse( + Result.success(.init(message: .dummy())) + ) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.deleteMessage(messageId: messageId, hard: false) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(deleteError) + } + + func test_deleteMessage_withHardDelete_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.deleteMessage(messageId: messageId, hard: true) { _ in } + + // Then + let expectedEndpoint = Endpoint.deleteMessage(messageId: messageId, hard: true) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_deleteMessage_failedResponse_callsCompletionWithError() { + // Given + let messageId = MessageId.unique + let testError = TestError() + let expectation = self.expectation(description: "Delete message completes") + var deleteError: Error? + + // When + controller.deleteMessage(messageId: messageId) { error in + deleteError = error + expectation.fulfill() + } + + // Simulate failed response + client.mockAPIClient.test_simulateResponse(Result.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssert(deleteError is TestError) + } +} + +// MARK: - Reactions Tests + +extension LivestreamChannelController_Tests { + func test_addReaction_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reactionType = MessageReactionType(rawValue: "like") + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Add reaction completes") + var reactionError: Error? + + // When + controller.addReaction( + reactionType, + to: messageId, + score: 5, + enforceUnique: true, + skipPush: true, + pushEmojiCode: "👍", + extraData: ["key": .string("value")] + ) { error in + reactionError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.addReaction( + reactionType, + score: 5, + enforceUnique: true, + extraData: ["key": .string("value")], + skipPush: true, + emojiCode: "👍", + messageId: messageId + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(reactionError) + } + + func test_addReaction_withDefaultParameters_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reactionType = MessageReactionType(rawValue: "heart") + let apiClient = client.mockAPIClient + + // When + controller.addReaction(reactionType, to: messageId) { _ in } + + // Then + let expectedEndpoint = Endpoint.addReaction( + reactionType, + score: 1, + enforceUnique: false, + extraData: [:], + skipPush: false, + emojiCode: nil, + messageId: messageId + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_deleteReaction_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reactionType = MessageReactionType(rawValue: "like") + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Delete reaction completes") + var reactionError: Error? + + // When + controller.deleteReaction(reactionType, from: messageId) { error in + reactionError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.deleteReaction(reactionType, messageId: messageId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(reactionError) + } + + func test_loadReactions_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Load reactions completes") + var loadResult: Result<[ChatMessageReaction], Error>? + + // When + controller.loadReactions(for: messageId, limit: 50, offset: 10) { result in + loadResult = result + expectation.fulfill() + } + + // Simulate successful response + let mockReactions = [MessageReactionPayload.dummy( + messageId: messageId, + user: UserPayload.dummy(userId: .unique) + )] + let reactionsPayload = MessageReactionsPayload(reactions: mockReactions) + client.mockAPIClient.test_simulateResponse(Result.success(reactionsPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedPagination = Pagination(pageSize: 50, offset: 10) + let expectedEndpoint = Endpoint.loadReactions(messageId: messageId, pagination: expectedPagination) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNotNil(loadResult) + if case .success = loadResult { + // Test passes + } else { + XCTFail("Expected success result") + } + } + + func test_loadReactions_withDefaultParameters_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.loadReactions(for: messageId) { _ in } + + // Then + let expectedPagination = Pagination(pageSize: 25, offset: 0) + let expectedEndpoint = Endpoint.loadReactions(messageId: messageId, pagination: expectedPagination) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadReactions_failedResponse_callsCompletionWithError() { + // Given + let messageId = MessageId.unique + let testError = TestError() + let expectation = self.expectation(description: "Load reactions completes") + var loadResult: Result<[ChatMessageReaction], Error>? + + // When + controller.loadReactions(for: messageId) { result in + loadResult = result + expectation.fulfill() + } + + // Simulate failed response + client.mockAPIClient.test_simulateResponse(Result.failure(testError)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNotNil(loadResult) + if case .failure(let error) = loadResult { + XCTAssert(error is TestError) + } else { + XCTFail("Expected failure result") + } + } +} + +// MARK: - Message Actions Tests + +extension LivestreamChannelController_Tests { + func test_flag_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let reason = "spam" + let extraData: [String: RawJSON] = ["key": .string("value")] + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Flag message completes") + var flagError: Error? + + // When + controller.flag(messageId: messageId, reason: reason, extraData: extraData) { error in + flagError = error + expectation.fulfill() + } + + // Simulate successful response + let flagPayload = FlagMessagePayload( + currentUser: CurrentUserPayload.dummy(userId: .unique, role: .user), + flaggedMessageId: messageId + ) + client.mockAPIClient.test_simulateResponse(Result.success(flagPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.flagMessage( + true, + with: messageId, + reason: reason, + extraData: extraData + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(flagError) + } + + func test_flag_withDefaultParameters_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.flag(messageId: messageId) { _ in } + + // Then + let expectedEndpoint = Endpoint.flagMessage( + true, + with: messageId, + reason: nil, + extraData: nil + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_unflag_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Unflag message completes") + var unflagError: Error? + + // When + controller.unflag(messageId: messageId) { error in + unflagError = error + expectation.fulfill() + } + + // Simulate successful response + let flagPayload = FlagMessagePayload( + currentUser: CurrentUserPayload.dummy(userId: .unique, role: .user), + flaggedMessageId: messageId + ) + client.mockAPIClient.test_simulateResponse(Result.success(flagPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.flagMessage( + false, + with: messageId, + reason: nil, + extraData: nil + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(unflagError) + } + + func test_pin_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let pinning = MessagePinning.expirationTime(20) + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Pin message completes") + var pinError: Error? + + // When + controller.pin(messageId: messageId, pinning: pinning) { error in + pinError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: true)) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(pinError) + } + + func test_pin_withDefaultPinning_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + + // When + controller.pin(messageId: messageId) { _ in } + + // Then + let expectedEndpoint = Endpoint.pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: true)) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_unpin_makesCorrectAPICall() { + // Given + let messageId = MessageId.unique + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Unpin message completes") + var unpinError: Error? + + // When + controller.unpin(messageId: messageId) { error in + unpinError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.pinMessage( + messageId: messageId, + request: .init(set: .init(pinned: false)) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(unpinError) + } + + func test_loadPinnedMessages_makesCorrectAPICall() { + // Given + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Load pinned messages completes") + var loadResult: Result<[ChatMessage], Error>? + let sorting: [Sorting] = [.init(key: .pinnedAt, isAscending: false)] + let pagination = PinnedMessagesPagination.after(.unique, inclusive: false) + + // When + controller.loadPinnedMessages( + pageSize: 50, + sorting: sorting, + pagination: pagination + ) { result in + loadResult = result + expectation.fulfill() + } + + // Simulate successful response + let pinnedMessagesPayload = PinnedMessagesPayload(messages: [.dummy()]) + client.mockAPIClient.test_simulateResponse(Result.success(pinnedMessagesPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedQuery = PinnedMessagesQuery( + pageSize: 50, + sorting: sorting, + pagination: pagination + ) + let expectedEndpoint = Endpoint.pinnedMessages(cid: controller.cid!, query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNotNil(loadResult) + if case .success = loadResult { + // Test passes + } else { + XCTFail("Expected success result") + } + } + + func test_loadPinnedMessages_withDefaultParameters_makesCorrectAPICall() { + // Given + let apiClient = client.mockAPIClient + + // When + controller.loadPinnedMessages { _ in } + + // Then + let expectedQuery = PinnedMessagesQuery( + pageSize: 25, + sorting: [], + pagination: nil + ) + let expectedEndpoint = Endpoint.pinnedMessages(cid: controller.cid!, query: expectedQuery) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } +} + +// MARK: - Slow Mode Tests + +extension LivestreamChannelController_Tests { + func test_enableSlowMode_makesCorrectAPICall() { + // Given + let cooldownDuration = 30 + let apiClient = client.mockAPIClient + let expectation = self.expectation(description: "Enable slow mode completes") + var slowModeError: Error? + + // When + controller.enableSlowMode(cooldownDuration: cooldownDuration) { error in + slowModeError = error + expectation.fulfill() + } + + // Simulate successful response + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + let expectedEndpoint = Endpoint.enableSlowMode(cid: controller.cid!, cooldownDuration: cooldownDuration) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + XCTAssertNil(slowModeError) + } + + func test_disableSlowMode_makesCorrectCall() { + // Given + let expectation = self.expectation(description: "Disable slow mode completes") + var slowModeError: Error? + + // When + controller.disableSlowMode { error in + slowModeError = error + expectation.fulfill() + } + + // Simulate successful response - this goes through the updater + client.mockAPIClient.test_simulateResponse(Result.success(.init())) + + waitForExpectations(timeout: defaultTimeout) + + // Then + XCTAssertNil(slowModeError) + } + + func test_currentCooldownTime_withNoCooldown_returnsZero() { + // Given + let channelPayload = ChannelPayload.dummy( + channel: .dummy(cid: controller.cid!, cooldownDuration: 0) + ) + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // When + let cooldownTime = controller.currentCooldownTime() + + // Then + XCTAssertEqual(cooldownTime, 0) + } + + func test_currentCooldownTime_withNoChannel_returnsZero() { + // Given + // Use a fresh controller with no channel data loaded + let freshController = LivestreamChannelController( + channelQuery: ChannelQuery(cid: .unique), + client: client + ) + + // When + let cooldownTime = freshController.currentCooldownTime() + + // Then + XCTAssertEqual(cooldownTime, 0) + } + + func test_currentCooldownTime_withActiveSlowMode_returnsCorrectTime() { + // Given + let currentUserId = UserId.unique + client.mockAuthenticationRepository.mockedCurrentUserId = currentUserId + let currentDate = Date() + let messageDate = currentDate.addingTimeInterval(-10) // 10 seconds ago + let cooldownDuration = 30 + + // Create a mock channel payload with cooldown + let channelPayload = ChannelPayload.dummy( + channel: .dummy( + cid: controller.cid!, + ownCapabilities: [], + cooldownDuration: cooldownDuration + ), + messages: [ + .dummy( + messageId: .unique, + authorUserId: currentUserId, + createdAt: messageDate + ) + ] + ) + + // Load the channel data through normal API flow + let exp = expectation(description: "sync completion") + controller.synchronize { _ in + exp.fulfill() + } + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + waitForExpectations(timeout: defaultTimeout) + + // When + let cooldownTime = controller.currentCooldownTime() + + // Then + // Should be approximately 20 seconds (30 - 10) + XCTAssertGreaterThan(cooldownTime, 18) + XCTAssertLessThan(cooldownTime, 22) + } + + func test_currentCooldownTime_withSkipSlowModeCapability_returnsZero() { + // Given + let currentUserId = UserId.unique + client.setToken(token: .unique(userId: currentUserId)) + let currentDate = Date() + let messageDate = currentDate.addingTimeInterval(-10) + + // Create a mock channel payload with skip slow mode capability + let channelPayload = ChannelPayload.dummy( + channel: .dummy( + cid: controller.cid!, + ownCapabilities: [ChannelCapability.skipSlowMode.rawValue], + cooldownDuration: 30 + ), + messages: [ + .dummy( + messageId: .unique, + authorUserId: currentUserId, + createdAt: messageDate + ) + ] + ) + + // Load the channel data through normal API flow + controller.synchronize() + client.mockAPIClient.test_simulateResponse(.success(channelPayload)) + + // When + let cooldownTime = controller.currentCooldownTime() + + // Then + XCTAssertEqual(cooldownTime, 0) + } +} + +class MockPaginationStateHandler: MessagesPaginationStateHandling { + init() { + state = .initial + } + + var state: MessagesPaginationState + + var beginCallCount = 0 + var endCallCount = 0 + + func begin(pagination: MessagesPagination) { + beginCallCount += 1 + } + + func end(pagination: MessagesPagination, with result: Result<[MessagePayload], any Error>) { + endCallCount += 1 + } +} diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift index 38297c84501..072e2b828eb 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -342,8 +342,9 @@ final class ChannelDTO_Tests: XCTestCase { // Pinned message should be older than `message` to ensure it's not returned first in `latestMessages` let pinnedMessage = dummyPinnedMessagePayload(createdAt: .unique(before: messageCreatedAt)) - - let pendingMessage = dummyMessagePayload(createdAt: messageCreatedAt) + + // Same of pending messages + let pendingMessage = dummyMessagePayload(createdAt: .unique(before: messageCreatedAt)) let payload = dummyPayload( with: channelId, diff --git a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift index cf4d3448a0f..f5c97838855 100644 --- a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift @@ -1770,6 +1770,42 @@ final class ChannelUpdater_Tests: XCTestCase { AssertAsync.willBeEqual(completionCalledError as? TestError, error) } + // MARK: - Disable slow mode + + func test_disableSlowMode_makesCorrectAPICall() { + let cid = ChannelId.unique + + channelUpdater.disableSlowMode(cid: cid) { _ in } + + // Assert that disableSlowMode calls enableSlowMode endpoint with cooldownDuration: 0 + let referenceEndpoint = Endpoint.enableSlowMode(cid: cid, cooldownDuration: 0) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + } + + func test_disableSlowMode_successfulResponse_isPropagatedToCompletion() { + var completionCalled = false + channelUpdater.disableSlowMode(cid: .unique) { error in + XCTAssertNil(error) + completionCalled = true + } + + XCTAssertFalse(completionCalled) + + apiClient.test_simulateResponse(Result.success(.init())) + + AssertAsync.willBeTrue(completionCalled) + } + + func test_disableSlowMode_errorResponse_isPropagatedToCompletion() { + var completionCalledError: Error? + channelUpdater.disableSlowMode(cid: .unique) { completionCalledError = $0 } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + AssertAsync.willBeEqual(completionCalledError as? TestError, error) + } + // MARK: - Start watching func test_startWatching_makesCorrectAPICall() { diff --git a/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift b/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift index c41467811d9..d8bc5ce1060 100644 --- a/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift +++ b/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift @@ -275,4 +275,79 @@ final class EventNotificationCenter_Tests: XCTestCase { center.process(events) } } + + // MARK: - Manual Event Handling Tests + + func test_registerManualEventHandling_callsManualEventHandler() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let cid: ChannelId = .unique + + center.registerManualEventHandling(for: cid) + + XCTAssertEqual(mockHandler.registerCallCount, 1) + XCTAssertEqual(mockHandler.registerCalledWith, [cid]) + } + + func test_unregisterManualEventHandling_callsManualEventHandler() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let cid: ChannelId = .unique + + center.unregisterManualEventHandling(for: cid) + + XCTAssertEqual(mockHandler.unregisterCallCount, 1) + XCTAssertEqual(mockHandler.unregisterCalledWith, [cid]) + } + + func test_process_whenManualEventHandlerReturnsEvent_eventIsAddedToEventsToPost() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + + // Create event logger to check published events + let eventLogger = EventLogger(center) + + // Create test event and mock return value + let originalEvent = TestEvent() + let manualEvent = TestEvent() + mockHandler.handleReturnValue = manualEvent + + // Process event + center.process(originalEvent) + + // Verify manual handler was called with original event + AssertAsync { + Assert.willBeEqual(mockHandler.handleCallCount, 1) + Assert.willBeEqual(mockHandler.handleCalledWith as? [TestEvent], [originalEvent]) + // Verify manual event was posted + Assert.willBeEqual(eventLogger.events as? [TestEvent], [manualEvent]) + } + } + + func test_process_whenManualEventHandlerReturnsNil_eventIsProcessedByMiddlewares() { + let mockHandler = ManualEventHandler_Mock() + let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + + // Create event logger to check published events + let eventLogger = EventLogger(center) + + // Create test event + let originalEvent = TestEvent() + mockHandler.handleReturnValue = nil // Manual handler doesn't handle this event + + // Add a middleware that will process the event + let middleware = EventMiddleware_Mock { event, _ in event } + center.add(middleware: middleware) + + // Process event + center.process(originalEvent) + + // Verify manual handler was called with original event + AssertAsync { + Assert.willBeEqual(mockHandler.handleCallCount, 1) + Assert.willBeEqual(mockHandler.handleCalledWith as? [TestEvent], [originalEvent]) + // Verify original event was posted (processed by middleware) + Assert.willBeEqual(eventLogger.events as? [TestEvent], [originalEvent]) + } + } } diff --git a/Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift b/Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift new file mode 100644 index 00000000000..c7dee2865d7 --- /dev/null +++ b/Tests/StreamChatTests/Workers/ManualEventHandler_Tests.swift @@ -0,0 +1,326 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ManualEventHandler_Tests: XCTestCase { + var database: DatabaseContainer_Spy! + var handler: ManualEventHandler! + var cid: ChannelId! + var cachedChannel: ChatChannel! + + override func setUp() { + super.setUp() + + database = DatabaseContainer_Spy() + cid = .unique + + // Setup database with channel and current user + try! database.createChannel(cid: cid, withMessages: false) + try! database.createCurrentUser() + + // Get the channel from database to use as cached channel + cachedChannel = .mock(cid: cid) + + // Create handler with pre-cached channel to avoid registration requirements + handler = ManualEventHandler( + database: database, + cachedChannels: [cid: cachedChannel] + ) + + // Register the channel so events are processed + handler.register(channelId: cid) + } + + override func tearDown() { + handler = nil + database = nil + cachedChannel = nil + cid = nil + super.tearDown() + } + + // MARK: - Event Handling - Non-EventDTO + + func test_handle_nonEventDTO_returnsNil() { + struct NonEventDTO: Event {} + let event = NonEventDTO() + + let result = handler.handle(event) + XCTAssertNil(result) + } + + // MARK: - Event Handling - Missing CID + + func test_handle_eventWithoutCid_returnsNil() throws { + // Create a simple event DTO that has no cid + struct TestEventDTO: EventDTO { + let payload: EventPayload = EventPayload( + eventType: .healthCheck, + connectionId: .unique + ) + } + + let eventDTO = TestEventDTO() + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + XCTAssertNil(result, "Events without cid should return nil") + } + + // MARK: - Event Handling - Unregistered Channel + + func test_handle_unregisteredChannel_returnsNil() throws { + let unregisteredCid: ChannelId = .unique + let eventPayload = EventPayload( + eventType: .messageNew, + cid: unregisteredCid, + user: .dummy(userId: .unique), + message: .dummy(messageId: .unique, authorUserId: .unique), + createdAt: .unique + ) + let eventDTO = try! MessageNewEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + XCTAssertNil(result) + } + + // MARK: - Event Handling - Unsupported Event Type + + func test_handle_unsupportedEventType_returnsNil() throws { + // Use a typing event which is not handled by ManualEventHandler + let eventPayload = EventPayload( + eventType: .userStartTyping, + cid: cid, + user: .dummy(userId: .unique), + createdAt: .unique + ) + let eventDTO = try! TypingEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + XCTAssertNil(result, "Unsupported event types should return nil") + } + + // MARK: - Message New Event + + func test_handle_messageNewEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageNew, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + watcherCount: 10, + unreadCount: .init(channels: 1, messages: 2, threads: 0), + createdAt: createdAt + ) + let eventDTO = try! MessageNewEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageNewEvent = try XCTUnwrap(result as? MessageNewEvent) + XCTAssertEqual(messageNewEvent.user.id, userId) + XCTAssertEqual(messageNewEvent.message.id, messageId) + XCTAssertEqual(messageNewEvent.cid, cid) + XCTAssertEqual(messageNewEvent.watcherCount, 10) + XCTAssertEqual(messageNewEvent.unreadCount?.messages, 2) + XCTAssertEqual(messageNewEvent.createdAt, createdAt) + } + + // MARK: - Message Updated Event + + func test_handle_messageUpdatedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageUpdated, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + createdAt: createdAt + ) + let eventDTO = try! MessageUpdatedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageUpdatedEvent = try XCTUnwrap(result as? MessageUpdatedEvent) + XCTAssertEqual(messageUpdatedEvent.user.id, userId) + XCTAssertEqual(messageUpdatedEvent.message.id, messageId) + XCTAssertEqual(messageUpdatedEvent.cid, cid) + XCTAssertEqual(messageUpdatedEvent.createdAt, createdAt) + } + + // MARK: - Message Deleted Event + + func test_handle_messageDeletedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageDeleted, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + createdAt: createdAt, + hardDelete: true + ) + let eventDTO = try! MessageDeletedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageDeletedEvent = try XCTUnwrap(result as? MessageDeletedEvent) + XCTAssertEqual(messageDeletedEvent.user?.id, userId) + XCTAssertEqual(messageDeletedEvent.message.id, messageId) + XCTAssertEqual(messageDeletedEvent.cid, cid) + XCTAssertEqual(messageDeletedEvent.isHardDelete, true) + XCTAssertEqual(messageDeletedEvent.createdAt, createdAt) + } + + func test_handle_messageDeletedEvent_withoutUser_returnsEvent() throws { + let messageId: MessageId = .unique + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .messageDeleted, + cid: cid, + user: nil, + message: .dummy(messageId: messageId, authorUserId: .unique), + createdAt: createdAt, + hardDelete: false + ) + let eventDTO = try! MessageDeletedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let messageDeletedEvent = try XCTUnwrap(result as? MessageDeletedEvent) + XCTAssertNil(messageDeletedEvent.user) + XCTAssertEqual(messageDeletedEvent.message.id, messageId) + XCTAssertEqual(messageDeletedEvent.cid, cid) + XCTAssertEqual(messageDeletedEvent.isHardDelete, false) + XCTAssertEqual(messageDeletedEvent.createdAt, createdAt) + } + + // MARK: - Reaction New Event + + func test_handle_reactionNewEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let reactionType: MessageReactionType = "like" + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .reactionNew, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + reaction: .dummy(type: reactionType, messageId: messageId, user: .dummy(userId: userId)), + createdAt: createdAt + ) + let eventDTO = try! ReactionNewEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let reactionNewEvent = try XCTUnwrap(result as? ReactionNewEvent) + XCTAssertEqual(reactionNewEvent.user.id, userId) + XCTAssertEqual(reactionNewEvent.message.id, messageId) + XCTAssertEqual(reactionNewEvent.cid, cid) + XCTAssertEqual(reactionNewEvent.reaction.type, reactionType) + XCTAssertEqual(reactionNewEvent.createdAt, createdAt) + } + + // MARK: - Reaction Updated Event + + func test_handle_reactionUpdatedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let reactionType: MessageReactionType = "love" + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .reactionUpdated, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + reaction: .dummy(type: reactionType, messageId: messageId, user: .dummy(userId: userId)), + createdAt: createdAt + ) + let eventDTO = try! ReactionUpdatedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let reactionUpdatedEvent = try XCTUnwrap(result as? ReactionUpdatedEvent) + XCTAssertEqual(reactionUpdatedEvent.user.id, userId) + XCTAssertEqual(reactionUpdatedEvent.message.id, messageId) + XCTAssertEqual(reactionUpdatedEvent.cid, cid) + XCTAssertEqual(reactionUpdatedEvent.reaction.type, reactionType) + XCTAssertEqual(reactionUpdatedEvent.createdAt, createdAt) + } + + // MARK: - Reaction Deleted Event + + func test_handle_reactionDeletedEvent_withValidData_returnsEvent() throws { + let userId: UserId = .unique + let messageId: MessageId = .unique + let reactionType: MessageReactionType = "angry" + let createdAt = Date.unique + + let eventPayload = EventPayload( + eventType: .reactionDeleted, + cid: cid, + user: .dummy(userId: userId), + message: .dummy(messageId: messageId, authorUserId: userId), + reaction: .dummy(type: reactionType, messageId: messageId, user: .dummy(userId: userId)), + createdAt: createdAt + ) + let eventDTO = try! ReactionDeletedEventDTO(from: eventPayload) + + var result: Event! + try database.writeSynchronously { _ in + result = self.handler.handle(eventDTO) + } + + let reactionDeletedEvent = try XCTUnwrap(result as? ReactionDeletedEvent) + XCTAssertEqual(reactionDeletedEvent.user.id, userId) + XCTAssertEqual(reactionDeletedEvent.message.id, messageId) + XCTAssertEqual(reactionDeletedEvent.cid, cid) + XCTAssertEqual(reactionDeletedEvent.reaction.type, reactionType) + XCTAssertEqual(reactionDeletedEvent.createdAt, createdAt) + } +} diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift index 5f6ca55d7d4..3e856b29b8a 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift @@ -718,7 +718,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: true ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 1) @@ -731,7 +731,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: true ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 0) @@ -744,7 +744,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: false ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 0) @@ -757,7 +757,7 @@ final class ChatChannelVC_Tests: XCTestCase { isSentByCurrentUser: true ) - let pendingEvent = NewMessagePendingEvent(message: message) + let pendingEvent = NewMessagePendingEvent(message: message, cid: cid) vc.eventsController(vc.eventsController, didReceiveEvent: pendingEvent) XCTAssertEqual(channelControllerMock.loadFirstPageCallCount, 0)