diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index 58e3b13cce4..6eccf90463a 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -55,10 +55,10 @@ jobs: - uses: ./.github/actions/bootstrap env: INSTALL_INTERFACE_ANALYZER: true - - run: bundle exec fastlane validate_public_interface - run: bundle exec fastlane lint_pr - run: bundle exec fastlane rubocop - run: bundle exec fastlane run_swift_format strict:true + - run: bundle exec fastlane validate_public_interface - run: bundle exec fastlane pod_lint if: startsWith(github.event.pull_request.head.ref, 'release/') diff --git a/.swiftformat b/.swiftformat index 7cd15b4c1cf..99138dfc2d9 100644 --- a/.swiftformat +++ b/.swiftformat @@ -33,3 +33,6 @@ --wraparguments before-first --wrapparameters before-first --wrapcollections before-first + +# Exclude paths +--exclude **/Generated,Sources/StreamChatUI/StreamNuke,Sources/StreamChatUI/StreamSwiftyGif,Sources/StreamChatUI/StreamDifferenceKit diff --git a/.swiftformat-snippets b/.swiftformat-snippets deleted file mode 100644 index ed0e2f5dc8e..00000000000 --- a/.swiftformat-snippets +++ /dev/null @@ -1,28 +0,0 @@ -# Stream rules ---swiftversion 5.6 ---ifdef no-indent ---disable fileHeader - -# Rules inferred from Swift Standard Library: ---disable anyObjectProtocol, wrapMultilineStatementBraces ---indent 4 ---enable isEmpty ---disable redundantParens # it generates mistakes for e.g. "if (a || b), let x = ... {}" ---semicolons inline ---nospaceoperators ..., ..< # what about ==, +=? ---commas inline ---trimwhitespace nonblank-lines ---stripunusedargs closure-only ---maxwidth 132 - ---binarygrouping 4,7 ---octalgrouping none ---hexgrouping none ---fractiongrouping disabled ---exponentgrouping disabled ---hexliteralcase lowercase ---exponentcase lowercase - ---wraparguments before-first ---wrapparameters before-first ---wrapcollections before-first diff --git a/CHANGELOG.md b/CHANGELOG.md index 203945d63f4..49b5a671ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed +# [4.84.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.84.0) +_August 06, 2025_ + +## 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) + +## StreamChatUI +### 🐞 Fixed +- Fix the height of the attachment view in the composer when using larger dynamic type [3762](https://github.com/GetStream/stream-chat-swift/pull/3762) +- Remove animation in message reactions when opening a sheet in the channel view [#3763](https://github.com/GetStream/stream-chat-swift/pull/3763) +- Fix video player not playable when GalleryVC is opened [#3773](https://github.com/GetStream/stream-chat-swift/pull/3773) + # [4.83.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.83.0) _July 28, 2025_ 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..6a6a4700322 --- /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 + 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..b85a00fe87e --- /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 + } + } + } +} + +private 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/Gemfile.lock b/Gemfile.lock index 1c61dcdb4cc..460e1b770ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -26,22 +26,23 @@ GEM ast (2.4.3) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1114.0) - aws-sdk-core (3.225.1) + aws-partitions (1.1139.0) + aws-sdk-core (3.228.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.104.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (1.109.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.189.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-s3 (1.195.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.12.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.3.0) @@ -52,7 +53,7 @@ GEM cork nap open4 (~> 1.3) - clamp (1.3.2) + clamp (1.3.3) cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) @@ -99,20 +100,20 @@ GEM connection_pool (2.5.3) cork (0.3.0) colored2 (~> 3.1) - danger (9.5.1) + danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) claide-plugins (>= 0.9.2) - colored2 (~> 3.1) + colored2 (>= 3.1, < 5) cork (~> 0.1) faraday (>= 0.9.0, < 3.0) faraday-http-cache (~> 2.0) - git (~> 1.13) - kramdown (~> 2.3) + git (>= 1.13, < 3.0) + kramdown (>= 2.5.1, < 3.0) kramdown-parser-gfm (~> 1.0) octokit (>= 4.0) pstore (~> 0.1) - terminal-table (>= 1, < 4) + terminal-table (>= 1, < 5) danger-commit_lint (0.0.7) danger-plugin-api (~> 1.0) danger-plugin-api (1.0.0) @@ -145,12 +146,12 @@ GEM faraday (>= 0.8.0) http-cookie (~> 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-http-cache (2.5.1) faraday (>= 0.8) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.1.1) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) @@ -208,7 +209,7 @@ GEM fastlane pry fastlane-plugin-sonarcloud_metric_kit (0.2.1) - fastlane-plugin-stream_actions (0.3.84) + fastlane-plugin-stream_actions (0.3.90) xctest_list (= 1.2.1) fastlane-plugin-versioning (0.7.1) fastlane-sirp (1.0.0) @@ -220,8 +221,10 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - git (1.19.1) + git (2.3.3) + activesupport (>= 5.0) addressable (~> 2.8) + process_executer (~> 1.1) rchardet (~> 1.8) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -267,14 +270,14 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.12.2) - jwt (2.10.1) + json (2.13.2) + jwt (2.10.2) base64 kramdown (2.5.1) rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - lefthook (1.11.16) + lefthook (1.12.2) logger (1.7.0) method_source (1.1.0) mini_magick (4.13.2) @@ -282,14 +285,14 @@ GEM mini_portile2 (2.8.9) minitest (5.25.5) molinillo (0.8.0) - multi_json (1.15.0) + multi_json (1.17.0) multipart-post (2.4.1) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) - naturally (2.2.2) + naturally (2.3.0) net-http-persistent (4.0.6) connection_pool (~> 2.2, >= 2.2.4) netrc (0.11.0) @@ -305,20 +308,21 @@ GEM optparse (0.6.0) os (1.1.4) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc plist (3.7.2) prism (1.4.0) + process_executer (1.3.0) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) pstore (0.2.0) public_suffix (4.0.7) - puma (6.6.0) + puma (6.6.1) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.16) + rack (3.2.0) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -349,7 +353,7 @@ GEM rubocop-ast (>= 1.23.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.45.1) + rubocop-ast (1.46.0) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-performance (1.19.1) @@ -387,7 +391,7 @@ GEM clamp (~> 1.3) nokogiri (>= 1.14.3) xcodeproj (~> 1.27) - sorbet-runtime (0.5.12164) + sorbet-runtime (0.5.12368) stream-chat-ruby (3.0.0) faraday faraday-multipart @@ -399,7 +403,7 @@ GEM terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - tilt (2.6.0) + tilt (2.6.1) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.2) @@ -441,7 +445,7 @@ DEPENDENCIES fastlane-plugin-create_xcframework fastlane-plugin-lizard fastlane-plugin-sonarcloud_metric_kit - fastlane-plugin-stream_actions (= 0.3.84) + fastlane-plugin-stream_actions (= 0.3.90) fastlane-plugin-versioning faye-websocket json diff --git a/LICENSE b/LICENSE index 49088d477f5..864c3c855f7 100644 --- a/LICENSE +++ b/LICENSE @@ -81,7 +81,7 @@ party or to operate a service; (c) allow any third party to access or use the Software Source Code; (d) sublicense or distribute the Software Source Code or any Modifications in Source Code or other derivative works based on any part of the Software Source Code; (e) use the Software in any manner that competes with -Stream.io or its business; or (e) otherwise use the Software in any manner that +Stream.io or its business; or (f) otherwise use the Software in any manner that exceeds the scope of use permitted in this Agreement. Customer shall use the Software in compliance with any accompanying documentation any laws applicable to Customer. diff --git a/README.md b/README.md index 30683e0396c..9baf7e56668 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@

- StreamChat - StreamChatUI + StreamChat + StreamChatUI

This is the official iOS SDK for [Stream Chat](https://getstream.io/chat/sdk/ios/), a service for building chat and messaging applications. This library includes both a low-level SDK and a set of reusable UI components. 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/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index e59935cf10c..d85da2ab1e4 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -517,7 +517,9 @@ public class ChatClient { } } - if !removeDevice { + authenticationRepository.clearCurrentUserId() + + if removeDevice == false { authenticationRepository.logOutUser() } 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/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index b7b19e07641..69d8b22ca5a 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -64,6 +64,7 @@ class ChannelDTO: NSManagedObject { @NSManaged var currentlyTypingUsers: Set @NSManaged var messages: Set @NSManaged var pinnedMessages: Set + @NSManaged var pendingMessages: Set @NSManaged var reads: Set /// Helper properties used for sorting channels with unread counts of the current user. @@ -331,7 +332,19 @@ extension NSManagedObjectContext { dto.reads.formUnion(reads) try payload.messages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } - try payload.pendingMessages?.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } + + var pendingMessages = Set() + try payload.pendingMessages?.forEach { + let pending = try saveMessage( + payload: $0, + channelDTO: dto, + syncOwnReactions: true, + cache: cache + ) + pendingMessages.insert(pending) + } + + dto.pendingMessages = pendingMessages // Recalculate reads for existing messages (saveMessage updates it for messages in the payload) let channelReadDTOs = dto.reads @@ -597,6 +610,7 @@ extension ChatChannel { }() let membership = try dto.membership.map { try $0.asModel() } let pinnedMessages = dto.pinnedMessages.compactMap { try? $0.relationshipAsModel(depth: depth) } + let pendingMessages = dto.pendingMessages.compactMap { try? $0.relationshipAsModel(depth: depth) } let previewMessage = try? dto.previewMessage?.relationshipAsModel(depth: depth) let draftMessage = try? dto.draftMessage?.relationshipAsModel(depth: depth) let typingUsers = Set(dto.currentlyTypingUsers.compactMap { try? $0.asModel() }) @@ -632,6 +646,7 @@ extension ChatChannel { latestMessages: latestMessages, lastMessageFromCurrentUser: latestMessageFromUser, pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, muteDetails: muteDetails, previewMessage: previewMessage, draftMessage: draftMessage.map(DraftMessage.init), diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index efb003985b4..17b35000091 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -74,6 +74,7 @@ + @@ -258,6 +259,7 @@ + diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift index 3d84274c3fb..24d6c990f2d 100644 --- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift +++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift @@ -7,5 +7,5 @@ import Foundation extension SystemEnvironment { /// A Stream Chat version. - public static let version: String = "4.83.0" + public static let version: String = "4.84.0" } diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist index 8f18f296930..48eed4dd753 100644 --- a/Sources/StreamChat/Info.plist +++ b/Sources/StreamChat/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.83.0 + 4.84.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index d8ccb8736d1..0e5cc07febd 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -129,6 +129,10 @@ public struct ChatChannel { /// This field contains only the pinned messages of the channel. You can get all existing messages in the channel by creating /// and using a `ChatChannelController` for this channel id. public let pinnedMessages: [ChatMessage] + + /// Messages that are pending for moderation on the server. + /// These messages are visible only for the user that sent them, until they are approved. + public let pendingMessages: [ChatMessage] /// Read states of the users for this channel. /// @@ -199,6 +203,7 @@ public struct ChatChannel { latestMessages: [ChatMessage], lastMessageFromCurrentUser: ChatMessage?, pinnedMessages: [ChatMessage], + pendingMessages: [ChatMessage], muteDetails: MuteDetails?, previewMessage: ChatMessage?, draftMessage: DraftMessage?, @@ -237,6 +242,7 @@ public struct ChatChannel { self.previewMessage = previewMessage self.draftMessage = draftMessage self.activeLiveLocations = activeLiveLocations + self.pendingMessages = pendingMessages } /// Returns a new `ChatChannel` with the provided data replaced. @@ -275,6 +281,52 @@ public struct ChatChannel { latestMessages: latestMessages, lastMessageFromCurrentUser: lastMessageFromCurrentUser, pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, + muteDetails: muteDetails, + previewMessage: previewMessage, + draftMessage: draftMessage, + 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, 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/Repositories/AuthenticationRepository.swift b/Sources/StreamChat/Repositories/AuthenticationRepository.swift index ae02bec9cd2..ee3c6e31051 100644 --- a/Sources/StreamChat/Repositories/AuthenticationRepository.swift +++ b/Sources/StreamChat/Repositories/AuthenticationRepository.swift @@ -211,6 +211,10 @@ class AuthenticationRepository { currentUserId = nil } + func clearCurrentUserId() { + currentUserId = nil + } + func refreshToken(completion: @escaping (Error?) -> Void) { guard let tokenProvider = tokenProvider else { let error = ClientError.MissingTokenProvider() 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/Reactions/ChatMessageReactions+Types.swift b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactions+Types.swift index 9b55a9ec5ba..74a9cd35b75 100644 --- a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactions+Types.swift +++ b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactions+Types.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The information of a reaction ready to be displayed in a view. -public struct ChatMessageReactionData { +public struct ChatMessageReactionData: Equatable { /// The type of the reaction. public let type: MessageReactionType /// The score value of the reaction. By default it is the same value as `count`. diff --git a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsView.swift b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsView.swift index f7a0d98d0b9..e9fddb64c46 100644 --- a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsView.swift +++ b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsView.swift @@ -8,7 +8,12 @@ import UIKit /// The view that shows the list of reactions attached to the message. open class ChatMessageReactionsView: _View, ThemeProvider { public var content: Content? { - didSet { updateContentIfNeeded() } + didSet { + if oldValue?.reactions == content?.reactions { + return + } + updateContentIfNeeded() + } } open var reactionItemView: ChatMessageReactionItemView.Type { 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/CommonViews/Attachments/AttachmentViews/FileAttachmentView.swift b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/FileAttachmentView.swift index 2e2aaf22830..55d37ae47c5 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/FileAttachmentView.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/FileAttachmentView.swift @@ -80,7 +80,7 @@ open class FileAttachmentView: _View, AppearanceProvider { addSubview(fileNameAndSizeStack) NSLayoutConstraint.activate([ - heightAnchor.pin(equalToConstant: height), + heightAnchor.pin(greaterThanOrEqualToConstant: height), fileIconImageView.leadingAnchor.pin(equalTo: layoutMarginsGuide.leadingAnchor), fileIconImageView.topAnchor.pin(equalTo: layoutMarginsGuide.topAnchor), fileIconImageView.bottomAnchor.pin(equalTo: layoutMarginsGuide.bottomAnchor), diff --git a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentsPreviewVC.swift b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentsPreviewVC.swift index a6edbb07bd1..b0f8fd579c1 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentsPreviewVC.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentsPreviewVC.swift @@ -165,6 +165,7 @@ open class AttachmentsPreviewVC: _ViewController, ComponentsProvider { // constraint is not yet created, append to the vertical constraint and activate it. if verticalAttachmentPreviews.count > maxNumberOfVerticalItems, let firstAttachmentView = verticalAttachmentPreviews.first { if verticalScrollViewHeightConstraint == nil { + verticalStackView.layoutIfNeeded() let attachmentHeight = firstAttachmentView .systemLayoutSizeFitting(.init(width: CGFloat.infinity, height: CGFloat.infinity)) .height 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/Sources/StreamChatUI/Gallery/GalleryVC.swift b/Sources/StreamChatUI/Gallery/GalleryVC.swift index d9c8b9834ef..ba69b80d494 100644 --- a/Sources/StreamChatUI/Gallery/GalleryVC.swift +++ b/Sources/StreamChatUI/Gallery/GalleryVC.swift @@ -249,6 +249,8 @@ open class GalleryVC: _ViewController, override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + + updateContent() attachmentsCollectionView.scrollToItem( at: .init(item: content.currentPage, section: 0), at: .centeredHorizontally, diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist index 8f18f296930..48eed4dd753 100644 --- a/Sources/StreamChatUI/Info.plist +++ b/Sources/StreamChatUI/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.83.0 + 4.84.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/StreamChat-XCFramework.podspec b/StreamChat-XCFramework.podspec index c64bced5906..e94a0e5071c 100644 --- a/StreamChat-XCFramework.podspec +++ b/StreamChat-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChat-XCFramework" - spec.version = "4.83.0" + spec.version = "4.84.0" spec.summary = "StreamChat iOS Client" spec.description = "stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications." diff --git a/StreamChat.podspec b/StreamChat.podspec index 5510eb29018..11875c7d97f 100644 --- a/StreamChat.podspec +++ b/StreamChat.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChat" - spec.version = "4.83.0" + spec.version = "4.84.0" spec.summary = "StreamChat iOS Chat Client" spec.description = "stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications." 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/StreamChatArtifacts.json b/StreamChatArtifacts.json index c0896aa9fc1..5d7435465b8 100644 --- a/StreamChatArtifacts.json +++ b/StreamChatArtifacts.json @@ -1 +1 @@ -{"4.7.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.7.0/StreamChat-All.zip","4.8.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.8.0/StreamChat-All.zip","4.9.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.9.0/StreamChat-All.zip","4.10.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.0/StreamChat-All.zip","4.10.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.1/StreamChat-All.zip","4.11.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.11.0/StreamChat-All.zip","4.12.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.12.0/StreamChat-All.zip","4.13.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.0/StreamChat-All.zip","4.13.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.1/StreamChat-All.zip","4.14.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.14.0/StreamChat-All.zip","4.15.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.0/StreamChat-All.zip","4.15.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.1/StreamChat-All.zip","4.16.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.16.0/StreamChat-All.zip","4.17.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.17.0/StreamChat-All.zip","4.18.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.18.0/StreamChat-All.zip","4.19.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.19.0/StreamChat-All.zip","4.20.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.20.0/StreamChat-All.zip","4.21.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.0/StreamChat-All.zip","4.21.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.1/StreamChat-All.zip","4.21.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.2/StreamChat-All.zip","4.22.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.22.0/StreamChat-All.zip","4.23.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.23.0/StreamChat-All.zip","4.24.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.0/StreamChat-All.zip","4.24.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.1/StreamChat-All.zip","4.25.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.0/StreamChat-All.zip","4.25.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.1/StreamChat-All.zip","4.26.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.26.0/StreamChat-All.zip","4.27.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.0/StreamChat-All.zip","4.27.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.1/StreamChat-All.zip","4.28.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.28.0/StreamChat-All.zip","4.29.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.29.0/StreamChat-All.zip","4.30.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.30.0/StreamChat-All.zip","4.31.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.31.0/StreamChat-All.zip","4.32.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.32.0/StreamChat-All.zip","4.33.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.33.0/StreamChat-All.zip","4.34.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.34.0/StreamChat-All.zip","4.35.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.0/StreamChat-All.zip","4.35.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.1/StreamChat-All.zip","4.35.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.2/StreamChat-All.zip","4.36.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.36.0/StreamChat-All.zip","4.37.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.0/StreamChat-All.zip","4.37.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.1/StreamChat-All.zip","4.38.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.38.0/StreamChat-All.zip","4.39.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.39.0/StreamChat-All.zip","4.40.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.40.0/StreamChat-All.zip","4.41.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.41.0/StreamChat-All.zip","4.42.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.42.0/StreamChat-All.zip","4.43.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.43.0/StreamChat-All.zip","4.44.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.44.0/StreamChat-All.zip","4.45.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.45.0/StreamChat-All.zip","4.46.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.46.0/StreamChat-All.zip","4.47.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.0/StreamChat-All.zip","4.47.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.1/StreamChat-All.zip","4.48.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.0/StreamChat-All.zip","4.48.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.1/StreamChat-All.zip","4.49.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.49.0/StreamChat-All.zip","4.50.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.50.0/StreamChat-All.zip","4.51.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.51.0/StreamChat-All.zip","4.52.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.52.0/StreamChat-All.zip","4.53.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.53.0/StreamChat-All.zip","4.54.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.54.0/StreamChat-All.zip","4.55.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.55.0/StreamChat-All.zip","4.56.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.0/StreamChat-All.zip","4.56.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.1/StreamChat-All.zip","4.57.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.57.0/StreamChat-All.zip","4.58.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.58.0/StreamChat-All.zip","4.59.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.59.0/StreamChat-All.zip","4.60.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.60.0/StreamChat-All.zip","4.61.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.61.0/StreamChat-All.zip","4.62.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.62.0/StreamChat-All.zip","4.63.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.63.0/StreamChat-All.zip","4.64.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.64.0/StreamChat-All.zip","4.65.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.65.0/StreamChat-All.zip","4.66.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.66.0/StreamChat-All.zip","4.67.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.67.0/StreamChat-All.zip","4.68.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.68.0/StreamChat-All.zip","4.69.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.69.0/StreamChat-All.zip","4.70.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.70.0/StreamChat-All.zip","4.71.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.71.0/StreamChat-All.zip","4.72.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.72.0/StreamChat-All.zip","4.73.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.73.0/StreamChat-All.zip","4.74.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.74.0/StreamChat-All.zip","4.75.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.75.0/StreamChat-All.zip","4.76.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.76.0/StreamChat-All.zip","4.77.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.77.0/StreamChat-All.zip","4.78.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.78.0/StreamChat-All.zip","4.79.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.0/StreamChat-All.zip","4.79.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.1/StreamChat-All.zip","4.80.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.80.0/StreamChat-All.zip","4.81.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.81.0/StreamChat-All.zip","4.82.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.82.0/StreamChat-All.zip","4.83.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.83.0/StreamChat-All.zip"} \ No newline at end of file +{"4.7.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.7.0/StreamChat-All.zip","4.8.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.8.0/StreamChat-All.zip","4.9.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.9.0/StreamChat-All.zip","4.10.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.0/StreamChat-All.zip","4.10.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.1/StreamChat-All.zip","4.11.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.11.0/StreamChat-All.zip","4.12.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.12.0/StreamChat-All.zip","4.13.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.0/StreamChat-All.zip","4.13.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.1/StreamChat-All.zip","4.14.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.14.0/StreamChat-All.zip","4.15.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.0/StreamChat-All.zip","4.15.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.1/StreamChat-All.zip","4.16.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.16.0/StreamChat-All.zip","4.17.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.17.0/StreamChat-All.zip","4.18.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.18.0/StreamChat-All.zip","4.19.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.19.0/StreamChat-All.zip","4.20.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.20.0/StreamChat-All.zip","4.21.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.0/StreamChat-All.zip","4.21.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.1/StreamChat-All.zip","4.21.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.2/StreamChat-All.zip","4.22.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.22.0/StreamChat-All.zip","4.23.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.23.0/StreamChat-All.zip","4.24.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.0/StreamChat-All.zip","4.24.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.1/StreamChat-All.zip","4.25.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.0/StreamChat-All.zip","4.25.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.1/StreamChat-All.zip","4.26.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.26.0/StreamChat-All.zip","4.27.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.0/StreamChat-All.zip","4.27.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.1/StreamChat-All.zip","4.28.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.28.0/StreamChat-All.zip","4.29.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.29.0/StreamChat-All.zip","4.30.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.30.0/StreamChat-All.zip","4.31.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.31.0/StreamChat-All.zip","4.32.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.32.0/StreamChat-All.zip","4.33.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.33.0/StreamChat-All.zip","4.34.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.34.0/StreamChat-All.zip","4.35.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.0/StreamChat-All.zip","4.35.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.1/StreamChat-All.zip","4.35.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.2/StreamChat-All.zip","4.36.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.36.0/StreamChat-All.zip","4.37.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.0/StreamChat-All.zip","4.37.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.1/StreamChat-All.zip","4.38.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.38.0/StreamChat-All.zip","4.39.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.39.0/StreamChat-All.zip","4.40.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.40.0/StreamChat-All.zip","4.41.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.41.0/StreamChat-All.zip","4.42.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.42.0/StreamChat-All.zip","4.43.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.43.0/StreamChat-All.zip","4.44.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.44.0/StreamChat-All.zip","4.45.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.45.0/StreamChat-All.zip","4.46.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.46.0/StreamChat-All.zip","4.47.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.0/StreamChat-All.zip","4.47.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.1/StreamChat-All.zip","4.48.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.0/StreamChat-All.zip","4.48.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.1/StreamChat-All.zip","4.49.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.49.0/StreamChat-All.zip","4.50.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.50.0/StreamChat-All.zip","4.51.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.51.0/StreamChat-All.zip","4.52.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.52.0/StreamChat-All.zip","4.53.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.53.0/StreamChat-All.zip","4.54.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.54.0/StreamChat-All.zip","4.55.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.55.0/StreamChat-All.zip","4.56.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.0/StreamChat-All.zip","4.56.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.1/StreamChat-All.zip","4.57.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.57.0/StreamChat-All.zip","4.58.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.58.0/StreamChat-All.zip","4.59.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.59.0/StreamChat-All.zip","4.60.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.60.0/StreamChat-All.zip","4.61.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.61.0/StreamChat-All.zip","4.62.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.62.0/StreamChat-All.zip","4.63.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.63.0/StreamChat-All.zip","4.64.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.64.0/StreamChat-All.zip","4.65.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.65.0/StreamChat-All.zip","4.66.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.66.0/StreamChat-All.zip","4.67.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.67.0/StreamChat-All.zip","4.68.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.68.0/StreamChat-All.zip","4.69.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.69.0/StreamChat-All.zip","4.70.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.70.0/StreamChat-All.zip","4.71.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.71.0/StreamChat-All.zip","4.72.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.72.0/StreamChat-All.zip","4.73.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.73.0/StreamChat-All.zip","4.74.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.74.0/StreamChat-All.zip","4.75.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.75.0/StreamChat-All.zip","4.76.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.76.0/StreamChat-All.zip","4.77.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.77.0/StreamChat-All.zip","4.78.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.78.0/StreamChat-All.zip","4.79.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.0/StreamChat-All.zip","4.79.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.1/StreamChat-All.zip","4.80.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.80.0/StreamChat-All.zip","4.81.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.81.0/StreamChat-All.zip","4.82.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.82.0/StreamChat-All.zip","4.83.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.83.0/StreamChat-All.zip","4.84.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.84.0/StreamChat-All.zip"} \ No newline at end of file diff --git a/StreamChatUI-XCFramework.podspec b/StreamChatUI-XCFramework.podspec index c8867cbec6d..642b1a625d0 100644 --- a/StreamChatUI-XCFramework.podspec +++ b/StreamChatUI-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChatUI-XCFramework" - spec.version = "4.83.0" + spec.version = "4.84.0" spec.summary = "StreamChat UI Components" spec.description = "StreamChatUI SDK offers flexible UI components able to display data provided by StreamChat SDK." diff --git a/StreamChatUI.podspec b/StreamChatUI.podspec index c5a61b66d82..26b23c5b729 100644 --- a/StreamChatUI.podspec +++ b/StreamChatUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChatUI" - spec.version = "4.83.0" + spec.version = "4.84.0" spec.summary = "StreamChat UI Components" spec.description = "StreamChatUI SDK offers flexible UI components able to display data provided by StreamChat SDK." diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift index 5317951c8dc..c8b4ffec70b 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift @@ -94,6 +94,7 @@ public extension ChatChannel { extraData: [String: RawJSON] = [:], latestMessages: [ChatMessage] = [], pinnedMessages: [ChatMessage] = [], + pendingMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, previewMessage: ChatMessage? = nil, draftMessage: DraftMessage? = nil, @@ -125,6 +126,7 @@ public extension ChatChannel { latestMessages: latestMessages, lastMessageFromCurrentUser: nil, pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, muteDetails: muteDetails, previewMessage: previewMessage, draftMessage: draftMessage, @@ -155,6 +157,7 @@ public extension ChatChannel { extraData: [String: RawJSON] = [:], latestMessages: [ChatMessage] = [], pinnedMessages: [ChatMessage] = [], + pendingMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, previewMessage: ChatMessage? = nil, draftMessage: DraftMessage? = nil, @@ -184,6 +187,7 @@ public extension ChatChannel { latestMessages: latestMessages, lastMessageFromCurrentUser: nil, pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, muteDetails: muteDetails, previewMessage: previewMessage, draftMessage: draftMessage, @@ -213,6 +217,7 @@ public extension ChatChannel { extraData: [String: RawJSON] = [:], latestMessages: [ChatMessage] = [], pinnedMessages: [ChatMessage] = [], + pendingMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, previewMessage: ChatMessage? = nil, draftMessage: DraftMessage? = nil, @@ -241,6 +246,7 @@ public extension ChatChannel { latestMessages: latestMessages, lastMessageFromCurrentUser: nil, pinnedMessages: pinnedMessages, + pendingMessages: pendingMessages, muteDetails: muteDetails, previewMessage: previewMessage, draftMessage: draftMessage, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift index 54183c8f911..bf7faf98ffa 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift @@ -13,6 +13,7 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy { static let refreshToken = "refreshToken(completion:)" static let clearTokenProvider = "clearTokenProvider()" static let logOut = "logOutUser()" + static let clearCurrentUserId = "clearCurrentUserId()" static let completeTokenWaiters = "completeTokenWaiters(token:)" static let completeTokenCompletions = "completeTokenCompletions(error:)" static let setToken = "setToken(token:completeTokenWaiters:)" @@ -95,6 +96,10 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy { record() } + override func clearCurrentUserId() { + record() + } + var resetCallCount: Int = 0 override func reset() { resetCallCount += 1 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 d09aac6b6fd..1c15855a8e0 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -325,13 +325,16 @@ final class ChatClient_Tests: XCTestCase { try $0.saveCurrentUser(payload: .dummy(userId: userId, role: .admin)) try $0.saveCurrentDevice(.unique) } + let expectation = self.expectation(description: "logout completes") client.logout { expectation.fulfill() } - waitForExpectations(timeout: defaultTimeout) + /// Erasing current user id should be called right after calling logout. + XCTAssertCall(AuthenticationRepository_Mock.Signature.clearCurrentUserId, on: testEnv.authenticationRepository!) // THEN + waitForExpectations(timeout: defaultTimeout) XCTAssertCall(ConnectionRepository_Mock.Signature.disconnect, on: testEnv.connectionRepository!) XCTAssertEqual(testEnv.apiClient?.request_endpoint?.path, .devices) XCTAssertEqual(testEnv.apiClient?.request_endpoint?.method, .delete) @@ -1126,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 1a62755af6f..072e2b828eb 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -343,9 +343,13 @@ 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)) + // Same of pending messages + let pendingMessage = dummyMessagePayload(createdAt: .unique(before: messageCreatedAt)) + let payload = dummyPayload( with: channelId, messages: [message], + pendingMessages: [pendingMessage], pinnedMessages: [pinnedMessage], ownCapabilities: ["join-channel", "delete-channel"] ) @@ -452,7 +456,11 @@ final class ChannelDTO_Tests: XCTestCase { Assert.willBeEqual(payload.pinnedMessages[0].pinnedAt, loadedChannel.pinnedMessages[0].pinDetails?.pinnedAt) Assert.willBeEqual(payload.pinnedMessages[0].pinExpires, loadedChannel.pinnedMessages[0].pinDetails?.expiresAt) Assert.willBeEqual(payload.pinnedMessages[0].pinnedBy?.id, loadedChannel.pinnedMessages[0].pinDetails?.pinnedBy.id) - + + // Pending Messages + Assert.willBeEqual(payload.pendingMessages?[0].id, loadedChannel.pendingMessages[0].id) + Assert.willBeEqual(payload.pendingMessages?[0].text, loadedChannel.pendingMessages[0].text) + // Message user Assert.willBeEqual(payload.messages[0].user.id, loadedChannel.latestMessages.first?.author.id) Assert.willBeEqual(payload.messages[0].user.createdAt, loadedChannel.latestMessages.first?.author.userCreatedAt) 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) diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.default-light.png index fff84e1fe7a..664b29fb969 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.extraExtraExtraLarge-light.png index d476954ea20..1d3d9336089 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.extraExtraExtraLarge-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.rightToLeftLayout-default.png index 12d8e353d97..d0bad8cfa15 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.rightToLeftLayout-default.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.small-dark.png index d3e5549fec8..10a95649c39 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.small-dark.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedOneAfterThree.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.default-light.png index fff84e1fe7a..664b29fb969 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.extraExtraExtraLarge-light.png index d476954ea20..1d3d9336089 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.extraExtraExtraLarge-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.rightToLeftLayout-default.png index 12d8e353d97..d0bad8cfa15 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.rightToLeftLayout-default.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.small-dark.png index d3e5549fec8..10a95649c39 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.small-dark.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedSameTime.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.default-light.png index fff84e1fe7a..664b29fb969 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.extraExtraExtraLarge-light.png index d476954ea20..1d3d9336089 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.extraExtraExtraLarge-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.rightToLeftLayout-default.png index 12d8e353d97..d0bad8cfa15 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.rightToLeftLayout-default.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.small-dark.png index d3e5549fec8..10a95649c39 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.small-dark.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedThreeAfterOne.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.default-light.png index fff84e1fe7a..664b29fb969 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.extraExtraExtraLarge-light.png index d476954ea20..1d3d9336089 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.extraExtraExtraLarge-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.rightToLeftLayout-default.png index 12d8e353d97..d0bad8cfa15 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.rightToLeftLayout-default.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.small-dark.png index d3e5549fec8..10a95649c39 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.small-dark.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withFourAttachments_addedTwoAfterTwo.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withLongFileNames.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withLongFileNames.default-light.png index 72db63d1388..93c9524438b 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withLongFileNames.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withLongFileNames.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.default-light.png index 6b6d46d1018..db0603e498c 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.extraExtraExtraLarge-light.png index e95504130d3..0b7287af099 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.extraExtraExtraLarge-light.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.rightToLeftLayout-default.png index 97e2134ab50..2ba7e8f601e 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.rightToLeftLayout-default.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.small-dark.png index baed3026c0f..0429ae1e63d 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.small-dark.png and b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_attachmentsPreview_withMultipleAttachmentTypes.small-dark.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5cebd316cf3..f6c4fe9c976 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -795,8 +795,8 @@ desc 'Run source code formatting/linting' lane :run_swift_format do |options| Dir.chdir('..') do strict = options[:strict] ? '--lint' : nil - sources_matrix[:swiftformat_include].each do |path| - sh("mint run swiftformat #{strict} --config .swiftformat --exclude #{sources_matrix[:swiftformat_exclude].join(',')} #{path}") + sources_matrix[:swiftformat].each do |path| + sh("mint run swiftformat #{strict} --config .swiftformat #{path}") next if path == 'Tests' sh("mint run swiftlint lint --config .swiftlint.yml --fix --progress --quiet --reporter json #{path}") unless strict @@ -832,8 +832,7 @@ lane :sources_matrix do size: ['Sources', xcode_project], xcmetrics: ['Sources'], public_interface: ['Sources'], - swiftformat_include: ['Sources', 'DemoApp', 'Tests', 'Integration'], - swiftformat_exclude: ['**/Generated', 'Sources/StreamChatUI/StreamNuke', 'Sources/StreamChatUI/StreamSwiftyGif', 'Sources/StreamChatUI/StreamDifferenceKit'] + swiftformat: ['Sources', 'DemoApp', 'Tests', 'Integration'] } end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index f6b1d8414f6..1da41d0e38c 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -5,4 +5,4 @@ gem 'fastlane-plugin-versioning' gem 'fastlane-plugin-create_xcframework' gem 'fastlane-plugin-sonarcloud_metric_kit' -gem 'fastlane-plugin-stream_actions', '0.3.84' +gem 'fastlane-plugin-stream_actions', '0.3.90' diff --git a/lefthook.yml b/lefthook.yml index 1c373353f4d..eb0ee4f11fd 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,9 +1,14 @@ pre-commit: parallel: false jobs: - - run: mint run swiftformat --config .swiftformat --exclude '**/Generated', 'Sources/StreamChatUI/StreamNuke', 'Sources/StreamChatUI/StreamSwiftyGif', 'Sources/StreamChatUI/StreamDifferenceKit' {staged_files} + - run: mint run swiftformat --config .swiftformat {staged_files} glob: "*.{swift}" stage_fixed: true + exclude: + - "**/Generated/**" + - Sources/StreamChatUI/StreamNuke/** + - Sources/StreamChatUI/StreamSwiftyGif/** + - Sources/StreamChatUI/StreamDifferenceKit/** skip: - merge - rebase @@ -11,13 +16,23 @@ pre-commit: - run: mint run swiftlint lint --config .swiftlint.yml --fix --progress --quiet --reporter json {staged_files} glob: "*.{swift}" stage_fixed: true + exclude: + - "**/Generated/**" + - Sources/StreamChatUI/StreamNuke/** + - Sources/StreamChatUI/StreamSwiftyGif/** + - Sources/StreamChatUI/StreamDifferenceKit/** skip: - merge - rebase - - run: mint run swiftlint lint --config .swiftlint.yml --strict --progress --quiet --reporter json {staged_files} +pre-push: + jobs: + - run: mint run swiftlint lint --config .swiftlint.yml --strict --progress --quiet --reporter json {push_files} glob: "*.{swift}" - stage_fixed: true + exclude: + - "**/Generated/**" + - Sources/StreamChatUI/StreamNuke/** + - Sources/StreamChatUI/StreamSwiftyGif/** + - Sources/StreamChatUI/StreamDifferenceKit/** skip: - - merge - - rebase + - merge-commit diff --git a/yeetd-normal.pkg.2 b/yeetd-normal.pkg.2 new file mode 100644 index 00000000000..e7fe6d35adc Binary files /dev/null and b/yeetd-normal.pkg.2 differ