diff --git a/.github/actions/python-cache/action.yml b/.github/actions/python-cache/action.yml deleted file mode 100644 index 21fc1af5f4c..00000000000 --- a/.github/actions/python-cache/action.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: 'Python Cache' -description: 'Cache Python dependencies' -runs: - using: "composite" - steps: - - uses: actions/setup-python@v4 - with: - python-version: 3.11 - cache: 'pip' - - run: pip3 install -r requirements.txt --break-system-packages - shell: bash diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml index 423005d9dda..9ef01bac050 100644 --- a/.github/workflows/cron-checks.yml +++ b/.github/workflows/cron-checks.yml @@ -97,16 +97,6 @@ jobs: - name: Allure TestOps Launch Removal if: cancelled() run: bundle exec fastlane allure_launch_removal launch_id:$LAUNCH_ID - - uses: 8398a7/action-slack@v3 - with: - status: ${{ job.status }} - text: "You shall not pass!" - job_name: "${{ github.workflow }}: ${{ github.job }}" - fields: message,commit,author,action,workflow,job,took - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - MATRIX_CONTEXT: ${{ toJson(matrix) }} - if: failure() && github.event_name == 'schedule' - name: Parse xcresult if: failure() run: | @@ -165,16 +155,6 @@ jobs: - name: Run LLC Tests (Debug) run: bundle exec fastlane test device:"${{ matrix.device }} (${{ matrix.ios }})" cron:true timeout-minutes: 100 - - uses: 8398a7/action-slack@v3 - with: - status: ${{ job.status }} - text: "You shall not pass!" - job_name: "${{ github.workflow }}: ${{ github.job }}" - fields: message,commit,author,action,workflow,job,took - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - MATRIX_CONTEXT: ${{ toJson(matrix) }} - if: failure() && github.event_name == 'schedule' - name: Parse xcresult if: failure() run: | @@ -224,3 +204,18 @@ jobs: - run: bundle exec fastlane rubocop - run: ./Scripts/run-linter.sh - run: bundle exec fastlane pod_lint + + slack: + name: Slack Report + runs-on: ubuntu-latest + needs: [build-and-test-debug, test-e2e-debug, build-test-app-and-frameworks, build-old-xcode, automated-code-review] + if: failure() && github.event_name == 'schedule' + steps: + - uses: 8398a7/action-slack@v3 + with: + status: cancelled + text: "You shall not pass!" + job_name: "${{ github.workflow }}: ${{ github.job }}" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_NIGHTLY_CHECKS }} diff --git a/.github/workflows/record-snapshots.yml b/.github/workflows/record-snapshots.yml index 2bbbd7d8790..c05f727ab6b 100644 --- a/.github/workflows/record-snapshots.yml +++ b/.github/workflows/record-snapshots.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.1 - - run: gh workflow run smoke-checks.yml --ref "${GITHUB_REF#refs/heads/}" -f snapshots=true + - run: gh workflow run smoke-checks.yml --ref "${GITHUB_REF#refs/heads/}" -f record_snapshots=true timeout-minutes: 5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/smoke-checks-llc.yml b/.github/workflows/smoke-checks-llc.yml deleted file mode 100644 index 9b789e6abf4..00000000000 --- a/.github/workflows/smoke-checks-llc.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Smoke Checks LLC - -on: - push: - branches: - - develop - - main - - pull_request: - paths-ignore: - - 'README.md' - - 'CHANGELOG.md' - - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI - IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.1)" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_PR_NUM: ${{ github.event.pull_request.number }} - -jobs: - test-llc-debug: - name: Test LLC (Debug) - runs-on: macos-15 - steps: - - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 100 - - uses: ./.github/actions/bootstrap - env: - INSTALL_YEETD: true - INSTALL_SONAR: true - - uses: ./.github/actions/python-cache - - name: Run LLC Tests (Debug) - run: bundle exec fastlane test device:"${{ env.IOS_SIMULATOR_DEVICE }}" - timeout-minutes: 60 - - name: Run Sonar analysis - run: bundle exec fastlane sonar_upload - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - PR_NUMBER: ${{ github.event.number }} - - uses: 8398a7/action-slack@v3 - with: - status: ${{ job.status }} - text: "You shall not pass!" - job_name: "Test LLC (Debug)" - fields: message,commit,author,action,workflow,job,took - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - MATRIX_CONTEXT: ${{ toJson(matrix) }} - if: ${{ github.event_name == 'push' && failure() }} - - name: Parse xcresult - if: failure() - run: | - brew install chargepoint/xcparse/xcparse - xcparse logs fastlane/test_output/StreamChat.xcresult fastlane/test_output/logs/ - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: Test Data LLC - path: | - fastlane/test_output/logs/*/Diagnostics/**/*.txt - fastlane/test_output/logs/*/Diagnostics/simctl_diagnostics/DiagnosticReports/* diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index d0fa2e6de2b..e5a73918c49 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -8,8 +8,8 @@ on: workflow_dispatch: inputs: - snapshots: - description: 'Should Snapshots be recorded on CI?' + record_snapshots: + description: 'Record snapshots on CI?' type: boolean required: false default: false @@ -28,7 +28,6 @@ jobs: build-test-app-and-frameworks: name: Build Test App and Frameworks runs-on: macos-15 - if: ${{ github.event_name != 'push' }} steps: - uses: actions/checkout@v4.1.1 - uses: ./.github/actions/ruby-cache @@ -50,7 +49,7 @@ jobs: runs-on: macos-15 env: XCODE_VERSION: "15.4" - if: ${{ github.event_name != 'push' && github.event.inputs.snapshots != 'true' }} + if: ${{ github.event.inputs.record_snapshots != 'true' }} steps: - uses: actions/checkout@v4.1.1 with: @@ -69,7 +68,7 @@ jobs: build-old-xcode: name: Build LLC + UI (Xcode 15) runs-on: macos-15 - if: ${{ github.event_name != 'push' && github.event.inputs.snapshots != 'true' }} + if: ${{ github.event.inputs.record_snapshots != 'true' }} env: XCODE_VERSION: "15.4" steps: @@ -84,11 +83,49 @@ jobs: run: bundle exec fastlane test_ui device:"iPhone 13" build_for_testing:true timeout-minutes: 25 + test-llc-debug: + name: Test LLC (Debug) + runs-on: macos-15 + if: ${{ github.event.inputs.record_snapshots != 'true' }} + steps: + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 100 + - uses: ./.github/actions/bootstrap + env: + INSTALL_YEETD: true + INSTALL_SONAR: true + - name: Run LLC Tests (Debug) + run: bundle exec fastlane test device:"${{ env.IOS_SIMULATOR_DEVICE }}" + timeout-minutes: 60 + - name: Run Sonar analysis + run: bundle exec fastlane sonar_upload + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + - name: Parse xcresult + if: failure() + run: | + brew install chargepoint/xcparse/xcparse + xcparse logs fastlane/test_output/StreamChat.xcresult fastlane/test_output/logs/ + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: Test Data LLC + path: | + fastlane/test_output/logs/*/Diagnostics/**/*.txt + fastlane/test_output/logs/*/Diagnostics/simctl_diagnostics/DiagnosticReports/* + - name: Upload Test Coverage + uses: actions/upload-artifact@v4 + with: + name: test-coverage-${{ github.event.pull_request.number }} + path: reports/sonarqube-generic-coverage.xml + test-ui-debug: name: Test UI (Debug) runs-on: macos-15 needs: build-test-app-and-frameworks - if: ${{ github.event_name != 'push' }} steps: - uses: actions/checkout@v4.1.1 - uses: actions/download-artifact@v4 @@ -100,7 +137,7 @@ jobs: INSTALL_YEETD: true SKIP_MINT_BOOTSTRAP: true - name: Run UI Tests (Debug) - run: bundle exec fastlane test_ui device:"${{ env.IOS_SIMULATOR_DEVICE }}" skip_build:true record:${{ github.event.inputs.snapshots }} + run: bundle exec fastlane test_ui device:"${{ env.IOS_SIMULATOR_DEVICE }}" skip_build:true record:"${{ github.event.inputs.record_snapshots }}" timeout-minutes: 120 env: GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} # to open a PR @@ -120,7 +157,7 @@ jobs: allure_testops_launch: name: Launch Allure TestOps runs-on: macos-14 - if: ${{ github.event_name != 'push' && github.event.inputs.snapshots != 'true' }} + if: ${{ github.event.inputs.record_snapshots != 'true' }} needs: build-test-app-and-frameworks outputs: launch_id: ${{ steps.get_launch_id.outputs.launch_id }} @@ -139,7 +176,7 @@ jobs: test-e2e-debug: name: Test E2E UI (Debug) runs-on: macos-15 - if: ${{ github.event_name != 'push' && github.event.inputs.snapshots != 'true' }} + if: ${{ github.event.inputs.record_snapshots != 'true' }} needs: - allure_testops_launch - build-test-app-and-frameworks @@ -195,7 +232,7 @@ jobs: name: Build Demo App + Example Apps runs-on: macos-14 needs: build-test-app-and-frameworks - if: ${{ github.event_name != 'push' && github.event.inputs.snapshots != 'true' }} + if: ${{ github.event.inputs.record_snapshots != 'true' }} steps: - uses: actions/checkout@v4.1.1 - uses: actions/download-artifact@v4 @@ -224,7 +261,7 @@ jobs: name: Test Integration runs-on: macos-14 needs: build-test-app-and-frameworks - if: ${{ github.event_name != 'push' && github.event.inputs.snapshots != 'true' }} + if: ${{ github.event.inputs.record_snapshots != 'true' }} steps: - uses: actions/checkout@v4.1.1 - uses: actions/download-artifact@v4 diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 00000000000..9bd2c2eee38 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,50 @@ +name: Sonar + +on: + push: + branches: + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + sonar: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4.1.1 + + - uses: actions/github-script@v6 + id: get_pr_data + with: + script: | + return ( + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + commit_sha: context.sha, + owner: context.repo.owner, + repo: context.repo.repo, + }) + ).data[0]; + + - uses: ./.github/actions/bootstrap + env: + INSTALL_SONAR: true + SKIP_MINT_BOOTSTRAP: true + + - name: Run Sonar analysis + run: | + ARTIFACT_NAME="test-coverage-${{ fromJson(steps.get_pr_data.outputs.result).number }}" + ARTIFACT=$(gh api repos/${{ github.repository }}/actions/artifacts | jq -r ".artifacts | map(select(.name==\"$ARTIFACT_NAME\")) | first") + if [[ "$ARTIFACT" == null || "$ARTIFACT" == "" ]]; then + echo "Artifact not found. Skipping Sonar analysis." + else + gh run download $(echo $ARTIFACT | jq .workflow_run.id) -n "$ARTIFACT_NAME" -D reports + bundle exec fastlane sonar_upload + fi + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/sync-mock-server.yml b/.github/workflows/sync-mock-server.yml index 0db87e3488b..7f61287de6f 100644 --- a/.github/workflows/sync-mock-server.yml +++ b/.github/workflows/sync-mock-server.yml @@ -27,8 +27,7 @@ jobs: status: ${{ job.status }} text: "You shall not pass!" job_name: "${{ github.workflow }}: ${{ github.job }}" - fields: message,commit,author,action,workflow,job,took + fields: repo,commit,author,workflow env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - MATRIX_CONTEXT: ${{ toJson(matrix) }} if: failure() diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 9d4444aad82..7444975d897 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -35,8 +35,7 @@ jobs: with: status: ${{ job.status }} text: "You shall not pass!" - fields: message,commit,author,action,workflow,job,took + fields: repo,commit,author,workflow env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - MATRIX_CONTEXT: ${{ toJson(matrix) }} if: failure() diff --git a/.github/workflows/update-copyright.yml b/.github/workflows/update-copyright.yml index 1649699d350..fd3bf72645a 100644 --- a/.github/workflows/update-copyright.yml +++ b/.github/workflows/update-copyright.yml @@ -26,8 +26,7 @@ jobs: status: ${{ job.status }} text: "You shall not pass!" job_name: "${{ github.workflow }}: ${{ github.job }}" - fields: message,commit,author,action,workflow,job,took + fields: repo,commit,author,workflow env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - MATRIX_CONTEXT: ${{ toJson(matrix) }} if: failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index 224ce362fef..7385687d1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed +# [4.73.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.73.0) +_February 27, 2025_ + +## StreamChat +### ✅ Added +- Add sending messages to only specific members [#3595](https://github.com/GetStream/stream-chat-swift/pull/3595) +- Add Draft Messages Support [#3588](https://github.com/GetStream/stream-chat-swift/pull/3588) + - Add `ChatChannel.draftMessage` + - Add `ChatMessage.draftReply` + - Add `ChannelController`: + - `updateDraftMessage()` + - `deleteDraftMessage()` + - `loadDraftMessage()` + - Add `MessageController`: + - `updateDraftReply()` + - `deleteDraftReply()` + - `loadDraftReply()` + - Add `CurrentUserController`: + - `deleteDraft()` + - `loadDraftMessages()` + - `loadMoreDraftMessages()` + +### 🐞 Fixed +- Update channel's preview message when coming back to online [#3574](https://github.com/GetStream/stream-chat-swift/pull/3574) +- Fix message transformer not being applied when editing a message [#3602](https://github.com/GetStream/stream-chat-swift/pull/3602) + +## StreamChatUI +### ✅ Added +- Add `Components.isDraftMessagesEnabled` to enable Draft Messages [#3588](https://github.com/GetStream/stream-chat-swift/pull/3588) +- Add draft preview in Channel List and Thread List if drafts are enabled [#3588](https://github.com/GetStream/stream-chat-swift/pull/3588) + # [4.72.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.72.0) _February 04, 2025_ ### 🔄 Changed - Revert 'Improve performance of model conversions with large extra data' [#3576](https://github.com/GetStream/stream-chat-swift/pull/3576) +- Expand `StreamAudioPlayer` to allow passing an `options` field to `AVURLAsset` initialiser [#3586](https://github.com/GetStream/stream-chat-swift/pull/3586) # [4.71.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.71.0) _January 28, 2025_ diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index fde449f275b..5bb0d19a69f 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -194,6 +194,7 @@ class AppConfigViewController: UITableViewController { } enum ChatClientConfigOption: String, CaseIterable { + case baseURL case isLocalStorageEnabled case staysConnectedInBackground case reconnectionTimeout @@ -351,6 +352,9 @@ class AppConfigViewController: UITableViewController { cell.textLabel?.text = option.rawValue switch option { + case .baseURL: + cell.detailTextLabel?.text = chatClientConfig.baseURL.description + cell.accessoryType = .disclosureIndicator case .isLocalStorageEnabled: cell.accessoryView = makeSwitchButton(chatClientConfig.isLocalStorageEnabled) { [weak self] newValue in self?.chatClientConfig.isLocalStorageEnabled = newValue @@ -384,6 +388,8 @@ class AppConfigViewController: UITableViewController { ) { let option = options[indexPath.row] switch option { + case .baseURL: + showBaseURLInputAlert() case .deletedMessagesVisibility: pushDeletedMessagesVisibilitySelectorVC() case .reconnectionTimeout: @@ -634,6 +640,36 @@ class AppConfigViewController: UITableViewController { navigationController?.pushViewController(selectorViewController, animated: true) } + private func showBaseURLInputAlert() { + let alert = UIAlertController( + title: "Base URL", + message: "Input the base URL for the Chat Client.", + preferredStyle: .alert + ) + + alert.addTextField { textField in + textField.placeholder = "Base URL" + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.text = self.chatClientConfig.baseURL.description + textField.textContentType = .URL + } + + alert.addAction(.init(title: "Set", style: .default, handler: { _ in + guard let urlString = alert.textFields?.first?.text, + let url = URL(string: urlString) + else { + return + } + self.chatClientConfig.baseURL = .init(url: url) + self.tableView.reloadData() + })) + + alert.addAction(.init(title: "Cancel", style: .destructive, handler: nil)) + + present(alert, animated: true, completion: nil) + } + private func showTokenDetailsAlert() { let alert = UIAlertController( title: "Token Refreshing", diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index cbe714c7989..082f36de7f4 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -9,15 +9,18 @@ import UIKit class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate { let channelListVC: UIViewController let threadListVC: UIViewController + let draftListVC: UIViewController let currentUserController: CurrentChatUserController init( channelListVC: UIViewController, threadListVC: UIViewController, + draftListVC: UIViewController, currentUserController: CurrentChatUserController ) { self.channelListVC = channelListVC self.threadListVC = threadListVC + self.draftListVC = draftListVC self.currentUserController = currentUserController super.init(nibName: nil, bundle: nil) } @@ -60,7 +63,10 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele threadListVC.tabBarItem.image = UIImage(systemName: "text.bubble") threadListVC.tabBarItem.badgeColor = .red - viewControllers = [channelListVC, threadListVC] + draftListVC.tabBarItem.title = "Drafts" + draftListVC.tabBarItem.image = UIImage(systemName: "bubble.and.pencil") + + viewControllers = [channelListVC, threadListVC, draftListVC] } func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { diff --git a/DemoApp/Screens/DemoDraftMessageListVC.swift b/DemoApp/Screens/DemoDraftMessageListVC.swift new file mode 100644 index 00000000000..2765be5d8e5 --- /dev/null +++ b/DemoApp/Screens/DemoDraftMessageListVC.swift @@ -0,0 +1,274 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +class DemoDraftMessageListVC: UIViewController, ThemeProvider { + var onLogout: (() -> Void)? + var onDisconnect: (() -> Void)? + + private let currentUserController: CurrentChatUserController + private var drafts: [DraftMessage] = [] + private var isPaginatingDrafts = false + + lazy var userAvatarView: CurrentChatUserAvatarView = components + .currentUserAvatarView.init() + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.dataSource = self + tableView.register(DemoDraftMessageCell.self, forCellReuseIdentifier: "DemoDraftMessageCell") + return tableView + }() + + private lazy var loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.translatesAutoresizingMaskIntoConstraints = false + return indicator + }() + + private lazy var emptyStateLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "No draft messages" + label.textAlignment = .center + label.textColor = Appearance.default.colorPalette.subtitleText + label.isHidden = true + return label + }() + + init(currentUserController: CurrentChatUserController) { + self.currentUserController = currentUserController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Drafts" + + userAvatarView.controller = currentUserController + userAvatarView.addTarget(self, action: #selector(didTapOnCurrentUserAvatar), for: .touchUpInside) + userAvatarView.translatesAutoresizingMaskIntoConstraints = false + + navigationItem.backButtonTitle = "" + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: userAvatarView) + + setupViews() + loadDrafts() + } + + private func setupViews() { + view.backgroundColor = Appearance.default.colorPalette.background + tableView.backgroundColor = Appearance.default.colorPalette.background + + view.addSubview(tableView) + view.addSubview(loadingIndicator) + view.addSubview(emptyStateLabel) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyStateLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + private func loadDrafts() { + currentUserController.delegate = self + loadingIndicator.startAnimating() + currentUserController.loadDraftMessages { [weak self] _ in + self?.loadingIndicator.stopAnimating() + } + } + + private func loadMoreDrafts() { + guard !isPaginatingDrafts && !currentUserController.hasLoadedAllDrafts else { + return + } + + isPaginatingDrafts = true + currentUserController.loadMoreDraftMessages { [weak self] _ in + self?.isPaginatingDrafts = false + } + } + + @objc private func didTapOnCurrentUserAvatar(_ sender: Any) { + presentUserOptionsAlert( + onLogout: onLogout, + onDisconnect: onDisconnect, + client: currentUserController.client + ) + } +} + +extension DemoDraftMessageListVC: CurrentChatUserControllerDelegate { + func currentUserController( + _ controller: CurrentChatUserController, + didChangeDraftMessages draftMessages: [DraftMessage] + ) { + drafts = draftMessages + tableView.reloadData() + emptyStateLabel.isHidden = !drafts.isEmpty + tableView.isHidden = drafts.isEmpty + } +} + +extension DemoDraftMessageListVC: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + drafts.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "DemoDraftMessageCell", for: indexPath) as? DemoDraftMessageCell + let draft = drafts[indexPath.row] + if let cid = draft.cid { + cell?.configure(with: draft, channel: currentUserController.dataStore.channel(cid: cid)) + } + return cell ?? .init() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let threshold: CGFloat = 100 + let contentOffset = scrollView.contentOffset.y + let maximumOffset = scrollView.contentSize.height - scrollView.frame.size.height + + if maximumOffset - contentOffset <= threshold { + loadMoreDrafts() + } + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in + guard let self = self else { return } + let draft = self.drafts[indexPath.row] + guard let cid = draft.cid else { return } + + self.currentUserController.deleteDraftMessage(for: cid, threadId: draft.threadId) + completion(true) + } + + return UISwipeActionsConfiguration(actions: [deleteAction]) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let draft = drafts[indexPath.row] + guard let cid = draft.cid else { return } + + let channelController = currentUserController.client.channelController( + for: cid, + messageOrdering: .topToBottom + ) + + if let parentId = draft.threadId { + let messageController = currentUserController.client.messageController( + cid: cid, + messageId: parentId + ) + let threadVC = DemoChatThreadVC() + threadVC.messageController = messageController + threadVC.channelController = channelController + navigationController?.pushViewController(threadVC, animated: true) + return + } + + let channelVC = DemoChatChannelVC() + channelVC.channelController = channelController + navigationController?.pushViewController(channelVC, animated: true) + } +} + +class DemoDraftMessageCell: UITableViewCell { + private let channelNameLabel: UILabel = { + let label = UILabel() + label.font = Appearance.default.fonts.bodyBold + label.textColor = Appearance.default.colorPalette.text + return label + }() + + private let messageLabel: UILabel = { + let label = UILabel() + label.font = Appearance.default.fonts.footnote + label.numberOfLines = 2 + label.textColor = Appearance.default.colorPalette.subtitleText + return label + }() + + private let dateLabel: UILabel = { + let label = UILabel() + label.font = Appearance.default.fonts.footnote + label.textColor = Appearance.default.colorPalette.subtitleText + return label + }() + + private let pencilImageView: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "bubble.and.pencil")) + imageView.contentMode = .scaleAspectFit + imageView.tintColor = Appearance.default.colorPalette.subtitleText + return imageView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = Appearance.default.colorPalette.background + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + VContainer(spacing: 4) { + HContainer { + channelNameLabel + Spacer() + dateLabel + } + HContainer(spacing: 4) { + pencilImageView + .width(20) + .height(20) + messageLabel + } + }.embed(in: contentView, insets: .init(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + + func configure(with draft: DraftMessage, channel: ChatChannel?) { + if let channel = channel { + let channelName = Appearance.default.formatters.channelName.format( + channel: channel, + forCurrentUserId: StreamChatWrapper.shared.client?.currentUserId + ) ?? "" + if draft.threadId != nil { + channelNameLabel.text = "Thread in # \(channelName)" + } else { + channelNameLabel.text = "# \(channelName)" + } + } + + messageLabel.text = draft.text + + let dateFormatter = Appearance.default.formatters.messageTimestamp + dateLabel.text = dateFormatter.format(draft.createdAt) + } +} diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 7920e40a6c0..3d63ce1573d 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -570,6 +570,16 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { channelController.createNewMessage(text: message, skipPush: true) } }), + .init(title: "Say Hi to a specific member", isEnabled: canSendMessage, handler: { [unowned self] _ in + self.rootViewController.presentAlert(title: "Enter the channel member id", textFieldPlaceholder: "Send message") { userId in + guard let userId, !userId.isEmpty, + channelController.channel?.lastActiveMembers.map(\.id).contains(userId) == true else { + self.rootViewController.presentAlert(title: "user id is not valid") + return + } + channelController.createNewMessage(text: "Hi", restrictedVisibility: [userId]) + } + }), .init(title: "Send system message", isEnabled: canSendMessage, handler: { [unowned self] _ in self.rootViewController.presentAlert(title: "Enter the message text", textFieldPlaceholder: "Send message") { message in guard let message = message, !message.isEmpty else { diff --git a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift index d700810c91d..e63244d3ff0 100644 --- a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift +++ b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift @@ -47,10 +47,22 @@ extension DemoAppCoordinator { threadListVC.onDisconnect = { [weak self] in self?.disconnect() } + + let draftsVC = DemoDraftMessageListVC( + currentUserController: client.currentUserController() + ) + draftsVC.onLogout = { [weak self] in + self?.logOut() + } + draftsVC.onDisconnect = { [weak self] in + self?.disconnect() + } + let tabBarViewController = DemoAppTabBarController( channelListVC: chatVC, threadListVC: UINavigationController(rootViewController: threadListVC), - currentUserController: StreamChatWrapper.shared.client!.currentUserController() + draftListVC: UINavigationController(rootViewController: draftsVC), + currentUserController: client.currentUserController() ) set(rootViewController: tabBarViewController, animated: animated) DemoAppConfiguration.showPerformanceTracker() diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index 49262cb9832..d266b3f5afe 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -49,6 +49,7 @@ extension StreamChatWrapper { Components.default.isJumpToUnreadEnabled = true Components.default.messageSwipeToReplyEnabled = true Components.default.isComposerLinkPreviewEnabled = true + Components.default.isDraftMessagesEnabled = true Components.default.channelListSearchStrategy = .messages // Customize UI components diff --git a/Gemfile.lock b/Gemfile.lock index 878033382db..0249c21c9be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -279,7 +279,7 @@ GEM method_source (1.1.0) mini_magick (4.13.2) mini_mime (1.1.5) - mini_portile2 (2.8.7) + mini_portile2 (2.8.8) minitest (5.25.1) molinillo (0.8.0) multi_json (1.15.0) @@ -295,7 +295,7 @@ GEM netrc (0.11.0) nio4r (2.7.3) nkf (0.2.0) - nokogiri (1.16.7) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) octokit (9.1.0) @@ -316,7 +316,7 @@ GEM puma (6.4.3) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.8) + rack (3.1.10) rack-protection (4.1.0) base64 (>= 0.1.0) logger (>= 1.6.0) diff --git a/Githubfile b/Githubfile index 95a4bae9b64..6af32281d0b 100644 --- a/Githubfile +++ b/Githubfile @@ -1,7 +1,7 @@ #!/bin/bash -export ALLURECTL_VERSION='2.15.1' -export XCRESULTS_VERSION='1.16.3' +export ALLURECTL_VERSION='2.16.0' +export XCRESULTS_VERSION='1.19.1' export YEETD_VERSION='1.0' export GCLOUD_VERSION='464.0.0' export MINT_VERSION='0.17.5' diff --git a/README.md b/README.md index 206faf1c526..ebde461e2d1 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/APIClient/Endpoints/CallEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/CallEndpoints.swift deleted file mode 100644 index 68501d697f0..00000000000 --- a/Sources/StreamChat/APIClient/Endpoints/CallEndpoints.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -extension Endpoint { - static func getCallToken(callId: String) -> Endpoint { - .init( - path: .callToken(callId), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: nil - ) - } - - static func createCall(cid: ChannelId, callId: String, type: String) -> Endpoint { - .init( - path: .createCall(cid.apiPath), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: CallRequestBody(id: callId, type: type) - ) - } -} diff --git a/Sources/StreamChat/APIClient/Endpoints/DraftEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/DraftEndpoints.swift new file mode 100644 index 00000000000..34f156cd49a --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/DraftEndpoints.swift @@ -0,0 +1,64 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension Endpoint { + static func drafts(query: DraftListQuery) -> Endpoint { + .init( + path: .drafts, + method: .post, + queryItems: nil, + requiresConnectionId: false, + requiresToken: true, + body: query + ) + } + + static func updateDraftMessage( + channelId: ChannelId, + requestBody: DraftMessageRequestBody + ) -> Endpoint { + let body: [String: AnyEncodable] = [ + "message": AnyEncodable(requestBody) + ] + return .init( + path: .draftMessage(channelId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: body + ) + } + + static func getDraftMessage( + channelId: ChannelId, + threadId: MessageId? + ) -> Endpoint { + .init( + path: .draftMessage(channelId), + method: .get, + queryItems: threadId.map { + ["parent_id": $0] + }, + requiresConnectionId: false, + body: nil + ) + } + + static func deleteDraftMessage( + channelId: ChannelId, + threadId: MessageId? + ) -> Endpoint { + .init( + path: .draftMessage(channelId), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: threadId.map { + ["parent_id": $0] + } + ) + } +} diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index 92df26052ef..232ab780388 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -7,7 +7,7 @@ import Foundation extension EndpointPath { var shouldBeQueuedOffline: Bool { switch self { - case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction: + case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction, .draftMessage: return true case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel, .deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread, @@ -15,7 +15,7 @@ extension EndpointPath { .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, .callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, .polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote, - .unread, .blockUser, .unblockUser: + .unread, .blockUser, .unblockUser, .drafts: return false } } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index 0bba5d72ba5..8a31cc30a76 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -51,6 +51,10 @@ enum EndpointPath: Codable { case messageAction(MessageId) case translateMessage(MessageId) + // Drafts + case drafts + case draftMessage(ChannelId) + case banMember case flagUser(Bool) case flagMessage(Bool) @@ -128,6 +132,9 @@ enum EndpointPath: Codable { case let .messageAction(messageId): return "messages/\(messageId)/action" case let .translateMessage(messageId): return "messages/\(messageId)/translate" + case .drafts: return "drafts/query" + case let .draftMessage(channelId): return "channels/\(channelId.apiPath)/draft" + case .banMember: return "moderation/ban" case let .flagUser(flag): return "moderation/\(flag ? "flag" : "unflag")" case let .flagMessage(flag): return "moderation/\(flag ? "flag" : "unflag")" diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/CallPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/CallPayloads.swift deleted file mode 100644 index 4c033027978..00000000000 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/CallPayloads.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -struct CallTokenPayload: Decodable, Equatable { - private enum CodingKeys: String, CodingKey { - case token - case agoraUid = "agora_uid" - case agoraAppId = "agora_app_id" - } - - /// The call token. - let token: String - /// The UID related to this token (Agora only). - let agoraUid: UInt? - /// The Agora App Id (Agora only). - let agoraAppId: String? - - init(token: String, agoraUid: UInt?, agoraAppId: String?) { - self.token = token - self.agoraUid = agoraUid - self.agoraAppId = agoraAppId - } -} - -struct AgoraPayload: Decodable, Equatable { - let channel: String -} - -struct HMSPayload: Decodable, Equatable { - private enum CodingKeys: String, CodingKey { - case roomId = "room_id" - case roomName = "room_name" - } - - let roomId: String - let roomName: String -} - -struct CallPayload: Decodable, Equatable { - let id: String - let provider: String - let agora: AgoraPayload? - let hms: HMSPayload? -} - -struct CreateCallPayload: Decodable, Equatable { - private enum CodingKeys: String, CodingKey { - case token - case agoraUid = "agora_uid" - case agoraAppId = "agora_app_id" - case call - } - - /// The call object. - let call: CallPayload - - /// The call token. - let token: String - - /// The UID related to this token (Agora only). - let agoraUid: UInt? - - /// The Agora App Id (Agora only). - let agoraAppId: String? - - init(call: CallPayload, token: String, agoraUid: UInt?, agoraAppId: String?) { - self.call = call - self.token = token - self.agoraUid = agoraUid - self.agoraAppId = agoraAppId - } -} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index f1c7dcf0c76..2d365f39cfb 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -45,6 +45,8 @@ struct ChannelPayload { let channelReads: [ChannelReadPayload] let isHidden: Bool? + + let draft: DraftPayload? } extension ChannelPayload { @@ -68,6 +70,7 @@ extension ChannelPayload: Decodable { case membership case watcherCount = "watcher_count" case hidden + case draft } init(from decoder: Decoder) throws { @@ -83,7 +86,8 @@ extension ChannelPayload: Decodable { pendingMessages: try container.decodeArrayIfPresentIgnoringFailures([MessagePayload.Boxed].self, forKey: .pendingMessages)?.map(\.message), pinnedMessages: try container.decodeArrayIgnoringFailures([MessagePayload].self, forKey: .pinnedMessages), channelReads: try container.decodeArrayIfPresentIgnoringFailures([ChannelReadPayload].self, forKey: .channelReads) ?? [], - isHidden: try container.decodeIfPresent(Bool.self, forKey: .hidden) + isHidden: try container.decodeIfPresent(Bool.self, forKey: .hidden), + draft: try container.decodeIfPresent(DraftPayload.self, forKey: .draft) ) } } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/DraftPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/DraftPayloads.swift new file mode 100644 index 00000000000..a94b633cfbb --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/DraftPayloads.swift @@ -0,0 +1,176 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +class DraftPayloadResponse: Decodable { + let draft: DraftPayload + + init(draft: DraftPayload) { + self.draft = draft + } +} + +class DraftListPayloadResponse: Decodable { + let drafts: [DraftPayload] + let next: String? + + init(drafts: [DraftPayload], next: String? = nil) { + self.drafts = drafts + self.next = next + } +} + +class DraftPayload: Decodable { + let cid: ChannelId? + let channelPayload: ChannelDetailPayload? + let createdAt: Date + let message: DraftMessagePayload + let quotedMessage: MessagePayload? + let parentId: String? + let parentMessage: MessagePayload? + + init( + cid: ChannelId?, + channelPayload: ChannelDetailPayload?, + createdAt: Date, + message: DraftMessagePayload, + quotedMessage: MessagePayload?, + parentId: String?, + parentMessage: MessagePayload? + ) { + self.cid = cid + self.channelPayload = channelPayload + self.createdAt = createdAt + self.message = message + self.quotedMessage = quotedMessage + self.parentId = parentId + self.parentMessage = parentMessage + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: MessagePayloadsCodingKeys.self) + cid = try container.decodeIfPresent(ChannelId.self, forKey: .channelId) + parentId = try container.decodeIfPresent(String.self, forKey: .parentId) + channelPayload = try container.decodeIfPresent(ChannelDetailPayload.self, forKey: .channel) + createdAt = try container.decode(Date.self, forKey: .createdAt) + quotedMessage = try container.decodeIfPresent(MessagePayload.self, forKey: .quotedMessage) + parentMessage = try container.decodeIfPresent(MessagePayload.self, forKey: .parentMessage) + message = try container.decode(DraftMessagePayload.self, forKey: .message) + } +} + +class DraftMessagePayload: Decodable { + let id: String + let text: String + let command: String? + let args: String? + let showReplyInChannel: Bool + let mentionedUsers: [UserPayload]? + let extraData: [String: RawJSON] + let attachments: [MessageAttachmentPayload]? + let isSilent: Bool + + init( + id: String, + text: String, + command: String?, + args: String?, + showReplyInChannel: Bool, + mentionedUsers: [UserPayload]?, + extraData: [String: RawJSON], + attachments: [MessageAttachmentPayload]?, + isSilent: Bool + ) { + self.id = id + self.text = text + self.command = command + self.args = args + self.showReplyInChannel = showReplyInChannel + self.mentionedUsers = mentionedUsers + self.extraData = extraData + self.attachments = attachments + self.isSilent = isSilent + } + + required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: MessagePayloadsCodingKeys.self) + id = try container.decode(String.self, forKey: .id) + text = try container.decode(String.self, forKey: .text) + command = try container.decodeIfPresent(String.self, forKey: .command) + args = try container.decodeIfPresent(String.self, forKey: .args) + showReplyInChannel = try container.decodeIfPresent(Bool.self, forKey: .showReplyInChannel) ?? false + mentionedUsers = try container.decodeIfPresent([UserPayload].self, forKey: .mentionedUsers) + attachments = try container.decodeIfPresent([MessageAttachmentPayload].self, forKey: .attachments) + isSilent = try container.decodeIfPresent(Bool.self, forKey: .isSilent) ?? false + if var payload = try? [String: RawJSON](from: decoder) { + payload.removeValues(forKeys: MessagePayloadsCodingKeys.allCases.map(\.rawValue)) + extraData = payload + } else { + extraData = [:] + } + } +} + +class DraftMessageRequestBody: Encodable { + let id: String + let text: String + let command: String? + let args: String? + let parentId: String? + let showReplyInChannel: Bool + let isSilent: Bool + let quotedMessageId: String? + let attachments: [MessageAttachmentPayload] + let mentionedUserIds: [UserId] + let extraData: [String: RawJSON] + + init( + id: String, + text: String, + command: String?, + args: String?, + parentId: String?, + showReplyInChannel: Bool, + isSilent: Bool, + quotedMessageId: String?, + attachments: [MessageAttachmentPayload], + mentionedUserIds: [UserId], + extraData: [String: RawJSON] + ) { + self.id = id + self.text = text + self.command = command + self.args = args + self.parentId = parentId + self.showReplyInChannel = showReplyInChannel + self.isSilent = isSilent + self.quotedMessageId = quotedMessageId + self.attachments = attachments + self.mentionedUserIds = mentionedUserIds + self.extraData = extraData + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: MessagePayloadsCodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(text, forKey: .text) + try container.encodeIfPresent(command, forKey: .command) + try container.encodeIfPresent(args, forKey: .args) + try container.encodeIfPresent(parentId, forKey: .parentId) + try container.encodeIfPresent(showReplyInChannel, forKey: .showReplyInChannel) + try container.encodeIfPresent(quotedMessageId, forKey: .quotedMessageId) + try container.encode(isSilent, forKey: .isSilent) + + if !attachments.isEmpty { + try container.encode(attachments, forKey: .attachments) + } + + if !mentionedUserIds.isEmpty { + try container.encode(mentionedUserIds, forKey: .mentionedUsers) + } + + try extraData.encode(to: encoder) + } +} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 5c60ea07092..880be5f98ac 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -8,6 +8,7 @@ import Foundation enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable { case id case cid + case channelId = "channel_cid" case type case user case userId = "user_id" @@ -22,6 +23,7 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable { case showReplyInChannel = "show_in_channel" case quotedMessageId = "quoted_message_id" case quotedMessage = "quoted_message" + case parentMessage = "parent_message" case mentionedUsers = "mentioned_users" case threadParticipants = "thread_participants" case replyCount = "reply_count" @@ -44,11 +46,14 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable { case moderationDetails = "moderation_details" // moderation v1 key case moderation // moderation v2 key case messageTextUpdatedAt = "message_text_updated_at" + case message case poll case pollId = "poll_id" case set case unset case skipEnrichUrl = "skip_enrich_url" + case restrictedVisibility = "restricted_visibility" + case draft } extension MessagePayload { @@ -107,6 +112,8 @@ class MessagePayload: Decodable { var poll: PollPayload? + var draft: DraftPayload? + /// Only message payload from `getMessage` endpoint contains channel data. It's a convenience workaround for having to /// make an extra call do get channel details. let channel: ChannelDetailPayload? @@ -171,6 +178,7 @@ class MessagePayload: Decodable { moderationDetails = try container.decodeIfPresent(MessageModerationDetailsPayload.self, forKey: .moderationDetails) messageTextUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .messageTextUpdatedAt) poll = try container.decodeIfPresent(PollPayload.self, forKey: .poll) + draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) } init( @@ -210,7 +218,8 @@ class MessagePayload: Decodable { moderation: MessageModerationDetailsPayload? = nil, moderationDetails: MessageModerationDetailsPayload? = nil, messageTextUpdatedAt: Date? = nil, - poll: PollPayload? = nil + poll: PollPayload? = nil, + draft: DraftPayload? = nil ) { self.id = id self.cid = cid @@ -249,6 +258,7 @@ class MessagePayload: Decodable { self.moderationDetails = moderationDetails self.messageTextUpdatedAt = messageTextUpdatedAt self.poll = poll + self.draft = draft } } @@ -272,6 +282,7 @@ struct MessageRequestBody: Encodable { var pinned: Bool var pinExpires: Date? var pollId: String? + var restrictedVisibility: [UserId]? let extraData: [String: RawJSON] init( @@ -290,6 +301,7 @@ struct MessageRequestBody: Encodable { pinned: Bool = false, pinExpires: Date? = nil, pollId: String? = nil, + restrictedVisibility: [UserId]? = nil, extraData: [String: RawJSON] ) { self.id = id @@ -307,6 +319,7 @@ struct MessageRequestBody: Encodable { self.pinned = pinned self.pinExpires = pinExpires self.pollId = pollId + self.restrictedVisibility = restrictedVisibility self.extraData = extraData } @@ -324,6 +337,7 @@ struct MessageRequestBody: Encodable { try container.encode(isSilent, forKey: .isSilent) try container.encodeIfPresent(pollId, forKey: .pollId) try container.encodeIfPresent(type, forKey: .type) + try container.encodeIfPresent(restrictedVisibility, forKey: .restrictedVisibility) if !attachments.isEmpty { try container.encode(attachments, forKey: .attachments) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ThreadListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ThreadListPayload.swift index 0c1bb613824..2be94378d69 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ThreadListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ThreadListPayload.swift @@ -18,6 +18,7 @@ enum ThreadCodingKeys: String, CodingKey, CaseIterable { case title case latestReplies = "latest_replies" case read + case draft } struct ThreadListPayload: Decodable { @@ -58,6 +59,7 @@ struct ThreadPayload: Decodable { let title: String? let latestReplies: [MessagePayload] let read: [ThreadReadPayload] + let draft: DraftPayload? let extraData: [String: RawJSON] init( @@ -74,6 +76,7 @@ struct ThreadPayload: Decodable { title: String?, latestReplies: [MessagePayload], read: [ThreadReadPayload], + draft: DraftPayload?, extraData: [String: RawJSON] ) { self.parentMessageId = parentMessageId @@ -89,6 +92,7 @@ struct ThreadPayload: Decodable { self.title = title self.latestReplies = latestReplies self.read = read + self.draft = draft self.extraData = extraData } @@ -109,6 +113,7 @@ struct ThreadPayload: Decodable { createdAt = try container.decode(Date.self, forKey: ThreadCodingKeys.createdAt) updatedAt = try container.decodeIfPresent(Date.self, forKey: ThreadCodingKeys.updatedAt) title = try container.decodeIfPresent(String.self, forKey: ThreadCodingKeys.title) + draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) latestReplies = try container.decodeArrayIfPresentIgnoringFailures( [MessagePayload].self, forKey: ThreadCodingKeys.latestReplies diff --git a/Sources/StreamChat/Audio/AudioPlayer/AudioPlaying.swift b/Sources/StreamChat/Audio/AudioPlayer/AudioPlaying.swift index 7b85a76aa9c..d1879d7aae6 100644 --- a/Sources/StreamChat/Audio/AudioPlayer/AudioPlaying.swift +++ b/Sources/StreamChat/Audio/AudioPlayer/AudioPlaying.swift @@ -121,47 +121,11 @@ open class StreamAudioPlayer: AudioPlaying, AppStateObserverDelegate { } open func loadAsset(from url: URL) { - /// We are going to check if the URL requested to load, represents the currentItem that we - /// have already loaded (if any). In this case, we will try either to resume the existing playback - /// or restart it, if possible. - if let currentItem = player.currentItem?.asset as? AVURLAsset, - url == currentItem.url, - context.assetLocation == url { - /// If the currentItem is paused, we want to continue the playback - /// Otherwise, no action is required - if context.state == .paused { - play() - } else if context.state == .stopped { - /// If the currentItem has stopped, we want to restart the playback. We are replacing - /// the currentItem with the same one to trigger the player's observers on the updated - /// currentItem. - player.replaceCurrentItem(with: .init(asset: currentItem)) - play() - } - - /// This case may be triggered if we call ``loadAsset`` on a player that is currently - /// playing the URL we provided. In this case we will Inform the delegate about the - /// current state. - notifyDelegates() - - return - } - - /// We call stop to update the currently set delegate that the playback has been stopped - /// and then we remove the current item from the player's queue. - stop() - player.replaceCurrentItem(with: nil) - - updateContext { - $0.state = .loading - $0.assetLocation = url - } - let asset = AVURLAsset(url: url) + loadAsset(from: url, options: nil) + } - assetPropertyLoader.loadProperties( - [.init(\.duration)], - of: asset - ) { [weak self] in self?.handleDurationLoading($0) } + open func loadAsset(from url: URL, options: [String: Any]?) { + handleAssetLoading(.init(url: url, options: options)) } open func play() { @@ -323,6 +287,57 @@ open class StreamAudioPlayer: AudioPlaying, AppStateObserverDelegate { context = newContext } + /// Loads an asset into the player and manages playback accordingly. + /// + /// If the asset matches the current item, playback is resumed if paused or + /// restarted if stopped. Otherwise, playback is stopped, the current item is + /// cleared, and the context is updated with the new asset. The asset's duration + /// is then loaded asynchronously. + /// + /// - Parameter asset: The AVURLAsset to load into the player. + private func handleAssetLoading(_ asset: AVURLAsset) { + /// We are going to check if the URL requested to load, represents the currentItem that we + /// have already loaded (if any). In this case, we will try either to resume the existing playback + /// or restart it, if possible. + if let currentItem = player.currentItem?.asset as? AVURLAsset, + asset.url == currentItem.url, + context.assetLocation == asset.url { + /// If the currentItem is paused, we want to continue the playback + /// Otherwise, no action is required + if context.state == .paused { + play() + } else if context.state == .stopped { + /// If the currentItem has stopped, we want to restart the playback. We are replacing + /// the currentItem with the same one to trigger the player's observers on the updated + /// currentItem. + player.replaceCurrentItem(with: .init(asset: currentItem)) + play() + } + + /// This case may be triggered if we call ``loadAsset`` on a player that is currently + /// playing the URL we provided. In this case we will Inform the delegate about the + /// current state. + notifyDelegates() + + return + } + + /// We call stop to update the currently set delegate that the playback has been stopped + /// and then we remove the current item from the player's queue. + stop() + player.replaceCurrentItem(with: nil) + + updateContext { + $0.state = .loading + $0.assetLocation = asset.url + } + + assetPropertyLoader.loadProperties( + [.init(\.duration)], + of: asset + ) { [weak self] in self?.handleDurationLoading($0) } + } + /// It's used by the assetPropertyLoader to handle the completion (successful or failed) of duration's /// asynchronous loading. private func handleDurationLoading( diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 4f27fa0235b..d756842abf5 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -142,7 +142,14 @@ extension ChatClient { ) -> PollsRepository = { PollsRepository(database: $0, apiClient: $1) } - + + var draftMessagesRepositoryBuilder: ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> DraftMessagesRepository = { + DraftMessagesRepository(database: $0, apiClient: $1) + } + var channelListUpdaterBuilder: ( _ database: DatabaseContainer, _ apiClient: APIClient diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 4ab7e02050d..1505ca7d8f2 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -78,6 +78,8 @@ public class ChatClient { let pollsRepository: PollsRepository + let draftMessagesRepository: DraftMessagesRepository + let channelListUpdater: ChannelListUpdater func makeMessagesPaginationStateHandler() -> MessagesPaginationStateHandling { @@ -212,6 +214,7 @@ public class ChatClient { apiClient ) pollsRepository = environment.pollsRepositoryBuilder(databaseContainer, apiClient) + draftMessagesRepository = environment.draftMessagesRepositoryBuilder(databaseContainer, apiClient) authRepository.delegate = self apiClientEncoder.connectionDetailsProviderDelegate = self diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift index 24491d433f2..47634239795 100644 --- a/Sources/StreamChat/ChatClientFactory.swift +++ b/Sources/StreamChat/ChatClientFactory.swift @@ -125,6 +125,7 @@ class ChatClientFactory { newProcessedMessageIds: { [weak center] in center?.newMessageIds ?? [] } ), ThreadUpdaterMiddleware(), + DraftUpdaterMiddleware(), UserTypingStateUpdaterMiddleware(), ChannelTruncatedEventMiddleware(), MemberEventMiddleware(), diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index c820a1e0f00..2b061ff3d9f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -187,6 +187,8 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP private let pollsRepository: PollsRepository + private let draftsRepository: DraftMessagesRepository + var _basePublishers: Any? /// 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, @@ -232,7 +234,8 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP client.apiClient ) pollsRepository = client.pollsRepository - + draftsRepository = client.draftMessagesRepository + super.init() setChannelObserver() @@ -747,6 +750,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - 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. /// - extraData: Additional extra data of the message object. /// - completion: Called when saving the message to the local DB finishes. /// @@ -760,6 +764,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP quotedMessageId: MessageId? = nil, skipPush: Bool = false, skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil ) { @@ -782,6 +787,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP quotedMessageId: quotedMessageId, skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, extraData: transformableInfo.extraData, poll: nil, completion: completion @@ -793,11 +799,13 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - Parameters: /// - text: The text of the system message. /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. + /// - restrictedVisibility: The list of user ids that can see the message. /// - extraData: The extra data for the message. /// - completion: Called when saving the message to the local DB finishes. public func createSystemMessage( text: String, messageId: MessageId? = nil, + restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil ) { @@ -822,6 +830,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP quotedMessageId: nil, skipPush: false, skipEnrichUrl: false, + restrictedVisibility: restrictedVisibility, poll: nil, extraData: extraData ) { result in @@ -834,6 +843,91 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } + /// Updates the draft message of this channel. + /// + /// If there is no draft message, a new draft message will be created. + /// - Parameters: + /// - text: The text of the draft message. + /// - 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: The attachments of the draft message. + /// - mentionedUserIds: The mentioned user ids of the draft message. + /// - quotedMessageId: The message that the draft message is quoting. + /// - command: The command of the draft message. + /// - extraData: The extra data of the draft message. + /// - completion: Called when the draft message is saved to the server. + public func updateDraftMessage( + text: String, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + command: Command? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + completion?(.failure(error ?? ClientError.Unknown())) + } + return + } + + draftsRepository.updateDraft( + for: cid, + threadId: nil, + text: text, + isSilent: isSilent, + showReplyInChannel: false, + command: command?.name, + arguments: command?.args, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + extraData: extraData + ) { result in + self.callback { + completion?(result) + } + } + } + + /// Loads the draft message of this channel. + /// + /// It is not necessary to call this method if the channel list query was called before. + public func loadDraftMessage( + completion: ((Result) -> Void)? = nil + ) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + completion?(.failure(error ?? ClientError.Unknown())) + } + return + } + + draftsRepository.getDraft(for: cid, threadId: nil) { result in + self.callback { + completion?(result) + } + } + } + + /// Deletes the draft message of this channel. + public func deleteDraftMessage(completion: ((Error?) -> Void)? = nil) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + completion?(error) + } + return + } + + draftsRepository.deleteDraft(for: cid, threadId: nil) { error in + self.callback { + completion?(error) + } + } + } + /// Creates a new poll. /// /// - Parameters: @@ -1432,6 +1526,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP quotedMessageId: MessageId? = nil, skipPush: Bool = false, skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:], poll: PollPayload?, completion: ((Result) -> Void)? = nil @@ -1461,6 +1556,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP quotedMessageId: quotedMessageId, skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, poll: poll, extraData: extraData ) { result in @@ -1493,7 +1589,7 @@ extension ChatChannelController { _ database: DatabaseContainer, _ apiClient: APIClient ) -> ChannelUpdater = ChannelUpdater.init - + var memberUpdaterBuilder: ( _ database: DatabaseContainer, _ apiClient: APIClient diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 1c1a1ee3dbf..6ef29d6bd3f 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -72,7 +72,6 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt /// The unread messages and channels count for the current user. /// /// Returns `noUnread` if `currentUser` doesn't exist yet. - /// public var unreadCount: UnreadCount { currentUser?.unreadCount ?? .noUnread } @@ -86,6 +85,31 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt /// The worker used to update the current user member for a given channel. private lazy var currentMemberUpdater = createMemberUpdater() + /// The query used for fetching the draft messages. + private var draftListQuery = DraftListQuery() + + /// Use for observing the current user's draft messages changes. + private var draftMessagesObserver: BackgroundListDatabaseObserver? + + /// The repository for draft messages. + private var draftMessagesRepository: DraftMessagesRepository + + /// The token for the next page of draft messages. + private var draftMessagesNextCursor: String? + + /// A flag to indicate whether all draft messages have been loaded. + public private(set) var hasLoadedAllDrafts: Bool = false + + /// The current user's draft messages. + public var draftMessages: [DraftMessage] { + if let observer = draftMessagesObserver { + return Array(observer.items) + } + + let observer = createDraftMessagesObserver(query: draftListQuery) + return Array(observer.items) + } + /// Creates a new `CurrentUserControllerGeneric`. /// /// - Parameters: @@ -95,6 +119,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt init(client: ChatClient, environment: Environment = .init()) { self.client = client self.environment = environment + draftMessagesRepository = client.draftMessagesRepository } /// Synchronize local data with remote. Waits for the client to connect but doesn’t initiate the connection itself. @@ -334,8 +359,79 @@ public extension CurrentChatUserController { } } - private func createMemberUpdater() -> ChannelMemberUpdater { - .init(database: client.databaseContainer, apiClient: client.apiClient) + /// Loads the draft messages for the current user. + /// + /// It will load the first page of drafts of the current user. + /// `loadMoreDraftMessages` can be used to load the next pages. + /// + /// - Parameters: + /// - query: The query for filtering the drafts. + /// - completion: Called when the API call is finished. + /// It is optional since it can be observed from the delegate events. + func loadDraftMessages( + query: DraftListQuery = DraftListQuery(), + completion: ((Result<[DraftMessage], Error>) -> Void)? = nil + ) { + draftListQuery = query + createDraftMessagesObserver(query: query) + draftMessagesRepository.loadDrafts(query: query) { result in + self.callback { + switch result { + case let .success(response): + self.draftMessagesNextCursor = response.next + self.hasLoadedAllDrafts = response.next == nil + completion?(.success(response.drafts)) + case let .failure(error): + completion?(.failure(error)) + } + } + } + } + + /// Loads more draft messages for the current user. + /// + /// - Parameters: + /// - limit: The number of draft messages to load. If `nil`, the default limit will be used. + /// - completion: Called when the API call is finished. + /// It is optional since it can be observed from the delegate events. + func loadMoreDraftMessages( + limit: Int? = nil, + completion: ((Result<[DraftMessage], Error>) -> Void)? = nil + ) { + guard let nextCursor = draftMessagesNextCursor else { + completion?(.success([])) + return + } + + let limit = limit ?? draftListQuery.pagination.pageSize + var updatedQuery = draftListQuery + updatedQuery.pagination = Pagination(pageSize: limit, cursor: nextCursor) + + draftMessagesRepository.loadDrafts(query: updatedQuery) { result in + self.callback { + switch result { + case let .success(response): + self.draftMessagesNextCursor = response.next + self.hasLoadedAllDrafts = response.next == nil + completion?(.success(response.drafts)) + case let .failure(error): + completion?(.failure(error)) + } + } + } + } + + /// Deletes the draft message of the given channel or thread. + func deleteDraftMessage( + for cid: ChannelId, + threadId: MessageId? = nil, + completion: ((Error?) -> Void)? = nil + ) { + draftMessagesRepository.deleteDraft(for: cid, threadId: threadId) { error in + self.callback { + completion?(error) + } + } } } @@ -350,6 +446,14 @@ extension CurrentChatUserController { _ fetchedResultsControllerType: NSFetchedResultsController.Type ) -> BackgroundEntityDatabaseObserver = BackgroundEntityDatabaseObserver.init + var draftMessagesObserverBuilder: ( + _ database: DatabaseContainer, + _ fetchRequest: NSFetchRequest, + _ itemCreator: @escaping (MessageDTO) throws -> DraftMessage + ) -> BackgroundListDatabaseObserver = { + .init(database: $0, fetchRequest: $1, itemCreator: $2, itemReuseKeyPaths: (\DraftMessage.id, \MessageDTO.id)) + } + var currentUserUpdaterBuilder = CurrentUserUpdater.init } } @@ -378,6 +482,28 @@ private extension CurrentChatUserController { NSFetchedResultsController.self ) } + + private func createMemberUpdater() -> ChannelMemberUpdater { + .init(database: client.databaseContainer, apiClient: client.apiClient) + } + + @discardableResult + private func createDraftMessagesObserver(query: DraftListQuery) -> BackgroundListDatabaseObserver { + let observer = environment.draftMessagesObserverBuilder( + client.databaseContainer, + MessageDTO.draftMessagesFetchRequest(query: query), + { DraftMessage(try $0.asModel()) } + ) + observer.onDidChange = { [weak self] _ in + guard let self = self else { return } + self.delegateCallback { + $0.currentUserController(self, didChangeDraftMessages: self.draftMessages) + } + } + try? observer.startObserving() + draftMessagesObserver = observer + return observer + } } // MARK: - Delegates @@ -389,12 +515,23 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { /// The controller observed a change in the `CurrentChatUser` entity. func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUser: EntityChange) + + /// The controller observed a change in the draft messages. + func currentUserController( + _ controller: CurrentChatUserController, + didChangeDraftMessages draftMessages: [DraftMessage] + ) } public extension CurrentChatUserControllerDelegate { func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) {} func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUser: EntityChange) {} + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeDraftMessages draftMessages: [DraftMessage] + ) {} } public extension CurrentChatUserController { diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index cf4b883c8f5..de88b4c0f67 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -187,6 +187,9 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP private let replyPaginationHandler: MessagesPaginationStateHandling private var replyPaginationState: MessagesPaginationState { replyPaginationHandler.state } + /// The drafts repository. + private let draftsRepository: DraftMessagesRepository + /// Creates a new `MessageControllerGeneric`. /// - Parameters: /// - client: The `Client` instance this controller belongs to. @@ -206,6 +209,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP client.databaseContainer, client.apiClient ) + draftsRepository = client.draftMessagesRepository super.init() setRepliesObserver() @@ -259,12 +263,21 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP extraData: [String: RawJSON]? = nil, completion: ((Error?) -> Void)? = nil ) { + var transformableInfo = NewMessageTransformableInfo( + text: text, + attachments: attachments, + extraData: extraData ?? message?.extraData ?? [:] + ) + if let transformer = client.config.modelsTransformer { + transformableInfo = transformer.transform(newMessageInfo: transformableInfo) + } + messageUpdater.editMessage( messageId: messageId, - text: text, + text: transformableInfo.text, skipEnrichUrl: skipEnrichUrl, - attachments: attachments, - extraData: extraData + attachments: transformableInfo.attachments, + extraData: transformableInfo.extraData ) { result in self.callback { completion?(result.error) @@ -294,6 +307,8 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// - Parameters: /// - messageId: The id for the sent message. By default, it is automatically generated by Stream.. /// - text: Text of the message. + /// - 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. /// - pinning: Pins the new message. `nil` if should not be pinned. /// - attachments: An array of the attachments for the message. /// `Note`: can be built-in types, custom attachment types conforming to `AttachmentEnvelope` protocol @@ -843,6 +858,78 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP } } } + + /// Updates the draft message for this thread. + /// + /// If there is no draft message, a new draft message will be created. + /// - Parameters: + /// - text: The text of the draft message. + /// - 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: The attachments of the draft message. + /// - mentionedUserIds: The mentioned user ids of the draft message. + /// - quotedMessageId: The message that the draft message is quoting. + /// - showReplyInChannel: If the draft message should be shown in the channel. + /// - command: The command of the draft message. + /// - extraData: The extra data of the draft message. + /// - completion: Called when the draft message is saved to the server. + public func updateDraftReply( + text: String, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + showReplyInChannel: Bool = false, + command: Command? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + draftsRepository.updateDraft( + for: cid, + threadId: messageId, + text: text, + isSilent: isSilent, + showReplyInChannel: showReplyInChannel, + command: command?.name, + arguments: command?.args, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + extraData: extraData + ) { result in + self.callback { + completion?(result) + } + } + } + + /// Loads the draft message for this thread. + /// + /// It is not necessary to call this method if the thread was loaded before. + public func loadDraftReply( + completion: ((Result) -> Void)? = nil + ) { + draftsRepository.getDraft( + for: cid, + threadId: messageId + ) { result in + self.callback { + completion?(result) + } + } + } + + /// Deletes the draft message for this thread. + public func deleteDraftReply(completion: ((Error?) -> Void)? = nil) { + draftsRepository.deleteDraft( + for: cid, + threadId: messageId + ) { error in + self.callback { + completion?(error) + } + } + } } // MARK: - Environment diff --git a/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift b/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift index 2e8b7aa99e4..af60ce40118 100644 --- a/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift +++ b/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift @@ -110,7 +110,11 @@ class AttachmentDTO: NSManagedObject { static func pendingUploadFetchRequest() -> NSFetchRequest { let request = NSFetchRequest(entityName: AttachmentDTO.entityName) request.sortDescriptors = [NSSortDescriptor(keyPath: \AttachmentDTO.id, ascending: true)] - request.predicate = NSPredicate(format: "localStateRaw == %@", LocalAttachmentState.pendingUpload.rawValue) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "localStateRaw == %@", LocalAttachmentState.pendingUpload.rawValue), + NSPredicate(format: "message.draftOfChannel == nil"), + NSPredicate(format: "message.draftOfThread == nil") + ]) return request } diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 5100e2af7d4..da4e1b40189 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -66,6 +66,7 @@ class ChannelDTO: NSManagedObject { @NSManaged var watchers: Set @NSManaged var memberListQueries: Set @NSManaged var previewMessage: MessageDTO? + @NSManaged var draftMessage: MessageDTO? /// If the current channel is muted by the current user, `mute` contains details. @NSManaged var mute: ChannelMuteDTO? @@ -328,6 +329,16 @@ extension NSManagedObjectContext { dto.updateOldestMessageAt(payload: payload) + if let draftMessage = payload.draft { + dto.draftMessage = try saveDraftMessage(payload: draftMessage, for: payload.channel.cid, cache: nil) + } else { + /// If the payload does not contain a draft message, we should + /// delete the existing draft message if it exists. + if let draftMessage = dto.draftMessage { + deleteDraftMessage(in: payload.channel.cid, threadId: draftMessage.parentMessageId) + } + } + try payload.pinnedMessages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } @@ -563,6 +574,7 @@ extension ChatChannel { let membership = try dto.membership.map { try $0.asModel() } let pinnedMessages = dto.pinnedMessages.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() }) let channel = try ChatChannel( @@ -596,7 +608,8 @@ extension ChatChannel { lastMessageFromCurrentUser: latestMessageFromUser, pinnedMessages: pinnedMessages, muteDetails: muteDetails, - previewMessage: previewMessage + previewMessage: previewMessage, + draftMessage: draftMessage.map(DraftMessage.init) ) if let transformer = clientConfig.modelsTransformer { diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index a72ffd4cc42..26eb70f3369 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -83,6 +83,11 @@ class MessageDTO: NSManagedObject { @NSManaged var searches: Set @NSManaged var previewOfChannel: ChannelDTO? + @NSManaged var draftOfChannel: ChannelDTO? + @NSManaged var draftOfThread: MessageDTO? + @NSManaged var draftReply: MessageDTO? + @NSManaged var isDraft: Bool + /// If the message is sent by the current user, this field /// contains channel reads of other channel members (excluding the current user), /// where `read.lastRead >= self.createdAt`. @@ -92,6 +97,8 @@ class MessageDTO: NSManagedObject { /// /// For messages authored NOT by the current user this field is always empty. @NSManaged var reads: Set + + @NSManaged var restrictedVisibility: Set? @NSManaged var pinned: Bool @NSManaged var pinnedBy: UserDTO? @@ -170,6 +177,22 @@ class MessageDTO: NSManagedObject { return request } + /// Returns all the draft messages. + static func draftMessagesFetchRequest(query: DraftListQuery) -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageDTO.entityName) + MessageDTO.applyPrefetchingState(to: request) + request.sortDescriptors = query.sorting.compactMap { $0.sortDescriptor() } + let isPartOfChannelOrThreadPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [ + NSPredicate(format: "draftOfChannel != nil"), + NSPredicate(format: "draftOfThread != nil") + ]) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + isPartOfChannelOrThreadPredicate, + NSPredicate(format: "isDraft == YES") + ]) + return request + } + static func allAttachmentsAreUploadedOrEmptyPredicate() -> NSCompoundPredicate { NSCompoundPredicate(orPredicateWithSubpredicates: [ .init( @@ -228,6 +251,10 @@ class MessageDTO: NSManagedObject { .init(format: "deletedAt == nil") } + private static func ignoreDraftMessagesPredicate() -> NSPredicate { + .init(format: "isDraft == NO") + } + private static func channelPredicate(with cid: String) -> NSPredicate { .init(format: "channel.cid == %@", cid) } @@ -293,7 +320,8 @@ class MessageDTO: NSManagedObject { messageTypePredicate, nonTruncatedMessagesPredicate(), ignoreOlderMessagesPredicate, - deletedMessagesPredicate(deletedMessagesVisibility: deletedMessagesVisibility) + deletedMessagesPredicate(deletedMessagesVisibility: deletedMessagesVisibility), + ignoreDraftMessagesPredicate() ] if filterNewerMessages { @@ -329,6 +357,7 @@ class MessageDTO: NSManagedObject { ]) var subpredicates = [ + ignoreDraftMessagesPredicate(), replyMessage, shouldShowInsideThread, ignoreNewerRepliesPredicate, @@ -349,7 +378,8 @@ class MessageDTO: NSManagedObject { channelPredicate(with: cid), messageSentPredicate(), nonTruncatedMessagesPredicate(), - nonDeletedMessagesPredicate() + nonDeletedMessagesPredicate(), + ignoreDraftMessagesPredicate() ]) } @@ -400,7 +430,8 @@ class MessageDTO: NSManagedObject { MessageDTO.applyPrefetchingState(to: request) request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "ANY searches.filterHash == %@", query.filterHash), - NSPredicate(format: "isHardDeleted == NO") + NSPredicate(format: "isHardDeleted == NO"), + ignoreDraftMessagesPredicate() ]) let sortDescriptors = query.sort.compactMap { $0.key.sortDescriptor(isAscending: $0.isAscending) } request.sortDescriptors = sortDescriptors.isEmpty ? [MessageSearchSortingKey.defaultSortDescriptor] : sortDescriptors @@ -647,6 +678,7 @@ extension NSManagedObjectContext: MessageDatabaseSession { skipPush: Bool, skipEnrichUrl: Bool, poll: PollPayload?, + restrictedVisibility: [UserId], extraData: [String: RawJSON] ) throws -> MessageDTO { guard let currentUserDTO = currentUser else { @@ -686,6 +718,7 @@ extension NSManagedObjectContext: MessageDatabaseSession { message.reactionScores = [:] message.reactionCounts = [:] message.reactionGroups = [] + message.restrictedVisibility = Set(restrictedVisibility) // Message type if parentMessageId != nil { @@ -737,10 +770,92 @@ extension NSManagedObjectContext: MessageDatabaseSession { return message } + func createNewDraftMessage( + in cid: ChannelId, + text: String, + command: String?, + arguments: String?, + parentMessageId: MessageId?, + attachments: [AnyAttachmentPayload], + mentionedUserIds: [UserId], + showReplyInChannel: Bool, + isSilent: Bool, + quotedMessageId: MessageId?, + extraData: [String: RawJSON] + ) throws -> MessageDTO { + guard let currentUserDTO = currentUser else { + throw ClientError.CurrentUserDoesNotExist() + } + + guard let channelDTO = ChannelDTO.load(cid: cid, context: self) else { + throw ClientError.ChannelDoesNotExist(cid: cid) + } + + /// Makes sure to delete the existing draft message if it exists. + deleteDraftMessage(in: cid, threadId: parentMessageId) + + let createdAt = Date() + let message = MessageDTO.loadOrCreate(id: .newUniqueId, context: self, cache: nil) + message.isDraft = true + message.locallyCreatedAt = createdAt.bridgeDate + message.createdAt = createdAt.bridgeDate + message.updatedAt = createdAt.bridgeDate + message.cid = cid.rawValue + message.text = text + message.command = command + message.args = arguments + message.parentMessageId = parentMessageId + message.extraData = try JSONEncoder.default.encode(extraData) + message.isSilent = isSilent + message.skipPush = false + message.skipEnrichUrl = false + message.reactionScores = [:] + message.reactionCounts = [:] + message.reactionGroups = [] + message.mentionedUserIds = mentionedUserIds + message.showReplyInChannel = showReplyInChannel + message.quotedMessage = quotedMessageId.flatMap { MessageDTO.load(id: $0, context: self) } + message.user = currentUserDTO.user + message.channel = channelDTO + message.attachments = Set( + try attachments.enumerated().map { index, attachment in + let id = AttachmentId(cid: cid, messageId: message.id, index: index) + return try createNewAttachment(attachment: attachment, id: id) + } + ) + + if parentMessageId != nil { + message.type = MessageType.reply.rawValue + } else { + message.type = MessageType.regular.rawValue + } + + if let threadId = parentMessageId { + let parentMessageDTO = self.message(id: threadId) + message.draftOfThread = parentMessageDTO + let threadDTO = thread(parentMessageId: threadId, cache: nil) + threadDTO?.parentMessageId = threadId + } else { + message.channel?.draftMessage = message + } + + return message + } + + /// Saves a message into the local DB. + /// - Parameters: + /// - payload: The message payload + /// - channelDTO: The channel dto. + /// - syncOwnReactions: Whether to sync own reactions. It should be set to `true` when the payload comes from an API response and `false` when the payload is received via WS events. For performance reasons the API + /// does not populate the `message.own_reactions` when sending events + /// - skipDraftUpdate: Whether to skip draft update. This is used when saving quoted and parent messages from + /// saveDraftMessage function to avoid an infinite loop since saving the draft would be called again. + /// - cache: The pre-warmed cache. func saveMessage( payload: MessagePayload, channelDTO: ChannelDTO, syncOwnReactions: Bool, + skipDraftUpdate: Bool = false, cache: PreWarmedCache? ) throws -> MessageDTO { let cid = try ChannelId(cid: channelDTO.cid) @@ -813,12 +928,24 @@ extension NSManagedObjectContext: MessageDatabaseSession { payload: quotedMessage, channelDTO: channelDTO, syncOwnReactions: false, + skipDraftUpdate: false, cache: cache ) } else { dto.quotedMessage = nil } + if let draft = payload.draft, skipDraftUpdate == false { + dto.draftReply = try saveDraftMessage(payload: draft, for: cid, cache: cache) + } else if skipDraftUpdate == false { + /// If the payload does not contain a draft reply, we should + /// delete the existing draft reply if it exists. + if let draft = dto.draftReply { + deleteDraftMessage(in: cid, threadId: draft.parentMessageId) + dto.draftReply = nil + } + } + let user = try saveUser(payload: payload.user) dto.user = user @@ -928,10 +1055,20 @@ extension NSManagedObjectContext: MessageDatabaseSession { return dto } - func saveMessages(messagesPayload: MessageListPayload, for cid: ChannelId?, syncOwnReactions: Bool = true) -> [MessageDTO] { + func saveMessages( + messagesPayload: MessageListPayload, + for cid: ChannelId?, + syncOwnReactions: Bool = true + ) -> [MessageDTO] { let cache = messagesPayload.getPayloadToModelIdMappings(context: self) return messagesPayload.messages.compactMapLoggingError { - try saveMessage(payload: $0, for: cid, syncOwnReactions: syncOwnReactions, cache: cache) + try saveMessage( + payload: $0, + for: cid, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: false, + cache: cache + ) } } @@ -939,6 +1076,7 @@ extension NSManagedObjectContext: MessageDatabaseSession { payload: MessagePayload, for cid: ChannelId?, syncOwnReactions: Bool = true, + skipDraftUpdate: Bool = false, cache: PreWarmedCache? ) throws -> MessageDTO { guard payload.channel != nil || cid != nil else { @@ -971,7 +1109,116 @@ extension NSManagedObjectContext: MessageDatabaseSession { throw ClientError.MessagePayloadSavingFailure(description) } - return try saveMessage(payload: payload, channelDTO: channel, syncOwnReactions: syncOwnReactions, cache: cache) + return try saveMessage( + payload: payload, + channelDTO: channel, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: skipDraftUpdate, + cache: cache + ) + } + + @discardableResult + func saveDraftMessage( + payload: DraftPayload, + for cid: ChannelId, + cache: PreWarmedCache? + ) throws -> MessageDTO { + let draftDetailsPayload = payload.message + let channelDTO: ChannelDTO? + if let channelPayload = payload.channelPayload { + channelDTO = try saveChannel(payload: channelPayload, query: nil, cache: cache) + } else { + channelDTO = ChannelDTO.load(cid: cid, context: self) + } + guard let channelDTO = channelDTO else { + throw ClientError.ChannelDoesNotExist(cid: cid) + } + guard let user = currentUser?.user else { + throw ClientError.CurrentUserDoesNotExist() + } + + // Delete existing draft message if it exists. + deleteDraftMessage(in: cid, threadId: payload.parentId) + + let dto = MessageDTO.loadOrCreate(id: draftDetailsPayload.id, context: self, cache: cache) + dto.cid = cid.rawValue + dto.text = draftDetailsPayload.text + dto.createdAt = payload.createdAt.bridgeDate + dto.updatedAt = payload.createdAt.bridgeDate + dto.reactionScores = [:] + dto.reactionCounts = [:] + dto.type = MessageType.regular.rawValue + dto.command = draftDetailsPayload.command + dto.args = draftDetailsPayload.args + dto.parentMessageId = payload.parentId + dto.showReplyInChannel = draftDetailsPayload.showReplyInChannel + dto.isSilent = draftDetailsPayload.isSilent + dto.user = user + dto.channel = channelDTO + dto.isDraft = true + + if let threadId = payload.parentId { + let threadDTO = thread(parentMessageId: threadId, cache: cache) + threadDTO?.parentMessageId = threadId + } + + if let parentMessage = payload.parentMessage { + dto.parentMessage = try saveMessage( + payload: parentMessage, + channelDTO: channelDTO, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: cache + ) + dto.draftOfThread = dto.parentMessage + } else if let parentMessageId = payload.parentId, + let parentMessage = message(id: parentMessageId) { + dto.parentMessage = parentMessage + dto.draftOfThread = parentMessage + } else { + dto.parentMessage = nil + dto.draftOfThread = nil + channelDTO.draftMessage = dto + } + + if let quotedMessage = payload.quotedMessage { + dto.quotedMessage = try saveMessage( + payload: quotedMessage, + channelDTO: channelDTO, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: cache + ) + } else { + dto.quotedMessage = nil + } + + if let mentionedUsers = draftDetailsPayload.mentionedUsers { + dto.mentionedUsers = try Set(mentionedUsers.map { try saveUser(payload: $0) }) + dto.mentionedUserIds = mentionedUsers.map(\.id) + } + + if let attachments = draftDetailsPayload.attachments { + dto.attachments = Set( + try attachments.enumerated().map { index, attachment in + let id = AttachmentId(cid: cid, messageId: draftDetailsPayload.id, index: index) + return try saveAttachment(payload: attachment, id: id) + } + ) + } + + do { + dto.extraData = try JSONEncoder.default.encode(draftDetailsPayload.extraData) + } catch { + log.error( + "Failed to decode extra payload for Message with id: <\(dto.id)>, using default value instead. " + + "Error: \(error)" + ) + dto.extraData = Data() + } + + return dto } func saveMessage(payload: MessagePayload, for query: MessageSearchQuery, cache: PreWarmedCache?) throws -> MessageDTO { @@ -980,6 +1227,21 @@ extension NSManagedObjectContext: MessageDatabaseSession { return messageDTO } + func deleteDraftMessage(in cid: ChannelId, threadId: MessageId?) { + if let threadId = threadId, let parentMessage = message(id: threadId) { + parentMessage.draftReply.map { + delete($0) + } + // Trigger thread update + let thread = thread(parentMessageId: threadId, cache: nil) + thread?.parentMessageId = threadId + } else if let channel = channel(cid: cid) { + channel.draftMessage.map { + delete($0) + } + } + } + func message(id: MessageId) -> MessageDTO? { .load(id: id, context: self) } func messageExists(id: MessageId) -> Bool { @@ -1301,6 +1563,11 @@ extension MessageDTO { // At the moment, we only provide the type for system messages when creating a message. let systemType = type == MessageType.system.rawValue ? type : nil + + var restrictedVisibilityArray: [UserId]? + if let restrictedVisibility { + restrictedVisibilityArray = Array(restrictedVisibility) + } return .init( id: id, @@ -1318,6 +1585,36 @@ extension MessageDTO { pinned: pinned, pinExpires: pinExpires?.bridgeDate, pollId: poll?.id, + restrictedVisibility: restrictedVisibilityArray, + extraData: extraData + ) + } + + func asDraftRequestBody() -> DraftMessageRequestBody { + let extraData: [String: RawJSON] + do { + extraData = try JSONDecoder.stream.decodeRawJSON(from: self.extraData) + } catch { + log.assertionFailure("Failed decoding saved extra data with error: \(error). This should never happen because the extra data must be a valid JSON to be saved.") + extraData = [:] + } + + let uploadedAttachments: [MessageAttachmentPayload] = attachments + .filter { $0.localState == .uploaded || $0.localState == nil } + .sorted { ($0.attachmentID?.index ?? 0) < ($1.attachmentID?.index ?? 0) } + .compactMap { $0.asRequestPayload() } + + return .init( + id: id, + text: text, + command: command, + args: args, + parentId: parentMessageId, + showReplyInChannel: showReplyInChannel, + isSilent: isSilent, + quotedMessageId: quotedMessage?.id, + attachments: uploadedAttachments, + mentionedUserIds: mentionedUserIds, extraData: extraData ) } @@ -1445,6 +1742,8 @@ private extension ChatMessage { let quotedMessage = try? dto.quotedMessage?.relationshipAsModel(depth: depth) + let draftReply = try? dto.draftReply?.relationshipAsModel(depth: depth) + let readBy = Set(dto.reads.compactMap { try? $0.user.asModel() }) let message = ChatMessage( @@ -1485,7 +1784,8 @@ private extension ChatMessage { moderationDetails: moderationDetails, readBy: readBy, poll: poll, - textUpdatedAt: textUpdatedAt + textUpdatedAt: textUpdatedAt, + draftReply: draftReply.map(DraftMessage.init) ) if let transformer = chatClientConfig?.modelsTransformer { diff --git a/Sources/StreamChat/Database/DTOs/ThreadDTO.swift b/Sources/StreamChat/Database/DTOs/ThreadDTO.swift index e007c4e8bcd..caf27cf04f8 100644 --- a/Sources/StreamChat/Database/DTOs/ThreadDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ThreadDTO.swift @@ -259,6 +259,17 @@ extension NSManagedObjectContext { currentUserUnreadCount = currentUserRead?.unreadMessagesCount ?? 0 } + if let draft = payload.draft { + parentMessageDTO.draftReply = try saveDraftMessage(payload: draft, for: payload.channel.cid, cache: cache) + } else { + /// If the payload does not contain a draft reply, we should + /// delete the existing draft reply if it exists. + if let draft = parentMessageDTO.draftReply { + deleteDraftMessage(in: payload.channel.cid, threadId: draft.parentMessageId) + parentMessageDTO.draftReply = nil + } + } + threadDTO.fill( parentMessage: parentMessageDTO, title: payload.title, diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index fe0579fa954..78206e75f91 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -104,6 +104,22 @@ protocol MessageDatabaseSession { skipPush: Bool, skipEnrichUrl: Bool, poll: PollPayload?, + restrictedVisibility: [UserId], + extraData: [String: RawJSON] + ) throws -> MessageDTO + + /// Creates a draft message in the database. + func createNewDraftMessage( + in cid: ChannelId, + text: String, + command: String?, + arguments: String?, + parentMessageId: MessageId?, + attachments: [AnyAttachmentPayload], + mentionedUserIds: [UserId], + showReplyInChannel: Bool, + isSilent: Bool, + quotedMessageId: MessageId?, extraData: [String: RawJSON] ) throws -> MessageDTO @@ -116,30 +132,48 @@ protocol MessageDatabaseSession { @discardableResult func saveMessages(messagesPayload: MessageListPayload, for cid: ChannelId?, syncOwnReactions: Bool) -> [MessageDTO] - /// Saves the provided message payload to the DB. Return's the matching `MessageDTO` if the save was successful. - /// Throws an error if the save fails. - /// - /// You must either provide `cid` or `payload.channel` value must not be `nil`. - /// The `syncOwnReactions` should be set to `true` when the payload comes from an API response and `false` when the payload - /// is received via WS events. For performance reasons the API does not populate the `message.own_reactions` when sending events + /// Saves a message into the local DB. + /// - Parameters: + /// - payload: The message payload + /// - cid: The channel ID. + /// - syncOwnReactions: Whether to sync own reactions. It should be set to `true` when the payload comes from an API response and `false` when the payload is received via WS events. For performance reasons the API + /// does not populate the `message.own_reactions` when sending events + /// - skipDraftUpdate: Whether to skip draft update. This is used when saving quoted and parent messages from + /// saveDraftMessage function to avoid an infinite loop since saving the draft would be called again. + /// - cache: The pre-warmed cache. @discardableResult func saveMessage( payload: MessagePayload, for cid: ChannelId?, syncOwnReactions: Bool, + skipDraftUpdate: Bool, cache: PreWarmedCache? ) throws -> MessageDTO - /// Saves the provided message payload to the DB. Return's the matching `MessageDTO` if the save was successful. + /// Saves the provided draft message payload to the DB. Return's the matching `MessageDTO` if the save was successful. /// Throws an error if the save fails. - /// - /// The `syncOwnReactions` should be set to `true` when the payload comes from an API response and `false` when the payload - /// is received via WS events. For performance reasons the API does not populate the `message.own_reactions` when sending events + @discardableResult + func saveDraftMessage( + payload: DraftPayload, + for cid: ChannelId, + cache: PreWarmedCache? + ) throws -> MessageDTO + + /// Saves a message into the local DB. + /// - Parameters: + /// - payload: The message payload + /// - channelDTO: The channel dto. + /// - syncOwnReactions: Whether to sync own reactions. It should be set to `true` when the payload comes from an API response and `false` when the payload is received via WS events. For performance reasons the API + /// does not populate the `message.own_reactions` when sending events + /// - skipDraftUpdate: Whether to skip draft update. This is used when saving quoted and parent messages from + /// saveDraftMessage function to avoid an infinite loop since saving the draft would be called again. + /// - cache: The pre-warmed cache. @discardableResult func saveMessage( payload: MessagePayload, channelDTO: ChannelDTO, syncOwnReactions: Bool, + skipDraftUpdate: Bool, cache: PreWarmedCache? ) throws -> MessageDTO @@ -246,6 +280,7 @@ extension MessageDatabaseSession { attachments: [AnyAttachmentPayload] = [], mentionedUserIds: [UserId] = [], pollPayload: PollPayload? = nil, + restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:] ) throws -> MessageDTO { try createNewMessage( @@ -266,6 +301,7 @@ extension MessageDatabaseSession { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: pollPayload, + restrictedVisibility: restrictedVisibility, extraData: extraData ) } @@ -320,6 +356,9 @@ protocol ChannelDatabaseSession { /// Removes a list of channels based on their id func removeChannels(cids: Set) + + /// Delete the draft message. + func deleteDraftMessage(in cid: ChannelId, threadId: MessageId?) } protocol ChannelReadDatabaseSession { @@ -748,6 +787,7 @@ extension DatabaseSession { payload: messagePayload, channelDTO: channelDTO, syncOwnReactions: false, + skipDraftUpdate: false, cache: nil ) diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 98617a53291..1a43f6d4654 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -63,6 +63,7 @@ + @@ -209,6 +210,7 @@ + @@ -227,6 +229,7 @@ + @@ -239,6 +242,9 @@ + + + diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift index f2576a43223..01b7f83bd28 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.72.0" + public static let version: String = "4.73.0" } diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist index 52cf65ec6e6..8caf4c3312b 100644 --- a/Sources/StreamChat/Info.plist +++ b/Sources/StreamChat/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.72.0 + 4.73.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index cdd0c4430ab..c69bb793409 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -157,6 +157,9 @@ public struct ChatChannel { /// because the preview message is the last `non-deleted` message sent to the channel. public let previewMessage: ChatMessage? + /// The draft message in the channel. + public let draftMessage: DraftMessage? + // MARK: - Internal var hasUnread: Bool { @@ -194,7 +197,8 @@ public struct ChatChannel { lastMessageFromCurrentUser: ChatMessage?, pinnedMessages: [ChatMessage], muteDetails: MuteDetails?, - previewMessage: ChatMessage? + previewMessage: ChatMessage?, + draftMessage: DraftMessage? ) { self.cid = cid self.name = name @@ -227,6 +231,7 @@ public struct ChatChannel { self.pinnedMessages = pinnedMessages self.muteDetails = muteDetails self.previewMessage = previewMessage + self.draftMessage = draftMessage } /// Returns a new `ChatChannel` with the provided data replaced. @@ -266,7 +271,8 @@ public struct ChatChannel { lastMessageFromCurrentUser: lastMessageFromCurrentUser, pinnedMessages: pinnedMessages, muteDetails: muteDetails, - previewMessage: previewMessage + previewMessage: previewMessage, + draftMessage: draftMessage ) } } @@ -315,6 +321,7 @@ extension ChatChannel: Hashable { guard lhs.team == rhs.team else { return false } guard lhs.truncatedAt == rhs.truncatedAt else { return false } guard lhs.ownCapabilities == rhs.ownCapabilities else { return false } + guard lhs.draftMessage == rhs.draftMessage else { return false } return true } diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index ee0e6cc1d82..f75be99d7f1 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -64,6 +64,9 @@ public struct ChatMessage { public var quotedMessage: ChatMessage? { _quotedMessage() } let _quotedMessage: () -> ChatMessage? + /// The draft reply to this message. Applies only for the messages of the current user. + public let draftReply: DraftMessage? + /// A flag indicating whether the message was bounced due to moderation. public let isBounced: Bool @@ -213,7 +216,8 @@ public struct ChatMessage { moderationDetails: MessageModerationDetails?, readBy: Set, poll: Poll?, - textUpdatedAt: Date? + textUpdatedAt: Date?, + draftReply: DraftMessage? ) { self.id = id self.cid = cid @@ -254,6 +258,7 @@ public struct ChatMessage { self.readBy = readBy _attachments = attachments _quotedMessage = { quotedMessage } + self.draftReply = draftReply } /// Returns a new `ChatMessage` with the provided data replaced. @@ -300,7 +305,8 @@ public struct ChatMessage { moderationDetails: moderationDetails, readBy: readBy, poll: poll, - textUpdatedAt: textUpdatedAt + textUpdatedAt: textUpdatedAt, + draftReply: draftReply ) } } @@ -431,6 +437,7 @@ extension ChatMessage: Hashable { guard lhs.quotedMessage == rhs.quotedMessage else { return false } guard lhs.translations == rhs.translations else { return false } guard lhs.type == rhs.type else { return false } + guard lhs.draftReply == rhs.draftReply else { return false } return true } diff --git a/Sources/StreamChat/Models/DraftMessage.swift b/Sources/StreamChat/Models/DraftMessage.swift new file mode 100644 index 00000000000..8742794a357 --- /dev/null +++ b/Sources/StreamChat/Models/DraftMessage.swift @@ -0,0 +1,166 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +public struct DraftMessage { + /// A unique identifier of the message. + public let id: MessageId + + /// The ChannelId this message belongs to. + public let cid: ChannelId? + + /// The ID of the parent message, if the message is a reply. + public let threadId: MessageId? + + /// The text of the message. + public let text: String + + /// 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. + public let isSilent: Bool + + /// If the message was created by a specific `/` command, the command is saved in this variable. + public let command: String? + + /// The date when the draft was created. + public let createdAt: Date + + /// If the message was created by a specific `/` command, the arguments of the command are stored in this variable. + public let arguments: String? + + /// If the message is a reply and this flag is `true`, the message should be also shown in the channel, not only in the + /// reply thread. + public let showReplyInChannel: Bool + + /// Additional data associated with the message. + public let extraData: [String: RawJSON] + + /// Quoted message. + /// + /// If message is inline reply this property will contain the message quoted by this reply. + public var quotedMessage: ChatMessage? { _quotedMessage() } + let _quotedMessage: () -> ChatMessage? + + /// A list of users that are mentioned in this message. + public let mentionedUsers: Set + + /// A list of attachments of the message. + public let attachments: [AnyChatMessageAttachment] + + /// This property is used to make it easier to convert to a regular message. + internal let currentUser: ChatUser + + init( + id: MessageId, + cid: ChannelId?, + threadId: MessageId?, + text: String, + isSilent: Bool, + command: String?, + createdAt: Date, + arguments: String?, + showReplyInChannel: Bool, + extraData: [String: RawJSON], + currentUser: ChatUser, + quotedMessage: @escaping () -> ChatMessage?, + mentionedUsers: Set, + attachments: [AnyChatMessageAttachment] + ) { + self.id = id + self.cid = cid + self.threadId = threadId + self.text = text + self.isSilent = isSilent + self.command = command + self.createdAt = createdAt + self.arguments = arguments + self.showReplyInChannel = showReplyInChannel + self.extraData = extraData + _quotedMessage = quotedMessage + self.mentionedUsers = mentionedUsers + self.attachments = attachments + self.currentUser = currentUser + } + + init(_ message: ChatMessage) { + id = message.id + cid = message.cid + text = message.text + isSilent = message.isSilent + command = message.command + createdAt = message.createdAt + arguments = message.arguments + threadId = message.parentMessageId + showReplyInChannel = message.showReplyInChannel + extraData = message.extraData + _quotedMessage = { message.quotedMessage } + mentionedUsers = message.mentionedUsers + attachments = message.allAttachments + currentUser = message.author + } +} + +extension DraftMessage: Equatable { + public static func == (lhs: DraftMessage, rhs: DraftMessage) -> Bool { + lhs.text == rhs.text + && lhs.id == rhs.id + && lhs.cid == rhs.cid + && lhs.isSilent == rhs.isSilent + && lhs.command == rhs.command + && lhs.createdAt == rhs.createdAt + && lhs.createdAt.timeIntervalSince1970 == rhs.createdAt.timeIntervalSince1970 + && lhs.arguments == rhs.arguments + && lhs.threadId == rhs.threadId + && lhs.quotedMessage == rhs.quotedMessage + && lhs.attachments == rhs.attachments + } +} + +extension ChatMessage { + /// Converts the draft message to a regular message so that it + /// can be easily used in existing UI components. + public init(_ draft: DraftMessage) { + id = draft.id + cid = draft.cid + text = draft.text + type = .regular + command = draft.command + createdAt = draft.createdAt + locallyCreatedAt = draft.createdAt + updatedAt = draft.createdAt + deletedAt = nil + arguments = draft.arguments + parentMessageId = draft.threadId + showReplyInChannel = draft.showReplyInChannel + replyCount = 0 + extraData = draft.extraData + _quotedMessage = { draft.quotedMessage } + isBounced = false + isSilent = false + isShadowed = false + reactionScores = [:] + reactionCounts = [:] + reactionGroups = [:] + author = draft.currentUser + mentionedUsers = draft.mentionedUsers + threadParticipants = [] + _attachments = draft.attachments + latestReplies = [] + localState = nil + isFlaggedByCurrentUser = false + latestReactions = [] + currentUserReactions = [] + isSentByCurrentUser = true + pinDetails = nil + translations = nil + originalLanguage = nil + moderationDetails = nil + readBy = [] + poll = nil + textUpdatedAt = nil + draftReply = nil + } +} diff --git a/Sources/StreamChat/Query/DraftListQuery.swift b/Sources/StreamChat/Query/DraftListQuery.swift new file mode 100644 index 00000000000..1c2367f219f --- /dev/null +++ b/Sources/StreamChat/Query/DraftListQuery.swift @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A query used to fetch the drafts of the current user. +public struct DraftListQuery: Encodable { + /// The pagination information to query the votes. + public var pagination: Pagination + /// The sorting parameter. By default drafts are sorted by newest first. + public var sorting: [Sorting] + + public init( + pagination: Pagination = .init(pageSize: 25, offset: 0), + sorting: [Sorting] = [.init(key: .createdAt, isAscending: false)] + ) { + self.pagination = pagination + self.sorting = sorting + } + + enum CodingKeys: CodingKey { + case pagination + case sort + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if !sorting.isEmpty { + try container.encode(sorting, forKey: .sort) + } + try pagination.encode(to: encoder) + } +} + +/// The type describing a value that can be used as a sorting when paginating all the drafts of the current user. +public struct DraftListSortingKey: RawRepresentable, Hashable, SortingKey { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// The supported sorting keys. +public extension DraftListSortingKey { + /// Sorts drafts by `created_at` field. + static let createdAt = Self(rawValue: MessagePayloadsCodingKeys.createdAt.rawValue) +} + +extension Sorting where Key == DraftListSortingKey { + func sortDescriptor() -> NSSortDescriptor? { + switch key { + case .createdAt: + return .init(keyPath: \MessageDTO.createdAt, ascending: isAscending) + default: + return nil + } + } +} diff --git a/Sources/StreamChat/Query/PollVoteListQuery.swift b/Sources/StreamChat/Query/PollVoteListQuery.swift index 91d76298eee..7f7d75c0962 100644 --- a/Sources/StreamChat/Query/PollVoteListQuery.swift +++ b/Sources/StreamChat/Query/PollVoteListQuery.swift @@ -12,7 +12,7 @@ public struct PollVoteListQuery: Encodable { public var optionId: String? /// The pagination information to query the votes. public var pagination: Pagination - // The sorting parameter. By default votes are sorted by newest first. + /// The sorting parameter. By default votes are sorted by newest first. public var sorting: [Sorting] /// The filter details to query the votes. public var filter: Filter? diff --git a/Sources/StreamChat/Repositories/DraftMessagesRepository.swift b/Sources/StreamChat/Repositories/DraftMessagesRepository.swift new file mode 100644 index 00000000000..a54bdf08f79 --- /dev/null +++ b/Sources/StreamChat/Repositories/DraftMessagesRepository.swift @@ -0,0 +1,160 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData + +struct DraftListResponse { + var drafts: [DraftMessage] + var next: String? +} + +class DraftMessagesRepository { + private let database: DatabaseContainer + private let apiClient: APIClient + + init(database: DatabaseContainer, apiClient: APIClient) { + self.database = database + self.apiClient = apiClient + } + + func loadDrafts( + query: DraftListQuery, + completion: @escaping (Result) -> Void + ) { + apiClient.request(endpoint: .drafts(query: query)) { [weak self] result in + switch result { + case .success(let response): + var drafts: [DraftMessage] = [] + self?.database.write({ session in + drafts = try response.drafts.compactMap { + guard let channelId = $0.channelPayload?.cid else { + return nil + } + return DraftMessage(try session + .saveDraftMessage(payload: $0, for: channelId, cache: nil) + .asModel()) + } + }, completion: { error in + if let error { + completion(.failure(error)) + return + } + completion(.success(DraftListResponse(drafts: drafts, next: response.next))) + }) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func updateDraft( + for cid: ChannelId, + threadId: MessageId?, + text: String, + isSilent: Bool, + showReplyInChannel: Bool, + command: String?, + arguments: String?, + attachments: [AnyAttachmentPayload], + mentionedUserIds: [UserId], + quotedMessageId: MessageId?, + extraData: [String: RawJSON], + completion: ((Result) -> Void)? + ) { + var draftRequestBody: DraftMessageRequestBody? + database.write({ (session) in + let newMessageDTO = try session.createNewDraftMessage( + in: cid, + text: text, + command: command, + arguments: arguments, + parentMessageId: threadId, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + showReplyInChannel: showReplyInChannel, + isSilent: isSilent, + quotedMessageId: quotedMessageId, + extraData: extraData + ) + draftRequestBody = newMessageDTO.asDraftRequestBody() + }) { error in + guard let requestBody = draftRequestBody, error == nil else { + completion?(.failure(error ?? ClientError.Unknown())) + return + } + + self.apiClient.request( + endpoint: .updateDraftMessage(channelId: cid, requestBody: requestBody) + ) { [weak self] result in + switch result { + case .success(let response): + var draft: ChatMessage? + self?.database.write({ session in + let draftPayload = response.draft + let messageDTO = try session.saveDraftMessage( + payload: draftPayload, + for: cid, + cache: nil + ) + draft = try messageDTO.asModel() + }, completion: { error in + if let draft { + completion?(.success(DraftMessage(draft))) + } else if let error { + completion?(.failure(error)) + } + }) + case .failure(let error): + completion?(.failure(error)) + } + } + } + } + + func getDraft( + for cid: ChannelId, + threadId: MessageId?, + completion: ((Result) -> Void)? + ) { + apiClient.request( + endpoint: .getDraftMessage(channelId: cid, threadId: threadId) + ) { [weak self] result in + switch result { + case .success(let response): + var draft: ChatMessage? + self?.database.write({ session in + let messageDTO = try session.saveDraftMessage( + payload: response.draft, + for: cid, + cache: nil + ) + draft = try messageDTO.asModel() + }) { error in + if let draft { + completion?(.success(DraftMessage(draft))) + } else if let error { + completion?(.failure(error)) + } + } + case .failure(let error): + completion?(.failure(error)) + } + } + } + + func deleteDraft( + for cid: ChannelId, + threadId: MessageId?, + completion: @escaping (Error?) -> Void + ) { + database.write { session in + session.deleteDraftMessage(in: cid, threadId: threadId) + } + apiClient.request( + endpoint: .deleteDraftMessage(channelId: cid, threadId: threadId) + ) { result in + completion(result.error) + } + } +} diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 3002062d983..80192a84684 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -137,7 +137,13 @@ class MessageRepository { ) { var messageModel: ChatMessage! database.write({ - let messageDTO = try $0.saveMessage(payload: message, for: cid, syncOwnReactions: false, cache: nil) + let messageDTO = try $0.saveMessage( + payload: message, + for: cid, + syncOwnReactions: false, + skipDraftUpdate: false, + cache: nil + ) if messageDTO.localMessageState == .sending || messageDTO.localMessageState == .sendingFailed { messageDTO.markMessageAsSent() } @@ -210,6 +216,7 @@ class MessageRepository { payload: message, for: ChannelId(cid: cid), syncOwnReactions: false, + skipDraftUpdate: false, cache: nil ) deletedMessage.localMessageState = nil @@ -238,7 +245,13 @@ class MessageRepository { case let .success(boxed): var message: ChatMessage? self.database.write({ session in - message = try session.saveMessage(payload: boxed.message, for: cid, syncOwnReactions: true, cache: nil).asModel() + message = try session.saveMessage( + payload: boxed.message, + for: cid, + syncOwnReactions: true, + skipDraftUpdate: false, + cache: nil + ).asModel() if !store { // Force load attachments before discarding changes _ = message?.attachmentCounts diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 4be633ba652..efce5af5158 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -487,6 +487,7 @@ public class Chat { /// - skipPushNotification: If true, skips sending push notification to channel members. /// - skipEnrichURL: If true, the url preview won't be attached to the message. /// - messageId: A custom id for the sent message. By default, it is automatically generated by Stream. + /// - restrictedVisibility: The list of user ids that should be able to see the message. /// /// - Throws: An error while sending a message to the Stream API. /// - Returns: An instance of `ChatMessage` which was delivered to the channel. @@ -501,7 +502,8 @@ public class Chat { silent: Bool = false, skipPushNotification: Bool = false, skipEnrichURL: Bool = false, - messageId: MessageId? = nil + messageId: MessageId? = nil, + restrictedVisibility: [UserId] = [] ) async throws -> ChatMessage { Task { try await stopTyping() } // errors explicitly ignored let localMessage = try await channelUpdater.createNewMessage( @@ -518,6 +520,7 @@ public class Chat { quotedMessageId: quotedMessageId, skipPush: skipPushNotification, skipEnrichUrl: skipEnrichURL, + restrictedVisibility: restrictedVisibility, extraData: extraData ) // Important to set up the waiter immediately @@ -531,11 +534,13 @@ public class Chat { /// - Parameters: /// - text: Text of the message. /// - messageId: A custom id for the sent message. By default, it is automatically generated by Stream. + /// - restrictedVisibility: The list of user ids that should be able to see the message. /// - extraData: Additional extra data of the message object. @discardableResult public func sendSystemMessage( with text: String, messageId: MessageId? = nil, + restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] = [:] ) async throws -> ChatMessage { let localMessage = try await channelUpdater.createNewMessage( @@ -552,6 +557,7 @@ public class Chat { quotedMessageId: nil, skipPush: false, skipEnrichUrl: false, + restrictedVisibility: restrictedVisibility, extraData: extraData ) // Important to set up the waiter immediately diff --git a/Sources/StreamChat/Utils/Database/NSManagedObject+Extensions.swift b/Sources/StreamChat/Utils/Database/NSManagedObject+Extensions.swift index 0424ae13e12..77b3e29eaac 100644 --- a/Sources/StreamChat/Utils/Database/NSManagedObject+Extensions.swift +++ b/Sources/StreamChat/Utils/Database/NSManagedObject+Extensions.swift @@ -179,7 +179,15 @@ class FetchCache { extension NSManagedObjectContext { func fetch(_ request: NSFetchRequest, using cache: FetchCache) throws -> [T] where T: NSFetchRequestResult { - if let objectIds = cache.get(request), !objectIds.contains(where: { $0.isTemporaryID }) { + func canUseCachedIds(_ objectIds: [NSManagedObjectID]) -> Bool { + // Ignore cache when inserted (but not yet saved) object id is present + guard !objectIds.contains(where: { $0.isTemporaryID }) else { return false } + // Context has pending inserted or deleted objects of this type (can affect ids returned by the fetch request) + guard !insertedObjects.contains(where: { $0 is T }) && !deletedObjects.contains(where: { $0 is T }) else { return false } + return true + } + + if let objectIds = cache.get(request), canUseCachedIds(objectIds) { return try objectIds.compactMap { try existingObject(with: $0) as? T } } diff --git a/Sources/StreamChat/Utils/SystemEnvironment+XStreamClient.swift b/Sources/StreamChat/Utils/SystemEnvironment+XStreamClient.swift index b450816ef3e..ffa403065e8 100644 --- a/Sources/StreamChat/Utils/SystemEnvironment+XStreamClient.swift +++ b/Sources/StreamChat/Utils/SystemEnvironment+XStreamClient.swift @@ -12,7 +12,7 @@ import IOKit extension SystemEnvironment { static let xStreamClientHeader: String = { - "stream-chat-\(sdkIdentifier)-client-v\(version)|app=\(appName)|app_version=\(appVersion)|os=\(os) \(osVersion)|device_model=\(model)|device_screen_ratio=\(scale)" + "stream-chat-\(sdkIdentifier)-v\(version)|app=\(appName)|app_version=\(appVersion)|os=\(os) \(osVersion)|device_model=\(model)" }() private static var sdkIdentifier: String { @@ -76,12 +76,4 @@ extension SystemEnvironment { return "MacOS" #endif } - - private static var scale: String { - #if os(iOS) - return String(format: "%0.2f", UIScreen.main.scale) - #elseif os(macOS) - return "1.00" - #endif - } } diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/DraftUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/DraftUpdaterMiddleware.swift new file mode 100644 index 00000000000..797b1c80272 --- /dev/null +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/DraftUpdaterMiddleware.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +struct DraftUpdaterMiddleware: EventMiddleware { + func handle(event: Event, session: DatabaseSession) -> Event? { + switch event { + case let event as DraftUpdatedEventDTO: + guard let draft = event.payload.draft else { break } + do { + try session.saveDraftMessage(payload: draft, for: event.cid, cache: nil) + } catch { + log.error("Failed to save draft message: \(error)") + } + case let event as DraftDeletedEventDTO: + let threadId = event.payload.draft?.parentId + session.deleteDraftMessage(in: event.cid, threadId: threadId) + default: + break + } + return event + } +} diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ThreadUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ThreadUpdaterMiddleware.swift index a330e2bdcb2..157305c1f39 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ThreadUpdaterMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ThreadUpdaterMiddleware.swift @@ -62,6 +62,7 @@ struct ThreadUpdaterMiddleware: EventMiddleware { payload: messagePayload, channelDTO: channelDTO, syncOwnReactions: false, + skipDraftUpdate: false, cache: nil ) else { diff --git a/Sources/StreamChat/WebSocketClient/Events/DraftEvents.swift b/Sources/StreamChat/WebSocketClient/Events/DraftEvents.swift new file mode 100644 index 00000000000..69263ba6087 --- /dev/null +++ b/Sources/StreamChat/WebSocketClient/Events/DraftEvents.swift @@ -0,0 +1,95 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Triggered when a draft message is updated or created. +public class DraftUpdatedEvent: Event { + /// The channel identifier of the draft. + public let cid: ChannelId + + /// The channel object of the draft. + public let channel: ChatChannel + + /// The draft message. + public let draftMessage: DraftMessage + + /// The event timestamp. + public let createdAt: Date + + init(cid: ChannelId, channel: ChatChannel, draftMessage: DraftMessage, createdAt: Date) { + self.cid = cid + self.channel = channel + self.draftMessage = draftMessage + self.createdAt = createdAt + } +} + +class DraftUpdatedEventDTO: EventDTO { + let cid: ChannelId + let draft: DraftPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + cid = try response.value(at: \.cid) + draft = try response.value(at: \.draft) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: any DatabaseSession) -> Event? { + guard + let messageDTO = session.message(id: draft.message.id), + let channelDTO = session.channel(cid: cid) else { + return nil + } + return try? DraftUpdatedEvent( + cid: cid, + channel: channelDTO.asModel(), + draftMessage: DraftMessage(messageDTO.asModel()), + createdAt: createdAt + ) + } +} + +/// Triggered when a draft message is deleted. +public class DraftDeletedEvent: Event { + /// The channel identifier of the draft. + public let cid: ChannelId + + /// The thread identifier of the draft. + public let threadId: MessageId? + + /// The event timestamp. + public let createdAt: Date + + init(cid: ChannelId, threadId: MessageId?, createdAt: Date) { + self.cid = cid + self.threadId = threadId + self.createdAt = createdAt + } +} + +class DraftDeletedEventDTO: EventDTO { + let cid: ChannelId + let draft: DraftPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + cid = try response.value(at: \.cid) + draft = try response.value(at: \.draft) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: any DatabaseSession) -> Event? { + DraftDeletedEvent( + cid: cid, + threadId: draft.parentId, + createdAt: createdAt + ) + } +} diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 3700a3506a6..c760f6358d4 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -39,6 +39,7 @@ class EventPayload: Decodable { case aiState = "ai_state" case messageId = "message_id" case aiMessage = "ai_message" + case draft } let eventType: EventType @@ -75,6 +76,7 @@ class EventPayload: Decodable { let aiState: String? let messageId: String? let aiMessage: String? + let draft: DraftPayload? init( eventType: EventType, @@ -106,7 +108,8 @@ class EventPayload: Decodable { vote: PollVotePayload? = nil, aiState: String? = nil, messageId: String? = nil, - aiMessage: String? = nil + aiMessage: String? = nil, + draft: DraftPayload? = nil ) { self.eventType = eventType self.connectionId = connectionId @@ -138,6 +141,7 @@ class EventPayload: Decodable { self.aiState = aiState self.messageId = messageId self.aiMessage = aiMessage + self.draft = draft } required init(from decoder: Decoder) throws { @@ -174,6 +178,7 @@ class EventPayload: Decodable { aiState = try container.decodeIfPresent(String.self, forKey: .aiState) messageId = try container.decodeIfPresent(String.self, forKey: .messageId) aiMessage = try container.decodeIfPresent(String.self, forKey: .aiMessage) + draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) } func event() throws -> Event { diff --git a/Sources/StreamChat/WebSocketClient/Events/EventType.swift b/Sources/StreamChat/WebSocketClient/Events/EventType.swift index a00676b38d4..1268e2b955e 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventType.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventType.swift @@ -154,6 +154,14 @@ public extension EventType { // When an AI typing indicator has been stopped. static let aiTypingIndicatorStop: Self = "ai_indicator.stop" + + // MARK: Drafts + + /// When a draft was updated. + static let draftUpdated: Self = "draft.updated" + + /// When a draft was deleted. + static let draftDeleted: Self = "draft.deleted" } extension EventType { @@ -222,6 +230,8 @@ extension EventType { case .aiTypingIndicatorChanged: return try AIIndicatorUpdateEventDTO(from: response) case .aiTypingIndicatorClear: return try AIIndicatorClearEventDTO(from: response) case .aiTypingIndicatorStop: return try AIIndicatorStopEventDTO(from: response) + case .draftUpdated: return try DraftUpdatedEventDTO(from: response) + case .draftDeleted: return try DraftDeletedEventDTO(from: response) default: if response.cid == nil { throw ClientError.UnknownUserEvent(response.eventType) diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index 3eb9225ac05..bb85df84b8d 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -293,7 +293,8 @@ private extension MessagePayload { moderationDetails: nil, readBy: [], poll: nil, - textUpdatedAt: messageTextUpdatedAt + textUpdatedAt: messageTextUpdatedAt, + draftReply: nil ) } } diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 37a02fc32df..fe62d9da076 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -308,6 +308,7 @@ class ChannelUpdater: Worker { quotedMessageId: MessageId?, skipPush: Bool, skipEnrichUrl: Bool, + restrictedVisibility: [UserId] = [], poll: PollPayload? = nil, extraData: [String: RawJSON], completion: ((Result) -> Void)? = nil @@ -332,6 +333,7 @@ class ChannelUpdater: Worker { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: poll, + restrictedVisibility: restrictedVisibility, extraData: extraData ) if quotedMessageId != nil { @@ -743,6 +745,7 @@ extension ChannelUpdater { quotedMessageId: MessageId?, skipPush: Bool, skipEnrichUrl: Bool, + restrictedVisibility: [UserId], extraData: [String: RawJSON] ) async throws -> ChatMessage { try await withCheckedThrowingContinuation { continuation in @@ -760,6 +763,7 @@ extension ChannelUpdater { quotedMessageId: quotedMessageId, skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, + restrictedVisibility: restrictedVisibility, extraData: extraData ) { result in continuation.resume(with: result) diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 986017a474b..cc38c233cd0 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -236,6 +236,7 @@ class MessageUpdater: Worker { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: nil, + restrictedVisibility: [], extraData: extraData ) @@ -764,7 +765,13 @@ class MessageUpdater: Worker { switch $0 { case let .success(payload): self.database.write({ session in - try session.saveMessage(payload: payload.message, for: cid, syncOwnReactions: true, cache: nil) + try session.saveMessage( + payload: payload.message, + for: cid, + syncOwnReactions: true, + skipDraftUpdate: true, + cache: nil + ) }, completion: { error in completion?(error) }) @@ -824,6 +831,7 @@ class MessageUpdater: Worker { payload: boxedMessage.message, for: boxedMessage.message.cid, syncOwnReactions: false, + skipDraftUpdate: true, cache: nil ) if completion != nil { diff --git a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift index e50c0de7c0e..ecda5d0d85c 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift @@ -226,6 +226,14 @@ open class ChatChannelVC: _ViewController, } } + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let draftMessage = channelController.channel?.draftMessage { + messageComposerVC.content.draftMessage(draftMessage) + } + } + override open func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -573,6 +581,12 @@ open class ChatChannelVC: _ViewController, } debugPrint("New Message Error: \(error) Message: \(message)") } + + if let draftUpdatedEvent = event as? DraftUpdatedEvent, + let draft = channelController.channel?.draftMessage, + draftUpdatedEvent.cid == channelController.cid { + messageComposerVC.content.draftMessage(draft) + } } // MARK: - AudioQueuePlayerDatasource diff --git a/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift b/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift index 7417748ccae..215f236c801 100644 --- a/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift +++ b/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift @@ -124,6 +124,11 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { .withoutAutoresizingMaskConstraints .withAccessibilityIdentifier(identifier: "unreadCountView") + /// The prefix text for the draft message. + open var draftPrefixText: String { + "\(L10n.Message.Preview.draft):" + } + /// Text of `titleLabel` which contains the channel name. open var titleText: String? { if let searchedMessage = content?.searchedMessage { @@ -149,38 +154,34 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { return typingUsersInfo } - if let previewMessage = content.channel.previewMessage { - if let pollPreviewText = pollAttachmentPreviewText(for: previewMessage) { - return pollPreviewText - } - - if isLastMessageVoiceRecording { - return previewMessageForAudioRecordingMessage(messageText: previewMessage.text) - } + if let dratMessage = content.channel.draftMessage.map(ChatMessage.init), components.isDraftMessagesEnabled { + let previewText = previewMessageContextText(previewMessage: dratMessage) + return previewMessageTextForDraft(messageText: previewText) + } + if let previewMessage = content.channel.previewMessage { if previewMessage.type == .system { return previewMessageTextForSystemMessage(messageText: previewMessage.text) } - var text = previewMessage.textContent ?? previewMessage.text - - if let translatedText = translatedPreviewText(for: previewMessage, messageText: text) { - text = translatedText + if let pollPreviewText = pollAttachmentPreviewText(for: previewMessage) { + return pollPreviewText } - if let attachmentText = attachmentPreviewText(for: previewMessage, messageText: text) { - text = attachmentText + let previewText = previewMessageContextText(previewMessage: previewMessage) + if isLastMessageVoiceRecording { + return previewText } if previewMessage.isSentByCurrentUser { - return previewMessageTextForCurrentUser(messageText: text) + return previewMessageTextForCurrentUser(messageText: previewText) } if content.channel.memberCount == 2 { - return previewMessageTextFor1on1Channel(messageText: text) + return previewMessageTextFor1on1Channel(messageText: previewText) } - return previewMessageTextFromAnotherUser(previewMessage.author, messageText: text) + return previewMessageTextFromAnotherUser(previewMessage.author, messageText: previewText) } return previewMessageTextForEmptyMessage() @@ -353,6 +354,14 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { at: status == .pending || status == .failed ? 0 : 1 ) } + + if content?.channel.draftMessage != nil { + let highlightOptions = TextHighlightOptions( + color: appearance.colorPalette.accentPrimary, + font: appearance.fonts.footnoteBold + ) + subtitleLabel.highlightText(draftPrefixText, options: highlightOptions) + } } // MARK: - Channel title rendering @@ -375,6 +384,26 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { // MARK: - Preview message text rendering + /// The message preview text based solely on the message content. + /// It does not account if it is sent by the current user or it is a draft etc. + /// + /// - Parameter previewMessage: The preview message of the channel. + /// - Returns: A string representing the message preview text. + open func previewMessageContextText(previewMessage: ChatMessage) -> String { + if previewMessage.voiceRecordingAttachments.isEmpty == false { + return previewMessageForAudioRecordingMessage(messageText: previewMessage.text) + } + + var text = previewMessage.textContent ?? previewMessage.text + if let translatedText = translatedPreviewText(for: previewMessage, messageText: text) { + text = translatedText + } + if let attachmentText = attachmentPreviewText(for: previewMessage, messageText: text) { + text = attachmentText + } + return text + } + /// The message preview text in case the message is empty. /// - Returns: A string representing the message preview text. open func previewMessageTextForEmptyMessage() -> String { @@ -416,6 +445,13 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { messageText } + /// The message preview text in case the message is a draft. + /// - Parameter messageText: The current text of the message. + /// - Returns: A string representing the message preview text. + open func previewMessageTextForDraft(messageText: String) -> String { + "\(draftPrefixText) \(messageText)" + } + /// The message preview text in case the message is from another user and it is not a 1on1 channel. /// - Parameter messageText: The current text of the message. /// - Returns: A string representing the message preview text. @@ -516,6 +552,10 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { extension ChatChannelListItemView { var isLastMessageVoiceRecording: Bool { - content?.channel.previewMessage?.voiceRecordingAttachments.isEmpty == false && typingUserString == nil + let previewMessage = content?.channel.previewMessage + let previewHasVoiceRecording = previewMessage?.voiceRecordingAttachments.isEmpty == false + let doesNotHaveDraft = content?.channel.draftMessage == nil + let noTypingUsers = typingUserString == nil + return previewHasVoiceRecording && noTypingUsers && doesNotHaveDraft } } diff --git a/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift b/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift index 54230124fc7..f76727b92ac 100644 --- a/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift +++ b/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift @@ -175,6 +175,14 @@ open class ChatThreadVC: _ViewController, navigationItem.largeTitleDisplayMode = .never } + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let draftMessage = messageController.message?.draftReply { + messageComposerVC.content.draftMessage(draftMessage) + } + } + override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -481,6 +489,10 @@ open class ChatThreadVC: _ViewController, if !isFirstPageLoaded && newMessage.isSentByCurrentUser && newMessage.isPartOfThread { messageController.loadFirstPage() } + case let event as DraftUpdatedEvent where event.draftMessage.threadId == messageController.messageId: + if let draft = messageController.message?.draftReply { + messageComposerVC.content.draftMessage(draft) + } default: break } diff --git a/Sources/StreamChatUI/ChatThreadList/ChatThreadListItemView.swift b/Sources/StreamChatUI/ChatThreadList/ChatThreadListItemView.swift index be04307a3aa..32d4737f594 100644 --- a/Sources/StreamChatUI/ChatThreadList/ChatThreadListItemView.swift +++ b/Sources/StreamChatUI/ChatThreadList/ChatThreadListItemView.swift @@ -252,6 +252,14 @@ open class ChatThreadListItemView: _View, ThemeProvider { replyAuthorAvatarView.content = latestReply?.author replyTitleLabel.text = latestReply?.author.name threadUnreadCountView.content = unreadReplies + + if thread.parentMessage.draftReply != nil { + let highlightOptions = TextHighlightOptions( + color: appearance.colorPalette.accentPrimary, + font: appearance.fonts.footnoteBold + ) + replyDescriptionLabel.highlightText(draftPrefixText, options: highlightOptions) + } } /// The timestamp text formatted. @@ -294,18 +302,33 @@ open class ChatThreadListItemView: _View, ThemeProvider { /// The reply preview text. open var replyPreviewText: String? { // TODO: On v5 the logic in ChatChannelItemView.subtitleText should be extracted to `Appearance.formatters` and shared with the `ChatThreadListItemView` + + if let draftReply = content?.thread.parentMessage.draftReply.map(ChatMessage.init), components.isDraftMessagesEnabled { + let previewText = previewText(for: draftReply) + return "\(draftPrefixText) \(previewText)" + } + guard let latestReply = content?.thread.latestReplies.last else { return nil } - - if latestReply.text.isEmpty { - return latestReply.allAttachments.first?.type.rawValue + + return previewText(for: latestReply) + } + + /// The prefix text for the draft message. + open var draftPrefixText: String { + "\(L10n.Message.Preview.draft):" + } + + private func previewText(for message: ChatMessage) -> String { + if message.text.isEmpty { + return message.allAttachments.first?.type.rawValue.capitalized ?? "" } - if latestReply.isDeleted { + if message.isDeleted { return L10n.Message.Item.deleted } - return latestReply.text + return message.text } } diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift index 17b0d86021d..b8c75ef75ea 100644 --- a/Sources/StreamChatUI/Components.swift +++ b/Sources/StreamChatUI/Components.swift @@ -43,6 +43,11 @@ public struct Components { /// A boolean value that determines whether the link preview should show when typing a message with links. public var isComposerLinkPreviewEnabled = false + /// A boolean value indicating if draft messages should be enabled. + /// + /// If enabled, the SDK will save the message content as a draft when the user navigates away from the composer. + public var isDraftMessagesEnabled: Bool = false + /// A view that displays a quoted message. public var quotedMessageView: QuotedChatMessageView.Type = QuotedChatMessageView.self diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index af61f979eb3..90fb63869db 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -79,6 +79,9 @@ open class ComposerVC: _ViewController, public var cooldownTime: Int /// A boolean value indicating if the message url enrichment should be skipped. public var skipEnrichUrl: Bool + /// A boolean value indicating if the message should be shown in the channel. + /// If the provided value is nil, it won't change the current checkbox state. + public var showReplyInChannel: Bool? /// A boolean that checks if the message contains any content. public var isEmpty: Bool { @@ -90,6 +93,14 @@ open class ComposerVC: _ViewController, return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty } + /// The text that should be sent to the backend considering the command. + public var inputText: String { + if let command = command { + return "/\(command.name) " + text + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + /// A boolean that checks if the composer is replying in a thread public var isInsideThread: Bool { threadMessage != nil } /// A boolean that checks if the composer recognised already a command. @@ -123,7 +134,8 @@ open class ComposerVC: _ViewController, command: Command?, extraData: [String: RawJSON] = [:], cooldownTime: Int = 0, - skipEnrichUrl: Bool = false + skipEnrichUrl: Bool = false, + showReplyInChannel: Bool? = nil ) { self.text = text self.state = state @@ -136,6 +148,7 @@ open class ComposerVC: _ViewController, self.extraData = extraData self.cooldownTime = cooldownTime self.skipEnrichUrl = skipEnrichUrl + self.showReplyInChannel = showReplyInChannel } /// Creates a new content struct with all empty data. @@ -172,6 +185,26 @@ open class ComposerVC: _ViewController, ) } + /// Sets the content state to new message from a saved draft. + /// + /// - Parameter message: The message which was saved as a draft. + public mutating func draftMessage(_ message: DraftMessage) { + self = .init( + text: message.text, + state: message.quotedMessage != nil ? .quote : .new, + editingMessage: nil, + quotingMessage: message.quotedMessage, + threadMessage: threadMessage, + attachments: message.attachments.toAnyAttachmentPayload(), + mentionedUsers: message.mentionedUsers, + command: message.command.map { Command(name: $0, args: message.text) }, + extraData: message.extraData, + cooldownTime: cooldownTime, + skipEnrichUrl: skipEnrichUrl, + showReplyInChannel: message.showReplyInChannel + ) + } + /// Sets the content state to editing a message. /// /// - Parameter message: The message that the composer will edit. @@ -484,6 +517,12 @@ open class ComposerVC: _ViewController, composerView.pin(to: view) } + override open func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + updateDraftMessageIfNeeded() + } + open func setupAttachmentsView() { addChildViewController(attachmentsVC, embedIn: composerView.inputMessageView.attachmentsViewContainer) attachmentsVC.didTapRemoveItemButton = { [weak self] index in @@ -684,6 +723,10 @@ open class ComposerVC: _ViewController, } else { composerView.checkboxControl.label.text = L10n.Composer.Checkmark.channelReply } + + if let showReplyInChannel = content.showReplyInChannel { + composerView.checkboxControl.isSelected = showReplyInChannel + } } } @@ -773,12 +816,9 @@ open class ComposerVC: _ViewController, return } - let text: String - if let command = content.command { - text = "/\(command.name) " + content.text - } else { - text = content.text.trimmingCharacters(in: .whitespacesAndNewlines) - } + deleteDraftMessageIfNeeded() + + let text = content.inputText if let editingMessage = content.editingMessage { editMessage(withId: editingMessage.id, newText: text) @@ -913,14 +953,7 @@ open class ComposerVC: _ViewController, open func createNewMessage(text: String) { guard let cid = channelController?.cid else { return } - // If the user included some mentions via suggestions, - // but then removed them from text, we should remove them from - // the content we'll send - for user in content.mentionedUsers { - if !text.contains(mentionText(for: user)) { - content.mentionedUsers.remove(user) - } - } + removeMentionUserIfNotIncluded(in: text) if let threadParentMessageId = content.threadMessage?.id { let messageController = channelController?.client.messageController( @@ -952,6 +985,67 @@ open class ComposerVC: _ViewController, ) } + /// Updates the draft message in the channel or thread if draft messages are enabled. + /// + /// The draft is created from the current content in the composer. + private func updateDraftMessageIfNeeded() { + guard components.isDraftMessagesEnabled else { + return + } + + let text = content.command != nil ? content.text : content.inputText + if content.isEmpty { + return + } + + removeMentionUserIfNotIncluded(in: text) + + if let threadParentMessageId = content.threadMessage?.id, let cid = channelController?.cid { + let messageController = channelController?.client.messageController( + cid: cid, + messageId: threadParentMessageId + ) + + messageController?.updateDraftReply( + text: text, + attachments: content.attachments, + mentionedUserIds: content.mentionedUsers.map(\.id), + quotedMessageId: content.quotingMessage?.id, + showReplyInChannel: composerView.checkboxControl.isSelected, + command: content.command, + extraData: content.extraData + ) + return + } + + channelController?.updateDraftMessage( + text: text, + attachments: content.attachments, + mentionedUserIds: content.mentionedUsers.map(\.id), + quotedMessageId: content.quotingMessage?.id, + command: content.command, + extraData: content.extraData + ) + } + + /// Deletes the draft message in the channel or thread if there is one. + private func deleteDraftMessageIfNeeded() { + if let threadParentMessageId = content.threadMessage?.id, let cid = channelController?.cid { + let messageController = channelController?.client.messageController( + cid: cid, + messageId: threadParentMessageId + ) + if messageController?.message?.draftReply != nil { + messageController?.deleteDraftReply() + } + return + } + + if channelController?.channel?.draftMessage != nil { + channelController?.deleteDraftMessage() + } + } + /// Updates an existing message. /// - Parameters: /// - id: The id of the editing message. @@ -1450,6 +1544,11 @@ open class ComposerVC: _ViewController, guard textView.text != content.text else { return } content.text = textView.text + + /// If the input message was erased and there is a draft message, the draft message should be deleted. + if content.isEmpty { + deleteDraftMessageIfNeeded() + } } open func textView( @@ -1663,6 +1762,17 @@ open class ComposerVC: _ViewController, present(alert, animated: true) } + + private 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 + for user in content.mentionedUsers { + if !currentText.contains(mentionText(for: user)) { + content.mentionedUsers.remove(user) + } + } + } } /// searchUsers does an autocomplete search on a list of ChatUser and returns users with `id` or `name` containing the search string diff --git a/Sources/StreamChatUI/Generated/L10n.swift b/Sources/StreamChatUI/Generated/L10n.swift index 6e476ad05ac..cd80a31b8dc 100644 --- a/Sources/StreamChatUI/Generated/L10n.swift +++ b/Sources/StreamChatUI/Generated/L10n.swift @@ -296,6 +296,8 @@ internal enum L10n { internal static var title: String { L10n.tr("Localizable", "message.moderation.title") } } internal enum Preview { + /// Draft + internal static var draft: String { L10n.tr("Localizable", "message.preview.draft") } /// %@ created: internal static func pollSomeoneCreated(_ p1: Any) -> String { return L10n.tr("Localizable", "message.preview.poll-someone-created", String(describing: p1)) diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist index 52cf65ec6e6..8caf4c3312b 100644 --- a/Sources/StreamChatUI/Info.plist +++ b/Sources/StreamChatUI/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.72.0 + 4.73.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings index 3d41b3249d8..9e0b35d1254 100644 --- a/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings @@ -306,6 +306,9 @@ /// Shown in the channel list or thread list message preview in case the current user created a poll. "message.preview.poll-you-created" = "You created:"; +/// Shown in the channel list or thread list message preview in case there is a draft. +"message.preview.draft" = "Draft"; + /// Alert title when closing a poll. "alert.poll.end-title" = "Nobody will be able to vote in this poll anymore."; /// Alert title when adding a comment to a poll. diff --git a/Sources/StreamChatUI/Utils/UILabel+highlightText.swift b/Sources/StreamChatUI/Utils/UILabel+highlightText.swift new file mode 100644 index 00000000000..a78d1e0b6a4 --- /dev/null +++ b/Sources/StreamChatUI/Utils/UILabel+highlightText.swift @@ -0,0 +1,41 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import UIKit + +struct TextHighlightOptions { + var color: UIColor + var font: UIFont? +} + +extension UILabel { + func highlightText(_ text: String, options: TextHighlightOptions) { + let currentText = self.text ?? "" + guard text.isEmpty == false && currentText.isEmpty == false else { + return + } + let currentTextString = currentText as NSString + let fullRange = NSRange(location: 0, length: currentTextString.length) + + let attributedString = NSMutableAttributedString(string: currentText) + if let currentFont = font { + attributedString.addAttribute(.font, value: currentFont, range: fullRange) + } + + let highlightRange = currentTextString.range(of: text) + attributedString.addAttribute( + .foregroundColor, + value: options.color, + range: highlightRange + ) + if let font = options.font { + attributedString.addAttribute( + .font, + value: font, + range: highlightRange + ) + } + attributedText = attributedString + } +} diff --git a/StreamChat-XCFramework.podspec b/StreamChat-XCFramework.podspec index 81feccf3138..cf8f9a3d8cd 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.72.0" + spec.version = "4.73.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 e5d00a80524..2f19de80b3f 100644 --- a/StreamChat.podspec +++ b/StreamChat.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChat" - spec.version = "4.72.0" + spec.version = "4.73.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 b34f37d920d..0f721f59b4c 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -228,10 +228,6 @@ 437FCA1626D79A910000223C /* ChatRemoteNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437FCA1526D79A910000223C /* ChatRemoteNotificationHandler.swift */; }; 437FCA1926D906B20000223C /* ChatPushNotificationContent_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437FCA1826D906B20000223C /* ChatPushNotificationContent_Tests.swift */; }; 43ABF8B526C2CD900034BD62 /* ComposerVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ABF8B326C2B7140034BD62 /* ComposerVC_Tests.swift */; }; - 43D3F0F62841052600B74921 /* CallPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D3F0F52841052600B74921 /* CallPayloads.swift */; }; - 43D3F0F72841052600B74921 /* CallPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D3F0F52841052600B74921 /* CallPayloads.swift */; }; - 43D3F0F9284106EE00B74921 /* CallEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D3F0F8284106EE00B74921 /* CallEndpoints.swift */; }; - 43D3F0FA284106EE00B74921 /* CallEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D3F0F8284106EE00B74921 /* CallEndpoints.swift */; }; 43D3F0FC28410A0200B74921 /* CreateCallRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D3F0FB28410A0200B74921 /* CreateCallRequestBody.swift */; }; 43D3F0FD28410A0200B74921 /* CreateCallRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D3F0FB28410A0200B74921 /* CreateCallRequestBody.swift */; }; 43EB3AE22671718200954323 /* AttachmentViewCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EB3AE12671718200954323 /* AttachmentViewCatalog.swift */; }; @@ -1338,8 +1334,6 @@ A3F65E3827EB716A003F6256 /* TypingStartCleanupMiddleware_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A0E9B82498C31300E9BD50 /* TypingStartCleanupMiddleware_Tests.swift */; }; A3F65E3A27EB72F6003F6256 /* Event+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D15D9127EA0125006B34D7 /* Event+Equatable.swift */; }; AC1E16FF269C70530040548B /* String+Extensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDB5412269C6F2A007CD465 /* String+Extensions_Tests.swift */; }; - AC82033A28C60B100002EFDD /* CallEndpoints_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC82033928C60B100002EFDD /* CallEndpoints_Tests.swift */; }; - AC82033C28C61F640002EFDD /* CreateCallPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC82033B28C61F640002EFDD /* CreateCallPayload_Tests.swift */; }; AC82033F28C6598C0002EFDD /* CallRequestBody_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC82033E28C6598C0002EFDD /* CallRequestBody_Tests.swift */; }; AC908384268B115F00ACFB8E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC908383268B115F00ACFB8E /* AppDelegate.swift */; }; AC90838D268B116000ACFB8E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AC90838C268B116000ACFB8E /* Assets.xcassets */; }; @@ -1459,6 +1453,8 @@ AD3D0CC226A88E5100A6D813 /* MessengerChatChannelHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3D0CC126A88E5100A6D813 /* MessengerChatChannelHeaderView.swift */; }; AD3D0CC426A89E6300A6D813 /* iMessageChatChannelHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3D0CC326A89E6300A6D813 /* iMessageChatChannelHeaderView.swift */; }; AD3EE5442832921400ACEFD9 /* VirtualTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D15D8527E9D4B5006B34D7 /* VirtualTime.swift */; }; + AD4118832D5E1368000EF88E /* UILabel+highlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */; }; + AD4118842D5E1368000EF88E /* UILabel+highlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */; }; AD43DE6D2A712B0F0040C0FD /* ChatChannelListSearchVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD43DE6C2A712B0F0040C0FD /* ChatChannelListSearchVC.swift */; }; AD43DE6E2A712B0F0040C0FD /* ChatChannelListSearchVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD43DE6C2A712B0F0040C0FD /* ChatChannelListSearchVC.swift */; }; AD43F90926153BAD00F2D4BB /* QuotedChatMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD43F90826153BAD00F2D4BB /* QuotedChatMessageView.swift */; }; @@ -1509,6 +1505,31 @@ AD52A21C2804851600D0157E /* CommandDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD52A21B2804851600D0157E /* CommandDTO.swift */; }; AD52A21D2804851600D0157E /* CommandDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD52A21B2804851600D0157E /* CommandDTO.swift */; }; AD540AE2260CECA10082D802 /* QuotedChatMessageView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD540AE1260CECA10082D802 /* QuotedChatMessageView_Tests.swift */; }; + AD545E602D523CB0008FD399 /* DraftPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E5F2D523CA8008FD399 /* DraftPayloads.swift */; }; + AD545E612D523CB0008FD399 /* DraftPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E5F2D523CA8008FD399 /* DraftPayloads.swift */; }; + AD545E632D52827B008FD399 /* DraftListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E622D528271008FD399 /* DraftListQuery.swift */; }; + AD545E642D52827B008FD399 /* DraftListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E622D528271008FD399 /* DraftListQuery.swift */; }; + AD545E662D53C271008FD399 /* DraftMessagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E652D53C26B008FD399 /* DraftMessagesRepository.swift */; }; + AD545E672D53C271008FD399 /* DraftMessagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E652D53C26B008FD399 /* DraftMessagesRepository.swift */; }; + AD545E692D5531BA008FD399 /* DemoDraftMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */; }; + AD545E6B2D5650B5008FD399 /* DraftPayloads_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E6A2D5650B5008FD399 /* DraftPayloads_Tests.swift */; }; + AD545E6D2D565316008FD399 /* DraftMessage.json in Resources */ = {isa = PBXBuildFile; fileRef = AD545E6C2D565316008FD399 /* DraftMessage.json */; }; + AD545E712D5A7463008FD399 /* DraftEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E702D5A745F008FD399 /* DraftEvents.swift */; }; + AD545E722D5A7463008FD399 /* DraftEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E702D5A745F008FD399 /* DraftEvents.swift */; }; + AD545E742D5A79DA008FD399 /* DraftUpdaterMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */; }; + AD545E752D5A79DA008FD399 /* DraftUpdaterMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */; }; + AD545E772D5BB3E0008FD399 /* DraftEndpoints_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E762D5BB3D6008FD399 /* DraftEndpoints_Tests.swift */; }; + AD545E792D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E782D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift */; }; + AD545E7B2D5BC1DC008FD399 /* DraftPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E7A2D5BC1DC008FD399 /* DraftPayload.swift */; }; + AD545E7D2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E7C2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift */; }; + AD545E7F2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E7E2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift */; }; + AD545E812D5D0006008FD399 /* MessageController+Drafts_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E802D5D0006008FD399 /* MessageController+Drafts_Tests.swift */; }; + AD545E832D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E822D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift */; }; + AD545E852D5D7591008FD399 /* DraftListQuery_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E842D5D7591008FD399 /* DraftListQuery_Tests.swift */; }; + AD545E872D5D805A008FD399 /* DraftEvents_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E862D5D805A008FD399 /* DraftEvents_Tests.swift */; }; + AD545E8B2D5D8095008FD399 /* DraftUpdated.json in Resources */ = {isa = PBXBuildFile; fileRef = AD545E892D5D8095008FD399 /* DraftUpdated.json */; }; + AD545E8C2D5D8095008FD399 /* DraftDeleted.json in Resources */ = {isa = PBXBuildFile; fileRef = AD545E882D5D8095008FD399 /* DraftDeleted.json */; }; + AD545E8E2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD545E8D2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift */; }; AD552E0128F46CE700199A6F /* ImageLoaderOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */; }; AD552E0228F46CE700199A6F /* ImageLoaderOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */; }; AD57979E2978C4F7006CC435 /* UploadedAttachmentPostProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD57979D2978C4F7006CC435 /* UploadedAttachmentPostProcessor.swift */; }; @@ -1660,6 +1681,9 @@ AD99C909279B0E9D009DD9C5 /* MessageDateSeparatorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */; }; AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; AD99C90D279B136D009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; + ADA03A222D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; }; + ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; }; + ADA03A252D65041B00DFE048 /* DraftMessage_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */; }; ADA2D64A2C46B66E001D2B44 /* DemoChatChannelListErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */; }; ADA3572F269C807A004AD8E9 /* ChatChannelHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */; }; ADA5A0F8276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */; }; @@ -1818,6 +1842,8 @@ ADF617692A09927000E70307 /* MessagesPaginationStateHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF617672A09926900E70307 /* MessagesPaginationStateHandler_Tests.swift */; }; ADF9E1F72A03E7E400109108 /* MessagesPaginationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF9E1F62A03E7E400109108 /* MessagesPaginationState.swift */; }; ADFA09C926A99E0A002A6EFA /* ChatThreadHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFA09C726A99C71002A6EFA /* ChatThreadHeaderView.swift */; }; + ADFD391D2D47D07C00F8E1B1 /* DraftEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */; }; + ADFD391E2D47D07C00F8E1B1 /* DraftEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */; }; BCE4831434E78C9538FA73F8 /* JSONDecoder_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */; }; BCE484BA1EE03FF336034250 /* FilterEncoding_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE483AC99F58A9034EA2ECE /* FilterEncoding_Tests.swift */; }; BCE48639FD7B6B05CD63A6AF /* FilterDecoding_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE4862E2C4943998F0DCBD9 /* FilterDecoding_Tests.swift */; }; @@ -3179,8 +3205,6 @@ 43ABF8B626C513D20034BD62 /* CurrentUserCustomRole.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = CurrentUserCustomRole.json; sourceTree = ""; }; 43BAAD472664F59600323D8E /* UserStopTypingThread.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = UserStopTypingThread.json; sourceTree = ""; }; 43BAAD482664F59600323D8E /* UserStartTypingThread.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = UserStartTypingThread.json; sourceTree = ""; }; - 43D3F0F52841052600B74921 /* CallPayloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallPayloads.swift; sourceTree = ""; }; - 43D3F0F8284106EE00B74921 /* CallEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallEndpoints.swift; sourceTree = ""; }; 43D3F0FB28410A0200B74921 /* CreateCallRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateCallRequestBody.swift; sourceTree = ""; }; 43EB3AE12671718200954323 /* AttachmentViewCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentViewCatalog.swift; sourceTree = ""; }; 43F4750B26F4E4FF0009487D /* ChatMessageReactionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionItemView.swift; sourceTree = ""; }; @@ -4139,8 +4163,6 @@ A3EA3327276C904700C84A52 /* ObjcAssociatedWeakObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjcAssociatedWeakObject.swift; sourceTree = ""; }; A3F65E3227EB6F63003F6256 /* AssertNetworkRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssertNetworkRequest.swift; sourceTree = ""; }; AC73783926A6AF1C002ED7B4 /* AttachmentPayloadLinkWithoutImagePreview.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AttachmentPayloadLinkWithoutImagePreview.json; sourceTree = ""; }; - AC82033928C60B100002EFDD /* CallEndpoints_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallEndpoints_Tests.swift; sourceTree = ""; }; - AC82033B28C61F640002EFDD /* CreateCallPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateCallPayload_Tests.swift; sourceTree = ""; }; AC82033E28C6598C0002EFDD /* CallRequestBody_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRequestBody_Tests.swift; sourceTree = ""; }; AC908381268B115F00ACFB8E /* YouTube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = YouTube.app; sourceTree = BUILT_PRODUCTS_DIR; }; AC908383268B115F00ACFB8E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -4234,6 +4256,7 @@ AD3D0CBF26A8727800A6D813 /* SlackChatChannelHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackChatChannelHeaderView.swift; sourceTree = ""; }; AD3D0CC126A88E5100A6D813 /* MessengerChatChannelHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessengerChatChannelHeaderView.swift; sourceTree = ""; }; AD3D0CC326A89E6300A6D813 /* iMessageChatChannelHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iMessageChatChannelHeaderView.swift; sourceTree = ""; }; + AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+highlightText.swift"; sourceTree = ""; }; AD43DE6C2A712B0F0040C0FD /* ChatChannelListSearchVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelListSearchVC.swift; sourceTree = ""; }; AD43F90826153BAD00F2D4BB /* QuotedChatMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedChatMessageView.swift; sourceTree = ""; }; AD447376263ABC5C0030E583 /* ChatCommandSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatCommandSuggestionCollectionViewCell.swift; sourceTree = ""; }; @@ -4267,6 +4290,26 @@ AD52A21B2804851600D0157E /* CommandDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandDTO.swift; sourceTree = ""; }; AD53DCDE27271D850019290C /* MessageReactionsPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MessageReactionsPayload.json; sourceTree = ""; }; AD540AE1260CECA10082D802 /* QuotedChatMessageView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedChatMessageView_Tests.swift; sourceTree = ""; }; + AD545E5F2D523CA8008FD399 /* DraftPayloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftPayloads.swift; sourceTree = ""; }; + AD545E622D528271008FD399 /* DraftListQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftListQuery.swift; sourceTree = ""; }; + AD545E652D53C26B008FD399 /* DraftMessagesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessagesRepository.swift; sourceTree = ""; }; + AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoDraftMessageListVC.swift; sourceTree = ""; }; + AD545E6A2D5650B5008FD399 /* DraftPayloads_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftPayloads_Tests.swift; sourceTree = ""; }; + AD545E6C2D565316008FD399 /* DraftMessage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = DraftMessage.json; sourceTree = ""; }; + AD545E702D5A745F008FD399 /* DraftEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftEvents.swift; sourceTree = ""; }; + AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftUpdaterMiddleware.swift; sourceTree = ""; }; + AD545E762D5BB3D6008FD399 /* DraftEndpoints_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftEndpoints_Tests.swift; sourceTree = ""; }; + AD545E782D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessagesRepository_Tests.swift; sourceTree = ""; }; + AD545E7A2D5BC1DC008FD399 /* DraftPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftPayload.swift; sourceTree = ""; }; + AD545E7C2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelController+Drafts_Tests.swift"; sourceTree = ""; }; + AD545E7E2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessagesRepository_Mock.swift; sourceTree = ""; }; + AD545E802D5D0006008FD399 /* MessageController+Drafts_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageController+Drafts_Tests.swift"; sourceTree = ""; }; + AD545E822D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUserController+Drafts_Tests.swift"; sourceTree = ""; }; + AD545E842D5D7591008FD399 /* DraftListQuery_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftListQuery_Tests.swift; sourceTree = ""; }; + AD545E862D5D805A008FD399 /* DraftEvents_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftEvents_Tests.swift; sourceTree = ""; }; + AD545E882D5D8095008FD399 /* DraftDeleted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = DraftDeleted.json; sourceTree = ""; }; + AD545E892D5D8095008FD399 /* DraftUpdated.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = DraftUpdated.json; sourceTree = ""; }; + AD545E8D2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftUpdaterMiddleware_Tests.swift; sourceTree = ""; }; AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoaderOptions.swift; sourceTree = ""; }; AD57979D2978C4F7006CC435 /* UploadedAttachmentPostProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachmentPostProcessor.swift; sourceTree = ""; }; AD57DE752A77D5A2005408B6 /* ChannelListSearchStrategy_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListSearchStrategy_Tests.swift; sourceTree = ""; }; @@ -4365,6 +4408,8 @@ AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDateSeparatorFormatter.swift; sourceTree = ""; }; AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLastActivityFormatter.swift; sourceTree = ""; }; AD9BE32526680E4200A6D284 /* Stream.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Stream.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + ADA03A212D64EFE900DFE048 /* DraftMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage.swift; sourceTree = ""; }; + ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage_Mock.swift; sourceTree = ""; }; ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatChannelListErrorView.swift; sourceTree = ""; }; ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelHeaderView.swift; sourceTree = ""; }; ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListDateSeparatorView.swift; sourceTree = ""; }; @@ -4472,6 +4517,7 @@ ADF617672A09926900E70307 /* MessagesPaginationStateHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationStateHandler_Tests.swift; sourceTree = ""; }; ADF9E1F62A03E7E400109108 /* MessagesPaginationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationState.swift; sourceTree = ""; }; ADFA09C726A99C71002A6EFA /* ChatThreadHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadHeaderView.swift; sourceTree = ""; }; + ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftEndpoints.swift; sourceTree = ""; }; BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONDecoder_Tests.swift; sourceTree = ""; }; BCE483AC99F58A9034EA2ECE /* FilterEncoding_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterEncoding_Tests.swift; sourceTree = ""; }; BCE4862E2C4943998F0DCBD9 /* FilterDecoding_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterDecoding_Tests.swift; sourceTree = ""; }; @@ -5536,6 +5582,7 @@ 841BAA0F2BCEADAC000C73E4 /* PollsEvents.swift */, AD7BE1692C209888000A5756 /* ThreadEvents.swift */, 848849B52CEE01070010E7CA /* AITypingEvents.swift */, + AD545E702D5A745F008FD399 /* DraftEvents.swift */, ); path = Events; sourceTree = ""; @@ -5651,6 +5698,7 @@ 79C5CBF025F66E9700D98001 /* ChannelWatcherListQuery.swift */, AD6E32A02BBC50110073831B /* ThreadListQuery.swift */, AD6E32A32BBC502D0073831B /* ThreadQuery.swift */, + AD545E622D528271008FD399 /* DraftListQuery.swift */, AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */, 7978FBB926E15A58002CA2DF /* MessageSearchQuery.swift */, 792A4F442480107A00EAF71D /* Pagination.swift */, @@ -5724,6 +5772,7 @@ 796610B7248E64EC00761629 /* EventMiddlewares */ = { isa = PBXGroup; children = ( + AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */, 79896D63250A62EE00BA8F1C /* ChannelReadUpdaterMiddleware.swift */, AD9632E02C0A43630073B814 /* ThreadUpdaterMiddleware.swift */, 79158CF325F133FB00186102 /* ChannelTruncatedEventMiddleware.swift */, @@ -5745,7 +5794,6 @@ isa = PBXGroup; children = ( ADF2BBEA2B9B622B0069D467 /* AppSettingsPayload.swift */, - 43D3F0F52841052600B74921 /* CallPayloads.swift */, DA9985ED24E175AA000E9885 /* ChannelCodingKeys.swift */, DAFAD6A224DD8E1A0043ED06 /* ChannelEditDetailPayload.swift */, 79682C4924BF37970071578E /* ChannelListPayload.swift */, @@ -5762,6 +5810,7 @@ 793728292498FFD300E13FE5 /* MemberPayload.swift */, 4F6B840F2D008D5F005645B0 /* MemberUpdatePayload.swift */, DABC6ABB2546FD0100A8FC78 /* MessageAttachmentPayload.swift */, + AD545E5F2D523CA8008FD399 /* DraftPayloads.swift */, 79682C4824BF37650071578E /* MessagePayloads.swift */, 8899BC3E2542FFA1003CB98B /* MessageReactionPayload.swift */, ADEEB7F12BD1368900C76602 /* MessageReactionGroupPayload.swift */, @@ -5807,9 +5856,9 @@ children = ( 88E26D7C2580F95300F55AB5 /* AttachmentEndpoints.swift */, 82C18FDB2C10C8E600C5283C /* BlockedUserPayload.swift */, - 43D3F0F8284106EE00B74921 /* CallEndpoints.swift */, 79877A132498E4EE00015F8B /* ChannelEndpoints.swift */, F6FF1DA924FD23D300151735 /* MessageEndpoints.swift */, + ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */, AD0CC01B2BDBD22D005E2C66 /* ReactionEndpoints.swift */, AD84377A2BB482CF000F3826 /* ThreadEndpoints.swift */, C143788F27BC03EE00E23965 /* EndpointPath.swift */, @@ -5907,6 +5956,7 @@ 4F877D392D019E0400CB66EC /* ChannelPinningScope.swift */, 79896D5D25065E6900BA8F1C /* ChannelRead.swift */, 79877A042498E4BB00015F8B /* ChannelType.swift */, + ADA03A212D64EFE900DFE048 /* DraftMessage.swift */, 799C9431247D2FB9001F1104 /* ChatMessage.swift */, 79877A052498E4BC00015F8B /* CurrentUser.swift */, 79877A022498E4BB00015F8B /* Device.swift */, @@ -6343,6 +6393,7 @@ 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, C12297D22AC57A3200C5FF04 /* Throttler.swift */, + AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */, AD169DEC2C9B112B00F58FAC /* KeyboardHandler */, AD95FD0F28F9B72200DBDF41 /* Extensions */, ACCA772826C40C7A007AE2ED /* ImageLoading */, @@ -6578,6 +6629,7 @@ 8A62705F24BE31B20040BFD6 /* Events */ = { isa = PBXGroup; children = ( + AD545E8A2D5D8095008FD399 /* Draft */, 84E46A332CFA1B73000CBDDE /* AIIndicator */, ADE57B802C3C5C4600DD6B88 /* Thread */, 8A0C3BCA24C1C38C00CAFD19 /* Channel */, @@ -6760,6 +6812,7 @@ A3227ECA284A607D00EBE6CC /* Screens */ = { isa = PBXGroup; children = ( + AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */, ADD328592C04DD8300BAD0E9 /* DemoAppTabBarController.swift */, C10B5C712A1F794A006A5BCB /* MembersViewController.swift */, C1CEF9062A1BC4E800414931 /* UserProfileViewController.swift */, @@ -6796,6 +6849,7 @@ A344074F27D753530044F150 /* Models + Extensions */ = { isa = PBXGroup; children = ( + ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */, A344075027D753530044F150 /* ChannelUnreadCount_Mock.swift */, A344075127D753530044F150 /* ChatChannel_Mock.swift */, A344075D27D753530044F150 /* ChatChannelMember_Mock.swift */, @@ -6828,6 +6882,7 @@ A344076127D753530044F150 /* DummyData */ = { isa = PBXGroup; children = ( + AD545E7A2D5BC1DC008FD399 /* DraftPayload.swift */, AD94905B2BF630D200E69224 /* ThreadPayload.swift */, 84C11BDE27FB2B4600000A9E /* ChannelPayload.swift */, 79D7A1CD2593A40900D3C2BF /* ChannelDetailPayload.swift */, @@ -6967,6 +7022,7 @@ A364D08D27D0BD8E0029857A /* EventMiddlewares */ = { isa = PBXGroup; children = ( + AD545E8D2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift */, 79896D65250A6D1500BA8F1C /* ChannelReadUpdaterMiddleware_Tests.swift */, AD7BE16C2C20CC02000A5756 /* ThreadUpdaterMiddlware_Tests.swift */, 79158CEA25F0EADF00186102 /* ChannelTruncatedEventMiddleware_Tests.swift */, @@ -6987,6 +7043,7 @@ A364D08E27D0BDB20029857A /* Events */ = { isa = PBXGroup; children = ( + AD545E862D5D805A008FD399 /* DraftEvents_Tests.swift */, 8A62706B24BF3DBC0040BFD6 /* ChannelEvents_Tests.swift */, 84A1D2EF26AB10DB00014712 /* EventDecoder_Tests.swift */, 794927F0249E3DE6009D7EB7 /* EventPayload_Tests.swift */, @@ -7039,9 +7096,9 @@ A364D09327D0BF330029857A /* Endpoints */ = { isa = PBXGroup; children = ( + AD545E762D5BB3D6008FD399 /* DraftEndpoints_Tests.swift */, AD8C7C652BA46A4A00260715 /* AppEndpoints_Tests.swift */, 88381E8625825A240047A6A3 /* AttachmentEndpoints_Tests.swift */, - AC82033928C60B100002EFDD /* CallEndpoints_Tests.swift */, DAEAF4B724DC026C0015FB28 /* ChannelEndpoints_Tests.swift */, AD6E32AC2BBC86950073831B /* ThreadEndpoint_Tests.swift */, 790A4C47252DDD1A001F4A23 /* DeviceEndpoints_Tests.swift */, @@ -7066,13 +7123,13 @@ A364D09427D0BF3A0029857A /* Payloads */ = { isa = PBXGroup; children = ( + AD545E6A2D5650B5008FD399 /* DraftPayloads_Tests.swift */, AD8C7C622BA464E600260715 /* AppSettingsPayload_Tests.swift */, AD6E32952BBB10890073831B /* ThreadListPayload_Tests.swift */, DA7229E224E140260074503A /* ChannelEditDetailPayload_Tests.swift */, 8A0D64AA24E57BF20017A3C0 /* ChannelListPayload_Tests.swift */, C122B8802A02645200D27F41 /* ChannelReadPayload_Tests.swift */, 882C574D252C76A300E60C44 /* ChannelMemberListPayload_Tests.swift */, - AC82033B28C61F640002EFDD /* CreateCallPayload_Tests.swift */, 79B5517624E595DA00CE9FEC /* CurrentUserPayloads_Tests.swift */, 430156DB26B1862C0006E7EA /* CustomDataHashMap_Tests.swift */, 790A4C4D252E0901001F4A23 /* DevicePayloads_Tests.swift */, @@ -7287,6 +7344,7 @@ A364D0A327D126490029857A /* Repositories */ = { isa = PBXGroup; children = ( + AD545E782D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift */, C11BAA4C2907EC7B004C5EA4 /* AuthenticationRepository_Tests.swift */, C18514FC292E34E10033387E /* ConnectionRepository_Tests.swift */, C1C5345B29AFE4C9006F9AF4 /* ChannelRepository_Tests.swift */, @@ -7342,6 +7400,7 @@ A364D0A827D128650029857A /* ChannelController */ = { isa = PBXGroup; children = ( + AD545E7C2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift */, 7952B3B224D314B100AC53D4 /* ChannelController_Tests.swift */, DA4AA3B32502719700FAAF6E /* ChannelController+Combine_Tests.swift */, DAE566E824FFD24000E39431 /* ChannelController+SwiftUI_Tests.swift */, @@ -7382,6 +7441,7 @@ A364D0AD27D1291E0029857A /* CurrentUserController */ = { isa = PBXGroup; children = ( + AD545E822D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift */, F69E7F7C24ED7562000F5252 /* CurrentUserController_Tests.swift */, DA4AA3B5250271B100FAAF6E /* CurrentUserController+Combine_Tests.swift */, DAE566EC24FFD27500E39431 /* CurrentUserController+SwiftUI_Tests.swift */, @@ -7431,6 +7491,7 @@ A364D0B227D129A90029857A /* MessageController */ = { isa = PBXGroup; children = ( + AD545E802D5D0006008FD399 /* MessageController+Drafts_Tests.swift */, F649B2362500F785008F98C8 /* MessageController_Tests.swift */, DAF1BED625066128003CEDC0 /* MessageController+Combine_Tests.swift */, DAF1BED225066107003CEDC0 /* MessageController+SwiftUI_Tests.swift */, @@ -7480,6 +7541,7 @@ A364D0B727D12A520029857A /* Query */ = { isa = PBXGroup; children = ( + AD545E842D5D7591008FD399 /* DraftListQuery_Tests.swift */, A3C7BAD027E4E02700BBF4FA /* ChannelListFilterScope_Tests.swift */, 799F611A2530B62C007F218C /* ChannelListQuery_Tests.swift */, AD0CC0142BDBC68E005E2C66 /* ReactionListQuery_Tests.swift */, @@ -8130,6 +8192,7 @@ A3C729552840BA4800FFE8B4 /* JSONs */ = { isa = PBXGroup; children = ( + AD545E6C2D565316008FD399 /* DraftMessage.json */, 798779F72498E47700015F8B /* Channel.json */, AD6E32972BBB13650073831B /* Thread.json */, AD6E32992BBB139D0073831B /* ThreadList.json */, @@ -8572,6 +8635,15 @@ path = PollResultsVoteListVC; sourceTree = ""; }; + AD545E8A2D5D8095008FD399 /* Draft */ = { + isa = PBXGroup; + children = ( + AD545E882D5D8095008FD399 /* DraftDeleted.json */, + AD545E892D5D8095008FD399 /* DraftUpdated.json */, + ); + path = Draft; + sourceTree = ""; + }; AD57DE742A77D566005408B6 /* Search */ = { isa = PBXGroup; children = ( @@ -9116,6 +9188,7 @@ C12D0A5E28FD58CE0099895A /* Repositories */ = { isa = PBXGroup; children = ( + AD545E7E2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift */, C12D0A5F28FD59B60099895A /* AuthenticationRepository_Mock.swift */, A344074E27D753530044F150 /* ConnectionRepository_Mock.swift */, C1C5345929AFDDAE006F9AF4 /* ChannelRepository_Mock.swift */, @@ -9258,6 +9331,7 @@ C1E8AD55278C8A440041B775 /* SyncRepository.swift */, 8451C48C2BD671A400849955 /* PollsRepository.swift */, AD0E278D2BF789630037554F /* ThreadsRepository.swift */, + AD545E652D53C26B008FD399 /* DraftMessagesRepository.swift */, ); path = Repositories; sourceTree = ""; @@ -10194,6 +10268,8 @@ A311B3F527E8B99D00CFCF6D /* HealthCheck.json in Resources */, A311B40727E8B9AD00CFCF6D /* NotificationRemovedFromChannel.json in Resources */, A311B41727E8B9B900CFCF6D /* UserStartTypingThread.json in Resources */, + AD545E8B2D5D8095008FD399 /* DraftUpdated.json in Resources */, + AD545E8C2D5D8095008FD399 /* DraftDeleted.json in Resources */, 799EC85F2853B3BE00F18770 /* BigChannelListPayload.json in Resources */, A311B40127E8B9AD00CFCF6D /* NotificationMarkRead.json in Resources */, A311B42927E8B9D800CFCF6D /* UserUpdateResponse.json in Resources */, @@ -10279,6 +10355,7 @@ A311B3E227E8B98C00CFCF6D /* UsersQuery.json in Resources */, A311B3FE27E8B9A800CFCF6D /* MessageDeleted.json in Resources */, A311B3D927E8B98C00CFCF6D /* ChannelPayloadWithCustom.json in Resources */, + AD545E6D2D565316008FD399 /* DraftMessage.json in Resources */, A311B42027E8B9C400CFCF6D /* FlagUserPayload+NoExtraData.json in Resources */, ADF3EEF62C00FC7B00DB36D6 /* NotificationMarkUnread+MissingFields.json in Resources */, A311B3E627E8B99200CFCF6D /* AttachmentPayloadImage.json in Resources */, @@ -10608,6 +10685,7 @@ C1FC2F8627416E150062530F /* Allocations.swift in Sources */, C1FC2F8C27416E1F0062530F /* UIImage+SwiftyGif.swift in Sources */, AD9632DC2C09E0350073B814 /* ChatThreadListRouter.swift in Sources */, + AD4118842D5E1368000EF88E /* UILabel+highlightText.swift in Sources */, 40FA4DE52A12A45400DA21D2 /* VoiceRecordingAttachmentComposerPreview.swift in Sources */, ADC4AAB02788C8850004BB35 /* Appearance+Formatters.swift in Sources */, AD6F531927175FDB00D428B4 /* ChatMessageGiphyView+GiphyBadge.swift in Sources */, @@ -11090,6 +11168,7 @@ AD053B9D2B3358E2003612B6 /* LocationAttachmentPayload.swift in Sources */, AD053BA12B3359DD003612B6 /* DemoAttachmentViewCatalog.swift in Sources */, AD053B9F2B335929003612B6 /* LocationAttachmentViewInjector.swift in Sources */, + AD545E692D5531BA008FD399 /* DemoDraftMessageListVC.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -11121,6 +11200,7 @@ A3C3BC6927E8AA4300224761 /* TestBuilder.swift in Sources */, A3C3BC6A27E8AA4300224761 /* TestMemberEvent.swift in Sources */, 40D4840A2A1264F1009E4134 /* MockAVPlayer.swift in Sources */, + AD545E7B2D5BC1DC008FD399 /* DraftPayload.swift in Sources */, A344078127D753530044F150 /* ChatUser_Mock.swift in Sources */, 84C85B452BF2B2D1008A7AA5 /* Poll+Unique.swift in Sources */, A3C3BC3327E87F2900224761 /* OfflineRequestsRepository_Mock.swift in Sources */, @@ -11261,6 +11341,7 @@ C163ED012992B378006D6124 /* NotificationExtensionLifecycle_Mock.swift in Sources */, 82E655352B06751D00D64906 /* QueueAwareDelegate.swift in Sources */, A3C3BC6D27E8AA4300224761 /* TestManagedObject.swift in Sources */, + ADA03A252D65041B00DFE048 /* DraftMessage_Mock.swift in Sources */, 8263464C2B0BACF600122D0E /* Difference.swift in Sources */, A3C3BC2427E87F1800224761 /* ChatChannelController_Spy.swift in Sources */, A3C3BC1927E87EFE00224761 /* ConnectionRepository_Mock.swift in Sources */, @@ -11283,6 +11364,7 @@ A3C3BC8527E8AB6200224761 /* Array+Subscript.swift in Sources */, 84196FA32805892500185E99 /* LocalMessageState+Extensions.swift in Sources */, 82E655452B067CAE00D64906 /* AssertResult.swift in Sources */, + AD545E7F2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift in Sources */, 82E655432B067C3600D64906 /* AssertAsync.swift in Sources */, 84C7CB152BC1F7EC0088890C /* MessageSearch_Mock.swift in Sources */, A34ECB5C27F5D0BF00A804C1 /* TestDataModel2.xcdatamodeld in Sources */, @@ -11337,6 +11419,7 @@ 84DCB853269F569A006CDF32 /* EventsController+SwiftUI.swift in Sources */, 4FF9B2692C6F697300A3B711 /* AttachmentDownloader.swift in Sources */, 40789D3129F6AC500018C2BB /* AudioRecording.swift in Sources */, + AD545E662D53C271008FD399 /* DraftMessagesRepository.swift in Sources */, 841BAA4B2BD1CCC0000C73E4 /* PollVoteDTO.swift in Sources */, 88381E65258258C20047A6A3 /* FileUploadPayload.swift in Sources */, 7978FBBF26E1667C002CA2DF /* MessageSearchController.swift in Sources */, @@ -11385,6 +11468,7 @@ 8A0D649724E579A50017A3C0 /* GuestEndpoints.swift in Sources */, 841BAA102BCEADAC000C73E4 /* PollsEvents.swift in Sources */, 8899BC47254305F8003CB98B /* MessageReactionRequestPayload.swift in Sources */, + AD545E722D5A7463008FD399 /* DraftEvents.swift in Sources */, 8A62705024B867190040BFD6 /* EventPayload.swift in Sources */, AD70DC362ADEC0F600CFC3B7 /* MessageModerationDetailsPayload.swift in Sources */, AD0E278E2BF789630037554F /* ThreadsRepository.swift in Sources */, @@ -11434,10 +11518,10 @@ 799C9445247D3DD2001F1104 /* WebSocketClient.swift in Sources */, AD6E32A12BBC50110073831B /* ThreadListQuery.swift in Sources */, AD8258A32BD2939500B9ED74 /* MessageReactionGroup.swift in Sources */, - 43D3F0F9284106EE00B74921 /* CallEndpoints.swift in Sources */, C15C8838286C7BF300E6A72C /* BackgroundListDatabaseObserver.swift in Sources */, DA640FBB2535CF8500D32944 /* ChannelListSortingKey.swift in Sources */, 8899BC3F2542FFA1003CB98B /* MessageReactionPayload.swift in Sources */, + ADFD391E2D47D07C00F8E1B1 /* DraftEndpoints.swift in Sources */, 797A756624814EF8003CF16D /* SystemEnvironment.swift in Sources */, 799C9438247D2FB9001F1104 /* ChatClientConfig.swift in Sources */, 7964F3B6249A314D002A09EC /* PrefixLogFormatter.swift in Sources */, @@ -11502,6 +11586,7 @@ DA4AA3B8250271BD00FAAF6E /* CurrentUserController+Combine.swift in Sources */, 79280F712487CD2B00CDEB89 /* Atomic.swift in Sources */, AD7AC99B260A9572004AADA5 /* MessagePinning.swift in Sources */, + ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */, 88DA57642631CF1F00FA8C53 /* MuteDetails.swift in Sources */, 7978FBBA26E15A58002CA2DF /* MessageSearchQuery.swift in Sources */, 4F8CA69C2CB665EB00EBEA2D /* EphemeralValuesContainer.swift in Sources */, @@ -11572,6 +11657,7 @@ DABC6ABC2546FD0100A8FC78 /* MessageAttachmentPayload.swift in Sources */, 4F427F662BA2F43200D92238 /* ConnectedUser.swift in Sources */, 4F73F39E2B91C7BF00563CD9 /* MessageState+Observer.swift in Sources */, + AD545E752D5A79DA008FD399 /* DraftUpdaterMiddleware.swift in Sources */, C1E8AD5E278EF5F30041B775 /* AsyncOperation.swift in Sources */, 88D85DA7252F3C1D00AE1030 /* MemberListController.swift in Sources */, 79280F4B248523C000CDEB89 /* ConnectionEvents.swift in Sources */, @@ -11579,6 +11665,7 @@ 882C574A252C767E00E60C44 /* ChannelMemberListPayload.swift in Sources */, DAFAD6A324DD8E1A0043ED06 /* ChannelEditDetailPayload.swift in Sources */, 84A43CB326A9A54700302763 /* EventSender.swift in Sources */, + AD545E612D523CB0008FD399 /* DraftPayloads.swift in Sources */, 88BEBCD62536FDBF00D9E8B7 /* MemberListController+SwiftUI.swift in Sources */, A3B0CFA227BBF52600F352F9 /* ChannelTruncateRequestPayload.swift in Sources */, 4FE6E1AD2BAC7A1B00C80AF1 /* UserListState+Observer.swift in Sources */, @@ -11618,7 +11705,6 @@ 4F45802E2BEE0B4B0099F540 /* ChannelListLinker.swift in Sources */, AD0CC02E2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift in Sources */, 841BA9F52BCE8089000C73E4 /* PollsEndpoints.swift in Sources */, - 43D3F0F62841052600B74921 /* CallPayloads.swift in Sources */, A30C3F20276B428F00DA5968 /* UnknownUserEvent.swift in Sources */, 40789D2129F6AC500018C2BB /* AppStateObserving.swift in Sources */, 4F427F6C2BA2F53200D92238 /* ConnectedUserState+Observer.swift in Sources */, @@ -11644,6 +11730,7 @@ AD0CC0342BDC4A6B005E2C66 /* ReactionListController+Combine.swift in Sources */, 88206FC425B18C88009D086A /* ConnectionRepository.swift in Sources */, 79682C4B24BF37CB0071578E /* ChannelListPayload.swift in Sources */, + AD545E642D52827B008FD399 /* DraftListQuery.swift in Sources */, 79D6CE3725F7C84600BE2EEC /* ChatChannelWatcherListController+SwiftUI.swift in Sources */, 4042968929FACA6A0089126D /* AudioValuePercentageNormaliser.swift in Sources */, DA9985EE24E175AA000E9885 /* ChannelCodingKeys.swift in Sources */, @@ -11759,6 +11846,7 @@ A34ECB4827F5C9FA00A804C1 /* UserEvents_IntegrationTests.swift in Sources */, AD6E32962BBB10890073831B /* ThreadListPayload_Tests.swift in Sources */, 8A0175F425013B6400570345 /* TypingEventSender_Tests.swift in Sources */, + AD545E872D5D805A008FD399 /* DraftEvents_Tests.swift in Sources */, 4F3554982C9C0F7200479229 /* StreamJSONDecoder_Tests.swift in Sources */, C149B744282A61FF00F25BED /* NSManagedObject+Validation_Tests.swift in Sources */, AD94905A2BF5702700E69224 /* ThreadsRepository_Tests.swift in Sources */, @@ -11773,16 +11861,17 @@ 4042969229FBF84B0089126D /* AudioSamplesProcessor_Tests.swift in Sources */, A3F65E3627EB70E0003F6256 /* EventLogger.swift in Sources */, DA8407332526003D005A0F62 /* UserListUpdater_Tests.swift in Sources */, + AD545E772D5BB3E0008FD399 /* DraftEndpoints_Tests.swift in Sources */, C12DBE5C2A614F310045D9F0 /* ListDatabaseObserver+Sorting_Tests.swift in Sources */, F69E7F7D24ED7562000F5252 /* CurrentUserController_Tests.swift in Sources */, A30C3F22276B4F8800DA5968 /* UnknownUserEvent_Tests.swift in Sources */, DA84074025260CA3005A0F62 /* UserListController_Tests.swift in Sources */, + AD545E852D5D7591008FD399 /* DraftListQuery_Tests.swift in Sources */, C1EE53A727BA53F300B1A6CA /* Endpoint_Tests.swift in Sources */, 84A1D2F426AB221E00014712 /* ChannelEventsController_Tests.swift in Sources */, 88381E6E258259310047A6A3 /* FileUploadPayload_Tests.swift in Sources */, AD6355162CE801AD009E498F /* MessageModerationDetailsDTO_Tests.swift in Sources */, 64F70D4B26257FD400C9F979 /* Error+InternetNotAvailable_Tests.swift in Sources */, - AC82033A28C60B100002EFDD /* CallEndpoints_Tests.swift in Sources */, 4FB4AB9F2BAD6DBD00712C4E /* Chat_Tests.swift in Sources */, 84FD350827FD8BE300D68D85 /* ChatChannel_Tests.swift in Sources */, F63CC37124E591990052844D /* EventObserver_Tests.swift in Sources */, @@ -11839,6 +11928,7 @@ 84D5BC71277B61B900A65C75 /* PinnedMessagesSortingKey_Tests.swift in Sources */, 88381E8725825A240047A6A3 /* AttachmentEndpoints_Tests.swift in Sources */, AD142ACA2C739D6600ABCC1F /* Poll_Tests.swift in Sources */, + AD545E6B2D5650B5008FD399 /* DraftPayloads_Tests.swift in Sources */, 84DCB855269F56A7006CDF32 /* EventsController+SwiftUI_Tests.swift in Sources */, 79B5517724E595DA00CE9FEC /* CurrentUserPayloads_Tests.swift in Sources */, 40B345F829C46AE500B96027 /* AudioPlaybackRate_Tests.swift in Sources */, @@ -11863,6 +11953,7 @@ 88EA9AE825471EF4007EE76B /* MessageReactionRequestPayload_Tests.swift in Sources */, 64C80615262EDA9600B1F7AD /* ChatMessage_Tests.swift in Sources */, F63CC37524E592DD0052844D /* MemberEventObserver_Tests.swift in Sources */, + AD545E832D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift in Sources */, ADA9DB892BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift in Sources */, 792FCB4724A33CC2000290C7 /* EventDataProcessorMiddleware_Tests.swift in Sources */, 797EEA4A24FFC37600C81203 /* ConnectionStatus_Tests.swift in Sources */, @@ -11902,10 +11993,11 @@ 79B5517C24E6A1CA00CE9FEC /* MessagePayloads_Tests.swift in Sources */, F6D61D9D2510B57F00EB0624 /* NSManagedObject_Tests.swift in Sources */, 79D6CE9525F7D72E00BE2EEC /* ChatChannelWatcherListController_Tests.swift in Sources */, + AD545E792D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift in Sources */, AD5BCCC92AB22A6600456CD9 /* Logger_Tests.swift in Sources */, A34ECB5027F5CAF200A804C1 /* PinnedMessagesQuery_IntegrationTests.swift in Sources */, 79896D66250A6D1800BA8F1C /* ChannelReadUpdaterMiddleware_Tests.swift in Sources */, - AC82033C28C61F640002EFDD /* CreateCallPayload_Tests.swift in Sources */, + AD545E8E2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift in Sources */, 796CBC6525FBAD12003299B0 /* Member_Tests.swift in Sources */, A34ECB4E27F5CABD00A804C1 /* MemberEvents_IntegrationTests.swift in Sources */, 79DDF812249CD5AC002F4412 /* APIClient_Tests.swift in Sources */, @@ -11971,6 +12063,7 @@ 79CD959424F9381700E87377 /* MulticastDelegate_Tests.swift in Sources */, C1C5345D29AFE526006F9AF4 /* ChannelRepository_Tests.swift in Sources */, C11BAA4D2907EC7B004C5EA4 /* AuthenticationRepository_Tests.swift in Sources */, + AD545E7D2D5CFC15008FD399 /* ChannelController+Drafts_Tests.swift in Sources */, C186BFA627A7F4E10099CCA6 /* AsyncOperation_Tests.swift in Sources */, A3C7BAD127E4E02700BBF4FA /* ChannelListFilterScope_Tests.swift in Sources */, DAD5C836250278AD0045117A /* ChannelController+Combine_Tests.swift in Sources */, @@ -12022,6 +12115,7 @@ 8459C9F42BFB929600F0D235 /* PollsRepository_Tests.swift in Sources */, 4F5151982BC407ED001B7152 /* UserList_Tests.swift in Sources */, 84CC56EC267B3F6B00DF2784 /* AnyAttachmentPayload_Tests.swift in Sources */, + AD545E812D5D0006008FD399 /* MessageController+Drafts_Tests.swift in Sources */, 88F7692B25837EE600BD36B0 /* AttachmentQueueUploader_Tests.swift in Sources */, 8819DFDE252622D900FD1A50 /* ModerationEndpoints_Tests.swift in Sources */, 4042969829FE92320089126D /* AudioAnalysisEngine_Tests.swift in Sources */, @@ -12240,6 +12334,7 @@ C121E814274544AD00023E4C /* ChannelVisibilityEventMiddleware.swift in Sources */, C121E815274544AD00023E4C /* EventDTOConverterMiddleware.swift in Sources */, C121E816274544AD00023E4C /* EventType.swift in Sources */, + AD545E712D5A7463008FD399 /* DraftEvents.swift in Sources */, AD70DC3A2ADEC3C400CFC3B7 /* MessageModerationDetailsDTO.swift in Sources */, AD0CC0242BDBF715005E2C66 /* ReactionListUpdater.swift in Sources */, 4F8CA69B2CB665EB00EBEA2D /* EphemeralValuesContainer.swift in Sources */, @@ -12294,6 +12389,7 @@ AD0E278F2BF789630037554F /* ThreadsRepository.swift in Sources */, 4FF2A80E2B8E011000941A64 /* ChatState+Observer.swift in Sources */, C121E833274544AD00023E4C /* UserListPayload.swift in Sources */, + ADA03A222D64EFE900DFE048 /* DraftMessage.swift in Sources */, C121E834274544AD00023E4C /* UserPayloads.swift in Sources */, C121E835274544AD00023E4C /* CurrentUserPayloads.swift in Sources */, C121E836274544AD00023E4C /* ChannelCodingKeys.swift in Sources */, @@ -12313,6 +12409,7 @@ C121E840274544AE00023E4C /* MessageAttachmentPayload.swift in Sources */, C121E841274544AE00023E4C /* FileUploadPayload.swift in Sources */, C121E842274544AE00023E4C /* MutedChannelPayload.swift in Sources */, + AD545E742D5A79DA008FD399 /* DraftUpdaterMiddleware.swift in Sources */, C121E843274544AE00023E4C /* RawJSON.swift in Sources */, 4F6B84112D008D6E005645B0 /* MemberUpdatePayload.swift in Sources */, 4F97F2782BA87E30001C4D66 /* MessageSearchState.swift in Sources */, @@ -12413,6 +12510,7 @@ C189D7792AEBC6CD00D4B966 /* BackgroundDatabaseObserver.swift in Sources */, 40789D1A29F6AC500018C2BB /* AudioPlayerObserving.swift in Sources */, 4042969329FBF84B0089126D /* AudioSamplesProcessor_Tests.swift in Sources */, + AD545E632D52827B008FD399 /* DraftListQuery.swift in Sources */, C173538F27D9F804008AC412 /* KeyedDecodingContainer+Array.swift in Sources */, C121E879274544AF00023E4C /* ChannelReadDTO.swift in Sources */, CFA41B6827DA952300427602 /* SystemEnvironment+XStreamClient.swift in Sources */, @@ -12424,6 +12522,7 @@ C1E8AD5F278EF5F40041B775 /* AsyncOperation.swift in Sources */, 8413D2F62BDDAAFF005ADA4E /* PollVoteListController+SwiftUI.swift in Sources */, AD78F9EE28EC718700BC0FCE /* URL+EnrichedURL.swift in Sources */, + ADFD391D2D47D07C00F8E1B1 /* DraftEndpoints.swift in Sources */, ADEEB7F62BD168D500C76602 /* MessageReactionGroupDTO.swift in Sources */, C121E87D274544AF00023E4C /* ChannelMemberListQueryDTO.swift in Sources */, C121E87E274544AF00023E4C /* DeviceDTO.swift in Sources */, @@ -12480,6 +12579,7 @@ 4F910C6D2BEE1BDC00214EB9 /* UnreadMessageLookup.swift in Sources */, 404296DE2A0114900089126D /* StreamAudioQueuePlayer_Tests.swift in Sources */, C121E89C274544B000023E4C /* DataController.swift in Sources */, + AD545E602D523CB0008FD399 /* DraftPayloads.swift in Sources */, AD17CDFA27E4DB2700E0D092 /* PushProvider.swift in Sources */, C121E89D274544B000023E4C /* UserSearchController.swift in Sources */, C121E89E274544B000023E4C /* MessageSearchController.swift in Sources */, @@ -12603,7 +12703,6 @@ C121E8E5274544B200023E4C /* Timers.swift in Sources */, C121E8E6274544B200023E4C /* SystemEnvironment.swift in Sources */, 4F97F2712BA86491001C4D66 /* UserSearchState.swift in Sources */, - 43D3F0F72841052600B74921 /* CallPayloads.swift in Sources */, C121E8E7274544B200023E4C /* Bundle+Extensions.swift in Sources */, C121E8E8274544B200023E4C /* OptionSet+Extensions.swift in Sources */, 40789D2029F6AC500018C2BB /* AudioPlaying.swift in Sources */, @@ -12617,11 +12716,11 @@ C121E8EF274544B200023E4C /* MainQueue+Synchronous.swift in Sources */, C121E8F0274544B200023E4C /* Dictionary+Extensions.swift in Sources */, C121E8F1274544B200023E4C /* MultipartFormData.swift in Sources */, + AD545E672D53C271008FD399 /* DraftMessagesRepository.swift in Sources */, C121E8F2274544B200023E4C /* SystemEnvironment+Version.swift in Sources */, AD78F9EF28EC718D00BC0FCE /* EventBatcher.swift in Sources */, 4F8E53162B7F58BE008C0F9F /* Chat.swift in Sources */, 404296DB2A0112D00089126D /* AudioQueuePlayer.swift in Sources */, - 43D3F0FA284106EE00B74921 /* CallEndpoints.swift in Sources */, 40A458EE2A03AC7C00C198F7 /* AVAsset+TotalAudioSamples.swift in Sources */, AD0CC0132BDBC1BF005E2C66 /* ReactionListQuery.swift in Sources */, 4F427F672BA2F43200D92238 /* ConnectedUser.swift in Sources */, @@ -12798,6 +12897,7 @@ C121EBC02746A1E900023E4C /* ZoomAnimator.swift in Sources */, 40824D362A1271D7003B61FD /* PillButton_Tests.swift in Sources */, ADD328692C06B3AE00BAD0E9 /* ChatThreadListItemView.swift in Sources */, + AD4118832D5E1368000EF88E /* UILabel+highlightText.swift in Sources */, 40824D1E2A1271B9003B61FD /* PillButton.swift in Sources */, AD78F9FD28EC735700BC0FCE /* SwiftyScanner.swift in Sources */, C121EBC12746A1E900023E4C /* VideoPlaybackControlView.swift in Sources */, diff --git a/StreamChatArtifacts.json b/StreamChatArtifacts.json index de31592b387..3403bdf56c7 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"} \ 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"} \ No newline at end of file diff --git a/StreamChatUI-XCFramework.podspec b/StreamChatUI-XCFramework.podspec index d05acfdd9d6..888b626c40f 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.72.0" + spec.version = "4.73.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 c8103e99eab..82a96c19958 100644 --- a/StreamChatUI.podspec +++ b/StreamChatUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChatUI" - spec.version = "4.72.0" + spec.version = "4.73.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/StreamChatTestMockServer/Fixtures/JSONs/http_add_member.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_add_member.json index 74b886d501f..51037ce2fd3 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_add_member.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_add_member.json @@ -1,11 +1,11 @@ { "channel": { - "id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "type": "messaging", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "last_message_at": "2025-01-31T16:18:37.094228Z", - "created_at": "2025-01-31T16:18:35.560277Z", - "updated_at": "2025-01-31T16:18:35.560277Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "last_message_at": "2025-02-15T00:14:54.796665Z", + "created_at": "2025-02-15T00:14:50.645951Z", + "updated_at": "2025-02-15T00:14:50.645951Z", "created_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -19,21 +19,21 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], - "team": "test", - "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test", + "type": "team" }, "frozen": false, "disabled": false, "member_count": 4, "config": { "created_at": "2021-03-01T19:26:18.406502Z", - "updated_at": "2024-11-13T11:49:47.368244Z", + "updated_at": "2025-02-13T14:46:16.795117Z", "name": "messaging", "typing_events": true, "read_events": true, @@ -103,6 +103,7 @@ "send-poll", "send-reaction", "send-reply", + "send-restricted-visibility-message", "send-typing-events", "set-channel-cooldown", "skip-slow-mode", @@ -133,15 +134,15 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -163,15 +164,15 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], "birthland": "Corellia" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -193,18 +194,18 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], + "birthland": "Tatooine", "team": "test", "type": "team", - "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "pando": "{\"speciality\":\"ios engineer\"}" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -226,23 +227,23 @@ "updated_at": "2025-01-16T12:34:56.765249Z", "banned": false, "online": false, - "last_active": "2025-01-29T16:35:31.022088Z", + "last_active": "2025-02-14T13:12:46.241933Z", "blocked_user_ids": [ ], "birthland": "Polis Massa", "private_settings": { - "readReceipts": { + "typingIndicators": { "enabled": false }, - "typingIndicators": { + "readReceipts": { "enabled": false } } }, "status": "member", - "created_at": "2025-01-31T16:18:37.521245Z", - "updated_at": "2025-01-31T16:18:37.521245Z", + "created_at": "2025-02-15T00:14:56.183774Z", + "updated_at": "2025-02-15T00:14:56.183774Z", "banned": false, "shadow_banned": false, "role": "admin", @@ -250,5 +251,5 @@ "notifications_muted": false } ], - "duration": "48.95ms" + "duration": "41.93ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_attachment.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_attachment.json index 87e398ab0bf..9eeca2efef8 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_attachment.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_attachment.json @@ -1,4 +1,4 @@ { - "file": "https://frankfurt.stream-io-cdn.com/102399/images/da9d0ac2-77f1-47ae-b369-8a80dde644be.yoda.jpg?Key-Pair-Id=APKAIHG36VEWPDULE23Q&Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly9mcmFua2Z1cnQuc3RyZWFtLWlvLWNkbi5jb20vMTAyMzk5L2ltYWdlcy9kYTlkMGFjMi03N2YxLTQ3YWUtYjM2OS04YTgwZGRlNjQ0YmUueW9kYS5qcGc~Km9oPTAqb3c9MCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE3Mzk1NDk5MTd9fX1dfQ__&Signature=Vn6bN5uCpQcw9TrIHzsG0IVOPezLsg8paeE3HQwK4SkUdqUWLQaAMDiEOcW9xsNbTLW3WZArI9buSklsJSXbD9Q1vjML9L3zXBJswGaYTHDrD9OOn4MRNpeHz~t8rCx55fMNwmAp7wX2evCdwVHS315GnWHsM6yl8i8CzsFXSxUggZI0Y207SEvBzc0ZUOxEUiFfYuGEYpwVmX9q2S2azEiqVB4Hprt6i4guXme-ESsikDF4mvy3cC7XKm5kNJw7IXgXOzyU9pPlQMyS-QvdfXtCWvBgQezwBnXFQwyrCOphB4GsyQ9jOIq4wL86LkwLex2ia8WN1qaWt1Ik2qK6mA__&oh=0&ow=0", - "duration": "115.34ms" + "file": "https://frankfurt.stream-io-cdn.com/102399/images/089374d3-ab0d-498b-8d95-49dc5b7b53e8.yoda.jpg?Key-Pair-Id=APKAIHG36VEWPDULE23Q&Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly9mcmFua2Z1cnQuc3RyZWFtLWlvLWNkbi5jb20vMTAyMzk5L2ltYWdlcy8wODkzNzRkMy1hYjBkLTQ5OGItOGQ5NS00OWRjNWI3YjUzZTgueW9kYS5qcGc~Km9oPTAqb3c9MCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE3NDA3ODgwOTZ9fX1dfQ__&Signature=UuBQWQmVcSzQM94oxRbgpxaQxfn0nMmv69b3-ziRM27g38RANQubgSq~9a~o86R4NbSZmIejBo8Hmb53uK39N57HzXuWPAAmW6M2uZE4Ytg7UVDvLQAch-Jwa6R50g4RvvpJbDHHZKz3itjl46q3~WmRfmwq-EDsei8qSON-ZW-bYNcZ67yQ3pKP7QxAK57cXyTFzl7TjlSgJ16K2wIcz~q24cSwNVsC5JeiqofY7b6DU69zNch7p0qF61YzY-7CAaRl9o9FP6kcEHsItd5czY9DIuOdwsDLSYUBwcxjnddDd-yS6HTXKPZskKPoH5Ygz1nJybCd7OfDJWc69y61Ag__&oh=0&ow=0", + "duration": "130.61ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_creation.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_creation.json index 676ae1ce815..aa43bef1d5c 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_creation.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_creation.json @@ -1,10 +1,10 @@ { "channel": { - "id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "type": "messaging", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:35.560277Z", - "updated_at": "2025-01-31T16:18:35.560277Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:50.645951Z", + "updated_at": "2025-02-15T00:14:50.645951Z", "created_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -18,21 +18,21 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], - "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine", "team": "test", - "type": "team" + "type": "team", + "pando": "{\"speciality\":\"ios engineer\"}", + "birthland": "Tatooine" }, "frozen": false, "disabled": false, "member_count": 3, "config": { "created_at": "2021-03-01T19:26:18.406502Z", - "updated_at": "2024-11-13T11:49:47.368244Z", + "updated_at": "2025-02-13T14:46:16.795117Z", "name": "messaging", "typing_events": true, "read_events": true, @@ -102,6 +102,7 @@ "send-poll", "send-reaction", "send-reply", + "send-restricted-visibility-message", "send-typing-events", "set-channel-cooldown", "skip-slow-mode", @@ -139,13 +140,13 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, - "last_read": "2025-01-31T16:18:35.584603445Z", + "last_read": "2025-02-15T00:14:50.728264225Z", "unread_messages": 0 }, { @@ -162,7 +163,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -171,7 +172,7 @@ "birthland": "Tatooine", "team": "test" }, - "last_read": "2025-01-31T16:18:35.584603445Z", + "last_read": "2025-02-15T00:14:50.728264225Z", "unread_messages": 0 } ], @@ -191,15 +192,15 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -221,15 +222,15 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], "birthland": "Corellia" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -251,18 +252,18 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], - "team": "test", "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -284,7 +285,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -294,8 +295,8 @@ "team": "test" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -305,5 +306,5 @@ "threads": [ ], - "duration": "65.79ms" + "duration": "134.36ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_removal.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_removal.json index dd8f1ad1fa5..dc1b2e92a05 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_removal.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channel_removal.json @@ -1,12 +1,12 @@ { - "duration": "30.73ms", + "duration": "79.06ms", "channel": { - "id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "type": "messaging", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:35.560277Z", - "updated_at": "2025-01-31T16:18:39.67828Z", - "deleted_at": "2025-01-31T16:18:39.924361Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:50.645951Z", + "updated_at": "2025-02-15T00:15:02.086477Z", + "deleted_at": "2025-02-15T00:15:02.914267Z", "created_by": null, "frozen": false, "disabled": false, @@ -26,15 +26,15 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -56,15 +56,15 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], "birthland": "Corellia" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -86,18 +86,18 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], + "team": "test", "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine", - "team": "test" + "birthland": "Tatooine" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -119,10 +119,11 @@ "updated_at": "2025-01-16T12:34:56.765249Z", "banned": false, "online": false, - "last_active": "2025-01-29T16:35:31.022088Z", + "last_active": "2025-02-14T13:12:46.241933Z", "blocked_user_ids": [ ], + "birthland": "Polis Massa", "private_settings": { "readReceipts": { "enabled": false @@ -130,12 +131,11 @@ "typingIndicators": { "enabled": false } - }, - "birthland": "Polis Massa" + } }, "status": "member", - "created_at": "2025-01-31T16:18:37.521245Z", - "updated_at": "2025-01-31T16:18:37.521245Z", + "created_at": "2025-02-15T00:14:56.183774Z", + "updated_at": "2025-02-15T00:14:56.183774Z", "banned": false, "shadow_banned": false, "role": "admin", @@ -145,7 +145,7 @@ ], "config": { "created_at": "2021-03-01T19:26:18.406502Z", - "updated_at": "2024-11-13T11:49:47.368244Z", + "updated_at": "2025-02-13T14:46:16.795117Z", "name": "messaging", "typing_events": true, "read_events": true, @@ -192,7 +192,7 @@ } ] }, - "truncated_at": "2025-01-31T16:18:39.924361Z", + "truncated_at": "2025-02-15T00:15:02.914267Z", "truncated_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -206,14 +206,14 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], - "team": "test", - "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test", + "type": "team" } } } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channels.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channels.json index 9fca117f4a2..d874ab9f87f 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channels.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_channels.json @@ -2,11 +2,11 @@ "channels": [ { "channel": { - "id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "type": "messaging", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:35.560277Z", - "updated_at": "2025-01-31T16:18:35.560277Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:50.645951Z", + "updated_at": "2025-02-15T00:14:50.645951Z", "created_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -20,21 +20,21 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], - "team": "test", - "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test", + "type": "team" }, "frozen": false, "disabled": false, "member_count": 3, "config": { "created_at": "2021-03-01T19:26:18.406502Z", - "updated_at": "2024-11-13T11:49:47.368244Z", + "updated_at": "2025-02-13T14:46:16.795117Z", "name": "messaging", "typing_events": true, "read_events": true, @@ -104,6 +104,7 @@ "send-poll", "send-reaction", "send-reply", + "send-restricted-visibility-message", "send-typing-events", "set-channel-cooldown", "skip-slow-mode", @@ -141,14 +142,14 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, "unread_messages": 0, - "last_read": "2025-01-31T16:18:35Z" + "last_read": "2025-02-15T00:14:51Z" }, { "user": { @@ -164,14 +165,14 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], "birthland": "Corellia" }, "unread_messages": 0, - "last_read": "2025-01-31T16:18:35Z" + "last_read": "2025-02-15T00:14:51Z" }, { "user": { @@ -187,17 +188,17 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], + "birthland": "Tatooine", "team": "test", "type": "team", - "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "pando": "{\"speciality\":\"ios engineer\"}" }, "unread_messages": 0, - "last_read": "2025-01-31T16:18:35Z" + "last_read": "2025-02-15T00:14:51Z" } ], "members": [ @@ -216,15 +217,15 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -246,15 +247,15 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], "birthland": "Corellia" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -276,18 +277,18 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], + "birthland": "Tatooine", "team": "test", "type": "team", - "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "pando": "{\"speciality\":\"ios engineer\"}" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -309,18 +310,18 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], - "team": "test", - "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test", + "type": "team" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "channel_role": "channel_member", @@ -331,5 +332,5 @@ ] } ], - "duration": "121.59ms" + "duration": "121.48ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_events.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_events.json index 0e684119d8d..48c06f29456 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_events.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_events.json @@ -1,8 +1,8 @@ { "event": { "type": "typing.start", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "channel_id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "channel_id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "channel_type": "messaging", "user": { "id": "luke_skywalker", @@ -17,16 +17,16 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], - "team": "test", - "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test", + "type": "team" }, - "created_at": "2025-01-31T16:18:36.200384029Z" + "created_at": "2025-02-15T00:14:52.576220416Z" }, - "duration": "6.70ms" + "duration": "6.35ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_giphy_link.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_giphy_link.json index f84ade5ea03..3e18620c9b7 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_giphy_link.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_giphy_link.json @@ -1,6 +1,6 @@ { "message": { - "id": "b78d492d-b171-4209-97fb-2327c8bfe34d", + "id": "779ca24a-b661-46fa-893e-e4e87eb6b46a", "text": "https://giphy.com/gifs/test-gw3IWyGkC0rsazTi", "html": "

https://giphy.com/gifs/test-gw3IWyGkC0rsazTi

\n", "type": "regular", @@ -17,14 +17,14 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], + "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", "birthland": "Tatooine", - "team": "test", - "type": "team" + "team": "test" }, "attachments": [ { @@ -32,9 +32,9 @@ "title": "Test Computer GIF - Find & Share on GIPHY", "title_link": "https://giphy.com/gifs/test-gw3IWyGkC0rsazTi", "text": "Discover & share this Test Computer GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExdnN6bThhZnN4bHQ3dTl3YzA2dDB6ZWhqcm4weGJtNzR6b2p0eHN2MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/gw3IWyGkC0rsazTi/giphy.webp", - "thumb_url": "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExdnN6bThhZnN4bHQ3dTl3YzA2dDB6ZWhqcm4weGJtNzR6b2p0eHN2MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/gw3IWyGkC0rsazTi/giphy.webp", - "asset_url": "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExdnN6bThhZnN4bHQ3dTl3YzA2dDB6ZWhqcm4weGJtNzR6b2p0eHN2MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/gw3IWyGkC0rsazTi/giphy.mp4", + "image_url": "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExMmMwOHM4ajZzemdzemlkeHg2dHV5cnlvbnhyczFxYXB0eDF2NGw5ZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/gw3IWyGkC0rsazTi/giphy.webp", + "thumb_url": "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExMmMwOHM4ajZzemdzemlkeHg2dHV5cnlvbnhyczFxYXB0eDF2NGw5ZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/gw3IWyGkC0rsazTi/giphy.webp", + "asset_url": "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExMmMwOHM4ajZzemdzemlkeHg2dHV5cnlvbnhyczFxYXB0eDF2NGw5ZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/gw3IWyGkC0rsazTi/giphy.mp4", "og_scrape_url": "https://giphy.com/gifs/test-gw3IWyGkC0rsazTi" } ], @@ -50,18 +50,26 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:39.448734Z", - "updated_at": "2025-01-31T16:18:39.448734Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:15:01.733573Z", + "updated_at": "2025-02-15T00:15:01.733573Z", "shadowed": false, "mentioned_users": [ ], + "i18n": { + "id_text": "https://giphy.com/gifs/test-gw3IWyGkC0rsazTi", + "ru_text": "https://giphy.com/gifs/test-gw3IWyGkC0rsazTi", + "language": "id" + }, "silent": false, "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] }, - "duration": "217.62ms" + "duration": "414.19ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message.json index d390b1059ea..40b056ae68d 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message.json @@ -1,6 +1,6 @@ { "message": { - "id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "text": "Test", "html": "

Test

\n", "type": "regular", @@ -17,7 +17,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -41,9 +41,9 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:37.094228Z", - "updated_at": "2025-01-31T16:18:37.094228Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:54.796665Z", + "updated_at": "2025-02-15T00:14:54.796665Z", "shadowed": false, "mentioned_users": [ @@ -52,7 +52,10 @@ "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] }, - "duration": "736.77ms" + "duration": "1673.28ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message_ephemeral.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message_ephemeral.json index bfa31c20fec..3ce1d660530 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message_ephemeral.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_message_ephemeral.json @@ -1,6 +1,6 @@ { "message": { - "id": "40ee6375-ab6f-4dd1-885b-6f37b7053420", + "id": "8ab9f3aa-85f3-4c28-921b-360b75d1bfe1", "text": "/giphy Test", "command": "giphy", "html": "

/giphy Test

\n", @@ -18,7 +18,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -31,8 +31,8 @@ { "type": "giphy", "title": "Test", - "title_link": "https://giphy.com/gifs/school-test-annoyed-cJ7gVV0QL9RPG", - "thumb_url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/giphy.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=giphy.gif&ct=g", + "title_link": "https://giphy.com/gifs/test-video-monicawut-YLIafuhsghrxWHXEVE", + "thumb_url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/giphy.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=giphy.gif&ct=g", "actions": [ { "name": "image_action", @@ -58,52 +58,52 @@ ], "giphy": { "original": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/giphy.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=giphy.gif&ct=g", - "width": "250", - "height": "157", - "size": "509090", - "frames": "25" + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/giphy.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=giphy.gif&ct=g", + "width": "480", + "height": "270", + "size": "204947", + "frames": "41" }, "fixed_height": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/200.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=200.gif&ct=g", - "width": "318", + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/200.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=200.gif&ct=g", + "width": "356", "height": "200", - "size": "568679", + "size": "127618", "frames": "" }, "fixed_height_still": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/200_s.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=200_s.gif&ct=g", - "width": "318", + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/200_s.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=200_s.gif&ct=g", + "width": "356", "height": "200", - "size": "23937", + "size": "13850", "frames": "" }, "fixed_height_downsampled": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/200_d.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=200_d.gif&ct=g", - "width": "318", + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/200_d.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=200_d.gif&ct=g", + "width": "356", "height": "200", - "size": "152752", + "size": "48900", "frames": "" }, "fixed_width": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/200w.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=200w.gif&ct=g", + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/200w.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=200w.gif&ct=g", "width": "200", - "height": "126", - "size": "387570", + "height": "113", + "size": "56861", "frames": "" }, "fixed_width_still": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/200w_s.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=200w_s.gif&ct=g", + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/200w_s.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=200w_s.gif&ct=g", "width": "200", - "height": "126", - "size": "15905", + "height": "113", + "size": "6521", "frames": "" }, "fixed_width_downsampled": { - "url": "https://media1.giphy.com/media/cJ7gVV0QL9RPG/200w_d.gif?cid=c4b036753mf32agujfmseqxlghr13nakqw3bgfmvrgjesayg&ep=v1_gifs_search&rid=200w_d.gif&ct=g", + "url": "https://media1.giphy.com/media/YLIafuhsghrxWHXEVE/200w_d.gif?cid=c4b03675wluerfv48eadtr30pghqzt4uhv684615i2lkoygs&ep=v1_gifs_search&rid=200w_d.gif&ct=g", "width": "200", - "height": "126", - "size": "89634", + "height": "113", + "size": "20122", "frames": "" } } @@ -121,22 +121,30 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:38.068878Z", - "updated_at": "2025-01-31T16:18:38.068878Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:57.857068Z", + "updated_at": "2025-02-15T00:14:57.857068Z", "shadowed": false, "mentioned_users": [ ], + "i18n": { + "id_text": "/giphy Test", + "ru_text": "/giphy Testo", + "language": "id" + }, "silent": false, "pinned": false, "pinned_at": null, "pinned_by": null, "pin_expires": null, + "restricted_visibility": [ + + ], "args": "Test", "command_info": { "name": "Giphy" } }, - "duration": "34.12ms" + "duration": "315.67ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_reaction.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_reaction.json index 09481e7d332..5b57f5e53b8 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_reaction.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_reaction.json @@ -1,6 +1,6 @@ { "message": { - "id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "text": "Test", "html": "

Test

\n", "type": "regular", @@ -17,7 +17,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -31,7 +31,7 @@ ], "latest_reactions": [ { - "message_id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "message_id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "user_id": "luke_skywalker", "user": { "id": "luke_skywalker", @@ -46,7 +46,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -57,13 +57,13 @@ }, "type": "like", "score": 1, - "created_at": "2025-01-31T16:18:37.304432Z", - "updated_at": "2025-01-31T16:18:37.304432Z" + "created_at": "2025-02-15T00:14:55.545757Z", + "updated_at": "2025-02-15T00:14:55.545757Z" } ], "own_reactions": [ { - "message_id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "message_id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "user_id": "luke_skywalker", "user": { "id": "luke_skywalker", @@ -78,7 +78,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -89,8 +89,8 @@ }, "type": "like", "score": 1, - "created_at": "2025-01-31T16:18:37.304432Z", - "updated_at": "2025-01-31T16:18:37.304432Z" + "created_at": "2025-02-15T00:14:55.545757Z", + "updated_at": "2025-02-15T00:14:55.545757Z" } ], "reaction_counts": { @@ -103,15 +103,15 @@ "like": { "count": 1, "sum_scores": 1, - "first_reaction_at": "2025-01-31T16:18:37.304432Z", - "last_reaction_at": "2025-01-31T16:18:37.304432Z" + "first_reaction_at": "2025-02-15T00:14:55.545757Z", + "last_reaction_at": "2025-02-15T00:14:55.545757Z" } }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:37.094228Z", - "updated_at": "2025-01-31T16:18:37.310539Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:54.796665Z", + "updated_at": "2025-02-15T00:14:55.555595Z", "shadowed": false, "mentioned_users": [ @@ -120,10 +120,13 @@ "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] }, "reaction": { - "message_id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "message_id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "user_id": "luke_skywalker", "user": { "id": "luke_skywalker", @@ -138,7 +141,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -149,8 +152,8 @@ }, "type": "like", "score": 1, - "created_at": "2025-01-31T16:18:37.304432Z", - "updated_at": "2025-01-31T16:18:37.304432Z" + "created_at": "2025-02-15T00:14:55.545757Z", + "updated_at": "2025-02-15T00:14:55.545757Z" }, - "duration": "25.87ms" + "duration": "30.29ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_truncate.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_truncate.json index 89cbffa70e8..d7427d6c6f2 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_truncate.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_truncate.json @@ -1,12 +1,12 @@ { - "duration": "57.01ms", + "duration": "229.96ms", "channel": { - "id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "type": "messaging", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "last_message_at": "0001-01-01T00:00:00Z", - "created_at": "2025-01-31T16:18:35.560277Z", - "updated_at": "2025-01-31T16:18:39.67828Z", + "created_at": "2025-02-15T00:14:50.645951Z", + "updated_at": "2025-02-15T00:15:02.086477Z", "created_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -20,14 +20,14 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], - "team": "test", - "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "birthland": "Tatooine", + "team": "test", + "type": "team" }, "frozen": false, "disabled": false, @@ -47,15 +47,15 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], "birthland": "Serenno" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -77,15 +77,15 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], "birthland": "Corellia" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -107,7 +107,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], @@ -117,8 +117,8 @@ "birthland": "Tatooine" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -140,11 +140,10 @@ "updated_at": "2025-01-16T12:34:56.765249Z", "banned": false, "online": false, - "last_active": "2025-01-29T16:35:31.022088Z", + "last_active": "2025-02-14T13:12:46.241933Z", "blocked_user_ids": [ ], - "birthland": "Polis Massa", "private_settings": { "readReceipts": { "enabled": false @@ -152,11 +151,12 @@ "typingIndicators": { "enabled": false } - } + }, + "birthland": "Polis Massa" }, "status": "member", - "created_at": "2025-01-31T16:18:37.521245Z", - "updated_at": "2025-01-31T16:18:37.521245Z", + "created_at": "2025-02-15T00:14:56.183774Z", + "updated_at": "2025-02-15T00:14:56.183774Z", "banned": false, "shadow_banned": false, "role": "admin", @@ -167,7 +167,7 @@ "member_count": 4, "config": { "created_at": "2021-03-01T19:26:18.406502Z", - "updated_at": "2024-11-13T11:49:47.368244Z", + "updated_at": "2025-02-13T14:46:16.795117Z", "name": "messaging", "typing_events": true, "read_events": true, @@ -214,7 +214,7 @@ } ] }, - "truncated_at": "2025-01-31T16:18:39.672019Z", + "truncated_at": "2025-02-15T00:15:02.05596Z", "truncated_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -228,7 +228,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], @@ -240,7 +240,7 @@ "name": "Sync Mock Server" }, "message": { - "id": "af2b17fe-a3d3-47de-bd9b-92eb08ae9877", + "id": "9ade079c-db9c-408a-a127-53a5dcfd95f6", "text": "Channel truncated", "html": "

Channel truncated

\n", "type": "system", @@ -257,14 +257,14 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.979000026Z", "blocked_user_ids": [ ], + "pando": "{\"speciality\":\"ios engineer\"}", "birthland": "Tatooine", "team": "test", - "type": "team", - "pando": "{\"speciality\":\"ios engineer\"}" + "type": "team" }, "attachments": [ @@ -281,9 +281,9 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:39.67202Z", - "updated_at": "2025-01-31T16:18:39.67202Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:15:02.055961Z", + "updated_at": "2025-02-15T00:15:02.055961Z", "shadowed": false, "mentioned_users": [ @@ -292,6 +292,9 @@ "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] } } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_unsplash_link.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_unsplash_link.json index 11db691bed6..f0b725aa231 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_unsplash_link.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_unsplash_link.json @@ -1,6 +1,6 @@ { "message": { - "id": "6a3952aa-9e3e-4c1a-8f41-f6c8223b6b94", + "id": "90a69671-5806-40ff-9d53-4babfed29140", "text": "https://unsplash.com/photos/1_2d3MRbI9c", "html": "

https://unsplash.com/photos/1_2d3MRbI9c

\n", "type": "regular", @@ -17,7 +17,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:59.541924866Z", "blocked_user_ids": [ ], @@ -32,8 +32,8 @@ "title": "Photo by Joao Branco on Unsplash", "title_link": "https://unsplash.com/photos/green-pine-tree-mountain-slope-scenery-1_2d3MRbI9c", "text": "Download this photo by Joao Branco on Unsplash", - "image_url": "https://images.unsplash.com/photo-1568574728383-06fca083883d?mark=https%3A%2F%2Fimages.unsplash.com%2Fopengraph%2Flogo.png&mark-w=64&mark-align=top%2Cleft&mark-pad=50&h=630&w=1200&crop=faces%2Cedges&blend-w=1&blend=000000&blend-mode=normal&blend-alpha=10&auto=format&fit=crop&q=60&ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzM4MzMzNTI0fA&ixlib=rb-4.0.3", - "thumb_url": "https://images.unsplash.com/photo-1568574728383-06fca083883d?mark=https%3A%2F%2Fimages.unsplash.com%2Fopengraph%2Flogo.png&mark-w=64&mark-align=top%2Cleft&mark-pad=50&h=630&w=1200&crop=faces%2Cedges&blend-w=1&blend=000000&blend-mode=normal&blend-alpha=10&auto=format&fit=crop&q=60&ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzM4MzMzNTI0fA&ixlib=rb-4.0.3", + "image_url": "https://images.unsplash.com/photo-1568574728383-06fca083883d?mark=https%3A%2F%2Fimages.unsplash.com%2Fopengraph%2Flogo.png&mark-w=64&mark-align=top%2Cleft&mark-pad=50&h=630&w=1200&crop=faces%2Cedges&blend-w=1&blend=000000&blend-mode=normal&blend-alpha=10&auto=format&fit=crop&q=60&ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzM5NTc4NTAwfA&ixlib=rb-4.0.3", + "thumb_url": "https://images.unsplash.com/photo-1568574728383-06fca083883d?mark=https%3A%2F%2Fimages.unsplash.com%2Fopengraph%2Flogo.png&mark-w=64&mark-align=top%2Cleft&mark-pad=50&h=630&w=1200&crop=faces%2Cedges&blend-w=1&blend=000000&blend-mode=normal&blend-alpha=10&auto=format&fit=crop&q=60&ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzM5NTc4NTAwfA&ixlib=rb-4.0.3", "og_scrape_url": "https://unsplash.com/photos/1_2d3MRbI9c" } ], @@ -49,18 +49,26 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:39.023537Z", - "updated_at": "2025-01-31T16:18:39.023537Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:15:00.705081Z", + "updated_at": "2025-02-15T00:15:00.705081Z", "shadowed": false, "mentioned_users": [ ], + "i18n": { + "id_text": "https://unsplash.com/photos/1_2d3MRbI9c", + "ru_text": "https://unsplash.com/photos/1_2d3MRbI9c", + "language": "id" + }, "silent": false, "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] }, - "duration": "290.16ms" + "duration": "754.21ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_youtube_link.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_youtube_link.json index de0d8883a02..e9aa2f91bfc 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_youtube_link.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/http_youtube_link.json @@ -1,6 +1,6 @@ { "message": { - "id": "29b0f068-c354-463c-922b-b381913da5df", + "id": "2eeec5e4-d9f4-4f4b-ac83-fd48ebc40e69", "text": "https://youtube.com/watch?v=xOX7MsrbaPY", "html": "

https://youtube.com/watch?v=xOX7MsrbaPY

\n", "type": "regular", @@ -17,7 +17,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -51,18 +51,26 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:38.512552Z", - "updated_at": "2025-01-31T16:18:38.512552Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:59.328297Z", + "updated_at": "2025-02-15T00:14:59.328297Z", "shadowed": false, "mentioned_users": [ ], + "i18n": { + "id_text": "https://youtube.com/watch?v=xOX7MsrbaPY", + "ru_text": "https://youtube.com/watch?v=xOX7MsrbaPY", + "language": "id" + }, "silent": false, "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] }, - "duration": "280.08ms" + "duration": "1186.74ms" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events.json index 4644bfec1a0..2aa8e3f11c0 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events.json @@ -1,7 +1,7 @@ { "type": "typing.start", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "channel_id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "channel_id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "channel_type": "messaging", "user": { "id": "luke_skywalker", @@ -16,7 +16,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -33,186 +33,186 @@ { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", - "created_at": "2025-01-29T12:48:17.917988Z", + "id": "e2c6bed363247a93ea49699385e50de99efba5707808cbd7b2d3f4e46a7cbfb9", + "created_at": "2025-02-13T17:54:30.965611Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", - "created_at": "2025-01-29T11:17:58.405065Z", + "id": "8078596852ae3ecc7bb8d20eb1bde24d596115270e266be1e7164009c28c841c89120222e5594b8929bafb7b55ec47e5b1db2e1274b6e0c9a8f24ba1087832d4dc8acefc857be26848f2b074fdaa42fe", + "created_at": "2025-02-12T13:52:59.060649Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "52c03e3cd1477169d34772e1755a6009448b9115b3fe99fc6f50b4e8ded1a397", - "created_at": "2025-01-27T14:10:52.981213Z", - "disabled": true, - "disabled_reason": "Unregistered", + "id": "805fbd71d240360c14b785cd89f59846acf40e15514e3cf89efdbc30f360999873c4713b6fd6f192072c075c41243b82f8b89ea4880f7694f72815934477e7cf8d80c303d7bb25c8c0730e6268a1e3d2", + "created_at": "2025-02-12T10:55:34.960205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", - "created_at": "2025-01-27T09:26:50.733298Z", + "id": "38cf0b02ebe66d4bbc17981502acf8ef7806337f45e3094fbf662293185dd663", + "created_at": "2025-02-12T10:45:49.811205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", - "created_at": "2025-01-27T07:18:18.62698Z", + "id": "2716b6f605e691bf7dc7d88d1a45968dd6807ed91dbbf793777d152f796994b0", + "created_at": "2025-02-11T01:03:34.75674Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", - "created_at": "2025-01-26T19:17:41.00587Z", + "id": "809975048776300710e9bf304eabc27813c3042266dc085420362ea254ebeccea39666720bc25ffc5a022b79062f1e5f1f1de6d1565850cc78ba3c9df1a810e7d4128b73c2f76210189febf4ee0df9b1", + "created_at": "2025-02-10T01:52:00.239203Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", - "created_at": "2025-01-26T18:56:24.995825Z", + "id": "8082e4f123b0f83543d1f7c04920c76ec7d4b4db9d8f2e8ff0cc4daeef338209be72df2b35257cfdb86623c14d241cf1d1f42b78fef01e16052d13b03f75ed135360886f1900d3a0e80e97b268514c77", + "created_at": "2025-02-09T10:04:50.694565Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", - "created_at": "2025-01-24T15:23:31.160845Z", + "id": "809a57e2ed4470cb0b372e79d31cff19276879f5b312b3b71e51fc05ad338bbda27220c4f36c1283e0340d6b8816702d123dda05604dfc0d9a2eae03099f718db1f14f13de887394280380cbcb945223", + "created_at": "2025-02-08T13:42:01.054257Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", - "created_at": "2025-01-24T05:36:02.440032Z", + "id": "800dd2e8d35942e0a2124b71e87dcb54467f63b148190dbd04821e71100924fdcf58d8cc17eb1030529f07bfaae1bc5c2f9313139067e7392edf1fdadf837762409698640194be2ca788aa9b5660ff4d", + "created_at": "2025-02-07T09:29:01.123928Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", - "created_at": "2025-01-23T05:30:44.092337Z", + "id": "8009a9c2db8394cdaa8ebd8f0f5731ba1fe28c3cc89397b8e38b0d55b018cafead52512e0d9dfbd10ca9abaacbfa1e96586f1e2c6a4f831977607fa415188249f6d88b7369e476554f0ce8a23e033f49", + "created_at": "2025-02-06T07:45:34.135591Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", - "created_at": "2025-01-22T04:43:11.923746Z", + "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", + "created_at": "2025-01-29T12:48:17.917988Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", - "created_at": "2025-01-21T03:06:34.117688Z", + "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", + "created_at": "2025-01-29T11:17:58.405065Z", + "disabled": true, + "disabled_reason": "Unregistered", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", - "created_at": "2025-01-15T13:18:37.405547Z", + "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", + "created_at": "2025-01-27T09:26:50.733298Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", - "created_at": "2025-01-15T07:59:32.530759Z", + "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", + "created_at": "2025-01-27T07:18:18.62698Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", - "created_at": "2025-01-14T20:36:03.102001Z", + "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", + "created_at": "2025-01-26T19:17:41.00587Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", - "created_at": "2025-01-14T17:54:05.295746Z", + "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", + "created_at": "2025-01-26T18:56:24.995825Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "2c0a2b1f6891af16b45df24c7b05b1598d414f64fae7c5ef3eb41d92b45bc275", - "created_at": "2025-01-13T06:09:35.973958Z", + "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", + "created_at": "2025-01-24T15:23:31.160845Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "f1f43292dc972188ccf2bb5a2523342482280cd37e6e80540c7f41890b8f5868", - "created_at": "2025-01-13T02:06:21.211287Z", + "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", + "created_at": "2025-01-24T05:36:02.440032Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80560b4f465af52f745b646beaeba703a1877ef6988eb06f38b5f023e6affb4f6a8d8c22123c53767d2f17fae4bfa1df218117400a0d175ffd2066bae361bab07d589bbfd9ae5e693e8af34476625f58", - "created_at": "2025-01-12T12:18:40.312161Z", + "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", + "created_at": "2025-01-23T05:30:44.092337Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8009ae3cb1eb8350aa78dfe4539e5858be8c0de73b66cccdb2baff95b211f3c13c56c9e9e837c9a44af93b9b631d5db658bfed4d899ba8ee773898acf49956d5df2638f37191fa5262572b75108cc075", - "created_at": "2025-01-10T14:43:30.030261Z", + "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", + "created_at": "2025-01-22T04:43:11.923746Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809155ab6e601835c405261f32a7046cc265d8899ffe5d3d484df422424101cba27d22e11531645c4c7d660a9b2c69641c9884622d08ba27b51458a03b67995e21791ba038a40f670fdcaa3a8f73403b", - "created_at": "2025-01-10T12:25:15.095949Z", + "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", + "created_at": "2025-01-21T03:06:34.117688Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a416c7a38c0b0e5f4cdc67cc22db4429b1abf069719b5fd249e067bd40f39a93c9ae72186cd4dbe9ee3f7d4b75b0572f6fcb6fd61f1447924bb5049e49d64111494690ab89f39ae64f13828d692357", - "created_at": "2025-01-08T21:35:48.016721Z", + "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", + "created_at": "2025-01-15T13:18:37.405547Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80677e1cd0e34efdaf88b76a3bb2ce7086c4c20853c3b1ca7c638a1da310ffd884491071d224cacd90e72a0116dcc1c3ebaab19a79e7eabb560f7ca21458945ff1a085e43a8b41d491f3eec625186aa1", - "created_at": "2025-01-08T16:07:56.454603Z", + "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", + "created_at": "2025-01-15T07:59:32.530759Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80d2897aafd7c4a6e382d80212ba469b5348669930fd34db0f8aae76f7059db340f7118963ef89d109480f2cb83679e7dd71a5766b764a8a546ff958f66f5ddbf17140658079e329f170bf9e47f68ae5", - "created_at": "2025-01-08T15:16:15.675842Z", + "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", + "created_at": "2025-01-14T20:36:03.102001Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca9df3ad609fa3e61a033f403e3b6acb7039aaa58285061f5bd189d9a1659857216129e70e3fa267691c5b4ca20452aaaf683aa7d2f1aa98adaa8c162e8f8ef668da54070e79c3b8470add34e45b31", - "created_at": "2025-01-08T12:58:51.72573Z", + "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", + "created_at": "2025-01-14T17:54:05.295746Z", "user_id": "luke_skywalker" } ], "invisible": false, + "type": "team", "pando": "{\"speciality\":\"ios engineer\"}", "birthland": "Tatooine", - "team": "test", - "type": "team" + "team": "test" }, - "created_at": "2025-01-31T16:18:36.200384029Z" + "created_at": "2025-02-15T00:14:52.576220416Z" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_channel.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_channel.json index 034bf7036d3..2d9a6672f71 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_channel.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_channel.json @@ -1,18 +1,18 @@ { "type": "channel.updated", - "created_at": "2025-01-31T16:18:37.542936957Z", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "channel_last_message_at": "2025-01-31T16:18:37.094228Z", + "created_at": "2025-02-15T00:14:56.210415251Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "channel_last_message_at": "2025-02-15T00:14:54.796665Z", "channel_member_count": 4, "channel_type": "messaging", - "channel_id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "channel_id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "channel": { - "id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "type": "messaging", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "last_message_at": "2025-01-31T16:18:37.094228Z", - "created_at": "2025-01-31T16:18:35.560277Z", - "updated_at": "2025-01-31T16:18:35.560277Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "last_message_at": "2025-02-15T00:14:54.796665Z", + "created_at": "2025-02-15T00:14:50.645951Z", + "updated_at": "2025-02-15T00:14:50.645951Z", "created_by": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -26,7 +26,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -43,178 +43,178 @@ { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", - "created_at": "2025-01-29T12:48:17.917988Z", + "id": "e2c6bed363247a93ea49699385e50de99efba5707808cbd7b2d3f4e46a7cbfb9", + "created_at": "2025-02-13T17:54:30.965611Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", - "created_at": "2025-01-29T11:17:58.405065Z", + "id": "8078596852ae3ecc7bb8d20eb1bde24d596115270e266be1e7164009c28c841c89120222e5594b8929bafb7b55ec47e5b1db2e1274b6e0c9a8f24ba1087832d4dc8acefc857be26848f2b074fdaa42fe", + "created_at": "2025-02-12T13:52:59.060649Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "52c03e3cd1477169d34772e1755a6009448b9115b3fe99fc6f50b4e8ded1a397", - "created_at": "2025-01-27T14:10:52.981213Z", - "disabled": true, - "disabled_reason": "Unregistered", + "id": "805fbd71d240360c14b785cd89f59846acf40e15514e3cf89efdbc30f360999873c4713b6fd6f192072c075c41243b82f8b89ea4880f7694f72815934477e7cf8d80c303d7bb25c8c0730e6268a1e3d2", + "created_at": "2025-02-12T10:55:34.960205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", - "created_at": "2025-01-27T09:26:50.733298Z", + "id": "38cf0b02ebe66d4bbc17981502acf8ef7806337f45e3094fbf662293185dd663", + "created_at": "2025-02-12T10:45:49.811205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", - "created_at": "2025-01-27T07:18:18.62698Z", + "id": "2716b6f605e691bf7dc7d88d1a45968dd6807ed91dbbf793777d152f796994b0", + "created_at": "2025-02-11T01:03:34.75674Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", - "created_at": "2025-01-26T19:17:41.00587Z", + "id": "809975048776300710e9bf304eabc27813c3042266dc085420362ea254ebeccea39666720bc25ffc5a022b79062f1e5f1f1de6d1565850cc78ba3c9df1a810e7d4128b73c2f76210189febf4ee0df9b1", + "created_at": "2025-02-10T01:52:00.239203Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", - "created_at": "2025-01-26T18:56:24.995825Z", + "id": "8082e4f123b0f83543d1f7c04920c76ec7d4b4db9d8f2e8ff0cc4daeef338209be72df2b35257cfdb86623c14d241cf1d1f42b78fef01e16052d13b03f75ed135360886f1900d3a0e80e97b268514c77", + "created_at": "2025-02-09T10:04:50.694565Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", - "created_at": "2025-01-24T15:23:31.160845Z", + "id": "809a57e2ed4470cb0b372e79d31cff19276879f5b312b3b71e51fc05ad338bbda27220c4f36c1283e0340d6b8816702d123dda05604dfc0d9a2eae03099f718db1f14f13de887394280380cbcb945223", + "created_at": "2025-02-08T13:42:01.054257Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", - "created_at": "2025-01-24T05:36:02.440032Z", + "id": "800dd2e8d35942e0a2124b71e87dcb54467f63b148190dbd04821e71100924fdcf58d8cc17eb1030529f07bfaae1bc5c2f9313139067e7392edf1fdadf837762409698640194be2ca788aa9b5660ff4d", + "created_at": "2025-02-07T09:29:01.123928Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", - "created_at": "2025-01-23T05:30:44.092337Z", + "id": "8009a9c2db8394cdaa8ebd8f0f5731ba1fe28c3cc89397b8e38b0d55b018cafead52512e0d9dfbd10ca9abaacbfa1e96586f1e2c6a4f831977607fa415188249f6d88b7369e476554f0ce8a23e033f49", + "created_at": "2025-02-06T07:45:34.135591Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", - "created_at": "2025-01-22T04:43:11.923746Z", + "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", + "created_at": "2025-01-29T12:48:17.917988Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", - "created_at": "2025-01-21T03:06:34.117688Z", + "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", + "created_at": "2025-01-29T11:17:58.405065Z", + "disabled": true, + "disabled_reason": "Unregistered", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", - "created_at": "2025-01-15T13:18:37.405547Z", + "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", + "created_at": "2025-01-27T09:26:50.733298Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", - "created_at": "2025-01-15T07:59:32.530759Z", + "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", + "created_at": "2025-01-27T07:18:18.62698Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", - "created_at": "2025-01-14T20:36:03.102001Z", + "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", + "created_at": "2025-01-26T19:17:41.00587Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", - "created_at": "2025-01-14T17:54:05.295746Z", + "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", + "created_at": "2025-01-26T18:56:24.995825Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "2c0a2b1f6891af16b45df24c7b05b1598d414f64fae7c5ef3eb41d92b45bc275", - "created_at": "2025-01-13T06:09:35.973958Z", + "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", + "created_at": "2025-01-24T15:23:31.160845Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "f1f43292dc972188ccf2bb5a2523342482280cd37e6e80540c7f41890b8f5868", - "created_at": "2025-01-13T02:06:21.211287Z", + "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", + "created_at": "2025-01-24T05:36:02.440032Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80560b4f465af52f745b646beaeba703a1877ef6988eb06f38b5f023e6affb4f6a8d8c22123c53767d2f17fae4bfa1df218117400a0d175ffd2066bae361bab07d589bbfd9ae5e693e8af34476625f58", - "created_at": "2025-01-12T12:18:40.312161Z", + "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", + "created_at": "2025-01-23T05:30:44.092337Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8009ae3cb1eb8350aa78dfe4539e5858be8c0de73b66cccdb2baff95b211f3c13c56c9e9e837c9a44af93b9b631d5db658bfed4d899ba8ee773898acf49956d5df2638f37191fa5262572b75108cc075", - "created_at": "2025-01-10T14:43:30.030261Z", + "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", + "created_at": "2025-01-22T04:43:11.923746Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809155ab6e601835c405261f32a7046cc265d8899ffe5d3d484df422424101cba27d22e11531645c4c7d660a9b2c69641c9884622d08ba27b51458a03b67995e21791ba038a40f670fdcaa3a8f73403b", - "created_at": "2025-01-10T12:25:15.095949Z", + "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", + "created_at": "2025-01-21T03:06:34.117688Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a416c7a38c0b0e5f4cdc67cc22db4429b1abf069719b5fd249e067bd40f39a93c9ae72186cd4dbe9ee3f7d4b75b0572f6fcb6fd61f1447924bb5049e49d64111494690ab89f39ae64f13828d692357", - "created_at": "2025-01-08T21:35:48.016721Z", + "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", + "created_at": "2025-01-15T13:18:37.405547Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80677e1cd0e34efdaf88b76a3bb2ce7086c4c20853c3b1ca7c638a1da310ffd884491071d224cacd90e72a0116dcc1c3ebaab19a79e7eabb560f7ca21458945ff1a085e43a8b41d491f3eec625186aa1", - "created_at": "2025-01-08T16:07:56.454603Z", + "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", + "created_at": "2025-01-15T07:59:32.530759Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80d2897aafd7c4a6e382d80212ba469b5348669930fd34db0f8aae76f7059db340f7118963ef89d109480f2cb83679e7dd71a5766b764a8a546ff958f66f5ddbf17140658079e329f170bf9e47f68ae5", - "created_at": "2025-01-08T15:16:15.675842Z", + "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", + "created_at": "2025-01-14T20:36:03.102001Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca9df3ad609fa3e61a033f403e3b6acb7039aaa58285061f5bd189d9a1659857216129e70e3fa267691c5b4ca20452aaaf683aa7d2f1aa98adaa8c162e8f8ef668da54070e79c3b8470add34e45b31", - "created_at": "2025-01-08T12:58:51.72573Z", + "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", + "created_at": "2025-01-14T17:54:05.295746Z", "user_id": "luke_skywalker" } ], @@ -242,7 +242,7 @@ "updated_at": "2024-07-11T05:45:57.296628Z", "banned": false, "online": false, - "last_active": "2025-01-14T21:47:15.722632Z", + "last_active": "2025-02-14T13:02:21.846063Z", "blocked_user_ids": [ ], @@ -328,8 +328,8 @@ "birthland": "Serenno" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -351,7 +351,7 @@ "updated_at": "2025-01-16T12:35:13.266041Z", "banned": false, "online": false, - "last_active": "2025-01-31T14:12:07.724208Z", + "last_active": "2025-02-14T09:00:12.17086Z", "blocked_user_ids": [ ], @@ -368,8 +368,22 @@ { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "baf4b773dce673f8798202e71525c4bd737d8516351a3b255eb96ce044401a0a", - "created_at": "2025-01-31T14:10:34.827212Z", + "id": "14216b28af8556468ae8dbf08a5ea602f9deb9c2eb115d9b6aacf7f7bde74ade", + "created_at": "2025-02-12T10:58:40.574878Z", + "user_id": "han_solo" + }, + { + "push_provider": "apn", + "push_provider_name": "APN-Configuration", + "id": "327aea75615b5163a87779ea32e241e360a049e418bc4e42993a996d3d6cf94c", + "created_at": "2025-02-04T13:16:37.534389Z", + "user_id": "han_solo" + }, + { + "push_provider": "apn", + "push_provider_name": "APN-Configuration", + "id": "4d6ab888ea255eb6412108c4875977860cc08099ba45de161959c4bc7a9cb69a", + "created_at": "2025-02-03T12:42:48.406824Z", "user_id": "han_solo" }, { @@ -518,35 +532,14 @@ "id": "ed4ffeeccf2e779b162b27135cab28916d2a9a907c0401d00b032c0bcc7432d1", "created_at": "2024-11-26T17:17:31.601203Z", "user_id": "han_solo" - }, - { - "push_provider": "apn", - "push_provider_name": "APN-Configuration", - "id": "802d5502038579bc88e1842e7adc19317c656fbb427024e9d09b2fccc3c761c0975082e99bff525721300c64af0500852f342e5b40af9c1fb581013a10c8090ce3a2fa4be55b4db96f5c822d40802321", - "created_at": "2024-11-11T15:05:13.174817Z", - "user_id": "han_solo" - }, - { - "push_provider": "apn", - "push_provider_name": "APN-Configuration", - "id": "308135f38a9df34059a827bcc671fe89b81b3ae8d9fa7b90bc2dfeacc896d9fe", - "created_at": "2024-11-11T07:31:34.464374Z", - "user_id": "han_solo" - }, - { - "push_provider": "apn", - "push_provider_name": "APN-Configuration", - "id": "ed8267ab036e152959b891f11ce28d119d3ae9a36d49a3da9d75106e753709c1", - "created_at": "2024-11-05T13:03:40.481073Z", - "user_id": "han_solo" } ], "invisible": false, "birthland": "Corellia" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "member", @@ -568,7 +561,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -585,190 +578,190 @@ { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", - "created_at": "2025-01-29T12:48:17.917988Z", + "id": "e2c6bed363247a93ea49699385e50de99efba5707808cbd7b2d3f4e46a7cbfb9", + "created_at": "2025-02-13T17:54:30.965611Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", - "created_at": "2025-01-29T11:17:58.405065Z", + "id": "8078596852ae3ecc7bb8d20eb1bde24d596115270e266be1e7164009c28c841c89120222e5594b8929bafb7b55ec47e5b1db2e1274b6e0c9a8f24ba1087832d4dc8acefc857be26848f2b074fdaa42fe", + "created_at": "2025-02-12T13:52:59.060649Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "52c03e3cd1477169d34772e1755a6009448b9115b3fe99fc6f50b4e8ded1a397", - "created_at": "2025-01-27T14:10:52.981213Z", - "disabled": true, - "disabled_reason": "Unregistered", + "id": "805fbd71d240360c14b785cd89f59846acf40e15514e3cf89efdbc30f360999873c4713b6fd6f192072c075c41243b82f8b89ea4880f7694f72815934477e7cf8d80c303d7bb25c8c0730e6268a1e3d2", + "created_at": "2025-02-12T10:55:34.960205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", - "created_at": "2025-01-27T09:26:50.733298Z", + "id": "38cf0b02ebe66d4bbc17981502acf8ef7806337f45e3094fbf662293185dd663", + "created_at": "2025-02-12T10:45:49.811205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", - "created_at": "2025-01-27T07:18:18.62698Z", + "id": "2716b6f605e691bf7dc7d88d1a45968dd6807ed91dbbf793777d152f796994b0", + "created_at": "2025-02-11T01:03:34.75674Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", - "created_at": "2025-01-26T19:17:41.00587Z", + "id": "809975048776300710e9bf304eabc27813c3042266dc085420362ea254ebeccea39666720bc25ffc5a022b79062f1e5f1f1de6d1565850cc78ba3c9df1a810e7d4128b73c2f76210189febf4ee0df9b1", + "created_at": "2025-02-10T01:52:00.239203Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", - "created_at": "2025-01-26T18:56:24.995825Z", + "id": "8082e4f123b0f83543d1f7c04920c76ec7d4b4db9d8f2e8ff0cc4daeef338209be72df2b35257cfdb86623c14d241cf1d1f42b78fef01e16052d13b03f75ed135360886f1900d3a0e80e97b268514c77", + "created_at": "2025-02-09T10:04:50.694565Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", - "created_at": "2025-01-24T15:23:31.160845Z", + "id": "809a57e2ed4470cb0b372e79d31cff19276879f5b312b3b71e51fc05ad338bbda27220c4f36c1283e0340d6b8816702d123dda05604dfc0d9a2eae03099f718db1f14f13de887394280380cbcb945223", + "created_at": "2025-02-08T13:42:01.054257Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", - "created_at": "2025-01-24T05:36:02.440032Z", + "id": "800dd2e8d35942e0a2124b71e87dcb54467f63b148190dbd04821e71100924fdcf58d8cc17eb1030529f07bfaae1bc5c2f9313139067e7392edf1fdadf837762409698640194be2ca788aa9b5660ff4d", + "created_at": "2025-02-07T09:29:01.123928Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", - "created_at": "2025-01-23T05:30:44.092337Z", + "id": "8009a9c2db8394cdaa8ebd8f0f5731ba1fe28c3cc89397b8e38b0d55b018cafead52512e0d9dfbd10ca9abaacbfa1e96586f1e2c6a4f831977607fa415188249f6d88b7369e476554f0ce8a23e033f49", + "created_at": "2025-02-06T07:45:34.135591Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", - "created_at": "2025-01-22T04:43:11.923746Z", + "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", + "created_at": "2025-01-29T12:48:17.917988Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", - "created_at": "2025-01-21T03:06:34.117688Z", + "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", + "created_at": "2025-01-29T11:17:58.405065Z", + "disabled": true, + "disabled_reason": "Unregistered", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", - "created_at": "2025-01-15T13:18:37.405547Z", + "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", + "created_at": "2025-01-27T09:26:50.733298Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", - "created_at": "2025-01-15T07:59:32.530759Z", + "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", + "created_at": "2025-01-27T07:18:18.62698Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", - "created_at": "2025-01-14T20:36:03.102001Z", + "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", + "created_at": "2025-01-26T19:17:41.00587Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", - "created_at": "2025-01-14T17:54:05.295746Z", + "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", + "created_at": "2025-01-26T18:56:24.995825Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "2c0a2b1f6891af16b45df24c7b05b1598d414f64fae7c5ef3eb41d92b45bc275", - "created_at": "2025-01-13T06:09:35.973958Z", + "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", + "created_at": "2025-01-24T15:23:31.160845Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "f1f43292dc972188ccf2bb5a2523342482280cd37e6e80540c7f41890b8f5868", - "created_at": "2025-01-13T02:06:21.211287Z", + "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", + "created_at": "2025-01-24T05:36:02.440032Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80560b4f465af52f745b646beaeba703a1877ef6988eb06f38b5f023e6affb4f6a8d8c22123c53767d2f17fae4bfa1df218117400a0d175ffd2066bae361bab07d589bbfd9ae5e693e8af34476625f58", - "created_at": "2025-01-12T12:18:40.312161Z", + "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", + "created_at": "2025-01-23T05:30:44.092337Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8009ae3cb1eb8350aa78dfe4539e5858be8c0de73b66cccdb2baff95b211f3c13c56c9e9e837c9a44af93b9b631d5db658bfed4d899ba8ee773898acf49956d5df2638f37191fa5262572b75108cc075", - "created_at": "2025-01-10T14:43:30.030261Z", + "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", + "created_at": "2025-01-22T04:43:11.923746Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809155ab6e601835c405261f32a7046cc265d8899ffe5d3d484df422424101cba27d22e11531645c4c7d660a9b2c69641c9884622d08ba27b51458a03b67995e21791ba038a40f670fdcaa3a8f73403b", - "created_at": "2025-01-10T12:25:15.095949Z", + "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", + "created_at": "2025-01-21T03:06:34.117688Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a416c7a38c0b0e5f4cdc67cc22db4429b1abf069719b5fd249e067bd40f39a93c9ae72186cd4dbe9ee3f7d4b75b0572f6fcb6fd61f1447924bb5049e49d64111494690ab89f39ae64f13828d692357", - "created_at": "2025-01-08T21:35:48.016721Z", + "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", + "created_at": "2025-01-15T13:18:37.405547Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80677e1cd0e34efdaf88b76a3bb2ce7086c4c20853c3b1ca7c638a1da310ffd884491071d224cacd90e72a0116dcc1c3ebaab19a79e7eabb560f7ca21458945ff1a085e43a8b41d491f3eec625186aa1", - "created_at": "2025-01-08T16:07:56.454603Z", + "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", + "created_at": "2025-01-15T07:59:32.530759Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80d2897aafd7c4a6e382d80212ba469b5348669930fd34db0f8aae76f7059db340f7118963ef89d109480f2cb83679e7dd71a5766b764a8a546ff958f66f5ddbf17140658079e329f170bf9e47f68ae5", - "created_at": "2025-01-08T15:16:15.675842Z", + "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", + "created_at": "2025-01-14T20:36:03.102001Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca9df3ad609fa3e61a033f403e3b6acb7039aaa58285061f5bd189d9a1659857216129e70e3fa267691c5b4ca20452aaaf683aa7d2f1aa98adaa8c162e8f8ef668da54070e79c3b8470add34e45b31", - "created_at": "2025-01-08T12:58:51.72573Z", + "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", + "created_at": "2025-01-14T17:54:05.295746Z", "user_id": "luke_skywalker" } ], "invisible": false, + "birthland": "Tatooine", "team": "test", "type": "team", - "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine" + "pando": "{\"speciality\":\"ios engineer\"}" }, "status": "member", - "created_at": "2025-01-31T16:18:35.564453Z", - "updated_at": "2025-01-31T16:18:35.564453Z", + "created_at": "2025-02-15T00:14:50.66974Z", + "updated_at": "2025-02-15T00:14:50.66974Z", "banned": false, "shadow_banned": false, "role": "owner", @@ -790,7 +783,7 @@ "updated_at": "2025-01-16T12:34:56.765249Z", "banned": false, "online": false, - "last_active": "2025-01-29T16:35:31.022088Z", + "last_active": "2025-02-14T13:12:46.241933Z", "blocked_user_ids": [ ], @@ -807,22 +800,36 @@ { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "06f2e16cacf2bc6c1917d945e3aa875d2c0faa6cd1a379f7c2c28b9b61269bd2", - "created_at": "2025-01-25T23:50:24.74497Z", + "id": "80c9f99c1d02347ba3599ab752e55308681244fe97fcbc95eb0e630bcd97cb3ee927e8b15d5085938b4b18246f2e9ba2de3f357a6ca9127b1971df14742725841f0fd8537233b5e43a07a24422411816", + "created_at": "2025-02-06T05:12:00.670052Z", "user_id": "leia_organa" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80902ac37a00a72af2fcebb5fa7db31603af4ed96681d8c356409dc391ce5d0a", - "created_at": "2025-01-24T05:28:13.061448Z", + "id": "8099cf56e010eb7a7ec2202eacffbc22c0d84b2c3007b9b45403f4f99fd42b670d6552521aea4c14263fd0ed0d034f2e3f25aa44145a4054ed7d067e25e2f221582d30de66f750e62e45e2c0fa73ed74", + "created_at": "2025-02-05T14:25:05.737649Z", + "user_id": "leia_organa" + }, + { + "push_provider": "apn", + "push_provider_name": "APN-Configuration", + "id": "003ddc257b04f7d989eb91bf70fd2e19aaef86bd7419d16993cba3e74f6e2ba4", + "created_at": "2025-02-01T23:38:43.460976Z", + "user_id": "leia_organa" + }, + { + "push_provider": "apn", + "push_provider_name": "APN-Configuration", + "id": "06f2e16cacf2bc6c1917d945e3aa875d2c0faa6cd1a379f7c2c28b9b61269bd2", + "created_at": "2025-01-25T23:50:24.74497Z", "user_id": "leia_organa" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80d7928379a90a4e06623ff511036f50df5fe140ae14ab04c4998662eaa42e607b960280e4a24dfd3ef49b360f7837c8b493e463bd2d6b1f467bcad9a70a5f3bd3250b1196a0938e63001952d68f75a6", - "created_at": "2025-01-23T15:22:37.89033Z", + "id": "80902ac37a00a72af2fcebb5fa7db31603af4ed96681d8c356409dc391ce5d0a", + "created_at": "2025-01-24T05:28:13.061448Z", "user_id": "leia_organa" }, { @@ -957,24 +964,9 @@ "id": "8060ebab3e48f5ca984a9673ab0decdeb67f9359925f57a73930160d9f8ee24acca80c515409ab89f73fbc1ef27f092faf1363b9806b754592fd4a93e1b6fdcf2802feea16aec889aaef14574d1dd076", "created_at": "2024-11-12T11:48:44.01663Z", "user_id": "leia_organa" - }, - { - "push_provider": "apn", - "push_provider_name": "APN-Configuration", - "id": "3fb4736e2bc02e51f13e403adef98fb1301f18e71b9825bf0aa6fef6666c2112", - "created_at": "2024-11-05T16:33:34.780594Z", - "user_id": "leia_organa" - }, - { - "push_provider": "apn", - "push_provider_name": "APN-Configuration", - "id": "80b04f87691de56ae755b28cddb3eee63ef786f3ed952f53632a14f1af72053ee01e026918a71ad7518de5ae73910c3528d00e465d78d1702d9e79a620b207c60c337a938c4c364e5e5b8f3963acefb4", - "created_at": "2024-10-26T18:09:15.629886Z", - "user_id": "leia_organa" } ], "invisible": false, - "birthland": "Polis Massa", "private_settings": { "readReceipts": { "enabled": false @@ -982,11 +974,12 @@ "typingIndicators": { "enabled": false } - } + }, + "birthland": "Polis Massa" }, "status": "member", - "created_at": "2025-01-31T16:18:37.521245Z", - "updated_at": "2025-01-31T16:18:37.521245Z", + "created_at": "2025-02-15T00:14:56.183774Z", + "updated_at": "2025-02-15T00:14:56.183774Z", "banned": false, "shadow_banned": false, "role": "admin", @@ -997,7 +990,7 @@ "member_count": 4, "config": { "created_at": "2021-03-01T19:26:18.406502Z", - "updated_at": "2024-11-13T11:49:47.368244Z", + "updated_at": "2025-02-13T14:46:16.795117Z", "name": "messaging", "typing_events": true, "read_events": true, @@ -1059,10 +1052,13 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:15:56.152156879Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], + "team": "test", + "type": "team", + "pando": "{\"speciality\":\"ios engineer\"}", "birthland": "Tatooine", "privacy_settings": { "read_receipts": { @@ -1071,9 +1067,6 @@ "typing_indicators": { "enabled": false } - }, - "team": "test", - "type": "team", - "pando": "{\"speciality\":\"ios engineer\"}" + } } } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_member.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_member.json index 4cad347181c..53f0207391c 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_member.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_events_member.json @@ -1,7 +1,7 @@ { "type": "member.added", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "channel_id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "channel_id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "channel_type": "messaging", "member": { "user_id": "leia_organa", @@ -10,12 +10,11 @@ "role": "admin", "created_at": "2024-04-04T09:42:00.68335Z", "updated_at": "2025-01-16T12:34:56.765249Z", - "last_active": "2025-01-29T16:35:31.022088Z", - "last_engaged_at": "2025-01-29T01:28:41.520256Z", + "last_active": "2025-02-14T13:12:46.241933Z", + "last_engaged_at": "2025-02-14T08:58:37.266996Z", "banned": false, "online": false, "language": "ru", - "image": "https://vignette.wikia.nocookie.net/starwars/images/f/fc/Leia_Organa_TLJ.png", "birthland": "Polis Massa", "private_settings": { "readReceipts": { @@ -25,11 +24,12 @@ "enabled": false } }, - "name": "Leia Organa" + "name": "Leia Organa", + "image": "https://vignette.wikia.nocookie.net/starwars/images/f/fc/Leia_Organa_TLJ.png" }, "status": "member", - "created_at": "2025-01-31T16:18:37.521245Z", - "updated_at": "2025-01-31T16:18:37.521245Z", + "created_at": "2025-02-15T00:14:56.183774Z", + "updated_at": "2025-02-15T00:14:56.183774Z", "banned": false, "shadow_banned": false, "archived_at": null, @@ -43,14 +43,11 @@ "role": "admin", "created_at": "2024-04-04T09:42:00.68335Z", "updated_at": "2025-01-16T12:34:56.765249Z", - "last_active": "2025-01-29T16:35:31.022088Z", - "last_engaged_at": "2025-01-29T01:28:41.520256Z", + "last_active": "2025-02-14T13:12:46.241933Z", + "last_engaged_at": "2025-02-14T08:58:37.266996Z", "banned": false, "online": false, "language": "ru", - "name": "Leia Organa", - "image": "https://vignette.wikia.nocookie.net/starwars/images/f/fc/Leia_Organa_TLJ.png", - "birthland": "Polis Massa", "private_settings": { "readReceipts": { "enabled": false @@ -58,8 +55,11 @@ "typingIndicators": { "enabled": false } - } + }, + "name": "Leia Organa", + "image": "https://vignette.wikia.nocookie.net/starwars/images/f/fc/Leia_Organa_TLJ.png", + "birthland": "Polis Massa" }, - "channel_last_message_at": "2025-01-31T16:18:37.094228Z", - "created_at": "2025-01-31T16:18:37.531605452Z" + "channel_last_message_at": "2025-02-15T00:14:54.796665Z", + "created_at": "2025-02-15T00:14:56.195943204Z" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_health_check.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_health_check.json index 88357c65958..1f4a4da6aac 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_health_check.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_health_check.json @@ -1,5 +1,5 @@ { - "connection_id": "679c8249-0a15-3bf6-0200-0000000001c0", + "connection_id": "67add740-0a15-3bf6-0200-000000000f44", "me": { "id": "luke_skywalker", "name": "Luke Skywalker", @@ -13,7 +13,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:18:35.2853414Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "privacy_settings": { "typing_indicators": { "enabled": true @@ -43,5 +43,5 @@ }, "cid": "*", "type": "health.check", - "created_at": "2025-01-31T16:18:35.302174503Z" + "created_at": "2025-02-15T00:14:49.812925379Z" } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_message.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_message.json index 0f540037903..13977ae18d9 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_message.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_message.json @@ -1,17 +1,17 @@ { "type": "message.new", - "created_at": "2025-01-31T16:18:37.112003835Z", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "channel_last_message_at": "2025-01-31T16:18:37.094228Z", + "created_at": "2025-02-15T00:14:54.844825276Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "channel_last_message_at": "2025-02-15T00:14:54.796665Z", "channel_member_count": 3, "channel_custom": { "name": "Sync Mock Server" }, "channel_type": "messaging", - "channel_id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "message_id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "channel_id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "message_id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "message": { - "id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "text": "Test", "html": "

Test

\n", "type": "regular", @@ -28,7 +28,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:17:53.97317831Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -37,178 +37,178 @@ { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", - "created_at": "2025-01-29T12:48:17.917988Z", + "id": "e2c6bed363247a93ea49699385e50de99efba5707808cbd7b2d3f4e46a7cbfb9", + "created_at": "2025-02-13T17:54:30.965611Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", - "created_at": "2025-01-29T11:17:58.405065Z", + "id": "8078596852ae3ecc7bb8d20eb1bde24d596115270e266be1e7164009c28c841c89120222e5594b8929bafb7b55ec47e5b1db2e1274b6e0c9a8f24ba1087832d4dc8acefc857be26848f2b074fdaa42fe", + "created_at": "2025-02-12T13:52:59.060649Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "52c03e3cd1477169d34772e1755a6009448b9115b3fe99fc6f50b4e8ded1a397", - "created_at": "2025-01-27T14:10:52.981213Z", - "disabled": true, - "disabled_reason": "Unregistered", + "id": "805fbd71d240360c14b785cd89f59846acf40e15514e3cf89efdbc30f360999873c4713b6fd6f192072c075c41243b82f8b89ea4880f7694f72815934477e7cf8d80c303d7bb25c8c0730e6268a1e3d2", + "created_at": "2025-02-12T10:55:34.960205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", - "created_at": "2025-01-27T09:26:50.733298Z", + "id": "38cf0b02ebe66d4bbc17981502acf8ef7806337f45e3094fbf662293185dd663", + "created_at": "2025-02-12T10:45:49.811205Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", - "created_at": "2025-01-27T07:18:18.62698Z", + "id": "2716b6f605e691bf7dc7d88d1a45968dd6807ed91dbbf793777d152f796994b0", + "created_at": "2025-02-11T01:03:34.75674Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", - "created_at": "2025-01-26T19:17:41.00587Z", + "id": "809975048776300710e9bf304eabc27813c3042266dc085420362ea254ebeccea39666720bc25ffc5a022b79062f1e5f1f1de6d1565850cc78ba3c9df1a810e7d4128b73c2f76210189febf4ee0df9b1", + "created_at": "2025-02-10T01:52:00.239203Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", - "created_at": "2025-01-26T18:56:24.995825Z", + "id": "8082e4f123b0f83543d1f7c04920c76ec7d4b4db9d8f2e8ff0cc4daeef338209be72df2b35257cfdb86623c14d241cf1d1f42b78fef01e16052d13b03f75ed135360886f1900d3a0e80e97b268514c77", + "created_at": "2025-02-09T10:04:50.694565Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", - "created_at": "2025-01-24T15:23:31.160845Z", + "id": "809a57e2ed4470cb0b372e79d31cff19276879f5b312b3b71e51fc05ad338bbda27220c4f36c1283e0340d6b8816702d123dda05604dfc0d9a2eae03099f718db1f14f13de887394280380cbcb945223", + "created_at": "2025-02-08T13:42:01.054257Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", - "created_at": "2025-01-24T05:36:02.440032Z", + "id": "800dd2e8d35942e0a2124b71e87dcb54467f63b148190dbd04821e71100924fdcf58d8cc17eb1030529f07bfaae1bc5c2f9313139067e7392edf1fdadf837762409698640194be2ca788aa9b5660ff4d", + "created_at": "2025-02-07T09:29:01.123928Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", - "created_at": "2025-01-23T05:30:44.092337Z", + "id": "8009a9c2db8394cdaa8ebd8f0f5731ba1fe28c3cc89397b8e38b0d55b018cafead52512e0d9dfbd10ca9abaacbfa1e96586f1e2c6a4f831977607fa415188249f6d88b7369e476554f0ce8a23e033f49", + "created_at": "2025-02-06T07:45:34.135591Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", - "created_at": "2025-01-22T04:43:11.923746Z", + "id": "adcc46baccb0cc4b2dfd893f7762b6ee15b0f9d9e39c014286a501dcce558ec3", + "created_at": "2025-01-29T12:48:17.917988Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", - "created_at": "2025-01-21T03:06:34.117688Z", + "id": "8d534aa2033befff9291da0e371b223b0f60a34ec0b498ad364259e20541d374", + "created_at": "2025-01-29T11:17:58.405065Z", + "disabled": true, + "disabled_reason": "Unregistered", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", - "created_at": "2025-01-15T13:18:37.405547Z", + "id": "27dc3cd279808ba179d46e04a2e95bc17a94062dfc91e1a317d7e39c314f65c6", + "created_at": "2025-01-27T09:26:50.733298Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", - "created_at": "2025-01-15T07:59:32.530759Z", + "id": "806dcda419b98288b170fcaee122e709197f8ae3c727329f1ea3ec2aed31419acd1028da485a462e39850aec933df018da4fb2d75f02f4a003f7749084d6b4a37818caffc674c41f947592f648155a93", + "created_at": "2025-01-27T07:18:18.62698Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", - "created_at": "2025-01-14T20:36:03.102001Z", + "id": "80ca028a4646d58ef07166eaed2c5f068430fb9d7aa6b2bb94d53f8ce6261df2f932611aea12cad01a7b2cbc5169a4a0242c8d95c78ed5cde48bc139414ec63bbf696a56fb7c9b07218fa17bf8ba0aec", + "created_at": "2025-01-26T19:17:41.00587Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", - "created_at": "2025-01-14T17:54:05.295746Z", + "id": "80647c152794bfb16ff75836d87e93363fe6827fd4a5673f84c839c65048fa9f8b4691a75f12ee974538b29858d8b6d1b935276ed594d657c754a84fb6229dd3b5881199598128dc1fae6a1e969d765b", + "created_at": "2025-01-26T18:56:24.995825Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "2c0a2b1f6891af16b45df24c7b05b1598d414f64fae7c5ef3eb41d92b45bc275", - "created_at": "2025-01-13T06:09:35.973958Z", + "id": "803fac504e3f9eca842b146b0d5dc358a258a83a1da25c53404f928eed4b607581a274499978e886f4923c1002d6ef33bd4b1374b0ede16c962dec4a562d53467ccf3c835c784544d07a181c56c886a5", + "created_at": "2025-01-24T15:23:31.160845Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "f1f43292dc972188ccf2bb5a2523342482280cd37e6e80540c7f41890b8f5868", - "created_at": "2025-01-13T02:06:21.211287Z", + "id": "8031fa3a733e7c218e91ad97413ed5640dbf934909f76ae7452fbd63c11eaa30825208b63462c29fe073410262afd99f39fa5f2d65d5bb834e6cff93e77e7fbeb7e63bdd3f2f6a91fa05dffe78335548", + "created_at": "2025-01-24T05:36:02.440032Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80560b4f465af52f745b646beaeba703a1877ef6988eb06f38b5f023e6affb4f6a8d8c22123c53767d2f17fae4bfa1df218117400a0d175ffd2066bae361bab07d589bbfd9ae5e693e8af34476625f58", - "created_at": "2025-01-12T12:18:40.312161Z", + "id": "d88cf79198e6489f4965d4855de57d12e33ba3d0d91c9490807c6f092f176c90", + "created_at": "2025-01-23T05:30:44.092337Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "8009ae3cb1eb8350aa78dfe4539e5858be8c0de73b66cccdb2baff95b211f3c13c56c9e9e837c9a44af93b9b631d5db658bfed4d899ba8ee773898acf49956d5df2638f37191fa5262572b75108cc075", - "created_at": "2025-01-10T14:43:30.030261Z", + "id": "80a866c1775d76e7884f3e27eae779ffe9a0fe2bdf8f8c5d9ae20ab1d3de4211d3dbfc689613a18ad21957a625ee60a00a2b811666d6a8b96ce62e17353626bb643bb9fa4062b6e6d6b8d9aea0371c79", + "created_at": "2025-01-22T04:43:11.923746Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "809155ab6e601835c405261f32a7046cc265d8899ffe5d3d484df422424101cba27d22e11531645c4c7d660a9b2c69641c9884622d08ba27b51458a03b67995e21791ba038a40f670fdcaa3a8f73403b", - "created_at": "2025-01-10T12:25:15.095949Z", + "id": "805adb0dfe07ccf6c50df056ee5b0cca2ef67f1cee8045db9e2dec6dd48d6c9633b86635efd73673c72a3bc8ab84e3af4f85b7d1a8e377563272e7999eb16fcbfbced5fe9e26dc9e08db8973e0891418", + "created_at": "2025-01-21T03:06:34.117688Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80a416c7a38c0b0e5f4cdc67cc22db4429b1abf069719b5fd249e067bd40f39a93c9ae72186cd4dbe9ee3f7d4b75b0572f6fcb6fd61f1447924bb5049e49d64111494690ab89f39ae64f13828d692357", - "created_at": "2025-01-08T21:35:48.016721Z", + "id": "3eef457f5a0c9dd01c600ee142c1cd812034f603690c0d3e8c6c6c6c073ab075", + "created_at": "2025-01-15T13:18:37.405547Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80677e1cd0e34efdaf88b76a3bb2ce7086c4c20853c3b1ca7c638a1da310ffd884491071d224cacd90e72a0116dcc1c3ebaab19a79e7eabb560f7ca21458945ff1a085e43a8b41d491f3eec625186aa1", - "created_at": "2025-01-08T16:07:56.454603Z", + "id": "9adb5efb25e5dd635103e3fa8475ccfb4806f262235015dcde5985fcddfc00db", + "created_at": "2025-01-15T07:59:32.530759Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80d2897aafd7c4a6e382d80212ba469b5348669930fd34db0f8aae76f7059db340f7118963ef89d109480f2cb83679e7dd71a5766b764a8a546ff958f66f5ddbf17140658079e329f170bf9e47f68ae5", - "created_at": "2025-01-08T15:16:15.675842Z", + "id": "9f4c91f42937f2f6991d772680e6d2f6f840e7eee3d4992328c3912e45365190", + "created_at": "2025-01-14T20:36:03.102001Z", "user_id": "luke_skywalker" }, { "push_provider": "apn", "push_provider_name": "APN-Configuration", - "id": "80ca9df3ad609fa3e61a033f403e3b6acb7039aaa58285061f5bd189d9a1659857216129e70e3fa267691c5b4ca20452aaaf683aa7d2f1aa98adaa8c162e8f8ef668da54070e79c3b8470add34e45b31", - "created_at": "2025-01-08T12:58:51.72573Z", + "id": "809065933d06e42d27179dfe7f24ef7c34e74c0898c184cc1de0abd791f0f9d2a1342ad140129c3a26ba7a4f86b3534358475d9964024ff373bc2c705bc2d999cbbe0402af60077fb7af6c1f73295bd7", + "created_at": "2025-01-14T17:54:05.295746Z", "user_id": "luke_skywalker" } ], @@ -233,9 +233,9 @@ }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:37.094228Z", - "updated_at": "2025-01-31T16:18:37.094228Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:54.796665Z", + "updated_at": "2025-02-15T00:14:54.796665Z", "shadowed": false, "mentioned_users": [ @@ -244,7 +244,10 @@ "pinned": false, "pinned_at": null, "pinned_by": null, - "pin_expires": null + "pin_expires": null, + "restricted_visibility": [ + + ] }, "user": { "id": "luke_skywalker", @@ -259,7 +262,7 @@ "updated_at": "2025-01-31T09:38:22.825054Z", "banned": false, "online": true, - "last_active": "2025-01-31T16:15:56.152156879Z", + "last_active": "2025-02-15T00:14:49.778901469Z", "blocked_user_ids": [ ], @@ -279,5 +282,5 @@ "watcher_count": 1, "unread_count": 0, "total_unread_count": 0, - "unread_channels": 1 + "unread_channels": 0 } \ No newline at end of file diff --git a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_reaction.json b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_reaction.json index 23e9d20d52b..3dfae71f5ef 100644 --- a/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_reaction.json +++ b/TestTools/StreamChatTestMockServer/Fixtures/JSONs/ws_reaction.json @@ -1,10 +1,10 @@ { "type": "reaction.new", - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "channel_id": "09f99d09-a2a7-4fb0-ae8d-25927c147b67", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "channel_id": "bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", "channel_type": "messaging", "message": { - "id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "text": "Test", "html": "

Test

\n", "type": "regular", @@ -13,46 +13,49 @@ "role": "admin", "created_at": "2024-04-04T09:26:11.805899Z", "updated_at": "2025-01-31T09:38:22.825054Z", - "last_active": "2025-01-31T16:15:56.152156879Z", - "last_engaged_at": "2025-01-31T00:11:13.65581Z", + "last_active": "2025-02-15T00:14:49.778901469Z", + "last_engaged_at": "2025-02-14T00:00:13.035318Z", "banned": false, "online": true, "language": "id", - "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine", - "name": "Luke Skywalker", "team": "test", "type": "team", - "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg" + "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", + "pando": "{\"speciality\":\"ios engineer\"}", + "birthland": "Tatooine", + "name": "Luke Skywalker" }, + "restricted_visibility": [ + + ], "attachments": [ ], "latest_reactions": [ { - "message_id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "message_id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "user_id": "luke_skywalker", "user": { "id": "luke_skywalker", "role": "admin", "created_at": "2024-04-04T09:26:11.805899Z", "updated_at": "2025-01-31T09:38:22.825054Z", - "last_active": "2025-01-31T16:15:56.152156879Z", - "last_engaged_at": "2025-01-31T00:11:13.65581Z", + "last_active": "2025-02-15T00:14:49.778901469Z", + "last_engaged_at": "2025-02-14T00:00:13.035318Z", "banned": false, "online": true, "language": "id", + "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", + "pando": "{\"speciality\":\"ios engineer\"}", "birthland": "Tatooine", "name": "Luke Skywalker", "team": "test", - "type": "team", - "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", - "pando": "{\"speciality\":\"ios engineer\"}" + "type": "team" }, "type": "like", "score": 1, - "created_at": "2025-01-31T16:18:37.304432Z", - "updated_at": "2025-01-31T16:18:37.304432Z" + "created_at": "2025-02-15T00:14:55.545757Z", + "updated_at": "2025-02-15T00:14:55.545757Z" } ], "own_reactions": [ @@ -68,15 +71,15 @@ "like": { "count": 1, "sum_scores": 1, - "first_reaction_at": "2025-01-31T16:18:37.304432Z", - "last_reaction_at": "2025-01-31T16:18:37.304432Z" + "first_reaction_at": "2025-02-15T00:14:55.545757Z", + "last_reaction_at": "2025-02-15T00:14:55.545757Z" } }, "reply_count": 0, "deleted_reply_count": 0, - "cid": "messaging:09f99d09-a2a7-4fb0-ae8d-25927c147b67", - "created_at": "2025-01-31T16:18:37.094228Z", - "updated_at": "2025-01-31T16:18:37.310539Z", + "cid": "messaging:bf00ad34-c8b4-413f-ba3a-b1fcc321da9b", + "created_at": "2025-02-15T00:14:54.796665Z", + "updated_at": "2025-02-15T00:14:55.555595Z", "shadowed": false, "mentioned_users": [ @@ -88,47 +91,47 @@ "pin_expires": null }, "reaction": { - "message_id": "b222d2eb-d0e3-4d82-a029-68a9e5a3d261", + "message_id": "aefe3ec6-d123-41a2-b055-f80546bf2e04", "user_id": "luke_skywalker", "user": { "id": "luke_skywalker", "role": "admin", "created_at": "2024-04-04T09:26:11.805899Z", "updated_at": "2025-01-31T09:38:22.825054Z", - "last_active": "2025-01-31T16:15:56.152156879Z", - "last_engaged_at": "2025-01-31T00:11:13.65581Z", + "last_active": "2025-02-15T00:14:49.778901469Z", + "last_engaged_at": "2025-02-14T00:00:13.035318Z", "banned": false, "online": true, "language": "id", + "name": "Luke Skywalker", "team": "test", "type": "team", "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", "pando": "{\"speciality\":\"ios engineer\"}", - "birthland": "Tatooine", - "name": "Luke Skywalker" + "birthland": "Tatooine" }, "type": "like", "score": 1, - "created_at": "2025-01-31T16:18:37.304432Z", - "updated_at": "2025-01-31T16:18:37.304432Z" + "created_at": "2025-02-15T00:14:55.545757Z", + "updated_at": "2025-02-15T00:14:55.545757Z" }, "user": { "id": "luke_skywalker", "role": "admin", "created_at": "2024-04-04T09:26:11.805899Z", "updated_at": "2025-01-31T09:38:22.825054Z", - "last_active": "2025-01-31T16:15:56.152156879Z", - "last_engaged_at": "2025-01-31T00:11:13.65581Z", + "last_active": "2025-02-15T00:14:49.778901469Z", + "last_engaged_at": "2025-02-14T00:00:13.035318Z", "banned": false, "online": true, "language": "id", + "team": "test", + "type": "team", "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", "pando": "{\"speciality\":\"ios engineer\"}", "birthland": "Tatooine", - "name": "Luke Skywalker", - "team": "test", - "type": "team" + "name": "Luke Skywalker" }, - "channel_last_message_at": "2025-01-31T16:18:37.094228Z", - "created_at": "2025-01-31T16:18:37.319338494Z" + "channel_last_message_at": "2025-02-15T00:14:54.796665Z", + "created_at": "2025-02-15T00:14:55.564979349Z" } \ No newline at end of file diff --git a/TestTools/StreamChatTestTools/Assertions/AssertJSONEqual.swift b/TestTools/StreamChatTestTools/Assertions/AssertJSONEqual.swift index 9825c4a845a..9a8676a1300 100644 --- a/TestTools/StreamChatTestTools/Assertions/AssertJSONEqual.swift +++ b/TestTools/StreamChatTestTools/Assertions/AssertJSONEqual.swift @@ -161,14 +161,19 @@ public func AssertJSONEqual( } public func AssertDictionary( + ignoringKeys: [String] = [], _ expression1: @autoclosure () throws -> [String: Any], _ expression2: @autoclosure () throws -> [String: Any], file: StaticString = #filePath, line: UInt = #line ) { do { - let expr1 = try NSDictionary(dictionary: expression1()) - let expr2 = try NSDictionary(dictionary: expression2()) + let expr1 = try NSDictionary( + dictionary: expression1().filter { !ignoringKeys.contains($0.key) } + ) + let expr2 = try NSDictionary( + dictionary: expression2().filter { !ignoringKeys.contains($0.key) } + ) XCTAssertEqual(expr1, expr2, file: file, line: line) } catch { XCTFail("Error: \(error)", file: file, line: line) diff --git a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift index eaf855cb650..246c4612360 100644 --- a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift +++ b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift @@ -52,7 +52,8 @@ extension ChatMessage { moderationDetails: nil, readBy: [], poll: nil, - textUpdatedAt: nil + textUpdatedAt: nil, + draftReply: nil ) } } diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/DraftMessage.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/DraftMessage.json new file mode 100644 index 00000000000..9986b895b31 --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/DraftMessage.json @@ -0,0 +1,85 @@ +{ + "draft": { + "channel": { + "cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "config": { + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "args": "[text]", + "description": "Post a random gif to the channel", + "name": "giphy", + "set": "fun_set" + } + ], + "connect_events": true, + "created_at": "2025-02-06T09:58:57.737862823Z", + "custom_events": true, + "mark_messages_pending": false, + "max_message_length": 5000, + "message_retention": "infinite", + "mutes": true, + "name": "messaging", + "polls": false, + "push_notifications": true, + "quotes": true, + "reactions": true, + "read_events": true, + "reminders": false, + "replies": true, + "search": true, + "skip_last_msg_update_for_system_msgs": false, + "typing_events": true, + "updated_at": "2025-02-06T09:58:57.737862946Z", + "uploads": true, + "url_enrichment": true + }, + "created_at": "2024-07-10T11:47:43.591964Z", + "created_by": { + "banned": false, + "birthland": "Corellia", + "blocked_user_ids": [], + "created_at": "2024-07-09T10:25:12.255599Z", + "id": "han_solo", + "image": "https://vignette.wikia.nocookie.net/starwars/images/e/e2/TFAHanSolo.png", + "language": "", + "last_active": "2025-02-11T12:26:45.282392588Z", + "name": "Han Solo", + "online": true, + "role": "user", + "teams": [], + "updated_at": "2024-07-09T10:25:12.260463Z" + }, + "disabled": false, + "frozen": false, + "id": "!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "last_message_at": "2025-02-11T12:11:59.677265Z", + "member_count": 2, + "type": "messaging", + "updated_at": "2024-07-10T11:47:43.591964Z" + }, + "channel_cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "created_at": "2025-02-11T12:27:04.780633395Z", + "message": { + "id": "eb8ab3c9-5ade-4b4d-b32d-3d7f037682c0", + "mentioned_users": [ + { + "id": "leia_organa", + "role": "user", + "created_at": "2023-09-27T13:21:25.781323Z", + "updated_at": "2023-09-27T13:21:25.781323Z", + "last_active": "2024-03-26T12:03:39.847771Z", + "banned": false, + "online": false + } + ], + "show_in_channel": false, + "silent": false, + "text": "Hi @Leia Organa ahah" + }, + "parent_message": null, + "quoted_message": null + }, + "duration": "22.64ms" +} diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Draft/DraftDeleted.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Draft/DraftDeleted.json new file mode 100644 index 00000000000..1f7e97a522d --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Draft/DraftDeleted.json @@ -0,0 +1,20 @@ +{ + "type": "draft.deleted", + "connection_id": "12345", + "cid": "messaging:general", + "created_at": "2024-02-11T15:42:21.123Z", + "draft": { + "cid": "messaging:general", + "created_at": "2024-02-11T15:42:21.123Z", + "parent_id": "thread-123", + "message": { + "id": "draft-123", + "text": "Test draft message", + "show_reply_in_channel": false, + "silent": false, + "mentioned_users": [], + "attachments": [], + "extra_data": {} + } + } +} \ No newline at end of file diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Draft/DraftUpdated.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Draft/DraftUpdated.json new file mode 100644 index 00000000000..c5a14270415 --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Draft/DraftUpdated.json @@ -0,0 +1,78 @@ +{ + "type": "draft.updated", + "connection_id": "12345", + "cid": "messaging:general", + "created_at": "2024-02-11T15:42:21.123Z", + "draft": { + "cid": "messaging:general", + "channel": { + "cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "config": { + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "args": "[text]", + "description": "Post a random gif to the channel", + "name": "giphy", + "set": "fun_set" + } + ], + "connect_events": true, + "created_at": "2025-02-06T09:58:57.737862823Z", + "custom_events": true, + "mark_messages_pending": false, + "max_message_length": 5000, + "message_retention": "infinite", + "mutes": true, + "name": "messaging", + "polls": false, + "push_notifications": true, + "quotes": true, + "reactions": true, + "read_events": true, + "reminders": false, + "replies": true, + "search": true, + "skip_last_msg_update_for_system_msgs": false, + "typing_events": true, + "updated_at": "2025-02-06T09:58:57.737862946Z", + "uploads": true, + "url_enrichment": true + }, + "created_at": "2024-07-10T11:47:43.591964Z", + "created_by": { + "banned": false, + "birthland": "Corellia", + "blocked_user_ids": [], + "created_at": "2024-07-09T10:25:12.255599Z", + "id": "han_solo", + "image": "https://vignette.wikia.nocookie.net/starwars/images/e/e2/TFAHanSolo.png", + "language": "", + "last_active": "2025-02-11T12:26:45.282392588Z", + "name": "Han Solo", + "online": true, + "role": "user", + "teams": [], + "updated_at": "2024-07-09T10:25:12.260463Z" + }, + "disabled": false, + "frozen": false, + "id": "!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "last_message_at": "2025-02-11T12:11:59.677265Z", + "member_count": 2, + "type": "messaging", + "updated_at": "2024-07-10T11:47:43.591964Z" + }, + "created_at": "2024-02-11T15:42:21.123Z", + "message": { + "id": "draft-123", + "text": "Test draft message", + "show_reply_in_channel": false, + "silent": false, + "mentioned_users": [], + "attachments": [], + "extra_data": {} + } + } +} diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift index f7b7adf14cd..326c3bc94c7 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift @@ -91,7 +91,8 @@ public extension ChatChannel { latestMessages: [ChatMessage] = [], pinnedMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, - previewMessage: ChatMessage? = nil + previewMessage: ChatMessage? = nil, + draftMessage: DraftMessage? = nil ) -> Self { self.init( cid: cid, @@ -120,7 +121,8 @@ public extension ChatChannel { lastMessageFromCurrentUser: nil, pinnedMessages: pinnedMessages, muteDetails: muteDetails, - previewMessage: previewMessage + previewMessage: previewMessage, + draftMessage: draftMessage ) } @@ -148,7 +150,8 @@ public extension ChatChannel { latestMessages: [ChatMessage] = [], pinnedMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, - previewMessage: ChatMessage? = nil + previewMessage: ChatMessage? = nil, + draftMessage: DraftMessage? = nil ) -> Self { self.init( cid: .init(type: .messaging, id: "!members" + .newUniqueId), @@ -175,7 +178,8 @@ public extension ChatChannel { lastMessageFromCurrentUser: nil, pinnedMessages: pinnedMessages, muteDetails: muteDetails, - previewMessage: previewMessage + previewMessage: previewMessage, + draftMessage: draftMessage ) } @@ -202,7 +206,8 @@ public extension ChatChannel { latestMessages: [ChatMessage] = [], pinnedMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, - previewMessage: ChatMessage? = nil + previewMessage: ChatMessage? = nil, + draftMessage: DraftMessage? = nil ) -> Self { self.init( cid: .init(type: .messaging, id: .newUniqueId), @@ -228,7 +233,8 @@ public extension ChatChannel { lastMessageFromCurrentUser: nil, pinnedMessages: pinnedMessages, muteDetails: muteDetails, - previewMessage: previewMessage + previewMessage: previewMessage, + draftMessage: draftMessage ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift index 6e226e4cbbd..8851e5383eb 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift @@ -49,7 +49,8 @@ public extension ChatMessage { readBy: Set = [], underlyingContext: NSManagedObjectContext? = nil, textUpdatedAt: Date? = nil, - poll: Poll? = nil + poll: Poll? = nil, + draftReply: DraftMessage? = nil ) -> Self { .init( id: id, @@ -89,7 +90,8 @@ public extension ChatMessage { moderationDetails: moderationsDetails, readBy: readBy, poll: poll, - textUpdatedAt: textUpdatedAt + textUpdatedAt: textUpdatedAt, + draftReply: draftReply ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/DraftMessage_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/DraftMessage_Mock.swift new file mode 100644 index 00000000000..bc23edb2345 --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/DraftMessage_Mock.swift @@ -0,0 +1,43 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat + +public extension DraftMessage { + /// Creates a new `ChatMessage` object from the provided data. + static func mock( + id: MessageId = .unique, + cid: ChannelId = .unique, + threadId: MessageId? = nil, + text: String = .unique, + currentUser: ChatUser = .unique, + command: String? = nil, + createdAt: Date = Date(timeIntervalSince1970: 113), + arguments: String? = nil, + quotedMessage: ChatMessage? = nil, + showReplyInChannel: Bool = false, + extraData: [String: RawJSON] = [:], + isSilent: Bool = false, + mentionedUsers: Set = [], + attachments: [AnyChatMessageAttachment] = [] + ) -> Self { + .init( + id: id, + cid: cid, + threadId: threadId, + text: text, + isSilent: isSilent, + command: command, + createdAt: createdAt, + arguments: arguments, + showReplyInChannel: showReplyInChannel, + extraData: extraData, + currentUser: currentUser, + quotedMessage: { quotedMessage }, + mentionedUsers: mentionedUsers, + attachments: attachments + ) + } +} diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 8b68ceb26b9..bd166069c44 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -148,7 +148,13 @@ extension ChatClient { internetConnection: { center, _ in InternetConnection_Mock(notificationCenter: center) }, - authenticationRepositoryBuilder: AuthenticationRepository_Mock.init + authenticationRepositoryBuilder: AuthenticationRepository_Mock.init, + syncRepositoryBuilder: SyncRepository_Mock.init, + pollsRepositoryBuilder: PollsRepository_Mock.init, + draftMessagesRepositoryBuilder: DraftMessagesRepository_Mock.init, + channelListUpdaterBuilder: ChannelListUpdater_Spy.init, + messageRepositoryBuilder: MessageRepository_Mock.init, + offlineRequestsRepositoryBuilder: OfflineRequestsRepository_Mock.init ) ) } @@ -203,6 +209,10 @@ extension ChatClient { pollsRepository as! PollsRepository_Mock } + var mockDraftMessagesRepository: DraftMessagesRepository_Mock { + draftMessagesRepository as! DraftMessagesRepository_Mock + } + func simulateProvidedConnectionId(connectionId: ConnectionId?) { guard let connectionId = connectionId else { webSocketClient( @@ -241,6 +251,7 @@ extension ChatClient.Environment { authenticationRepositoryBuilder: AuthenticationRepository_Mock.init, syncRepositoryBuilder: SyncRepository_Mock.init, pollsRepositoryBuilder: PollsRepository_Mock.init, + draftMessagesRepositoryBuilder: DraftMessagesRepository_Mock.init, channelListUpdaterBuilder: ChannelListUpdater_Spy.init, messageRepositoryBuilder: MessageRepository_Mock.init, offlineRequestsRepositoryBuilder: OfflineRequestsRepository_Mock.init diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift index 623eb91568a..3b77bcf6395 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift @@ -65,6 +65,7 @@ class ChatChannelController_Mock: ChatChannelController { quotedMessageId: MessageId? = nil, skipPush: Bool = false, skipEnrichUrl: Bool = false, + restrictedVisibility: [UserId] = [], extraData: [String : RawJSON] = [:], completion: ((Result) -> Void)? = nil ) { @@ -132,6 +133,41 @@ class ChatChannelController_Mock: ChatChannelController { ) { loadPageAroundMessageIdCallCount += 1 } + + var updateDraftMessage_callCount = 0 + var updateDraftMessage_completion: ((Result) -> Void)? + var updateDraftMessage_text = "" + + override func updateDraftMessage( + text: String, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + command: Command? = nil, + extraData: [String : RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + updateDraftMessage_text = text + updateDraftMessage_callCount += 1 + updateDraftMessage_completion = completion + } + + var deleteDraftMessage_callCount = 0 + var deleteDraftMessage_completion: ((Error?) -> Void)? + + override func deleteDraftMessage(completion: ((Error?) -> Void)? = nil) { + deleteDraftMessage_callCount += 1 + deleteDraftMessage_completion = completion + } + + var loadDraftMessage_callCount = 0 + var loadDraftMessage_completion: ((Result) -> Void)? + + override func loadDraftMessage(completion: ((Result) -> Void)? = nil) { + loadDraftMessage_callCount += 1 + loadDraftMessage_completion = completion + } } extension ChatChannelController_Mock { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 0a6830d8ef0..8491c8dcf4e 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -7,7 +7,6 @@ import Foundation /// This class allows you to wrap an existing `DatabaseSession` and adjust the behavior of its methods. class DatabaseSession_Mock: DatabaseSession { - /// The wrapped session let underlyingSession: DatabaseSession @@ -140,6 +139,7 @@ class DatabaseSession_Mock: DatabaseSession { skipPush: Bool, skipEnrichUrl: Bool, poll: PollPayload?, + restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] ) throws -> MessageDTO { try throwErrorIfNeeded() @@ -162,6 +162,35 @@ class DatabaseSession_Mock: DatabaseSession { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: poll, + restrictedVisibility: restrictedVisibility, + extraData: extraData + ) + } + + func createNewDraftMessage( + in cid: ChannelId, + text: String, + command: String?, + arguments: String?, + parentMessageId: MessageId?, + attachments: [AnyAttachmentPayload], + mentionedUserIds: [UserId], + showReplyInChannel: Bool, + isSilent: Bool, + quotedMessageId: MessageId?, + extraData: [String : RawJSON] + ) throws -> MessageDTO { + try underlyingSession.createNewDraftMessage( + in: cid, + text: text, + command: command, + arguments: arguments, + parentMessageId: parentMessageId, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + showReplyInChannel: showReplyInChannel, + isSilent: isSilent, + quotedMessageId: quotedMessageId, extraData: extraData ) } @@ -170,15 +199,58 @@ class DatabaseSession_Mock: DatabaseSession { payload: MessagePayload, for cid: ChannelId?, syncOwnReactions: Bool, + skipDraftUpdate: Bool, cache: PreWarmedCache? ) throws -> MessageDTO { try throwErrorIfNeeded() - return try underlyingSession.saveMessage(payload: payload, for: cid, syncOwnReactions: syncOwnReactions, cache: cache) + return try underlyingSession.saveMessage( + payload: payload, + for: cid, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: skipDraftUpdate, + cache: cache + ) } - func saveMessage(payload: MessagePayload, channelDTO: ChannelDTO, syncOwnReactions: Bool, cache: PreWarmedCache?) throws -> MessageDTO { + func saveMessage( + payload: MessagePayload, + for cid: ChannelId?, + syncOwnReactions: Bool, + cache: PreWarmedCache? + ) throws -> MessageDTO { + try saveMessage(payload: payload, for: cid, syncOwnReactions: syncOwnReactions, skipDraftUpdate: false, cache: cache) + } + + func saveMessage( + payload: MessagePayload, + channelDTO: ChannelDTO, + syncOwnReactions: Bool, + skipDraftUpdate: Bool, + cache: PreWarmedCache? + ) throws -> MessageDTO { try throwErrorIfNeeded() - return try underlyingSession.saveMessage(payload: payload, channelDTO: channelDTO, syncOwnReactions: syncOwnReactions, cache: cache) + return try underlyingSession.saveMessage( + payload: payload, + channelDTO: channelDTO, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: skipDraftUpdate, + cache: cache + ) + } + + func saveMessage( + payload: MessagePayload, + channelDTO: ChannelDTO, + syncOwnReactions: Bool, + cache: PreWarmedCache? + ) throws -> MessageDTO { + try saveMessage( + payload: payload, + channelDTO: channelDTO, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: false, + cache: cache + ) } func saveMessages(messagesPayload: MessageListPayload, for cid: ChannelId?, syncOwnReactions: Bool) -> [MessageDTO] { @@ -210,6 +282,14 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.delete(message: message) } + func saveDraftMessage(payload: DraftPayload, for cid: ChannelId, cache: PreWarmedCache?) throws -> MessageDTO { + try underlyingSession.saveDraftMessage(payload: payload, for: cid, cache: cache) + } + + func deleteDraftMessage(in cid: ChannelId, threadId: MessageId?) { + underlyingSession.deleteDraftMessage(in: cid, threadId: threadId) + } + func preview(for cid: ChannelId) -> MessageDTO? { underlyingSession.preview(for: cid) } @@ -529,3 +609,37 @@ private extension DatabaseSession_Mock { throw error } } + +extension DatabaseSession { + @discardableResult + func saveMessage( + payload: MessagePayload, + for cid: ChannelId?, + syncOwnReactions: Bool, + cache: PreWarmedCache? + ) throws -> MessageDTO { + try self.saveMessage( + payload: payload, + for: cid, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: false, + cache: cache + ) + } + + @discardableResult + func saveMessage( + payload: MessagePayload, + channelDTO: ChannelDTO, + syncOwnReactions: Bool, + cache: PreWarmedCache? + ) throws -> MessageDTO { + try self.saveMessage( + payload: payload, + channelDTO: channelDTO, + syncOwnReactions: syncOwnReactions, + skipDraftUpdate: false, + cache: cache + ) + } +} diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/DraftMessagesRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/DraftMessagesRepository_Mock.swift new file mode 100644 index 00000000000..ef00a1b8693 --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/DraftMessagesRepository_Mock.swift @@ -0,0 +1,104 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +import XCTest + +final class DraftMessagesRepository_Mock: DraftMessagesRepository { + // MARK: - Load Drafts + + var loadDrafts_callCount = 0 + var loadDrafts_calledWith: DraftListQuery? + var loadDrafts_completion: ((Result) -> Void)? + + override func loadDrafts( + query: DraftListQuery, + completion: @escaping (Result) -> Void + ) { + loadDrafts_callCount += 1 + loadDrafts_calledWith = query + loadDrafts_completion = completion + } + + // MARK: - Update Draft + + var updateDraft_callCount = 0 + var updateDraft_calledWith: ( + cid: ChannelId, + threadId: MessageId?, + text: String, + isSilent: Bool, + showReplyInChannel: Bool, + command: String?, + arguments: String?, + attachments: [AnyAttachmentPayload], + mentionedUserIds: [UserId], + quotedMessageId: MessageId?, + extraData: [String: RawJSON] + )? + var updateDraft_completion: ((Result) -> Void)? + + override func updateDraft( + for cid: ChannelId, + threadId: MessageId?, + text: String, + isSilent: Bool, + showReplyInChannel: Bool, + command: String?, + arguments: String?, + attachments: [AnyAttachmentPayload], + mentionedUserIds: [UserId], + quotedMessageId: MessageId?, + extraData: [String: RawJSON], + completion: ((Result) -> Void)? + ) { + updateDraft_callCount += 1 + updateDraft_calledWith = ( + cid: cid, + threadId: threadId, + text: text, + isSilent: isSilent, + showReplyInChannel: showReplyInChannel, + command: command, + arguments: arguments, + attachments: attachments, + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessageId, + extraData: extraData + ) + updateDraft_completion = completion + } + + // MARK: - Get Draft + + var getDraft_callCount = 0 + var getDraft_calledWith: (cid: ChannelId, threadId: MessageId?)? + var getDraft_completion: ((Result) -> Void)? + + override func getDraft( + for cid: ChannelId, + threadId: MessageId?, + completion: ((Result) -> Void)? + ) { + getDraft_callCount += 1 + getDraft_calledWith = (cid: cid, threadId: threadId) + getDraft_completion = completion + } + + // MARK: - Delete Draft + + var deleteDraft_callCount = 0 + var deleteDraft_calledWith: (cid: ChannelId, threadId: MessageId?)? + var deleteDraft_completion: ((Error?) -> Void)? + + override func deleteDraft( + for cid: ChannelId, + threadId: MessageId?, + completion: @escaping (Error?) -> Void + ) { + deleteDraft_callCount += 1 + deleteDraft_calledWith = (cid: cid, threadId: threadId) + deleteDraft_completion = completion + } +} diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/Chat_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/Chat_Mock.swift index 64613be9ad3..4eb034f924a 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/State/Chat_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/State/Chat_Mock.swift @@ -59,7 +59,8 @@ public class Chat_Mock: Chat, Spy { silent: Bool = false, skipPushNotification: Bool = false, skipEnrichURL: Bool = false, - messageId: MessageId? = nil + messageId: MessageId? = nil, + restrictedVisibility: [UserId] = [] ) async throws -> ChatMessage { createNewMessageCallCount += 1 return ChatMessage.mock() diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift index cfea188495b..77a1393c7fd 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift @@ -353,6 +353,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { quotedMessageId: MessageId?, skipPush: Bool, skipEnrichUrl: Bool, + restrictedVisibility: [UserId] = [], poll: PollPayload?, extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift index 7eb16e4d5c6..48e5b32ddd6 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift @@ -322,7 +322,13 @@ extension DatabaseContainer { reactionCounts: reactionCounts ) - let messageDTO = try session.saveMessage(payload: message, channelDTO: channelDTO, syncOwnReactions: true, cache: nil) + let messageDTO = try session.saveMessage( + payload: message, + channelDTO: channelDTO, + syncOwnReactions: true, + skipDraftUpdate: true, + cache: nil) + messageDTO.localMessageState = localState messageDTO.reactionCounts = reactionCounts.mapKeys(\.rawValue) messageDTO.reactionScores = reactionScores.mapKeys(\.rawValue) @@ -340,7 +346,13 @@ extension DatabaseContainer { extraData: extraData ) - let replyDTO = try session.saveMessage(payload: reply, for: cid, syncOwnReactions: true, cache: nil) + let replyDTO = try session.saveMessage( + payload: reply, + for: cid, + syncOwnReactions: true, + skipDraftUpdate: true, + cache: nil + ) messageDTO.replies.insert(replyDTO) } } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift index b1a3c59c49c..7dd7c0a72e2 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift @@ -17,7 +17,8 @@ extension ChannelPayload { pendingMessages: [MessagePayload] = [], pinnedMessages: [MessagePayload] = [], channelReads: [ChannelReadPayload] = [], - isHidden: Bool? = nil + isHidden: Bool? = nil, + draft: DraftPayload? = nil ) -> Self { .init( channel: channel, @@ -29,7 +30,8 @@ extension ChannelPayload { pendingMessages: pendingMessages, pinnedMessages: pinnedMessages, channelReads: channelReads, - isHidden: isHidden + isHidden: isHidden, + draft: draft ) } } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/DraftPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/DraftPayload.swift new file mode 100644 index 00000000000..b294b6b4e2d --- /dev/null +++ b/TestTools/StreamChatTestTools/TestData/DummyData/DraftPayload.swift @@ -0,0 +1,55 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat + +extension DraftPayload { + /// Returns dummy draft payload with the given values. + static func dummy( + cid: ChannelId? = nil, + channelPayload: ChannelDetailPayload? = nil, + createdAt: Date = .unique, + message: DraftMessagePayload = .dummy(), + quotedMessage: MessagePayload? = nil, + parentId: String? = nil, + parentMessage: MessagePayload? = nil + ) -> DraftPayload { + .init( + cid: cid, + channelPayload: channelPayload, + createdAt: createdAt, + message: message, + quotedMessage: quotedMessage, + parentId: parentId, + parentMessage: parentMessage + ) + } +} + +extension DraftMessagePayload { + static func dummy( + id: String = .unique, + text: String = .unique, + command: String? = nil, + args: String? = nil, + showReplyInChannel: Bool = false, + mentionedUsers: [UserPayload]? = nil, + extraData: [String: RawJSON] = [:], + attachments: [MessageAttachmentPayload]? = nil, + isSilent: Bool = false + ) -> DraftMessagePayload { + .init( + id: id, + text: text, + command: command, + args: args, + showReplyInChannel: showReplyInChannel, + mentionedUsers: mentionedUsers, + extraData: extraData, + attachments: attachments, + isSilent: isSilent + ) + } +} diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift index 0288cbbaf41..16aea0d94d5 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift @@ -49,7 +49,8 @@ extension MessagePayload { moderationDetails: MessageModerationDetailsPayload? = nil, mentionedUsers: [UserPayload] = [.dummy(userId: .unique)], messageTextUpdatedAt: Date? = nil, - poll: PollPayload? = nil + poll: PollPayload? = nil, + draft: DraftPayload? = nil ) -> MessagePayload { .init( id: messageId, @@ -89,7 +90,8 @@ extension MessagePayload { moderation: moderation, moderationDetails: moderationDetails, messageTextUpdatedAt: messageTextUpdatedAt, - poll: poll + poll: poll, + draft: draft ) } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ThreadPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ThreadPayload.swift index 9fa46c9ad55..4826f568588 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/ThreadPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/ThreadPayload.swift @@ -22,6 +22,7 @@ extension ThreadPayload { title: String? = nil, latestReplies: [MessagePayload] = [], read: [ThreadReadPayload] = [], + draft: DraftPayload? = nil, extraData: [String: RawJSON] = [:] ) -> Self { .init( @@ -38,6 +39,7 @@ extension ThreadPayload { title: title, latestReplies: latestReplies, read: read, + draft: draft, extraData: extraData ) } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift index 218a14ea64c..cbb963852af 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift @@ -201,7 +201,8 @@ extension XCTestCase { pendingMessages: pendingMessages, pinnedMessages: pinnedMessages, channelReads: channelReads ?? [dummyChannelRead], - isHidden: false + isHidden: false, + draft: nil ) return payload @@ -316,7 +317,8 @@ extension XCTestCase { pendingMessages: nil, pinnedMessages: [dummyMessageWithNoExtraData], channelReads: [dummyChannelReadWithNoExtraData], - isHidden: nil + isHidden: nil, + draft: nil ) return payload @@ -336,6 +338,7 @@ extension XCTestCase { title: String? = .unique, latestReplies: [MessagePayload] = [], read: [ThreadReadPayload] = [], + draft: DraftPayload? = nil, extraData: [String: RawJSON] = [:] ) -> ThreadPayload { .init( @@ -352,6 +355,7 @@ extension XCTestCase { title: title, latestReplies: latestReplies, read: read, + draft: draft, extraData: extraData ) } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/CallEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/CallEndpoints_Tests.swift deleted file mode 100644 index 7fa29f43338..00000000000 --- a/Tests/StreamChatTests/APIClient/Endpoints/CallEndpoints_Tests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class CallEndpoints_Tests: XCTestCase { - func test_getCallToken_buildsCorrectly() { - let callId: String = .unique - - // GIVEN - let expectedEndpoint = Endpoint( - path: .callToken(callId), - method: .post, - queryItems: nil, - requiresConnectionId: false, - requiresToken: true - ) - - // WHEN - let endpoint: Endpoint = .getCallToken(callId: callId) - - // THEN - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("calls/\(callId)", endpoint.path.value) - } - - func test_createCall_buildsCorrectly() { - let channedId: ChannelId = .unique - let callId: String = .unique - let callType = "video" - - // GIVEN - let expectedEndpoint: Endpoint = Endpoint( - path: .createCall(channedId.apiPath), - method: .post, - body: CallRequestBody(id: callId, type: callType) - ) - - // WHEN - let endpoint: Endpoint = .createCall(cid: channedId, callId: callId, type: callType) - - // THEN - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("channels/\(channedId.apiPath)/call", endpoint.path.value) - } -} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/DraftEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/DraftEndpoints_Tests.swift new file mode 100644 index 00000000000..55c04c1defd --- /dev/null +++ b/Tests/StreamChatTests/APIClient/Endpoints/DraftEndpoints_Tests.swift @@ -0,0 +1,142 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class DraftEndpoints_Tests: XCTestCase { + func test_drafts() throws { + let query = DraftListQuery() + let endpoint = Endpoint.drafts(query: query) + let body = try AnyEndpoint(endpoint).bodyAsDictionary() + let expectedBody: [String: Any] = [ + "limit": 25, + "sort": [ + ["field": "created_at", "direction": -1] + ] + ] + + XCTAssertEqual(endpoint.method, .post) + XCTAssertEqual(endpoint.path.value, "drafts/query") + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertNil(endpoint.queryItems) + AssertDictionary(body, expectedBody) + } + + func test_updateDraftMessage() throws { + let cid = ChannelId(type: .messaging, id: "123") + let requestBody = DraftMessageRequestBody( + id: "draft-id", + text: "Hello", + command: nil, + args: nil, + parentId: "parent-id", + showReplyInChannel: true, + isSilent: false, + quotedMessageId: "quoted-id", + attachments: [], + mentionedUserIds: ["user1", "user2"], + extraData: [:] + ) + + let endpoint = Endpoint.updateDraftMessage( + channelId: cid, + requestBody: requestBody + ) + + let body = try AnyEndpoint(endpoint).bodyAsDictionary() + let expectedBody: [String: Any] = [ + "message": [ + "id": "draft-id", + "text": "Hello", + "parent_id": "parent-id", + "show_in_channel": true, + "silent": false, + "quoted_message_id": "quoted-id", + "mentioned_users": ["user1", "user2"] + ] + ] + + XCTAssertEqual(endpoint.method, .post) + XCTAssertEqual(endpoint.path.value, "channels/messaging/123/draft") + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertNil(endpoint.queryItems) + AssertDictionary(body, expectedBody) + } + + func test_getDraftMessage() throws { + let cid = ChannelId(type: .messaging, id: "123") + let threadId = "thread-id" + + let endpoint = Endpoint.getDraftMessage( + channelId: cid, + threadId: threadId + ) + + let queryItems = try AnyEndpoint(endpoint).queryItemsAsDictionary() + let expectedQueryItems: [String: Any] = [ + "parent_id": "thread-id" + ] + + XCTAssertEqual(endpoint.method, .get) + XCTAssertEqual(endpoint.path.value, "channels/messaging/123/draft") + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertNil(endpoint.body) + AssertDictionary(queryItems, expectedQueryItems) + } + + func test_getDraftMessage_withoutThreadId() throws { + let cid = ChannelId(type: .messaging, id: "123") + + let endpoint = Endpoint.getDraftMessage( + channelId: cid, + threadId: nil + ) + + XCTAssertEqual(endpoint.method, .get) + XCTAssertEqual(endpoint.path.value, "channels/messaging/123/draft") + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertNil(endpoint.body) + XCTAssertNil(endpoint.queryItems) + } + + func test_deleteDraftMessage() throws { + let cid = ChannelId(type: .messaging, id: "123") + let threadId = "thread-id" + + let endpoint = Endpoint.deleteDraftMessage( + channelId: cid, + threadId: threadId + ) + + let body = try AnyEndpoint(endpoint).bodyAsDictionary() + let expectedBody: [String: Any] = [ + "parent_id": "thread-id" + ] + + XCTAssertEqual(endpoint.method, .delete) + XCTAssertEqual(endpoint.path.value, "channels/messaging/123/draft") + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertNil(endpoint.queryItems) + AssertDictionary(body, expectedBody) + } + + func test_deleteDraftMessage_withoutThreadId() throws { + let cid = ChannelId(type: .messaging, id: "123") + + let endpoint = Endpoint.deleteDraftMessage( + channelId: cid, + threadId: nil + ) + + XCTAssertEqual(endpoint.method, .delete) + XCTAssertEqual(endpoint.path.value, "channels/messaging/123/draft") + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertNil(endpoint.queryItems) + XCTAssertNil(endpoint.body) + } +} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift index 1a35aef3aaf..852df46419c 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift @@ -80,6 +80,14 @@ final class EndpointPathTests: XCTestCase { XCTAssertEqual(path, "channels/\(cid.apiPath)/member/1") } + func test_drafts_shouldNOTBeQueuedOffline() { + XCTAssertFalse(EndpointPath.drafts.shouldBeQueuedOffline) + } + + func test_draftMessage_shouldBeQueuedOffline() { + XCTAssertTrue(EndpointPath.draftMessage(.unique).shouldBeQueuedOffline) + } + // MARK: - Codable func test_isProperlyEncodedAndDecoded() throws { @@ -135,6 +143,9 @@ final class EndpointPathTests: XCTestCase { assertResultEncodingAndDecoding(.pollOption(pollId: "test_poll", optionId: "option_id")) assertResultEncodingAndDecoding(.pollVoteInMessage(messageId: "test_message", pollId: "test_poll")) assertResultEncodingAndDecoding(.pollVote(messageId: "test_message", pollId: "test_poll", voteId: "test_vote")) + + assertResultEncodingAndDecoding(.drafts) + assertResultEncodingAndDecoding(.draftMessage(ChannelId(type: .messaging, id: "test_channel"))) } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 61b59ce987f..50dc214ca9c 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -247,7 +247,8 @@ final class ChannelListPayload_Tests: XCTestCase { unreadMessagesCount: (0..<10).randomElement()! ) }, - isHidden: false + isHidden: false, + draft: nil ) } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CreateCallPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CreateCallPayload_Tests.swift deleted file mode 100644 index 2da85f4bdef..00000000000 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/CreateCallPayload_Tests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamChat -import StreamChatTestTools -import XCTest - -final class CreateCallPayload_Tests: XCTestCase { - func test_createCallPayload_decodedCorrectly() throws { - // Create Call Payload data - let id: String = .unique - let provider: String = "agora" - let token: String = .unique - let channel: String = .unique - let roomId: String = .unique - let roomName: String = .unique - let agoraUid: UInt = 10 - let agoraAppId: String = .unique - - let expectedPayload = CreateCallPayload( - call: CallPayload( - id: id, - provider: provider, - agora: AgoraPayload(channel: channel), - hms: HMSPayload( - roomId: roomId, - roomName: roomName - ) - ), - token: token, - agoraUid: agoraUid, - agoraAppId: agoraAppId - ) - - // GIVEN - let mockData: [String: Any] = [ - "call": [ - "id": id, - "provider": provider, - "agora": [ - "channel": channel - ], - "hms": [ - "room_id": roomId, - "room_name": roomName - ] - ] as [String: Any], - "token": token, - "agora_uid": agoraUid, - "agora_app_id": agoraAppId - ] - let mockJson = try JSONSerialization.data(withJSONObject: mockData) - - // WHEN - let payload = try JSONDecoder.default.decode(CreateCallPayload.self, from: mockJson) - - // THEN - XCTAssertEqual(expectedPayload, payload) - } -} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/DraftPayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/DraftPayloads_Tests.swift new file mode 100644 index 00000000000..64b6f8b7728 --- /dev/null +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/DraftPayloads_Tests.swift @@ -0,0 +1,91 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +private extension Data { + static let draftMessage = XCTestCase.mockData(fromJSONFile: "DraftMessage") +} + +final class DraftPayloads_Tests: XCTestCase { + func test_draftPayloadResponse_decodingFromJSON() throws { + // Given + let json = try JSONDecoder.default.decode(DraftPayloadResponse.self, from: .draftMessage) + + // Then + XCTAssertEqual(json.draft.cid?.rawValue, "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E") + XCTAssertEqual(json.draft.message.id, "eb8ab3c9-5ade-4b4d-b32d-3d7f037682c0") + XCTAssertEqual(json.draft.message.text, "Hi @Leia Organa ahah") + XCTAssertEqual(json.draft.message.mentionedUsers?.count, 1) + XCTAssertEqual(json.draft.message.mentionedUsers?.first?.id, "leia_organa") + XCTAssertFalse(json.draft.message.showReplyInChannel) + XCTAssertFalse(json.draft.message.isSilent) + XCTAssertNil(json.draft.parentMessage) + XCTAssertNil(json.draft.quotedMessage) + XCTAssertNotNil(json.draft.channelPayload) + } + + func test_draftListPayloadResponse_decodingFromJSON() throws { + let jsonString = """ + { + "drafts": [{ + "channel_cid": "messaging:123", + "created_at": "2025-02-11T12:27:04.780633395Z", + "message": { + "id": "draft-1", + "text": "Hello world" + } + }], + "next": "next-page-token" + } + """ + let data = Data(jsonString.utf8) + + // When + let response = try JSONDecoder.default.decode(DraftListPayloadResponse.self, from: data) + + // Then + XCTAssertEqual(response.drafts.count, 1) + XCTAssertEqual(response.drafts[0].cid?.rawValue, "messaging:123") + XCTAssertEqual(response.drafts[0].message.id, "draft-1") + XCTAssertEqual(response.drafts[0].message.text, "Hello world") + XCTAssertEqual(response.next, "next-page-token") + } + + func test_draftMessageRequestBody_encoding() throws { + // Given + let requestBody = DraftMessageRequestBody( + id: "draft-id", + text: "Hello @user1", + command: "/giphy", + args: "hello", + parentId: "parent-123", + showReplyInChannel: true, + isSilent: false, + quotedMessageId: "quoted-123", + attachments: [], + mentionedUserIds: ["user1"], + extraData: ["custom_field": .string("value")] + ) + + // When + let encodedData = try JSONEncoder.default.encode(requestBody) + let decodedJSON = try JSONDecoder.default.decode([String: RawJSON].self, from: encodedData) + + // Then + XCTAssertEqual(decodedJSON["id"]?.stringValue, "draft-id") + XCTAssertEqual(decodedJSON["text"]?.stringValue, "Hello @user1") + XCTAssertEqual(decodedJSON["command"]?.stringValue, "/giphy") + XCTAssertEqual(decodedJSON["args"]?.stringValue, "hello") + XCTAssertEqual(decodedJSON["parent_id"]?.stringValue, "parent-123") + XCTAssertEqual(decodedJSON["show_in_channel"]?.boolValue, true) + XCTAssertEqual(decodedJSON["silent"]?.boolValue, false) + XCTAssertEqual(decodedJSON["quoted_message_id"]?.stringValue, "quoted-123") + XCTAssertEqual(decodedJSON["mentioned_users"]?.stringArrayValue, ["user1"]) + XCTAssertEqual(decodedJSON["custom_field"]?.stringValue, "value") + } +} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift index 2184badce02..12e07939c48 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift @@ -403,7 +403,8 @@ final class IdentifiablePayload_Tests: XCTestCase { unreadMessagesCount: (0..<10).randomElement()! ) }, - isHidden: false + isHidden: false, + draft: nil ) } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift index a12103b9832..65e35c8dc8a 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift @@ -155,6 +155,7 @@ final class MessageRequestBody_Tests: XCTestCase { mentionedUserIds: [.unique], pinned: true, pinExpires: "2021-05-15T06:43:08.776Z".toDate(), + restrictedVisibility: ["test"], extraData: ["secret_note": .string("Anakin is Vader ;-)")] ) @@ -171,7 +172,8 @@ final class MessageRequestBody_Tests: XCTestCase { "secret_note": "Anakin is Vader ;-)", "command": payload.command!, "pinned": true, - "pin_expires": "2021-05-15T06:43:08.776Z" + "pin_expires": "2021-05-15T06:43:08.776Z", + "restricted_visibility": ["test"] ] let expectedJSON = try JSONSerialization.data(withJSONObject: expected, options: []) AssertJSONEqual(serializedJSON, expectedJSON) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController+Drafts_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController+Drafts_Tests.swift new file mode 100644 index 00000000000..13ff832bdc3 --- /dev/null +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController+Drafts_Tests.swift @@ -0,0 +1,202 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ChannelController_Drafts_Tests: XCTestCase { + var client: ChatClient_Mock! + var controller: ChatChannelController! + var draftsRepository: DraftMessagesRepository_Mock! + + override func setUp() { + super.setUp() + + client = ChatClient.mock + draftsRepository = client.draftMessagesRepository as? DraftMessagesRepository_Mock + + let query = ChannelQuery(cid: .unique) + controller = ChatChannelController( + channelQuery: query, + channelListQuery: nil, + client: client, + isChannelAlreadyCreated: true + ) + } + + override func tearDown() { + client.cleanUp() + draftsRepository = nil + controller = nil + client = nil + + super.tearDown() + } + + // MARK: - Update Draft Tests + + func test_updateDraftMessage_whenChannelNotCreated_fails() { + // Create controller with non-created channel + controller = ChatChannelController( + channelQuery: .init(cid: .unique), + channelListQuery: nil, + client: client, + isChannelAlreadyCreated: false + ) + + let expectation = expectation(description: "updateDraft completion called") + controller.updateDraftMessage(text: "test") { result in + if case .failure(let error) = result { + XCTAssertTrue(error is ClientError.ChannelNotCreatedYet) + expectation.fulfill() + } + } + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.updateDraft_callCount, 0) + } + + func test_updateDraftMessage_whenSuccessful() { + let text = "Draft message" + let message = DraftMessage.mock(text: text) + + let expectation = expectation(description: "updateDraft completion called") + controller.updateDraftMessage( + text: text, + isSilent: false, + attachments: [], + mentionedUserIds: [], + quotedMessageId: nil, + extraData: [:] + ) { result in + XCTAssertEqual(try? result.get(), message) + expectation.fulfill() + } + + draftsRepository.updateDraft_completion?(.success(message)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.updateDraft_callCount, 1) + let calledWith = draftsRepository.updateDraft_calledWith + XCTAssertEqual(calledWith?.cid, controller.cid) + XCTAssertEqual(calledWith?.threadId, nil) + } + + func test_updateDraftMessage_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "updateDraft completion called") + controller.updateDraftMessage(text: "test") { result in + XCTAssertEqual(error, result.error as? TestError) + expectation.fulfill() + } + + draftsRepository.updateDraft_completion?(.failure(error)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.updateDraft_callCount, 1) + } + + // MARK: - Load Draft Tests + + func test_loadDraftMessage_whenChannelNotCreated_fails() { + // Create controller with non-created channel + controller = ChatChannelController( + channelQuery: .init(cid: .unique), + channelListQuery: nil, + client: client, + isChannelAlreadyCreated: false + ) + + let expectation = expectation(description: "loadDraft completion called") + controller.loadDraftMessage { result in + if case .failure(let error) = result { + XCTAssertTrue(error is ClientError.ChannelNotCreatedYet) + expectation.fulfill() + } + } + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.getDraft_callCount, 0) + } + + func test_loadDraftMessage_whenSuccessful() { + let message = DraftMessage.mock() + + let expectation = expectation(description: "loadDraft completion called") + controller.loadDraftMessage { result in + XCTAssertEqual(try? result.get(), message) + expectation.fulfill() + } + + draftsRepository.getDraft_completion?(.success(message)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.getDraft_callCount, 1) + } + + func test_loadDraftMessage_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "loadDraft completion called") + controller.loadDraftMessage { result in + XCTAssertEqual(error, result.error as? TestError) + expectation.fulfill() + } + + draftsRepository.getDraft_completion?(.failure(error)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.getDraft_callCount, 1) + } + + // MARK: - Delete Draft Tests + + func test_deleteDraftMessage_whenChannelNotCreated_fails() { + // Create controller with non-created channel + controller = ChatChannelController( + channelQuery: .init(cid: .unique), + channelListQuery: nil, + client: client, + isChannelAlreadyCreated: false + ) + + let expectation = expectation(description: "deleteDraft completion called") + controller.deleteDraftMessage { error in + XCTAssertTrue(error is ClientError.ChannelNotCreatedYet) + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.deleteDraft_callCount, 0) + } + + func test_deleteDraftMessage_whenSuccessful() { + let expectation = expectation(description: "deleteDraft completion called") + controller.deleteDraftMessage { error in + XCTAssertNil(error) + expectation.fulfill() + } + + draftsRepository.deleteDraft_completion?(nil) + + waitForExpectations(timeout: defaultTimeout) + } + + func test_deleteDraftMessage_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "deleteDraft completion called") + controller.deleteDraftMessage { receivedError in + XCTAssertEqual(error, receivedError as? TestError) + expectation.fulfill() + } + + draftsRepository.deleteDraft_completion?(error) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.deleteDraft_callCount, 1) + } +} diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 47e8f3b72d3..6c2a4af21cb 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -954,6 +954,7 @@ final class ChannelController_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) // Simulate sending failed for this message diff --git a/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Drafts_Tests.swift b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Drafts_Tests.swift new file mode 100644 index 00000000000..1001d1e2966 --- /dev/null +++ b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Drafts_Tests.swift @@ -0,0 +1,222 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class CurrentUserController_Drafts_Tests: XCTestCase { + var client: ChatClient_Mock! + var controller: CurrentChatUserController! + var draftsRepository: DraftMessagesRepository_Mock! + + override func setUp() { + super.setUp() + + client = ChatClient.mock + draftsRepository = client.draftMessagesRepository as? DraftMessagesRepository_Mock + controller = CurrentChatUserController(client: client) + } + + override func tearDown() { + client.cleanUp() + draftsRepository = nil + controller = nil + client = nil + + super.tearDown() + } + + // MARK: - Load Draft Messages Tests + + func test_loadDraftMessages_whenSuccessful() { + let messages: [DraftMessage] = [.mock(), .mock()] + let nextCursor = "next_page" + + let expectation = expectation(description: "loadDraftMessages completion called") + controller.loadDraftMessages { result in + XCTAssertEqual(try? result.get(), messages) + expectation.fulfill() + } + + draftsRepository.loadDrafts_completion?(.success(.init(drafts: messages, next: nextCursor))) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.loadDrafts_callCount, 1) + XCTAssertFalse(controller.hasLoadedAllDrafts) + } + + func test_loadDraftMessages_whenNoNextCursor_marksAsLoadedAll() { + let messages: [DraftMessage] = [.mock(), .mock()] + + let expectation = expectation(description: "loadDraftMessages completion called") + controller.loadDraftMessages { result in + XCTAssertEqual(try? result.get(), messages) + expectation.fulfill() + } + + draftsRepository.loadDrafts_completion?(.success(.init(drafts: messages, next: nil))) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.loadDrafts_callCount, 1) + XCTAssertTrue(controller.hasLoadedAllDrafts) + } + + func test_loadDraftMessages_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "loadDraftMessages completion called") + controller.loadDraftMessages { result in + XCTAssertEqual(error, result.error as? TestError) + expectation.fulfill() + } + + draftsRepository.loadDrafts_completion?(.failure(error)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.loadDrafts_callCount, 1) + } + + // MARK: - Load More Draft Messages Tests + + func test_loadMoreDraftMessages_whenNoNextCursor_returnsEmpty() { + let expectation = expectation(description: "loadMoreDraftMessages completion called") + controller.loadMoreDraftMessages { result in + XCTAssertEqual(try? result.get(), []) + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.loadDrafts_callCount, 0) + } + + func test_loadMoreDraftMessages_whenSuccessful() { + // First load initial page + let initialMessages: [DraftMessage] = [.mock(), .mock()] + let nextCursor = "next_page" + controller.loadDraftMessages { _ in } + draftsRepository.loadDrafts_completion?(.success(.init(drafts: initialMessages, next: nextCursor))) + + // Then load more + let moreMessages: [DraftMessage] = [.mock(), .mock()] + let expectation = expectation(description: "loadMoreDraftMessages completion called") + controller.loadMoreDraftMessages { result in + XCTAssertEqual(try? result.get(), moreMessages) + expectation.fulfill() + } + + draftsRepository.loadDrafts_completion?(.success(.init(drafts: moreMessages, next: nil))) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.loadDrafts_callCount, 2) + XCTAssertTrue(controller.hasLoadedAllDrafts) + } + + func test_loadMoreDraftMessages_whenFailure() { + // First load initial page + let initialMessages: [DraftMessage] = [.mock(), .mock()] + let nextCursor = "next_page" + controller.loadDraftMessages { _ in } + draftsRepository.loadDrafts_completion?(.success(.init(drafts: initialMessages, next: nextCursor))) + + // Then try to load more + let error = TestError() + let expectation = expectation(description: "loadMoreDraftMessages completion called") + controller.loadMoreDraftMessages { result in + XCTAssertEqual(error, result.error as? TestError) + expectation.fulfill() + } + + draftsRepository.loadDrafts_completion?(.failure(error)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.loadDrafts_callCount, 2) + } + + // MARK: - Delete Draft Message Tests + + func test_deleteDraftMessage_whenSuccessful() { + let cid = ChannelId.unique + let threadId = MessageId.unique + + let expectation = expectation(description: "deleteDraftMessage completion called") + controller.deleteDraftMessage(for: cid, threadId: threadId) { error in + XCTAssertNil(error) + expectation.fulfill() + } + + draftsRepository.deleteDraft_completion?(nil) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.deleteDraft_callCount, 1) + let calledWith = draftsRepository.deleteDraft_calledWith + XCTAssertEqual(calledWith?.cid, cid) + XCTAssertEqual(calledWith?.threadId, threadId) + } + + func test_deleteDraftMessage_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "deleteDraftMessage completion called") + controller.deleteDraftMessage(for: .unique) { receivedError in + XCTAssertEqual(error, receivedError as? TestError) + expectation.fulfill() + } + + draftsRepository.deleteDraft_completion?(error) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.deleteDraft_callCount, 1) + } + + // MARK: - Delegate Tests + + func test_draftMessagesObserver_notifiesDelegate() throws { + class DelegateMock: CurrentChatUserControllerDelegate { + var messages: [DraftMessage] = [] + let expectation = XCTestExpectation(description: "Did Change Draft Messages") + let expectedMessagesCount: Int + + init(expectedMessagesCount: Int) { + self.expectedMessagesCount = expectedMessagesCount + } + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeDraftMessages messages: [DraftMessage] + ) { + self.messages = messages + guard expectedMessagesCount == messages.count else { return } + expectation.fulfill() + } + } + + let delegate = DelegateMock(expectedMessagesCount: 2) + controller.loadDraftMessages() + controller.delegate = delegate + + try client.databaseContainer.writeSynchronously { session in + let date = Date.unique + let cid = ChannelId.unique + let parentId = MessageId.unique + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + try session.saveMessage(payload: .dummy(messageId: parentId), for: cid, syncOwnReactions: false, cache: nil) + + // Test a draft in a channel and thread in the same channel + let messages = [ + DraftPayload.dummy(cid: cid, createdAt: date, message: .dummy(text: "1")), + DraftPayload.dummy(cid: cid, createdAt: date.addingTimeInterval(1), message: .dummy(text: "2"), parentId: parentId) + ] + + try messages.forEach { + try session.saveDraftMessage(payload: $0, for: cid, cache: nil) + } + } + + wait(for: [delegate.expectation], timeout: defaultTimeout) + XCTAssertEqual(controller.draftMessages.count, 2) + XCTAssertEqual(delegate.messages.map(\.text), ["2", "1"]) + } +} diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController+Drafts_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController+Drafts_Tests.swift new file mode 100644 index 00000000000..1991ce8a0c9 --- /dev/null +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController+Drafts_Tests.swift @@ -0,0 +1,153 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class MessageController_Drafts_Tests: XCTestCase { + var client: ChatClient_Mock! + var controller: ChatMessageController! + var draftsRepository: DraftMessagesRepository_Mock! + + override func setUp() { + super.setUp() + + client = ChatClient.mock + draftsRepository = client.draftMessagesRepository as? DraftMessagesRepository_Mock + + let cid = ChannelId.unique + let messageId = MessageId.unique + controller = ChatMessageController( + client: client, + cid: cid, + messageId: messageId, + replyPaginationHandler: MessagesPaginationStateHandler_Mock() + ) + } + + override func tearDown() { + client.cleanUp() + draftsRepository = nil + controller = nil + client = nil + + super.tearDown() + } + + // MARK: - Update Draft Reply Tests + + func test_updateDraftReply_whenSuccessful() { + let text = "Draft reply" + let message = DraftMessage.mock(text: text) + + let expectation = expectation(description: "updateDraft completion called") + controller.updateDraftReply( + text: text, + isSilent: false, + attachments: [], + mentionedUserIds: [], + quotedMessageId: nil, + showReplyInChannel: false, + extraData: [:] + ) { result in + XCTAssertEqual(try? result.get(), message) + expectation.fulfill() + } + + draftsRepository.updateDraft_completion?(.success(message)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.updateDraft_callCount, 1) + + let calledWith = draftsRepository.updateDraft_calledWith + XCTAssertEqual(calledWith?.cid, controller.cid) + XCTAssertEqual(calledWith?.threadId, controller.messageId) + } + + func test_updateDraftReply_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "updateDraft completion called") + controller.updateDraftReply(text: "test") { result in + XCTAssertEqual(error, result.error as? TestError) + expectation.fulfill() + } + + draftsRepository.updateDraft_completion?(.failure(error)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.updateDraft_callCount, 1) + } + + // MARK: - Load Draft Reply Tests + + func test_loadDraftReply_whenSuccessful() { + let message = DraftMessage.mock() + + let expectation = expectation(description: "loadDraft completion called") + controller.loadDraftReply { result in + XCTAssertEqual(try? result.get(), message) + expectation.fulfill() + } + + draftsRepository.getDraft_completion?(.success(message)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.getDraft_callCount, 1) + + let calledWith = draftsRepository.getDraft_calledWith + XCTAssertEqual(calledWith?.cid, controller.cid) + XCTAssertEqual(calledWith?.threadId, controller.messageId) + } + + func test_loadDraftReply_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "loadDraft completion called") + controller.loadDraftReply { result in + XCTAssertEqual(error, result.error as? TestError) + expectation.fulfill() + } + + draftsRepository.getDraft_completion?(.failure(error)) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.getDraft_callCount, 1) + } + + // MARK: - Delete Draft Reply Tests + + func test_deleteDraftReply_whenSuccessful() { + let expectation = expectation(description: "deleteDraft completion called") + controller.deleteDraftReply { error in + XCTAssertNil(error) + expectation.fulfill() + } + + draftsRepository.deleteDraft_completion?(nil) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.deleteDraft_callCount, 1) + + let calledWith = draftsRepository.deleteDraft_calledWith + XCTAssertEqual(calledWith?.cid, controller.cid) + XCTAssertEqual(calledWith?.threadId, controller.messageId) + } + + func test_deleteDraftReply_whenFailure() { + let error = TestError() + + let expectation = expectation(description: "deleteDraft completion called") + controller.deleteDraftReply { receivedError in + XCTAssertEqual(error, receivedError as? TestError) + expectation.fulfill() + } + + draftsRepository.deleteDraft_completion?(error) + + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(draftsRepository.deleteDraft_callCount, 1) + } +} diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index 257a865c371..6156818365c 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -988,6 +988,46 @@ final class MessageController_Tests: XCTestCase { XCTAssertEqual(env.messageUpdater.editMessage_extraData, extraData) } + func test_editMessage_whenMessageTransformerIsProvided_callsUpdaterWithTransformedValues() throws { + class MockTransformer: StreamModelsTransformer { + var mockTransformedMessage = NewMessageTransformableInfo( + text: "transformed", + attachments: [.mockFile], + extraData: ["transformed": true] + ) + func transform(newMessageInfo: NewMessageTransformableInfo) -> NewMessageTransformableInfo { + mockTransformedMessage + } + } + + let transformer = MockTransformer() + var config = ChatClientConfig(apiKeyString: .unique) + config.modelsTransformer = transformer + client = .mock(config: config) + controller = ChatMessageController( + client: client, + cid: cid, + messageId: messageId, + replyPaginationHandler: replyPaginationHandler, + environment: env.controllerEnvironment + ) + + let exp = expectation(description: "should complete edit message") + + controller.editMessage( + text: .unique + ) { _ in + exp.fulfill() + } + + env.messageUpdater.editMessage_completion?(.success(.unique)) + wait(for: [exp], timeout: defaultTimeout) + + XCTAssertEqual(env.messageUpdater.editMessage_text, transformer.mockTransformedMessage.text) + XCTAssertEqual(env.messageUpdater.editMessage_attachments, transformer.mockTransformedMessage.attachments) + XCTAssertEqual(env.messageUpdater.editMessage_extraData, transformer.mockTransformedMessage.extraData) + } + // MARK: - Flag message func test_flag_propagatesError() { diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift index 08a23642965..4e42cdd9255 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -1105,7 +1105,8 @@ final class ChannelDTO_Tests: XCTestCase { pendingMessages: nil, pinnedMessages: [], channelReads: [currentUserChannelReadPayload], - isHidden: false + isHidden: false, + draft: nil ) let unreadMessages = 5 @@ -1161,7 +1162,8 @@ final class ChannelDTO_Tests: XCTestCase { pendingMessages: nil, pinnedMessages: [], channelReads: [], - isHidden: nil + isHidden: nil, + draft: nil ) try database.writeSynchronously { session in try session.saveChannel(payload: channelPayload) @@ -1494,6 +1496,240 @@ final class ChannelDTO_Tests: XCTestCase { // THEN XCTAssertEqual(channel.cid, transformer.mockTransformedChannel.cid) } + + func test_saveChannel_savesAndLoadsDraftMessage() throws { + // GIVEN + let cid: ChannelId = .unique + let draftMessagePayload = DraftMessagePayload( + id: .unique, + text: "Draft message text", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: ["custom_key": .string("custom_value")], + attachments: nil, + isSilent: false + ) + + let channelPayload = ChannelPayload( + channel: .dummy(cid: cid), + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: DraftPayload( + cid: cid, + channelPayload: nil, + createdAt: .init(), + message: draftMessagePayload, + quotedMessage: nil, + parentId: nil, + parentMessage: nil + ) + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: channelPayload) + } + + // THEN + let channel = try XCTUnwrap(database.viewContext.channel(cid: cid)?.asModel()) + let draftMessage = try XCTUnwrap(channel.draftMessage) + XCTAssertEqual(draftMessage.id, draftMessagePayload.id) + XCTAssertEqual(draftMessage.text, draftMessagePayload.text) + XCTAssertEqual(draftMessage.extraData, draftMessagePayload.extraData) + } + + func test_saveChannel_whenDraftMessageIsNil_removesExistingDraft() throws { + // GIVEN + let cid: ChannelId = .unique + let draftMessagePayload = DraftMessagePayload( + id: .unique, + text: "Draft message text", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ) + + let channelPayload = ChannelPayload( + channel: .dummy(cid: cid), + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: DraftPayload( + cid: cid, + channelPayload: nil, + createdAt: .init(), + message: draftMessagePayload, + quotedMessage: nil, + parentId: nil, + parentMessage: nil + ) + ) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: channelPayload) + } + + // Verify draft exists + var channel = try XCTUnwrap(database.viewContext.channel(cid: cid)?.asModel()) + XCTAssertNotNil(channel.draftMessage) + + // WHEN + // Save channel without draft + let payloadWithoutDraft = ChannelPayload( + channel: .dummy(cid: cid), + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: nil + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: payloadWithoutDraft) + } + + // THEN + channel = try XCTUnwrap(database.viewContext.channel(cid: cid)?.asModel()) + XCTAssertNil(channel.draftMessage) + } + + func test_saveChannel_draftMessageWithQuotedMessage() throws { + // GIVEN + let cid: ChannelId = .unique + let quotedMessagePayload: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + text: "Quoted message" + ) + let draftMessagePayload = DraftMessagePayload( + id: .unique, + text: "Draft message text", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ) + + let channelPayload = ChannelPayload( + channel: .dummy(cid: cid), + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: DraftPayload( + cid: cid, + channelPayload: nil, + createdAt: .init(), + message: draftMessagePayload, + quotedMessage: quotedMessagePayload, + parentId: nil, + parentMessage: nil + ) + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: channelPayload) + } + + // THEN + let channel = try XCTUnwrap(database.viewContext.channel(cid: cid)?.asModel()) + let draftMessage = try XCTUnwrap(channel.draftMessage) + let quotedMessage = try XCTUnwrap(draftMessage.quotedMessage) + XCTAssertEqual(quotedMessage.id, quotedMessagePayload.id) + XCTAssertEqual(quotedMessage.text, quotedMessagePayload.text) + } + + func test_saveChannel_draftMessageWithParentMessage() throws { + // GIVEN + let cid: ChannelId = .unique + let parentMessagePayload: MessagePayload = .dummy( + messageId: .unique, + authorUserId: .unique, + text: "Parent message" + ) + let draftMessagePayload = DraftMessagePayload( + id: .unique, + text: "Draft message text", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ) + + let channelPayload = ChannelPayload( + channel: .dummy(cid: cid), + watcherCount: nil, + watchers: nil, + members: [], + membership: nil, + messages: [], + pendingMessages: nil, + pinnedMessages: [], + channelReads: [], + isHidden: nil, + draft: DraftPayload( + cid: cid, + channelPayload: nil, + createdAt: .init(), + message: draftMessagePayload, + quotedMessage: nil, + parentId: parentMessagePayload.id, + parentMessage: parentMessagePayload + ) + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: channelPayload) + } + + // THEN + let channel = try XCTUnwrap(database.viewContext.channel(cid: cid)?.asModel()) + let draftMessage = try XCTUnwrap(channel.draftMessage) + XCTAssertEqual(draftMessage.threadId, parentMessagePayload.id) + let parentMessageId = try XCTUnwrap(draftMessage.threadId) + XCTAssertEqual(parentMessageId, parentMessagePayload.id) + } } private extension ChannelDTO_Tests { diff --git a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift index a795cf6311f..9ff21331acc 100644 --- a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift @@ -1201,6 +1201,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: messageExtraData ) @@ -1290,6 +1291,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) } @@ -1394,6 +1396,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) message1Id = message1DTO.id @@ -1418,6 +1421,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) // Reset the `locallyCreateAt` value of the second message to simulate the message was sent @@ -1561,6 +1565,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: true, skipEnrichUrl: true, poll: nil, + restrictedVisibility: [], extraData: [:] ) newMessageId = messageDTO.id @@ -1632,6 +1637,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: true, skipEnrichUrl: true, poll: nil, + restrictedVisibility: [], extraData: [:] ) newMessageId = messageDTO.id @@ -1682,6 +1688,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) messageId = messageDTO.id @@ -1729,6 +1736,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) threadReplyId = replyShownInChannelDTO.id @@ -1789,6 +1797,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) } @@ -1818,6 +1827,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) }, completion: completion) @@ -1861,6 +1871,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) }, completion: completion) @@ -1950,6 +1961,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + restrictedVisibility: [], extraData: [:] ) // Get reply messageId @@ -2514,6 +2526,88 @@ final class MessageDTO_Tests: XCTestCase { XCTAssertEqual(predicateCount, 1) } + func test_channelMessagesPredicate_shouldNotIncludeDraftMessages() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + + // Create a regular message + let regularMessage = MessagePayload.dummy( + messageId: .unique, + text: "Regular message" + ) + + // Create a draft message + let draftPayload = DraftPayload.dummy(cid: cid, message: .dummy(text: "Draft message")) + let threadDraftPayload = DraftPayload.dummy(cid: cid, message: .dummy(text: "Thread draft message"), parentId: regularMessage.id) + + // Save regular, draft message in the channel and draft message in the thread + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + let channel = try session.saveChannel(payload: channelPayload) + try session.saveMessage(payload: regularMessage, channelDTO: channel, syncOwnReactions: false, cache: nil) + try session.saveDraftMessage(payload: draftPayload, for: cid, cache: nil) + try session.saveDraftMessage(payload: threadDraftPayload, for: cid, cache: nil) + } + + // Create an observer with channelMessagesPredicate + var receivedMessages: [ChatMessage] = [] + let observer = try createMessagesFRC(for: channelPayload) { _ in } + receivedMessages = Array(observer.items) + + // Only the regular message should be included in the results + XCTAssertEqual(receivedMessages.count, 1) + XCTAssertEqual(receivedMessages.first?.id, regularMessage.id) + XCTAssertEqual(receivedMessages.first?.text, regularMessage.text) + XCTAssertEqual(database.viewContext.channel(cid: cid)?.draftMessage?.id, draftPayload.message.id) + } + + func test_threadMessagesPredicate_shouldNotIncludeDraftMessages() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + let parentMessagePayload = MessagePayload.dummy(cid: cid) + + // Create a regular message + let regularMessage = MessagePayload.dummy( + type: .regular, + messageId: .unique, + parentId: parentMessagePayload.id, + text: "Regular message", + cid: cid + ) + + // Create a draft message + let threadDraftPayload = DraftPayload.dummy( + cid: cid, message: .dummy(text: "Thread draft message"), parentId: regularMessage.id + ) + + // Save regular, draft message in the channel and draft message in the thread + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + let channel = try session.saveChannel(payload: channelPayload) + try session.saveMessage(payload: parentMessagePayload, channelDTO: channel, syncOwnReactions: false, cache: nil) + let reply = try session.saveMessage(payload: regularMessage, for: cid, syncOwnReactions: false, cache: nil) + reply.showInsideThread = true + try session.saveDraftMessage(payload: threadDraftPayload, for: cid, cache: nil) + } + + // Create an observer with channelMessagesPredicate + var receivedMessages: [ChatMessage] = [] + let observer = try createThreadMessagesFRC(for: parentMessagePayload) { _ in } + receivedMessages = Array(observer.items) + + // Only the regular message should be included in the results + XCTAssertEqual(receivedMessages.count, 1) + XCTAssertEqual(receivedMessages.first?.id, regularMessage.id) + XCTAssertEqual(receivedMessages.first?.text, regularMessage.text) + XCTAssertEqual(database.viewContext.message(id: regularMessage.id)?.draftReply?.id, threadDraftPayload.message.id) + } + // MARK: - allAttachmentsAreUploadedOrEmptyPredicate() func test_allAttachmentsAreUploadedOrEmptyPredicate_whenEmpty_returnsMessages() throws { @@ -4353,4 +4447,333 @@ final class MessageDTO_Tests: XCTestCase { try observer.startObserving(onContextDidChange: { _, changes in onChange(changes) }) return observer } + + // Creates a messages observer (FRC wrapper) + private func createThreadMessagesFRC( + for payload: MessagePayload, + onChange: @escaping ([ListChange]) -> Void + ) throws -> StateLayerDatabaseObserver { + let observer = StateLayerDatabaseObserver( + database: database, + fetchRequest: MessageDTO.repliesFetchRequest( + for: payload.id, + pageSize: 25, + deletedMessagesVisibility: .alwaysHidden, + shouldShowShadowedMessages: false + ), + itemCreator: { try $0.asModel() as ChatMessage }, + itemReuseKeyPaths: (\ChatMessage.id, \MessageDTO.id), + sorting: [] + ) + try observer.startObserving(onContextDidChange: { _, changes in onChange(changes) }) + return observer + } + + // MARK: - Draft Messages Tests + + func test_saveDraftMessage_inChannel() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + let draftPayload = DraftPayload( + cid: cid, + channelPayload: channelDetailPayload, + createdAt: .init(), + message: .init( + id: .unique, + text: "Draft message", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ), + quotedMessage: nil, + parentId: nil, + parentMessage: nil + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + try session.saveChannel(payload: channelPayload) + try session.saveDraftMessage(payload: draftPayload, for: cid, cache: nil) + } + + // THEN + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: cid)) + let draftMessage = try XCTUnwrap(channelDTO.draftMessage) + XCTAssertEqual(draftMessage.text, "Draft message") + XCTAssertEqual(draftMessage.type, MessageType.regular.rawValue) + XCTAssertTrue(draftMessage.isDraft) + XCTAssertNil(draftMessage.parentMessageId) + } + + func test_saveDraftMessage_inThread() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + let parentMessageId = MessageId.unique + let parentMessage = MessagePayload.dummy(messageId: parentMessageId) + + let draftPayload = DraftPayload( + cid: cid, + channelPayload: channelDetailPayload, + createdAt: .init(), + message: .init( + id: .unique, + text: "Draft reply", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ), + quotedMessage: nil, + parentId: parentMessageId, + parentMessage: parentMessage + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + let channel = try session.saveChannel(payload: channelPayload) + _ = try session.saveMessage(payload: parentMessage, channelDTO: channel, syncOwnReactions: false, cache: nil) + try session.saveDraftMessage(payload: draftPayload, for: cid, cache: nil) + } + + // THEN + let parentMessageDTO = try XCTUnwrap(database.viewContext.message(id: parentMessageId)) + let draftReply = try XCTUnwrap(parentMessageDTO.draftReply) + XCTAssertEqual(draftReply.text, "Draft reply") + XCTAssertEqual(draftReply.type, MessageType.regular.rawValue) + XCTAssertTrue(draftReply.isDraft) + XCTAssertEqual(draftReply.parentMessageId, parentMessageId) + } + + func test_saveDraftMessage_whenParentOrQuotedMessageContainsDraft_shouldNotCrash() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + let draftMessage = DraftMessagePayload.dummy(text: "Draft Reply") + let parentMessageId = MessageId.unique + let parentMessage = MessagePayload.dummy( + messageId: parentMessageId, + draft: .dummy(cid: cid, message: draftMessage) + ) + let draftPayload = DraftPayload.dummy( + cid: cid, + channelPayload: channelDetailPayload, + message: draftMessage, + quotedMessage: .dummy(cid: cid, draft: .dummy(cid: cid, message: draftMessage)), + parentId: parentMessageId, + parentMessage: parentMessage + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + let channel = try session.saveChannel(payload: channelPayload) + _ = try session.saveMessage(payload: parentMessage, channelDTO: channel, syncOwnReactions: false, cache: nil) + try session.saveDraftMessage(payload: draftPayload, for: cid, cache: nil) + } + + // THEN + let parentMessageDTO = try XCTUnwrap(database.viewContext.message(id: parentMessageId)) + let draftReply = try XCTUnwrap(parentMessageDTO.draftReply) + XCTAssertEqual(draftReply.text, "Draft Reply") + XCTAssertEqual(draftReply.type, MessageType.regular.rawValue) + XCTAssertTrue(draftReply.isDraft) + XCTAssertEqual(draftReply.parentMessageId, parentMessageId) + } + + func test_deleteDraftMessage_fromChannel() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + let draftPayload = DraftPayload( + cid: cid, + channelPayload: channelDetailPayload, + createdAt: .init(), + message: .init( + id: .unique, + text: "Draft message", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ), + quotedMessage: nil, + parentId: nil, + parentMessage: nil + ) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + try session.saveChannel(payload: channelPayload) + try session.saveDraftMessage(payload: draftPayload, for: cid, cache: nil) + } + + // WHEN + try database.writeSynchronously { session in + session.deleteDraftMessage(in: cid, threadId: nil) + } + + // THEN + let channelDTO = try XCTUnwrap(database.viewContext.channel(cid: cid)) + XCTAssertNil(channelDTO.draftMessage) + } + + func test_deleteDraftMessage_fromThread() throws { + // GIVEN + let cid: ChannelId = .unique + let currentUser: CurrentUserPayload = .dummy(userId: .unique, role: .user) + let channelDetailPayload = ChannelDetailPayload.dummy(cid: cid) + let channelPayload: ChannelPayload = .dummy(channel: channelDetailPayload) + let parentMessageId = MessageId.unique + let parentMessage = MessagePayload.dummy(messageId: parentMessageId) + + let draftPayload = DraftPayload( + cid: cid, + channelPayload: channelDetailPayload, + createdAt: .init(), + message: .init( + id: .unique, + text: "Draft reply", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ), + quotedMessage: nil, + parentId: parentMessageId, + parentMessage: parentMessage + ) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + let channel = try session.saveChannel(payload: channelPayload) + _ = try session.saveMessage(payload: parentMessage, channelDTO: channel, syncOwnReactions: false, cache: nil) + try session.saveDraftMessage(payload: draftPayload, for: cid, cache: nil) + } + + // WHEN + try database.writeSynchronously { session in + session.deleteDraftMessage(in: cid, threadId: parentMessageId) + } + + // THEN + let parentMessageDTO = try XCTUnwrap(database.viewContext.message(id: parentMessageId)) + XCTAssertNil(parentMessageDTO.draftReply) + } + + func test_saveMessage_savesAndLoadsDraftReply() throws { + // GIVEN + let cid: ChannelId = .unique + let draftMessagePayload = DraftMessagePayload.dummy( + text: "Draft message text" + ) + let messagePayload = MessagePayload.dummy( + draft: .dummy( + cid: cid, + message: draftMessagePayload + ) + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + try session.saveMessage(payload: messagePayload, for: cid, syncOwnReactions: false, cache: nil) + } + + // THEN + let message = try XCTUnwrap(database.viewContext.message(id: messagePayload.id)?.asModel()) + let draftReply = try XCTUnwrap(message.draftReply) + XCTAssertEqual(draftReply.id, draftMessagePayload.id) + XCTAssertEqual(draftReply.text, draftMessagePayload.text) + XCTAssertEqual(draftReply.extraData, draftMessagePayload.extraData) + } + + func test_saveMessage_whenDraftReplyIsNil_removesExistingDraft() throws { + // GIVEN + let cid: ChannelId = .unique + let draftMessagePayload = DraftMessagePayload.dummy( + text: "Draft message text" + ) + let messagePayload = MessagePayload.dummy( + draft: .dummy( + cid: cid, + message: draftMessagePayload + ) + ) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + try session.saveMessage(payload: messagePayload, for: cid, syncOwnReactions: false, cache: nil) + } + + // Verify draft exists + var message = try XCTUnwrap(database.viewContext.message(id: messagePayload.id)?.asModel()) + XCTAssertNotNil(message.draftReply) + + // WHEN + // Save channel without draft + let payloadWithoutDraft = MessagePayload.dummy( + messageId: messagePayload.id, + draft: nil + ) + + try database.writeSynchronously { session in + try session.saveMessage(payload: payloadWithoutDraft, for: cid, syncOwnReactions: false, cache: nil) + } + + // THEN + message = try XCTUnwrap(database.viewContext.message(id: messagePayload.id)?.asModel()) + XCTAssertNil(message.draftReply) + } + + func test_saveMessage_whenSkipsDraftUpdate_shouldNotSaveDraft() throws { + // GIVEN + let cid: ChannelId = .unique + let draftMessagePayload = DraftMessagePayload.dummy( + text: "Draft message text" + ) + let messagePayload = MessagePayload.dummy( + draft: .dummy( + cid: cid, + message: draftMessagePayload + ) + ) + + // WHEN + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + try session.saveMessage(payload: messagePayload, for: cid, syncOwnReactions: false, skipDraftUpdate: true, cache: nil) + } + + // THEN + let message = try XCTUnwrap(database.viewContext.message(id: messagePayload.id)?.asModel()) + XCTAssertNil(message.draftReply) + } } diff --git a/Tests/StreamChatTests/Database/DTOs/ThreadDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ThreadDTO_Tests.swift index 321c69f532c..007aee885fd 100644 --- a/Tests/StreamChatTests/Database/DTOs/ThreadDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ThreadDTO_Tests.swift @@ -53,6 +53,7 @@ final class ThreadDTO_Tests: XCTestCase { title: "Test", latestReplies: [.dummy(), .dummy()], read: [dummyThreadReadPayload()], + draft: nil, extraData: [:] ) @@ -91,6 +92,7 @@ final class ThreadDTO_Tests: XCTestCase { title: "Test", latestReplies: [.dummy(), .dummy()], read: [dummyThreadReadPayload()], + draft: nil, extraData: [:] ) @@ -139,4 +141,148 @@ final class ThreadDTO_Tests: XCTestCase { ["1", "2", "3"] ) } + + func test_saveThreadPayload_withDraftReply() throws { + // GIVEN + let draftMessagePayload = DraftMessagePayload( + id: .unique, + text: "Draft reply text", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ) + + let draftPayload = DraftPayload( + cid: .unique, + channelPayload: nil, + createdAt: .init(), + message: draftMessagePayload, + quotedMessage: nil, + parentId: nil, + parentMessage: nil + ) + + let payload = ThreadPayload( + parentMessageId: .unique, + parentMessage: .dummy(), + channel: .dummy(), + createdBy: .dummy(userId: .unique), + replyCount: 10, + participantCount: 10, + threadParticipants: [dummyThreadParticipantPayload()], + lastMessageAt: .unique, + createdAt: .unique, + updatedAt: .unique, + title: "Test", + latestReplies: [.dummy(), .dummy()], + read: [dummyThreadReadPayload()], + draft: draftPayload, + extraData: [:] + ) + + _ = try database.viewContext.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + + // WHEN + let dto = try database.viewContext.saveThread( + payload: payload, + cache: nil + ) + + // THEN + XCTAssertEqual(dto.parentMessage.draftReply?.text, "Draft reply text") + XCTAssertTrue(dto.parentMessage.draftReply?.isDraft ?? false) + XCTAssertEqual(dto.parentMessage.draftReply?.type, MessageType.regular.rawValue) + + // Verify the draft reply is included in the model + let model = try dto.asModel() + XCTAssertEqual(model.parentMessage.draftReply?.text, "Draft reply text") + } + + func test_saveThreadPayload_whenDraftIsNil_removesExistingDraft() throws { + // GIVEN + // First save a thread with a draft + let draftMessagePayload = DraftMessagePayload( + id: .unique, + text: "Draft reply text", + command: nil, + args: nil, + showReplyInChannel: false, + mentionedUsers: nil, + extraData: [:], + attachments: nil, + isSilent: false + ) + + let draftPayload = DraftPayload( + cid: .unique, + channelPayload: nil, + createdAt: .init(), + message: draftMessagePayload, + quotedMessage: nil, + parentId: nil, + parentMessage: nil + ) + + let payloadWithDraft = ThreadPayload( + parentMessageId: .unique, + parentMessage: .dummy(), + channel: .dummy(), + createdBy: .dummy(userId: .unique), + replyCount: 10, + participantCount: 10, + threadParticipants: [dummyThreadParticipantPayload()], + lastMessageAt: .unique, + createdAt: .unique, + updatedAt: .unique, + title: "Test", + latestReplies: [.dummy(), .dummy()], + read: [dummyThreadReadPayload()], + draft: draftPayload, + extraData: [:] + ) + + _ = try database.viewContext.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + + let dto = try database.viewContext.saveThread( + payload: payloadWithDraft, + cache: nil + ) + + // Verify draft exists + XCTAssertNotNil(dto.parentMessage.draftReply) + + // WHEN + // Save the same thread without a draft + let payloadWithoutDraft = ThreadPayload( + parentMessageId: payloadWithDraft.parentMessageId, + parentMessage: payloadWithDraft.parentMessage, + channel: payloadWithDraft.channel, + createdBy: payloadWithDraft.createdBy, + replyCount: payloadWithDraft.replyCount, + participantCount: payloadWithDraft.participantCount, + threadParticipants: payloadWithDraft.threadParticipants, + lastMessageAt: payloadWithDraft.lastMessageAt, + createdAt: payloadWithDraft.createdAt, + updatedAt: payloadWithDraft.updatedAt, + title: payloadWithDraft.title, + latestReplies: payloadWithDraft.latestReplies, + read: payloadWithDraft.read, + draft: nil, + extraData: payloadWithDraft.extraData + ) + + let updatedDto = try database.viewContext.saveThread( + payload: payloadWithoutDraft, + cache: nil + ) + + // THEN + XCTAssertNil(updatedDto.parentMessage.draftReply) + let model = try updatedDto.asModel() + XCTAssertNil(model.parentMessage.draftReply) + } } diff --git a/Tests/StreamChatTests/Database/FetchCache_Tests.swift b/Tests/StreamChatTests/Database/FetchCache_Tests.swift index d35f5957e0d..7bff87de786 100644 --- a/Tests/StreamChatTests/Database/FetchCache_Tests.swift +++ b/Tests/StreamChatTests/Database/FetchCache_Tests.swift @@ -56,6 +56,27 @@ final class FetchCache_Tests: XCTestCase { XCTAssertEqual(cache.cacheEntriesCount, 3) } + + func test_ignoringCacheIfContextHasInsertedOrDeletedObjectsOfThatType() async throws { + let database = DatabaseContainer(kind: .inMemory, chatClientConfig: .init(apiKeyString: .unique)) + let cid = ChannelId.unique + try database.createCurrentUser(id: .unique, name: "") + try database.createChannel(cid: cid, withMessages: true) + try await database.write { session in + // FetchCache caches the response + guard let firstPreviewMessage = session.preview(for: cid) else { throw ClientError.Unknown("Preview message missing") } + + // Insert a new message what is newer than the current preview message + let newMessagePayload = MessagePayload.dummy(createdAt: firstPreviewMessage.createdAt.bridgeDate.addingTimeInterval(1.0)) + try session.saveMessage(payload: newMessagePayload, for: cid, syncOwnReactions: false, cache: nil) + + guard let secondPreviewMessage = session.preview(for: cid) else { throw ClientError.Unknown("Preview message missing") } + XCTAssertEqual(newMessagePayload.id, secondPreviewMessage.id) + XCTAssertNotEqual(firstPreviewMessage.id, secondPreviewMessage.id) + } + } + + // MARK: - Test Data private var tenIds: [TestId] { (1...10).map { _ in TestId() } diff --git a/Tests/StreamChatTests/Query/DraftListQuery_Tests.swift b/Tests/StreamChatTests/Query/DraftListQuery_Tests.swift new file mode 100644 index 00000000000..efaf3d7e350 --- /dev/null +++ b/Tests/StreamChatTests/Query/DraftListQuery_Tests.swift @@ -0,0 +1,65 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class DraftListQuery_Tests: XCTestCase { + func test_defaultInitialization() { + let query = DraftListQuery() + + XCTAssertEqual(query.pagination.pageSize, 25) + XCTAssertEqual(query.pagination.offset, 0) + XCTAssertEqual(query.sorting.count, 1) + XCTAssertEqual(query.sorting[0].key, .createdAt) + XCTAssertFalse(query.sorting[0].isAscending) + } + + func test_customInitialization() { + let pagination = Pagination(pageSize: 10, offset: 5) + let sorting = [Sorting(key: .createdAt, isAscending: true)] + + let query = DraftListQuery(pagination: pagination, sorting: sorting) + + XCTAssertEqual(query.pagination.pageSize, 10) + XCTAssertEqual(query.pagination.offset, 5) + XCTAssertEqual(query.sorting.count, 1) + XCTAssertEqual(query.sorting[0].key, .createdAt) + XCTAssertTrue(query.sorting[0].isAscending) + } + + func test_encode() throws { + let query = DraftListQuery( + pagination: .init(pageSize: 20, offset: 10), + sorting: [.init(key: .createdAt, isAscending: true)] + ) + + let expectedData: [String: Any] = [ + "offset": 10, + "limit": 20, + "sort": [["field": "created_at", "direction": 1]] + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + AssertJSONEqual(expectedJSON, encodedJSON) + } + + func test_encode_withoutSorting() throws { + let query = DraftListQuery( + pagination: .init(pageSize: 20, offset: 10), + sorting: [] + ) + + let expectedData: [String: Any] = [ + "offset": 10, + "limit": 20 + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + AssertJSONEqual(expectedJSON, encodedJSON) + } +} diff --git a/Tests/StreamChatTests/Repositories/DraftMessagesRepository_Tests.swift b/Tests/StreamChatTests/Repositories/DraftMessagesRepository_Tests.swift new file mode 100644 index 00000000000..b3cd5f2e98a --- /dev/null +++ b/Tests/StreamChatTests/Repositories/DraftMessagesRepository_Tests.swift @@ -0,0 +1,252 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class DraftMessagesRepository_Tests: XCTestCase { + var apiClient: APIClient_Spy! + var database: DatabaseContainer! + var repository: DraftMessagesRepository! + + override func setUp() { + super.setUp() + + apiClient = APIClient_Spy() + database = DatabaseContainer_Spy() + repository = DraftMessagesRepository(database: database, apiClient: apiClient) + } + + override func tearDown() { + apiClient.cleanUp() + + apiClient = nil + repository = nil + database = nil + + super.tearDown() + } + + // MARK: - Load Drafts Tests + + func test_loadDrafts_whenSuccessful() throws { + let channelId = ChannelId.unique + try savePreExistingData(channelId: channelId, threadId: nil) + + let query = DraftListQuery() + let draftPayload1 = DraftPayload.dummy( + cid: channelId, + channelPayload: .dummy(cid: channelId), + message: .dummy(text: "Draft 1") + ) + let draftPayload2 = DraftPayload.dummy( + cid: channelId, + channelPayload: .dummy(cid: channelId), + message: .dummy(text: "Draft 2") + ) + + let payload = DraftListPayloadResponse(drafts: [draftPayload1, draftPayload2], next: "next_page") + + let completionCalled = expectation(description: "completion called") + repository.loadDrafts(query: query) { result in + XCTAssertNil(result.error) + XCTAssertEqual(result.value?.drafts.count, 2) + XCTAssertEqual(result.value?.next, "next_page") + completionCalled.fulfill() + } + + apiClient.test_simulateResponse(.success(payload)) + + wait(for: [completionCalled], timeout: defaultTimeout) + + let referenceEndpoint: Endpoint = .drafts(query: query) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + } + + func test_loadDrafts_whenFailure() { + let query = DraftListQuery() + let completionCalled = expectation(description: "completion called") + + repository.loadDrafts(query: query) { result in + XCTAssertNotNil(result.error) + completionCalled.fulfill() + } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + wait(for: [completionCalled], timeout: defaultTimeout) + } + + // MARK: - Update Draft Tests + + func test_updateDraft_whenSuccessful() throws { + let channelId = ChannelId.unique + let text = "Draft message" + let threadId: MessageId = .unique + try savePreExistingData(channelId: channelId, threadId: threadId) + + let draftPayload = DraftPayload.dummy( + cid: channelId, + channelPayload: .dummy(cid: channelId), + message: .dummy( + text: text, + showReplyInChannel: false, + isSilent: false + ), + parentId: threadId + ) + + let completionCalled = expectation(description: "completion called") + repository.updateDraft( + for: channelId, + threadId: threadId, + text: text, + isSilent: false, + showReplyInChannel: true, + command: nil, + arguments: nil, + attachments: [], + mentionedUserIds: ["leia"], + quotedMessageId: "quotedID", + extraData: [:] + ) { result in + XCTAssertNil(result.error) + completionCalled.fulfill() + } + + wait(for: [apiClient.request_expectation], timeout: defaultTimeout) + + apiClient.test_simulateResponse(.success(DraftPayloadResponse(draft: draftPayload))) + + wait(for: [completionCalled], timeout: defaultTimeout) + + let requestBodyMessage = try XCTUnwrap(apiClient.request_endpoint?.bodyAsDictionary()["message"] as? [String: Any]) + AssertDictionary(ignoringKeys: ["id"], requestBodyMessage, [ + "mentioned_users": ["leia"], + "parent_id": threadId, + "show_in_channel": 1, + "silent": 0, + "text": text + ]) + } + + func test_updateDraft_whenFailure() { + let channelId = ChannelId.unique + let text = "Draft message" + + let completionCalled = expectation(description: "completion called") + repository.updateDraft( + for: channelId, + threadId: nil, + text: text, + isSilent: false, + showReplyInChannel: false, + command: nil, + arguments: nil, + attachments: [], + mentionedUserIds: [], + quotedMessageId: nil, + extraData: [:] + ) { result in + XCTAssertNotNil(result.error) + completionCalled.fulfill() + } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + wait(for: [completionCalled], timeout: defaultTimeout) + } + + // MARK: - Get Draft Tests + + func test_getDraft_whenSuccessful() throws { + let channelId = ChannelId.unique + let threadId: MessageId = .unique + try savePreExistingData(channelId: channelId, threadId: threadId) + + let draftPayload = DraftPayload.dummy( + cid: channelId, + channelPayload: .dummy(cid: channelId), + parentId: threadId + ) + + let completionCalled = expectation(description: "completion called") + repository.getDraft(for: channelId, threadId: threadId) { result in + XCTAssertNil(result.error) + completionCalled.fulfill() + } + + apiClient.test_simulateResponse(.success(DraftPayloadResponse(draft: draftPayload))) + + wait(for: [completionCalled], timeout: defaultTimeout) + + let referenceEndpoint: Endpoint = .getDraftMessage(channelId: channelId, threadId: threadId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + } + + func test_getDraft_whenFailure() { + let channelId = ChannelId.unique + let threadId: MessageId? = nil + + let completionCalled = expectation(description: "completion called") + repository.getDraft(for: channelId, threadId: threadId) { result in + XCTAssertNotNil(result.error) + completionCalled.fulfill() + } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + wait(for: [completionCalled], timeout: defaultTimeout) + } + + // MARK: - Delete Draft Tests + + func test_deleteDraft_whenSuccessful() { + let channelId = ChannelId.unique + let threadId: MessageId? = .unique + + let completionCalled = expectation(description: "completion called") + repository.deleteDraft(for: channelId, threadId: threadId) { error in + XCTAssertNil(error) + completionCalled.fulfill() + } + + apiClient.test_simulateResponse(Result.success(.init())) + + wait(for: [completionCalled], timeout: defaultTimeout) + + let referenceEndpoint: Endpoint = .deleteDraftMessage(channelId: channelId, threadId: threadId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + } + + func test_deleteDraft_whenFailure() { + let channelId = ChannelId.unique + let threadId: MessageId? = nil + + let completionCalled = expectation(description: "completion called") + repository.deleteDraft(for: channelId, threadId: threadId) { error in + XCTAssertNotNil(error) + completionCalled.fulfill() + } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + wait(for: [completionCalled], timeout: defaultTimeout) + } + + private func savePreExistingData(channelId: ChannelId, threadId: MessageId?) throws { + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .user)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: channelId))) + if let threadId { + try session.saveMessage(payload: .dummy(messageId: threadId), for: channelId, syncOwnReactions: false, cache: nil) + } + } + } +} diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift index fb396e96d34..27b6c09cbb4 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift @@ -51,7 +51,8 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase { pendingMessages: nil, pinnedMessages: [], channelReads: [currentUserReadPayload], - isHidden: false + isHidden: false, + draft: nil ) try! database.writeSynchronously { session in diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/DraftUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/DraftUpdaterMiddleware_Tests.swift new file mode 100644 index 00000000000..bbcc4a63daa --- /dev/null +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/DraftUpdaterMiddleware_Tests.swift @@ -0,0 +1,167 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class DraftUpdaterMiddleware_Tests: XCTestCase { + var middleware: DraftUpdaterMiddleware! + var center: EventNotificationCenter_Mock! + var database: DatabaseContainer_Spy! + + override func setUp() { + super.setUp() + database = DatabaseContainer_Spy() + center = EventNotificationCenter_Mock(database: database) + middleware = DraftUpdaterMiddleware() + } + + override func tearDown() { + database = nil + AssertAsync.canBeReleased(&database) + super.tearDown() + } + + func test_forwardsOtherEvents() throws { + let event = TestEvent() + let forwardedEvent = middleware.handle(event: event, session: database.viewContext) + let unwrappedForwardedEvent = try XCTUnwrap(forwardedEvent as? TestEvent) + XCTAssertEqual(unwrappedForwardedEvent, event) + } + + // MARK: - Draft Updated Event Tests + + func test_draftUpdatedEvent_savesMessageToDatabase() throws { + let currentUserId = UserId.unique + let cid = ChannelId.unique + let draftId = MessageId.unique + + let eventPayload = EventPayload( + eventType: .draftUpdated, + cid: cid, + createdAt: .unique, + draft: .dummy( + cid: cid, + message: .dummy( + id: draftId, + text: "Test draft" + ) + ) + ) + + let event = try DraftUpdatedEventDTO(from: eventPayload) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .user)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + + // Verify draft doesn't exist before the event + XCTAssertNil(session.message(id: draftId)) + + _ = self.middleware.handle(event: event, session: session) + + // Verify draft was saved + let savedDraft = try XCTUnwrap(session.message(id: draftId)) + XCTAssertEqual(savedDraft.text, "Test draft") + XCTAssertEqual(savedDraft.cid, cid.rawValue) + } + } + + // MARK: - Draft Deleted Event Tests + + func test_draftDeletedEvent_deletesMessageFromDatabase() throws { + let currentUserId = UserId.unique + let cid = ChannelId.unique + let draftId = MessageId.unique + + let eventPayload = EventPayload( + eventType: .draftDeleted, + cid: cid, + createdAt: .unique, + draft: .dummy( + cid: cid, + message: .dummy( + id: draftId, + text: "Test draft" + ) + ) + ) + + let event = try DraftDeletedEventDTO(from: eventPayload) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .user)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + + // Save a draft message first + try session.saveDraftMessage( + payload: eventPayload.draft!, + for: cid, + cache: nil + ) + + // Verify draft exists before deletion + XCTAssertNotNil(session.message(id: draftId)) + + _ = self.middleware.handle(event: event, session: session) + + // Verify draft was deleted + XCTAssertNil(session.message(id: draftId)) + } + } + + func test_draftDeletedEvent_whenThreadIdExists_deletesMessageFromThread() throws { + let currentUserId = UserId.unique + let cid = ChannelId.unique + let draftId = MessageId.unique + let threadId = MessageId.unique + + let eventPayload = EventPayload( + eventType: .draftDeleted, + cid: cid, + createdAt: .unique, + draft: .dummy( + cid: cid, + message: .dummy( + id: draftId, + text: "Test draft" + ) + ) + ) + + let event = try DraftDeletedEventDTO(from: eventPayload) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .user)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + + // Save a thread and draft message + try session.saveThread( + payload: .dummy( + parentMessageId: threadId, + channel: .dummy(cid: cid), + latestReplies: [] + ), + cache: nil + ) + try session.saveDraftMessage( + payload: eventPayload.draft!, + for: cid, + cache: nil + ) + + // Verify draft exists before deletion + XCTAssertNotNil(session.message(id: draftId)) + + _ = self.middleware.handle(event: event, session: session) + + // Verify draft was deleted + XCTAssertNil(session.message(id: draftId)) + + // Verify thread still exists + XCTAssertNotNil(session.thread(parentMessageId: threadId, cache: nil)) + } + } +} diff --git a/Tests/StreamChatTests/WebSocketClient/Events/DraftEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/DraftEvents_Tests.swift new file mode 100644 index 00000000000..db7af746dba --- /dev/null +++ b/Tests/StreamChatTests/WebSocketClient/Events/DraftEvents_Tests.swift @@ -0,0 +1,79 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class DraftEvents_Tests: XCTestCase { + let draftId: String = "draft-123" + let cid = ChannelId(type: .messaging, id: "general") + let threadId: MessageId = "thread-123" + + var eventDecoder: EventDecoder! + + override func setUp() { + super.setUp() + eventDecoder = EventDecoder() + } + + override func tearDown() { + super.tearDown() + eventDecoder = nil + } + + // MARK: - DraftUpdatedEvent Tests + + func test_draftUpdatedEvent_decoding() throws { + let json = XCTestCase.mockData(fromJSONFile: "DraftUpdated") + let event = try eventDecoder.decode(from: json) as? DraftUpdatedEventDTO + XCTAssertEqual(event?.cid, cid) + XCTAssertEqual(event?.draft.message.id, draftId) + XCTAssertEqual(event?.draft.message.text, "Test draft message") + XCTAssertEqual(event?.createdAt.description, "2024-02-11 15:42:21 +0000") + } + + func test_draftUpdatedEvent_toDomainEvent() throws { + let json = XCTestCase.mockData(fromJSONFile: "DraftUpdated") + let event = try eventDecoder.decode(from: json) as? DraftUpdatedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Save required data + _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + _ = try session.saveMessage(payload: .dummy(messageId: draftId, authorUserId: "test-user"), for: cid, cache: nil) + + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? DraftUpdatedEvent) + XCTAssertEqual(domainEvent.cid, cid) + XCTAssertEqual(domainEvent.draftMessage.id, draftId) + } + + func test_draftUpdatedEvent_toDomainEvent_returnsNilWhenMissingData() throws { + let json = XCTestCase.mockData(fromJSONFile: "DraftUpdated") + let event = try eventDecoder.decode(from: json) as? DraftUpdatedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Don't save any data to test nil case + XCTAssertNil(event?.toDomainEvent(session: session)) + } + + // MARK: - DraftDeletedEvent Tests + + func test_draftDeletedEvent_decoding() throws { + let json = XCTestCase.mockData(fromJSONFile: "DraftDeleted") + let event = try eventDecoder.decode(from: json) as? DraftDeletedEventDTO + XCTAssertEqual(event?.cid, cid) + XCTAssertEqual(event?.draft.parentId, threadId) + XCTAssertEqual(event?.createdAt.description, "2024-02-11 15:42:21 +0000") + } + + func test_draftDeletedEvent_toDomainEvent() throws { + let json = XCTestCase.mockData(fromJSONFile: "DraftDeleted") + let event = try eventDecoder.decode(from: json) as? DraftDeletedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? DraftDeletedEvent) + XCTAssertEqual(domainEvent.cid, cid) + XCTAssertEqual(domainEvent.threadId, threadId) + } +} diff --git a/Tests/StreamChatTests/Workers/ThreadsRepository_Tests.swift b/Tests/StreamChatTests/Workers/ThreadsRepository_Tests.swift index 401eeeb7f67..6ea6ec95789 100644 --- a/Tests/StreamChatTests/Workers/ThreadsRepository_Tests.swift +++ b/Tests/StreamChatTests/Workers/ThreadsRepository_Tests.swift @@ -38,6 +38,7 @@ final class ThreadsRepository_Tests: XCTestCase { payload: .dummy(messageId: messageId), for: channelId, syncOwnReactions: false, + skipDraftUpdate: true, cache: nil ) } diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift index f76321d7a0c..6ba4e1efdf4 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift @@ -1523,6 +1523,98 @@ final class ChatChannelVC_Tests: XCTestCase { XCTAssertEqual(mockAudioQueuePlayerNextItemProvider.findNextItemWasCalledWithLookUpScope, .subsequentMessagesFromUser) XCTAssertEqual(actual, expected) } + + // MARK: - Draft Messages + + func test_channelWithDraftMessage_showsDraftInComposer() { + let draftMessage = DraftMessage.mock(text: "Draft message text") + + channelControllerMock.channel_mock = .mock( + cid: .unique, + draftMessage: draftMessage + ) + + vc.view.layoutIfNeeded() + + AssertSnapshot(vc, variants: [.defaultLight]) + } + + func test_channelWithDraftMessage_withQuotedMessage_showsDraftInComposer() { + let quotedMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Quoted message", + author: .mock(id: .unique) + ) + + let draftMessage = DraftMessage.mock( + text: "Draft with quote", + quotedMessage: quotedMessage + ) + + channelControllerMock.channel_mock = .mock( + cid: .unique, + draftMessage: draftMessage + ) + + vc.view.layoutIfNeeded() + + AssertSnapshot(vc, variants: [.defaultLight]) + } + + func test_channelWithDraftMessage_withCommand_showsDraftInComposer() { + let draftMessage = DraftMessage.mock( + text: "Hey", + command: "Giphy" + ) + + channelControllerMock.channel_mock = .mock( + cid: .unique, + draftMessage: draftMessage + ) + + vc.view.layoutIfNeeded() + + AssertSnapshot(vc, variants: [.defaultLight]) + } + + func test_channelWithDraftMessage_withUnknownCommand_showsDraftInComposer() { + let draftMessage = DraftMessage.mock( + text: "/test" + ) + + channelControllerMock.channel_mock = .mock( + cid: .unique, + draftMessage: draftMessage + ) + + vc.view.layoutIfNeeded() + + AssertSnapshot(vc, variants: [.defaultLight]) + } + + func test_channelWithDraftMessage_whenDraftIsUpdatedFromEvent_updatesDraftInComposer() { + let draftMessage = DraftMessage.mock(text: "Draft Message") + + let channel = ChatChannel.mock(cid: .unique, draftMessage: draftMessage) + channelControllerMock.channel_mock = channel + + vc.view.layoutIfNeeded() + + let updatedDraftMessage = DraftMessage.mock(text: "Updated draft") + let updatedChannel = ChatChannel.mock(cid: .unique, draftMessage: updatedDraftMessage) + channelControllerMock.channel_mock = updatedChannel + channelControllerMock.mockCid = channel.cid + let event = DraftUpdatedEvent( + cid: channel.cid, + channel: channel, + draftMessage: updatedDraftMessage, + createdAt: .unique + ) + vc.eventsController(vc.eventsController, didReceiveEvent: event) + + AssertSnapshot(vc, variants: [.defaultLight]) + } } private extension ChatChannelVC_Tests { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_showsDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_showsDraftInComposer.default-light.png new file mode 100644 index 00000000000..3063f79a23c Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_showsDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_whenDraftIsUpdatedFromEvent_updatesDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_whenDraftIsUpdatedFromEvent_updatesDraftInComposer.default-light.png new file mode 100644 index 00000000000..3eb7592add4 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_whenDraftIsUpdatedFromEvent_updatesDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withCommand_showsDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withCommand_showsDraftInComposer.default-light.png new file mode 100644 index 00000000000..6d01a658d6b Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withCommand_showsDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withQuotedMessage_showsDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withQuotedMessage_showsDraftInComposer.default-light.png new file mode 100644 index 00000000000..335f080c05b Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withQuotedMessage_showsDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withUnknownCommand_showsDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withUnknownCommand_showsDraftInComposer.default-light.png new file mode 100644 index 00000000000..f75224664cc Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_channelWithDraftMessage_withUnknownCommand_showsDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift index 02ef589e15e..623bf6c9ee0 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -2,7 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -import StreamChat +@testable import StreamChat @testable import StreamChatTestTools @testable import StreamChatUI import StreamSwiftTestHelpers @@ -841,6 +841,71 @@ final class ChatChannelListItemView_Tests: XCTestCase { AssertSnapshot(view, variants: [.defaultLight]) } + func test_appearance_draftPreview() throws { + let message: DraftMessage = .mock( + text: "Cool" + ) + + let view = channelItemView( + content: .init( + channel: channel(draftMessage: message), + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view) + } + + func test_appearance_draftPreview_withAttachments() throws { + let messageWithGiphy: ChatMessage = try mockGiphyMessage( + text: "Example message", + isSentByCurrentUser: true + ) + let viewWithGiphy = channelItemView( + content: .init( + channel: channel(draftMessage: DraftMessage(messageWithGiphy)), + currentUserId: currentUser.id + ) + ) + + let messageWithVoiceRecording = ChatMessage.mock( + attachments: [ + .dummy( + type: .voiceRecording, + payload: try JSONEncoder().encode(VoiceRecordingAttachmentPayload( + title: nil, + voiceRecordingRemoteURL: .unique(), + file: .init(type: .aac, size: 120, mimeType: nil), + duration: nil, + waveformData: nil, + extraData: nil + )) + ) + ] + ) + let viewWithVoiceRecording = channelItemView( + content: .init( + channel: channel(draftMessage: DraftMessage(messageWithVoiceRecording)), + currentUserId: currentUser.id + ) + ) + + let messageWithAudioRecording = try mockAudioMessage( + text: "Example message", + isSentByCurrentUser: true + ) + let viewWithAudioRecording = channelItemView( + content: .init( + channel: channel(draftMessage: DraftMessage(messageWithAudioRecording)), + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(viewWithGiphy, variants: .onlyUserInterfaceStyles, suffix: "giphy") + AssertSnapshot(viewWithVoiceRecording, variants: .onlyUserInterfaceStyles, suffix: "voiceRecording") + AssertSnapshot(viewWithAudioRecording, variants: .onlyUserInterfaceStyles, suffix: "audioRecording") + } + func test_appearanceCustomization_usingAppearance() { var appearance = Appearance() appearance.fonts.bodyBold = .italicSystemFont(ofSize: 20) @@ -937,7 +1002,7 @@ final class ChatChannelListItemView_Tests: XCTestCase { id: .unique, cid: .unique, text: .unique, - author: .mock(id: .unique), + author: .mock(id: .unique, name: "Luke"), attachments: [ .dummy( type: .voiceRecording, @@ -1261,7 +1326,7 @@ final class ChatChannelListItemView_Tests: XCTestCase { id: .unique, cid: .unique, text: .unique, - author: .mock(id: .unique), + author: .mock(id: .unique, name: "Luke"), attachments: [ .dummy( type: .voiceRecording, @@ -1646,6 +1711,7 @@ final class ChatChannelListItemView_Tests: XCTestCase { ) -> ChatChannelListItemView { let view = ChatChannelListItemView().withoutAutoresizingMaskConstraints view.components = components + view.components.isDraftMessagesEnabled = true view.appearance = appearance view.appearance.formatters.channelListMessageTimestamp = DefaultMessageTimestampFormatter() view.content = content @@ -1655,6 +1721,7 @@ final class ChatChannelListItemView_Tests: XCTestCase { private func channel( previewMessage: ChatMessage? = nil, + draftMessage: DraftMessage? = nil, readEventsEnabled: Bool = true, memberCount: Int = 0, membership: ChatChannelMember? = nil @@ -1667,7 +1734,8 @@ final class ChatChannelListItemView_Tests: XCTestCase { config: .mock(readEventsEnabled: readEventsEnabled), membership: membership, memberCount: memberCount, - previewMessage: previewMessage + previewMessage: previewMessage, + draftMessage: draftMessage ) } diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.default-light.png new file mode 100644 index 00000000000..0f727538c69 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.extraExtraExtraLarge-light.png new file mode 100644 index 00000000000..80b080a364e Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.rightToLeftLayout-default.png new file mode 100644 index 00000000000..775db2ceaee Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.small-dark.png new file mode 100644 index 00000000000..7f6dcb344d3 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-audioRecording.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-audioRecording.png new file mode 100644 index 00000000000..2637fc7e669 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-audioRecording.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-giphy.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-giphy.png new file mode 100644 index 00000000000..23d8bb61dfc Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-giphy.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-voiceRecording.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-voiceRecording.png new file mode 100644 index 00000000000..a0820489039 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-dark-voiceRecording.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-audioRecording.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-audioRecording.png new file mode 100644 index 00000000000..07d33e90e39 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-audioRecording.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-giphy.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-giphy.png new file mode 100644 index 00000000000..6c7684adcff Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-giphy.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-voiceRecording.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-voiceRecording.png new file mode 100644 index 00000000000..00067892895 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_draftPreview_withAttachments.default-light-voiceRecording.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift index 45ec8b60449..4709faecf7c 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift @@ -2,7 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -import StreamChat +@testable import StreamChat @testable import StreamChatTestTools @testable import StreamChatUI import StreamSwiftTestHelpers @@ -297,6 +297,69 @@ final class ChatThreadVC_Tests: XCTestCase { XCTAssertEqual(messageListVCMock?.jumpToMessageCalledWith?.animated, false) } + // MARK: - Draft Messages + + func test_threadWithDraftReply_showsDraftInComposer() { + let draftMessage = DraftMessage.mock( + text: "Draft reply text", + showReplyInChannel: true + ) + + messageControllerMock.simulateInitial( + message: .mock( + id: .unique, + cid: .unique, + text: "Parent message", + author: .mock(id: .unique), + draftReply: draftMessage + ), + replies: [], + state: .localDataFetched + ) + messageControllerMock.simulate(state: .remoteDataFetched) + + vc.view.layoutIfNeeded() + + AssertSnapshot(vc, variants: [.defaultLight]) + } + + func test_threadWithDraftReply_whenDraftIsUpdatedFromEvent_updatesDraftInComposer() { + let parentMessage = ChatMessage.mock( + id: messageControllerMock.messageId, + cid: .unique, + text: "Parent message", + author: .mock(id: .unique), + draftReply: .mock(text: "Draft Message") + ) + messageControllerMock.simulateInitial( + message: parentMessage, + replies: [], + state: .localDataFetched + ) + messageControllerMock.simulate(state: .remoteDataFetched) + + vc.view.layoutIfNeeded() + + let draftReply = DraftMessage.mock(threadId: parentMessage.id, text: "Updated Draft Message") + let updatedParentMessage = ChatMessage.mock( + id: messageControllerMock.messageId, + cid: .unique, + text: "Parent message", + author: .mock(id: .unique), + draftReply: draftReply + ) + messageControllerMock.message_mock = updatedParentMessage + let updateDraftEvent = DraftUpdatedEvent( + cid: updatedParentMessage.cid!, + channel: .mock(cid: updatedParentMessage.cid!), + draftMessage: draftReply, + createdAt: .unique + ) + vc.eventsController(vc.eventsController, didReceiveEvent: updateDraftEvent) + + AssertSnapshot(vc, variants: [.defaultLight]) + } + // MARK: - audioQueuePlayerNextAssetURL func test_audioQueuePlayerNextAssetURL_callsNextAvailableVoiceRecordingProvideWithExpectedInputAndReturnsValue() throws { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThread/__Snapshots__/ChatThreadVC_Tests/test_threadWithDraftReply_showsDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatThread/__Snapshots__/ChatThreadVC_Tests/test_threadWithDraftReply_showsDraftInComposer.default-light.png new file mode 100644 index 00000000000..5cc4c786c48 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatThread/__Snapshots__/ChatThreadVC_Tests/test_threadWithDraftReply_showsDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThread/__Snapshots__/ChatThreadVC_Tests/test_threadWithDraftReply_whenDraftIsUpdatedFromEvent_updatesDraftInComposer.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatThread/__Snapshots__/ChatThreadVC_Tests/test_threadWithDraftReply_whenDraftIsUpdatedFromEvent_updatesDraftInComposer.default-light.png new file mode 100644 index 00000000000..ed26ae11bc6 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatThread/__Snapshots__/ChatThreadVC_Tests/test_threadWithDraftReply_whenDraftIsUpdatedFromEvent_updatesDraftInComposer.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift index 241c744c5bd..96377798d15 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift @@ -138,6 +138,46 @@ final class ChatThreadListItemView_Tests: XCTestCase { AssertSnapshot(view, variants: [.defaultLight]) } + func test_defaultAppearance_whenDraftMessage() { + let currentUser = mockVader + let thread = mockThread + .with( + parentMessage: .mock(text: "Parent", draftReply: .init(.mock(text: "Test"))), + latestReplies: [ + .mock(text: "", author: mockYoda, attachments: [.dummy(type: .audio)]) + ] + ) + + let view = threadItemView( + content: .init( + thread: thread, + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view, variants: [.defaultLight]) + } + + func test_defaultAppearance_whenDraftMessage_withAttachment() { + let currentUser = mockVader + let thread = mockThread + .with( + parentMessage: .mock(text: "Parent", draftReply: .init(.mock(text: "", attachments: [.dummy(type: .image)]))), + latestReplies: [ + .mock(text: "", author: mockYoda, attachments: [.dummy(type: .audio)]) + ] + ) + + let view = threadItemView( + content: .init( + thread: thread, + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view, variants: [.defaultLight]) + } + private func threadItemView( content: ChatThreadListItemView.Content?, components: Components = .mock, @@ -145,6 +185,7 @@ final class ChatThreadListItemView_Tests: XCTestCase { ) -> ChatThreadListItemView { let view = ChatThreadListItemView().withoutAutoresizingMaskConstraints view.components = components + view.components.isDraftMessagesEnabled = true view.appearance = appearance view.appearance.formatters.threadListMessageTimestamp = DefaultMessageTimestampFormatter() view.content = content diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenAttachments.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenAttachments.default-light.png index 08b1d7c2cd6..fb96441bbd8 100644 Binary files a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenAttachments.default-light.png and b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenAttachments.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenDraftMessage.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenDraftMessage.default-light.png new file mode 100644 index 00000000000..e9c96a5c77e Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenDraftMessage.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenDraftMessage_withAttachment.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenDraftMessage_withAttachment.default-light.png new file mode 100644 index 00000000000..6bb86da13e4 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_defaultAppearance_whenDraftMessage_withAttachment.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift index d00e6fb0ede..7dafbe8d478 100644 --- a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift @@ -35,6 +35,8 @@ final class ComposerVC_Tests: XCTestCase { ) composerVC = .init() + composerVC.components = .mock + composerVC.components.isDraftMessagesEnabled = true composerVC.channelController = mockedChatChannelController } @@ -1106,6 +1108,79 @@ final class ComposerVC_Tests: XCTestCase { XCTAssertTrue(containerViewController.view.subviews.last === floatingView) } + + // MARK: - Draft Message Handling + + func test_viewWillDisappear_whenContentIsEmpty_andNoDraftExists() { + composerVC.content = .initial() + let mock = ChatChannelController_Mock.mock() + mock.channel_mock = .mock(cid: .unique, draftMessage: nil) + composerVC.channelController = mock + + composerVC.viewWillDisappear(false) + + XCTAssertEqual(mock.updateDraftMessage_callCount, 0) + XCTAssertEqual(mock.deleteDraftMessage_callCount, 0) + } + + func test_viewWillDisappear_whenHasContent_updatesDraft() { + composerVC.content.draftMessage(.mock(text: "New draft text")) + let mock = ChatChannelController_Mock.mock() + mock.channel_mock = .mock(cid: .unique) + composerVC.channelController = mock + + composerVC.viewWillDisappear(false) + + XCTAssertEqual(mock.updateDraftMessage_callCount, 1) + XCTAssertEqual(mock.updateDraftMessage_text, "New draft text") + } + + func test_viewWillDisappear_whenHasCommand_updatesDraft() { + composerVC.content.draftMessage(.mock(text: "", command: "giphy")) + let mock = ChatChannelController_Mock.mock() + mock.channel_mock = .mock(cid: .unique) + composerVC.channelController = mock + + composerVC.viewWillDisappear(false) + + XCTAssertEqual(mock.updateDraftMessage_callCount, 1) + } + + func test_textViewDidChange_whenInputIsEmpty_whenHasDraft_deletesDraft() { + composerVC.content.text = "Hey" + let mock = ChatChannelController_Mock.mock() + mock.channel_mock = .mock(cid: .unique, draftMessage: .mock()) + composerVC.channelController = mock + + composerVC.composerView.inputMessageView.textView.text = "" + composerVC.textViewDidChange(composerVC.composerView.inputMessageView.textView) + + XCTAssertEqual(mock.deleteDraftMessage_callCount, 1) + } + + func test_textViewDidChange_whenInputIsEmpty_whenNoDraft_doesNotCallDeleteDraft() { + composerVC.content.text = "Hey" + let mock = ChatChannelController_Mock.mock() + mock.channel_mock = .mock(cid: .unique, draftMessage: nil) + composerVC.channelController = mock + + composerVC.composerView.inputMessageView.textView.text = "" + composerVC.textViewDidChange(composerVC.composerView.inputMessageView.textView) + + XCTAssertEqual(mock.deleteDraftMessage_callCount, 0) + } + + func test_textViewDidChange_whenHasContent_whenHasDraft_doesNotCallDeleteDraft() { + composerVC.content.text = "Hey" + let mock = ChatChannelController_Mock.mock() + mock.channel_mock = .mock(cid: .unique, draftMessage: .mock()) + composerVC.channelController = mock + + composerVC.composerView.inputMessageView.textView.text = "Ahahah" + composerVC.textViewDidChange(composerVC.composerView.inputMessageView.textView) + + XCTAssertEqual(mock.deleteDraftMessage_callCount, 0) + } } // MARK: - Helpers diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d31ce23273c..ca0501d9d31 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -25,7 +25,7 @@ before_all do |lane| if is_ci setup_ci setup_git_config - xcversion(version: xcode_version) unless [:publish_release, :allure_launch, :allure_upload, :pod_lint, :sync_mock_server, :copyright].include?(lane) + xcversion(version: xcode_version) unless [:publish_release, :sonar_upload, :allure_launch, :allure_upload, :pod_lint, :sync_mock_server, :copyright].include?(lane) end end @@ -336,6 +336,8 @@ lane :test do |options| } scan(scan_options) + + slather end desc 'Starts Sinatra web server' @@ -597,7 +599,7 @@ desc 'Runs ui tests in Debug config' lane :test_ui do |options| next unless is_check_required(sources: sources_matrix[:ui], force_check: @force_check) - record_mode = !options[:record].to_s.empty? + record_mode = options[:record].to_s == 'true' remove_snapshots if record_mode update_testplan_on_ci(path: 'Tests/StreamChatUITests/StreamChatUITestPlan.xctestplan') @@ -630,7 +632,7 @@ lane :test_ui do |options| pr_create( title: '[CI] Snapshots', base_branch: current_branch, - head_branch: "#{current_branch}-snapshots" + head_branch: "#{current_branch}-snapshots-#{Time.now.to_i}" ) end end diff --git a/fastlane/Sonarfile b/fastlane/Sonarfile index e35d69086fd..c9081508136 100755 --- a/fastlane/Sonarfile +++ b/fastlane/Sonarfile @@ -4,46 +4,23 @@ desc 'Get code coverage report and run complexity analysis for Sonar' lane :sonar_upload do next unless is_check_required(sources: sources_matrix[:unit], force_check: @force_check) - sonar_args = '' version_number = get_version_number( xcodeproj: 'StreamChat.xcodeproj', target: 'StreamChat' )[/\d+\.\d+\.\d/] - unless Dir.glob('test_output/*.xcresult').empty? - slather - lizard( - source_folder: './Sources/', - language: 'swift', - export_type: 'xml', - report_file: 'reports/lizard.xml' - ) - sonar_args = '-Dsonar.coverageReportPaths="reports/sonarqube-generic-coverage.xml" ' \ - '-Dsonar.swift.lizard.report="reports/lizard.xml"' - end - Dir.chdir('..') do - sh("./fastlane/sonar/bin/sonar-scanner -Dsonar.projectVersion=#{version_number} -Dproject.settings=sonar-project.properties #{sonar_args}") + sh("./fastlane/sonar/bin/sonar-scanner " \ + "-Dsonar.projectVersion=#{version_number} " \ + "-Dproject.settings=sonar-project.properties " \ + "-Dsonar.coverageReportPaths='reports/sonarqube-generic-coverage.xml'") end - next if sonar_args.empty? || ENV['GITHUB_EVENT_NAME'] == 'pull_request' || current_branch !~ /main|develop/ + next if ENV['GITHUB_EVENT_NAME'] == 'pull_request' || current_branch !~ /main|develop/ slack_sonarcloud_metrics(version_number: version_number) end -desc 'Gets Sonar options' -private_lane :sonar_options do |options| - default_options = { sonar_login: ENV.fetch('SONAR_TOKEN', nil), sonar_runner_args: options[:sonar_args] } - - if ENV['GITHUB_EVENT_NAME'] == 'pull_request' - default_options.merge(pull_request_branch: ENV.fetch('GITHUB_HEAD_REF', nil), - pull_request_base: ENV.fetch('GITHUB_BASE_REF', nil), - pull_request_key: ENV.fetch('GITHUB_PR_NUM', nil)) - else - default_options.merge(branch_name: current_branch, project_version: options[:version_number]) - end -end - desc 'Creates a report in Slack with SonarCloud analysis details' private_lane :slack_sonarcloud_metrics do |options| project_key = 'GetStream_stream-chat-swift' diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index adc06a4fa26..00000000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -lizard