diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml index e08f24b1f..39333b1a1 100644 --- a/.github/workflows/cron-checks.yml +++ b/.github/workflows/cron-checks.yml @@ -16,33 +16,13 @@ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: - build-test-app-and-frameworks: - name: Build Test App and Frameworks - runs-on: macos-15 - steps: - - uses: actions/checkout@v4.1.1 - - uses: ./.github/actions/ruby-cache - - uses: ./.github/actions/xcode-cache - - name: Build - run: bundle exec fastlane build_test_app_and_frameworks - timeout-minutes: 60 - - uses: actions/upload-artifact@v4 - if: success() - with: - name: cache-derived-data - path: | - derived_data/Build/**/*.app - derived_data/Build/**/*.xctestrun - derived_data/Build/**/*.framework - test-e2e-debug: name: Test E2E UI (Debug) - needs: build-test-app-and-frameworks strategy: matrix: include: - - ios: 18.2 - xcode: 16.2 + - ios: 18.3 + xcode: 16.3 os: macos-15 device: "iPhone 16 Pro" setup_runtime: false @@ -64,10 +44,6 @@ jobs: XCODE_VERSION: ${{ matrix.xcode }} steps: - uses: actions/checkout@v4.1.1 - - uses: actions/download-artifact@v4 - with: - name: cache-derived-data - path: derived_data/Build/ - uses: ./.github/actions/bootstrap env: INSTALL_ALLURE: true @@ -80,10 +56,13 @@ jobs: with: version: ${{ matrix.ios }} device: ${{ matrix.device }} + - name: Build + run: bundle exec fastlane build_test_app_and_frameworks + timeout-minutes: 60 - name: Launch Allure TestOps run: bundle exec fastlane allure_launch cron:true - name: Run UI Tests (Debug) - run: bundle exec fastlane test_e2e_mock device:"${{ matrix.device }} (${{ matrix.ios }})" cron:true test_without_building:true + run: bundle exec fastlane test_e2e_mock device:"${{ matrix.device }} (${{ matrix.ios }})" timeout-minutes: 90 - name: Allure TestOps Upload if: success() || failure() @@ -112,6 +91,8 @@ jobs: strategy: matrix: include: + - xcode: 16.3 + os: macos-15 - xcode: 16.2 os: macos-15 - xcode: 16.1 @@ -169,7 +150,7 @@ jobs: slack: name: Slack Report runs-on: ubuntu-latest - needs: [test-e2e-debug, build-test-app-and-frameworks, build-apps, build-old-xcode, automated-code-review] + needs: [test-e2e-debug, build-apps, build-old-xcode, automated-code-review] if: failure() && github.event_name == 'schedule' steps: - uses: 8398a7/action-slack@v3 diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index 9348c37e0..fcd9b08d5 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -20,7 +20,7 @@ concurrency: env: HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI - IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.2)" + IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.3)" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_NUM: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index e17d113af..1008ab9d7 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -9,6 +9,15 @@ on: types: [published] workflow_dispatch: + inputs: + release: + description: 'Build configuration' + required: true + default: 'Debug' + type: choice + options: + - Debug + - Release env: HOMEBREW_NO_INSTALL_CLEANUP: 1 @@ -32,7 +41,7 @@ jobs: APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_NUM: ${{ github.event.number }} - run: bundle exec fastlane swiftui_testflight_build + run: bundle exec fastlane swiftui_testflight_build configuration:"${{ github.event.inputs.release }}" - uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f0f2b93..3ceeb8959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed +# [4.79.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.79.0) +_May 29, 2025_ + +### ✅ Added +- Add extra data to user display info [#819](https://github.com/GetStream/stream-chat-swiftui/pull/819) +- Make message spacing in message list configurable [#830](https://github.com/GetStream/stream-chat-swiftui/pull/830) +- Show time, relative date, weekday, or short date for last message in channel list and search [#833](https://github.com/GetStream/stream-chat-swiftui/pull/833) + - Set `ChannelListConfig.messageRelativeDateFormatEnabled` to true for enabling it +- Add `MessageViewModel` to `MessageContainerView` to make it easier to customise presentation logic [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815) +- Add `MessageListConfig.messaeDisplayOptions.showOriginalTranslatedButton` to enable showing original text in translated message [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815) +- Add `Utils.originalTranslationsStore` to keep track of messages that should show the original text [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815) +- Add `ViewFactory.makeGalleryHeaderView` for customising header view in `GalleryView` [#837](https://github.com/GetStream/stream-chat-swiftui/pull/837) +- Add `ViewFactory.makeVideoPlayerHeaderView` for customising header view in `VideoPlayerView` [#837](https://github.com/GetStream/stream-chat-swiftui/pull/837) +- Add `Utils.messagePreviewFormatter` for customising message previews in lists [#839](https://github.com/GetStream/stream-chat-swiftui/pull/839) +### 🐞 Fixed +- Fix swipe to reply enabled when quoting a message is disabled [#824](https://github.com/GetStream/stream-chat-swiftui/pull/824) +- Fix mark unread action not removed when read events are disabled [#823](https://github.com/GetStream/stream-chat-swiftui/pull/823) +- Fix user mentions not working when commands are disabled [#826](https://github.com/GetStream/stream-chat-swiftui/pull/826) +- Fix edit message action shown when user does not have permissions [#835](https://github.com/GetStream/stream-chat-swiftui/pull/835) +- Fix error indicator not shown when editing a message fails [#840](https://github.com/GetStream/stream-chat-swiftui/pull/840) +- Fix read indicator shown for failed edited messages [#840](https://github.com/GetStream/stream-chat-swiftui/pull/840) +- Fix "clock" pending icon not shown when message is syncing (pending to be edited) [#840](https://github.com/GetStream/stream-chat-swiftui/pull/840) + # [4.78.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.78.0) _April 24, 2025_ diff --git a/DemoAppSwiftUI/AppDelegate.swift b/DemoAppSwiftUI/AppDelegate.swift index e0c1e5bf0..7cf4a97aa 100644 --- a/DemoAppSwiftUI/AppDelegate.swift +++ b/DemoAppSwiftUI/AppDelegate.swift @@ -63,7 +63,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { #endif let utils = Utils( + channelListConfig: ChannelListConfig( + messageRelativeDateFormatEnabled: true + ), messageListConfig: MessageListConfig( + messageDisplayOptions: .init(showOriginalTranslatedButton: true), dateIndicatorPlacement: .messageList, userBlockingEnabled: true, bouncedMessagesAlertActionsEnabled: true, diff --git a/Gemfile b/Gemfile index 3953c7216..48cf60c63 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,6 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } gem 'danger', group: :danger_dependencies gem 'fastlane', group: :fastlane_dependencies -gem 'jazzy' gem 'json' gem 'rubocop', '1.38', group: :rubocop_dependencies gem 'sinatra', group: :sinatra_dependencies @@ -18,7 +17,6 @@ group :fastlane_dependencies do gem 'cocoapods' gem 'fastlane-plugin-lizard' gem 'plist' - gem 'xcode-install' gem 'xctest_list' end diff --git a/Gemfile.lock b/Gemfile.lock index 2a789d4ae..c8e634605 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,8 +5,9 @@ GEM base64 nkf rexml - activesupport (7.2.1) + activesupport (7.2.2.1) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) @@ -22,37 +23,40 @@ GEM httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) artifactory (3.0.17) - ast (2.4.2) + ast (2.4.3) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.987.0) - aws-sdk-core (3.209.1) + aws-eventstream (1.3.2) + aws-partitions (1.1091.0) + aws-sdk-core (3.222.2) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.94.0) - aws-sdk-core (~> 3, >= 3.207.0) + logger + aws-sdk-kms (1.99.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.167.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-s3 (1.183.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.0) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) - bigdecimal (3.1.8) + benchmark (0.4.0) + bigdecimal (3.1.9) claide (1.1.0) claide-plugins (0.9.2) cork nap open4 (~> 1.3) clamp (1.3.2) - cocoapods (1.15.2) + cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.15.2) + cocoapods-core (= 1.16.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -66,8 +70,8 @@ GEM molinillo (~> 0.8.0) nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.15.2) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -91,11 +95,12 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.2) cork (0.3.0) colored2 (~> 3.1) - danger (9.5.0) + danger (9.5.1) + base64 (~> 0.2) claide (~> 1.0) claide-plugins (>= 0.9.2) colored2 (~> 3.1) @@ -106,13 +111,14 @@ GEM kramdown (~> 2.3) kramdown-parser-gfm (~> 1.0) octokit (>= 4.0) + pstore (~> 0.1) terminal-table (>= 1, < 4) danger-commit_lint (0.0.7) danger-plugin-api (~> 1.0) danger-plugin-api (1.0.0) danger (> 2.0) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) @@ -143,8 +149,8 @@ GEM faraday-http-cache (2.5.1) faraday (>= 0.8) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) @@ -152,8 +158,8 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.3.1) - fastlane (2.224.0) + fastimage (2.4.0) + fastlane (2.227.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -169,6 +175,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -192,7 +199,7 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-plugin-create_xcframework (1.1.2) fastlane-plugin-lizard (1.3.3) @@ -200,10 +207,12 @@ GEM fastlane pry fastlane-plugin-sonarcloud_metric_kit (0.2.1) - fastlane-plugin-stream_actions (0.3.77) + fastlane-plugin-stream_actions (0.3.79) xctest_list (= 1.2.1) - fastlane-plugin-versioning (0.6.0) - ffi (1.17.0) + fastlane-plugin-versioning (0.7.1) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.2) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -226,12 +235,12 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.1) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) + google-cloud-errors (1.5.0) google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) @@ -247,91 +256,82 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.7) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) - i18n (1.14.6) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) concurrent-ruby (~> 1.0) - jazzy (0.15.2) - cocoapods (~> 1.5) - mustache (~> 1.1) - open4 (~> 1.3) - redcarpet (~> 3.4) - rexml (>= 3.2.7, < 4.0) - rouge (>= 2.0.6, < 5.0) - sassc (~> 2.1) - sqlite3 (~> 1.3) - xcinvoke (~> 0.3.0) jmespath (1.6.2) - json (2.7.2) - jwt (2.9.3) + json (2.11.3) + jwt (2.10.1) base64 - kramdown (2.4.0) - rexml + kramdown (2.5.1) + rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - liferaft (0.0.6) - logger (1.6.1) + logger (1.7.0) method_source (1.1.0) mini_magick (4.13.2) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.1) + minitest (5.25.5) molinillo (0.8.0) multi_json (1.15.0) multipart-post (2.4.1) - mustache (1.1.1) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) - nanaimo (0.3.0) + mutex_m (0.3.0) + nanaimo (0.4.0) nap (1.1.0) naturally (2.2.1) netrc (0.11.0) - nio4r (2.7.3) + nio4r (2.7.4) nkf (0.2.0) nokogiri (1.18.8) mini_portile2 (~> 2.8.2) racc (~> 1.4) - octokit (9.1.0) + octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) - optparse (0.5.0) + optparse (0.6.0) os (1.1.4) - parallel (1.26.3) - parser (3.3.5.0) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) racc - plist (3.7.1) - pry (0.14.2) + plist (3.7.2) + prism (1.4.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) + pstore (0.2.0) public_suffix (4.0.7) - puma (6.4.3) + puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.12) - rack-protection (4.1.0) + rack (3.1.14) + rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.0.0) + rack-session (2.1.1) + base64 (>= 0.1.0) rack (>= 3.0.0) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) rainbow (3.1.1) rake (13.2.1) - rchardet (1.8.0) - redcarpet (3.6.0) - regexp_parser (2.9.2) + rchardet (1.9.0) + regexp_parser (2.10.0) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.9) - rouge (2.0.7) + rexml (3.4.1) + rouge (3.28.0) rubocop (1.38.0) json (~> 2.3) parallel (~> 1.10) @@ -342,8 +342,9 @@ GEM rubocop-ast (>= 1.23.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.32.3) - parser (>= 3.3.1.0) + rubocop-ast (1.44.1) + parser (>= 3.3.7.2) + prism (~> 1.4) rubocop-performance (1.19.1) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) @@ -352,13 +353,11 @@ GEM ruby-macho (2.5.1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) - sassc (2.4.0) - ffi (~> 1.9) + rubyzip (2.4.1) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - securerandom (0.3.1) + securerandom (0.4.1) security (0.1.5) signet (0.19.0) addressable (~> 2.8) @@ -368,25 +367,24 @@ GEM simctl (1.6.10) CFPropertyList naturally - sinatra (4.1.0) + sinatra (4.1.1) logger (>= 1.6.0) mustermann (~> 3.0) rack (>= 3.0.0, < 4) - rack-protection (= 4.1.0) + rack-protection (= 4.1.1) rack-session (>= 2.0.0, < 3) tilt (~> 2.0) - slather (2.8.4) + slather (2.8.5) CFPropertyList (>= 2.2, < 4) activesupport clamp (~> 1.3) nokogiri (>= 1.14.3) - xcodeproj (~> 1.25) - sqlite3 (1.7.3) - mini_portile2 (~> 2.8.0) + xcodeproj (~> 1.27) + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - tilt (2.4.0) + tilt (2.6.0) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.2) @@ -398,22 +396,16 @@ GEM concurrent-ruby (~> 1.0) uber (0.1.0) unicode-display_width (2.6.0) - webrick (1.8.2) word_wrap (1.0.0) - xcinvoke (0.3.0) - liferaft (~> 0.0.6) - xcode-install (2.8.1) - claide (>= 0.9.1) - fastlane (>= 2.1.0, < 3.0.0) - xcodeproj (1.25.1) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) + nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.3.0) - rouge (~> 2.0.7) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) xctest_list (1.2.1) @@ -429,9 +421,8 @@ DEPENDENCIES fastlane-plugin-create_xcframework fastlane-plugin-lizard fastlane-plugin-sonarcloud_metric_kit - fastlane-plugin-stream_actions (= 0.3.77) + fastlane-plugin-stream_actions (= 0.3.79) fastlane-plugin-versioning - jazzy json plist puma @@ -441,7 +432,6 @@ DEPENDENCIES rubocop-require_tools sinatra slather - xcode-install xctest_list BUNDLED WITH diff --git a/Package.swift b/Package.swift index 85a2bacc4..304688be1 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.78.0"), + .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.79.0"), ], targets: [ .target( diff --git a/README.md b/README.md index 856c56be3..c2ed386c3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- StreamChatSwiftUI + StreamChatSwiftUI

## SwiftUI StreamChat SDK diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersView.swift index 71bbadc68..56fd27a38 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersView.swift @@ -65,7 +65,8 @@ struct AddUsersView: View { id: user.id, name: user.name ?? "", imageURL: user.imageURL, - size: CGSize(width: itemSize, height: itemSize) + size: CGSize(width: itemSize, height: itemSize), + extraData: user.extraData ) factory.makeMessageAvatarView(for: userDisplayInfo) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoHelperViews.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoHelperViews.swift index 16c0e7978..2da62cdca 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoHelperViews.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoHelperViews.swift @@ -264,7 +264,8 @@ struct ChatInfoDirectChannelView: View { id: participant?.chatUser.id ?? "", name: participant?.chatUser.name ?? "", imageURL: participant?.chatUser.imageURL, - size: .init(width: 64, height: 64) + size: .init(width: 64, height: 64), + extraData: participant?.chatUser.extraData ?? [:] ) factory.makeMessageAvatarView(for: displayInfo) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift index 9b8e9e150..2e2f5519c 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift @@ -32,7 +32,8 @@ struct ChatInfoParticipantsView: View { let displayInfo = UserDisplayInfo( id: participant.chatUser.id, name: participant.chatUser.name ?? "", - imageURL: participant.chatUser.imageURL + imageURL: participant.chatUser.imageURL, + extraData: participant.chatUser.extraData ) factory.makeMessageAvatarView(for: displayInfo) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift index 9cc09d28e..30f6e993a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift @@ -83,7 +83,8 @@ public struct MediaAttachmentsView: View { id: mediaItem.message.author.id, name: mediaItem.message.author.name ?? "", imageURL: mediaItem.message.author.imageURL, - size: .init(width: 24, height: 24) + size: .init(width: 24, height: 24), + extraData: mediaItem.message.author.extraData ) ) .overlay( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift index a40b08780..d3af0ccba 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift @@ -71,7 +71,8 @@ struct PinnedMessageView: View { id: message.author.id, name: message.author.name ?? "", imageURL: message.author.imageURL, - size: avatarSize + size: avatarSize, + extraData: message.author.extraData ) ) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index b6e5c90bc..9b1329196 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -130,7 +130,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } @Published public private(set) var channel: ChatChannel? - + public var isMessageThread: Bool { messageController != nil } @@ -158,7 +158,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { channelDataSource.delegate = self messages = channelDataSource.messages channel = channelController.channel - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in if let scrollToMessage, let parentMessageId = scrollToMessage.parentMessageId, messageController == nil { let message = channelController.dataStore.message(id: parentMessageId) @@ -221,7 +221,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { checkHeaderType() checkUnreadCount() } - + @objc private func selectedMessageThread(notification: Notification) { if let message = notification.userInfo?[MessageRepliesConstants.selectedMessage] as? ChatMessage { @@ -490,7 +490,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { messageActionExecuted(.init(message: message, identifier: "edit")) } - public func messageActionExecuted(_ messageActionInfo: MessageActionInfo) { + open func messageActionExecuted(_ messageActionInfo: MessageActionInfo) { utils.messageActionsResolver.resolveMessageAction( info: messageActionInfo, viewModel: self @@ -498,6 +498,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } @objc public func onViewAppear() { + utils.originalTranslationsStore.clear() setActive() messages = channelDataSource.messages firstUnreadMessageId = channelDataSource.firstUnreadMessageId diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift index ce5416521..f2b5e67f8 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift @@ -453,6 +453,13 @@ open class MessageComposerViewModel: ObservableObject { } public var showCommandsOverlay: Bool { + // Mentions are really not commands, but at the moment this flag controls + // if the mentions are displayed or not, so if the command is related to mentions + // then we need to ignore if commands are available or not. + let isMentionsSuggestions = composerCommand?.id == "mentions" + if isMentionsSuggestions { + return true + } let commandAvailable = composerCommand != nil let configuredCommandsAvailable = channelController.channel?.config.commands.count ?? 0 > 0 return commandAvailable && configuredCommandsAvailable diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift index 6681b401d..7285fe704 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/GalleryView.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// View used for displaying image attachments in a gallery. -public struct GalleryView: View { +public struct GalleryView: View { @Environment(\.presentationMode) var presentationMode @@ -15,6 +15,7 @@ public struct GalleryView: View { @Injected(\.fonts) private var fonts @Injected(\.images) private var images + private let viewFactory: Factory var mediaAttachments: [MediaAttachment] var author: ChatUser @Binding var isShown: Bool @@ -23,6 +24,7 @@ public struct GalleryView: View { @State private var gridShown = false public init( + viewFactory: Factory = DefaultViewFactory.shared, imageAttachments: [ChatMessageImageAttachment], author: ChatUser, isShown: Binding, @@ -30,6 +32,7 @@ public struct GalleryView: View { ) { let mediaAttachments = imageAttachments.map { MediaAttachment(from: $0) } self.init( + viewFactory: viewFactory, mediaAttachments: mediaAttachments, author: author, isShown: isShown, @@ -38,11 +41,13 @@ public struct GalleryView: View { } public init( + viewFactory: Factory = DefaultViewFactory.shared, mediaAttachments: [MediaAttachment], author: ChatUser, isShown: Binding, selected: Int ) { + self.viewFactory = viewFactory self.mediaAttachments = mediaAttachments self.author = author _isShown = isShown @@ -52,10 +57,10 @@ public struct GalleryView: View { public var body: some View { GeometryReader { reader in VStack { - GalleryHeaderView( + viewFactory.makeGalleryHeaderView( title: author.name ?? "", subtitle: author.onlineText, - isShown: $isShown + shown: $isShown ) TabView(selection: $selected) { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/VideoPlayerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/VideoPlayerView.swift index 2a3255c33..2e6d35da1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/VideoPlayerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/VideoPlayerView.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// View used for displaying videos. -public struct VideoPlayerView: View { +public struct VideoPlayerView: View { @Environment(\.presentationMode) var presentationMode @Injected(\.fonts) private var fonts @@ -18,6 +18,7 @@ public struct VideoPlayerView: View { utils.fileCDN } + private let viewFactory: Factory let attachment: ChatMessageVideoAttachment let author: ChatUser @Binding var isShown: Bool @@ -26,10 +27,12 @@ public struct VideoPlayerView: View { @State private var error: Error? public init( + viewFactory: Factory = DefaultViewFactory.shared, attachment: ChatMessageVideoAttachment, author: ChatUser, isShown: Binding ) { + self.viewFactory = viewFactory self.attachment = attachment self.author = author _isShown = isShown @@ -37,10 +40,10 @@ public struct VideoPlayerView: View { public var body: some View { VStack { - GalleryHeaderView( + viewFactory.makeVideoPlayerHeaderView( title: author.name ?? "", subtitle: author.onlineText, - isShown: $isShown + shown: $isShown ) if let avPlayer { VideoPlayer(player: avPlayer) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index eeecbc7f7..b86521340 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -7,6 +7,7 @@ import StreamChat import SwiftUI public struct MessageContainerView: View { + @StateObject var messageViewModel: MessageViewModel @Environment(\.channelTranslationLanguage) var translationLanguage @Injected(\.fonts) private var fonts @@ -33,10 +34,12 @@ public struct MessageContainerView: View { @GestureState private var offset: CGSize = .zero private let replyThreshold: CGFloat = 60 - private let paddingValue: CGFloat = 8 - - var isSwipeToReplyPossible: Bool { - message.isInteractionEnabled && channel.config.repliesEnabled + private var paddingValue: CGFloat { + utils.messageListConfig.messagePaddings.singleBottom + } + + private var groupMessageInterItemSpacing: CGFloat { + utils.messageListConfig.messagePaddings.groupBottom } public init( @@ -49,7 +52,8 @@ public struct MessageContainerView: View { isLast: Bool, scrolledId: Binding, quotedMessage: Binding, - onLongPress: @escaping (MessageDisplayInfo) -> Void + onLongPress: @escaping (MessageDisplayInfo) -> Void, + viewModel: MessageViewModel? = nil ) { self.factory = factory self.channel = channel @@ -59,21 +63,27 @@ public struct MessageContainerView: View { self.isInThread = isInThread self.isLast = isLast self.onLongPress = onLongPress + _messageViewModel = .init( + wrappedValue: viewModel ?? MessageViewModel( + message: message, + channel: channel + ) + ) _scrolledId = scrolledId _quotedMessage = quotedMessage } public var body: some View { HStack(alignment: .bottom) { - if message.type == .system || (message.type == .error && message.isBounced == false) { + if messageViewModel.systemMessageShown { factory.makeSystemMessageView(message: message) } else { - if message.isRightAligned { + if messageViewModel.isRightAligned { MessageSpacer(spacerWidth: spacerWidth) } else { - if messageListConfig.messageDisplayOptions.showAvatars(for: channel) { + if let userDisplayInfo = messageViewModel.userDisplayInfo { factory.makeMessageAvatarView( - for: message.authorDisplayInfo + for: userDisplayInfo ) .opacity(showsAllInfo ? 1 : 0) .offset(y: bottomReactionsShown ? offsetYAvatar : 0) @@ -81,8 +91,8 @@ public struct MessageContainerView: View { } } - VStack(alignment: message.isRightAligned ? .trailing : .leading) { - if isMessagePinned { + VStack(alignment: messageViewModel.isRightAligned ? .trailing : .leading) { + if messageViewModel.isPinned { MessagePinDetailsView( message: message, reactionsShown: topReactionsShown @@ -109,9 +119,7 @@ public struct MessageContainerView: View { } ) : nil - - ((message.localState == .sendingFailed || message.isBounced) && !message.text.isEmpty) ? - SendFailureIndicator() : nil + messageViewModel.failureIndicatorShown ? SendFailureIndicator() : nil } ) .background( @@ -137,7 +145,7 @@ public struct MessageContainerView: View { coordinateSpace: .local ) .updating($offset) { (value, gestureState, _) in - guard isSwipeToReplyPossible else { + guard messageViewModel.isSwipeToQuoteReplyPossible else { return } // Using updating since onEnded is not called if the gesture is canceled. @@ -229,12 +237,12 @@ public struct MessageContainerView: View { } } - if message.textContent(for: translationLanguage) != nil, - let localizedName = translationLanguage?.localizedName { - Text(L10n.Message.translatedTo(localizedName)) - .font(fonts.footnote) - .foregroundColor(Color(colors.subtitleText)) + if messageViewModel.translatedText != nil { + factory.makeMessageTranslationFooterView( + messageViewModel: messageViewModel + ) } + if showsAllInfo && !message.isDeleted { if message.isSentByCurrentUser && channel.config.readEventsEnabled { HStack(spacing: 4) { @@ -243,15 +251,13 @@ public struct MessageContainerView: View { message: message ) - if messageListConfig.messageDisplayOptions.showMessageDate { + if messageViewModel.messageDateShown { factory.makeMessageDateView(for: message) } } - } else if !message.isRightAligned - && channel.memberCount > 2 - && messageListConfig.messageDisplayOptions.showAuthorName { + } else if messageViewModel.authorAndDateShown { factory.makeMessageAuthorAndDateView(for: message) - } else if messageListConfig.messageDisplayOptions.showMessageDate { + } else if messageViewModel.messageDateShown { factory.makeMessageDateView(for: message) } } @@ -265,20 +271,21 @@ public struct MessageContainerView: View { : nil ) - if !message.isRightAligned { + if !messageViewModel.isRightAligned { MessageSpacer(spacerWidth: spacerWidth) } } } .padding( .top, - topReactionsShown && !isMessagePinned ? messageListConfig.messageDisplayOptions.reactionsTopPadding(message) : 0 + topReactionsShown && !messageViewModel.isPinned ? messageListConfig.messageDisplayOptions + .reactionsTopPadding(message) : 0 ) .padding(.horizontal, messageListConfig.messagePaddings.horizontal) - .padding(.bottom, showsAllInfo || isMessagePinned ? paddingValue : 2) + .padding(.bottom, showsAllInfo || messageViewModel.isPinned ? paddingValue : groupMessageInterItemSpacing) .padding(.top, isLast ? paddingValue : 0) - .background(isMessagePinned ? Color(colors.pinnedBackground) : nil) - .padding(.bottom, isMessagePinned ? paddingValue / 2 : 0) + .background(messageViewModel.isPinned ? Color(colors.pinnedBackground) : nil) + .padding(.bottom, messageViewModel.isPinned ? paddingValue / 2 : 0) .transition( message.isSentByCurrentUser ? messageListConfig.messageDisplayOptions.currentUserMessageTransition : @@ -286,22 +293,23 @@ public struct MessageContainerView: View { ) .accessibilityElement(children: .contain) .accessibilityIdentifier("MessageContainerView") + // This is needed for the LinkDetectionTextView to work properly. + // TODO: This should be refactored on v5 so the TextView does not depend directly on the view model. + .environment(\.messageViewModel, messageViewModel) + .onChange(of: message, perform: { message in messageViewModel.message = message }) + .onChange(of: channel) { channel in messageViewModel.channel = channel } } private var maximumHorizontalSwipeDisplacement: CGFloat { replyThreshold + 30 } - private var isMessagePinned: Bool { - message.pinDetails != nil - } - private var contentWidth: CGFloat { let padding: CGFloat = messageListConfig.messagePaddings.horizontal let minimumWidth: CGFloat = 240 let available = max(minimumWidth, (width ?? 0) - spacerWidth) - 2 * padding let avatarSize: CGFloat = CGSize.messageAvatarSize.width + padding - let totalWidth = message.isRightAligned ? available : available - avatarSize + let totalWidth = messageViewModel.isRightAligned ? available : available - avatarSize return totalWidth } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift index e9fa2fe23..3604093e1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift @@ -110,13 +110,19 @@ public struct MessagePaddings { /// Horizontal padding for messages. public let horizontal: CGFloat public let quotedViewPadding: CGFloat + public let singleBottom: CGFloat + public let groupBottom: CGFloat public init( horizontal: CGFloat = 8, - quotedViewPadding: CGFloat = 8 + quotedViewPadding: CGFloat = 8, + singleBottom: CGFloat = 8, + groupBottom: CGFloat = 2 ) { self.horizontal = horizontal self.quotedViewPadding = quotedViewPadding + self.singleBottom = singleBottom + self.groupBottom = groupBottom } } @@ -142,6 +148,7 @@ public struct MessageDisplayOptions { public let otherUserMessageTransition: AnyTransition public let shouldAnimateReactions: Bool public let reactionsPlacement: ReactionsPlacement + public let showOriginalTranslatedButton: Bool public let messageLinkDisplayResolver: (ChatMessage) -> [NSAttributedString.Key: Any] public let spacerWidth: (CGFloat) -> CGFloat public let reactionsTopPadding: (ChatMessage) -> CGFloat @@ -161,6 +168,7 @@ public struct MessageDisplayOptions { otherUserMessageTransition: AnyTransition = .identity, shouldAnimateReactions: Bool = true, reactionsPlacement: ReactionsPlacement = .top, + showOriginalTranslatedButton: Bool = false, messageLinkDisplayResolver: @escaping (ChatMessage) -> [NSAttributedString.Key: Any] = MessageDisplayOptions .defaultLinkDisplay, spacerWidth: @escaping (CGFloat) -> CGFloat = MessageDisplayOptions.defaultSpacerWidth, @@ -184,6 +192,7 @@ public struct MessageDisplayOptions { self.newMessagesSeparatorSize = newMessagesSeparatorSize self.dateSeparator = dateSeparator self.reactionsPlacement = reactionsPlacement + self.showOriginalTranslatedButton = showOriginalTranslatedButton } public func showAvatars(for channel: ChatChannel) -> Bool { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift index 03f6a8d99..79f3907ce 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift @@ -112,7 +112,7 @@ public struct MessageReadIndicatorView: View { public var body: some View { HStack(spacing: 2) { - if showReadCount && !readUsers.isEmpty { + if showReadCount && shouldShowReads { Text("\(readUsers.count)") .font(fonts.footnoteBold) .foregroundColor(colors.tintColor) @@ -122,9 +122,9 @@ public struct MessageReadIndicatorView: View { uiImage: image ) .customizable() - .foregroundColor(!readUsers.isEmpty ? colors.tintColor : Color(colors.textLowEmphasis)) + .foregroundColor(shouldShowReads ? colors.tintColor : Color(colors.textLowEmphasis)) .frame(height: 16) - .opacity(localState == .sendingFailed ? 0.0 : 1) + .opacity(localState == .sendingFailed || localState == .syncingFailed ? 0.0 : 1) .accessibilityLabel( Text( readUsers.isEmpty ? L10n.Message.ReadStatus.seenByNoOne : L10n.Message.ReadStatus.seenByOthers @@ -137,12 +137,15 @@ public struct MessageReadIndicatorView: View { } private var image: UIImage { - !readUsers.isEmpty ? images.readByAll : (isMessageSending ? images.messageReceiptSending : images.messageSent) + shouldShowReads ? images.readByAll : (isMessageSending ? images.messageReceiptSending : images.messageSent) } private var isMessageSending: Bool { - localState == .sending - || localState == .pendingSend + localState == .sending || localState == .pendingSend || localState == .syncing + } + + private var shouldShowReads: Bool { + !readUsers.isEmpty && !isMessageSending } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index e30209027..8e52f5313 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -6,7 +6,6 @@ import StreamChat import SwiftUI public struct MessageListView: View, KeyboardReadable { - @Injected(\.utils) private var utils @Injected(\.chatClient) private var chatClient @Injected(\.colors) private var colors @@ -603,6 +602,10 @@ private struct ChannelTranslationLanguageKey: EnvironmentKey { static let defaultValue: TranslationLanguage? = nil } +private struct MessageViewModelKey: EnvironmentKey { + static let defaultValue: MessageViewModel? = nil +} + extension EnvironmentValues { var channelTranslationLanguage: TranslationLanguage? { get { @@ -612,4 +615,13 @@ extension EnvironmentValues { self[ChannelTranslationLanguageKey.self] = newValue } } + + var messageViewModel: MessageViewModel? { + get { + self[MessageViewModelKey.self] + } + set { + self[MessageViewModelKey.self] = newValue + } + } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageTranslationFooterView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageTranslationFooterView.swift new file mode 100644 index 000000000..9ee2cf15f --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageTranslationFooterView.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +public struct MessageTranslationFooterView: View { + @ObservedObject var messageViewModel: MessageViewModel + + @Injected(\.fonts) private var fonts + @Injected(\.colors) private var colors + @Injected(\.utils) private var utils + + public init( + messageViewModel: MessageViewModel + ) { + self.messageViewModel = messageViewModel + } + + public var body: some View { + if utils.messageListConfig.messageDisplayOptions.showOriginalTranslatedButton { + HStack(spacing: 4) { + if !messageViewModel.originalTextShown { + translatedToView + separatorView + } + showOriginalButton + } + } else { + translatedToView + } + } + + private var translatedToView: some View { + Text(messageViewModel.translatedLanguageText ?? "") + .font(fonts.footnote) + .foregroundColor(Color(colors.subtitleText)) + } + + private var separatorView: some View { + Text("•") + .font(fonts.footnote) + .foregroundColor(Color(colors.subtitleText)) + } + + private var showOriginalButton: some View { + Button( + action: { + if messageViewModel.originalTextShown { + messageViewModel.hideOriginalText() + } else { + messageViewModel.showOriginalText() + } + }, + label: { + Text(messageViewModel.originalTranslationButtonText) + .font(fonts.footnote) + .foregroundColor(Color(colors.subtitleText)) + } + ) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift index 8883c4867..30dbad471 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift @@ -252,53 +252,45 @@ struct StreamTextView: View { public struct LinkDetectionTextView: View { @Environment(\.layoutDirection) var layoutDirection @Environment(\.channelTranslationLanguage) var translationLanguage - + @Environment(\.messageViewModel) var messageViewModel + @Injected(\.colors) var colors @Injected(\.fonts) var fonts @Injected(\.utils) var utils var message: ChatMessage - - var text: LocalizedStringKey { - LocalizedStringKey(message.adjustedText) - } - - @State var displayedText: AttributedString? - + + // The translations store is used to detect changes so the textContent is re-rendered. + // The @Environment(\.messageViewModel) is not reactive like @EnvironmentObject. + // TODO: On v5 the TextView should be refactored and not depend directly on the view model. + @ObservedObject var originalTranslationsStore = InjectedValues[\.utils].originalTranslationsStore + + @State var text: AttributedString? @State var linkDetector = TextLinkDetector() - @State var tintColor = InjectedValues[\.colors].tintColor - public init(message: ChatMessage) { + public init( + message: ChatMessage + ) { self.message = message } public var body: some View { Group { - if let displayedText { - Text(displayedText) - } else { - Text(message.adjustedText) - } + Text(text ?? displayText) } .foregroundColor(textColor(for: message)) .font(fonts.body) .tint(tintColor) - .onAppear { - displayedText = attributedString(for: message) + .onChange(of: message) { message in + messageViewModel?.message = message + text = displayText } - .onChange(of: message, perform: { updated in - displayedText = attributedString(for: updated) - }) } - private func attributedString(for message: ChatMessage) -> AttributedString { - var text = message.adjustedText - - // Translation - if let translatedText = message.textContent(for: translationLanguage) { - text = translatedText - } + var displayText: AttributedString { + let text = messageViewModel?.textContent ?? message.text + // Markdown let attributes = AttributeContainer() .foregroundColor(textColor(for: message)) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageViewModel.swift new file mode 100644 index 000000000..d9c22fcd3 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageViewModel.swift @@ -0,0 +1,151 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import StreamChat + +/// The view model that contains the logic for displaying a message in the message list view. +open class MessageViewModel: ObservableObject { + @Injected(\.utils) private var utils + @Injected(\.chatClient) private var chatClient + + @Published public internal(set) var message: ChatMessage + @Published public internal(set) var channel: ChatChannel? + private var cancellables = Set() + + public init( + message: ChatMessage, + channel: ChatChannel? + ) { + self.message = message + self.channel = channel + utils.originalTranslationsStore.$originalTextMessageIds.sink( + receiveValue: { [weak self] _ in + self?.objectWillChange.send() + } + ) + .store(in: &cancellables) + } + + // MARK: - Inputs + + /// Show the original text of the message. + public func showOriginalText() { + utils.originalTranslationsStore.showOriginalText(for: message.id) + } + + /// Hide the original text of the message to show the translated text. + public func hideOriginalText() { + utils.originalTranslationsStore.hideOriginalText(for: message.id) + } + + // MARK: - Outputs + + public var originalTextShown: Bool { + utils.originalTranslationsStore.shouldShowOriginalText(for: message.id) + } + + public var systemMessageShown: Bool { + message.type == .system || (message.type == .error && message.isBounced == false) + } + + public var reactionsShown: Bool { + !message.reactionScores.isEmpty + && !message.isDeleted + && channel?.config.reactionsEnabled == true + } + + public var failureIndicatorShown: Bool { + message.isLastActionFailed && !message.text.isEmpty + } + + open var authorAndDateShown: Bool { + guard let channel = channel else { return false } + return !message.isRightAligned + && channel.memberCount > 2 + && messageListConfig.messageDisplayOptions.showAuthorName + } + + open var messageDateShown: Bool { + messageListConfig.messageDisplayOptions.showMessageDate + } + + public var isPinned: Bool { + message.isPinned + } + + public var isRightAligned: Bool { + message.isRightAligned + } + + public var userDisplayInfo: UserDisplayInfo? { + guard let channel = channel else { return nil } + guard messageListConfig.messageDisplayOptions.showAvatars(for: channel) else { return nil } + return message.authorDisplayInfo + } + + open var isSwipeToQuoteReplyPossible: Bool { + message.isInteractionEnabled && channel?.config.repliesEnabled == true + } + + open var textContent: String { + if !originalTextShown, let translatedText = translatedText { + return translatedText + } + + return message.adjustedText + } + + public var translatedText: String? { + if let language = channel?.membership?.language, + let translatedText = message.textContent(for: language) { + return translatedText + } + + return nil + } + + public var translatedLanguageText: String? { + guard let localizedName = channel?.membership?.language?.localizedName else { + return nil + } + + return L10n.Message.translatedTo(localizedName) + } + + public var originalTranslationButtonText: String { + originalTextShown ? L10n.Message.showTranslation : L10n.Message.showOriginal + } + + // MARK: - Helpers + + private var messageListConfig: MessageListConfig { + utils.messageListConfig + } +} + +/// A singleton store that keeps track of which messages have their original text shown. +/// +/// **Note:** This is not thread-safe, it should only be used on the main thread. +public class MessageOriginalTranslationsStore: ObservableObject { + internal init() {} + + @Published var originalTextMessageIds: Set = [] + + public func shouldShowOriginalText(for messageId: MessageId) -> Bool { + originalTextMessageIds.contains(messageId) + } + + public func showOriginalText(for messageId: MessageId) { + originalTextMessageIds.insert(messageId) + } + + public func hideOriginalText(for messageId: MessageId) { + originalTextMessageIds.remove(messageId) + } + + public func clear() { + originalTextMessageIds.removeAll() + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift index caaacda7b..3d796dbba 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift @@ -228,7 +228,8 @@ struct PollOptionView: View { id: vote.user?.id ?? "", name: vote.user?.name ?? "", imageURL: vote.user?.imageURL, - size: .init(width: 20, height: 20) + size: .init(width: 20, height: 20), + extraData: vote.user?.extraData ?? [:] ) ) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift index c404d7ea5..495396ae5 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift @@ -43,7 +43,8 @@ struct PollCommentsView: View { let displayInfo = UserDisplayInfo( id: comment.user?.id ?? "", name: comment.user?.name ?? "", - imageURL: comment.user?.imageURL + imageURL: comment.user?.imageURL, + extraData: comment.user?.extraData ?? [:] ) factory.makeMessageAvatarView(for: displayInfo) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollResultsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollResultsView.swift index d6a4d5bf5..4d443da76 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollResultsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollResultsView.swift @@ -113,7 +113,8 @@ struct PollOptionResultsView: View { id: vote.user?.id ?? "", name: vote.user?.name ?? "", imageURL: vote.user?.imageURL, - size: .init(width: 20, height: 20) + size: .init(width: 20, height: 20), + extraData: vote.user?.extraData ?? [:] ) ) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift index 8dc7cc287..2d9c49373 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift @@ -131,7 +131,7 @@ public extension MessageAction { ) messageActions.append(markThreadUnreadAction) } - } else if !message.isSentByCurrentUser { + } else if !message.isSentByCurrentUser && channel.canReceiveReadEvents { if !message.isPartOfThread || message.showReplyInChannel { let markUnreadAction = markAsUnreadAction( for: message, @@ -145,8 +145,8 @@ public extension MessageAction { } } - if message.isSentByCurrentUser { - if message.poll == nil && message.giphyAttachments.isEmpty { + if message.poll == nil, message.giphyAttachments.isEmpty { + if channel.canUpdateAnyMessage || channel.canUpdateOwnMessage && message.isSentByCurrentUser { let editAction = editMessageAction( for: message, channel: channel, @@ -154,7 +154,9 @@ public extension MessageAction { ) messageActions.append(editAction) } - + } + + if message.isSentByCurrentUser { let deleteAction = deleteMessageAction( for: message, channel: channel, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift index 7d20b5e1a..a41e32d80 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift @@ -10,6 +10,7 @@ public struct ReactionsOverlayView: View { @Injected(\.colors) private var colors @StateObject var viewModel: ReactionsOverlayViewModel + @StateObject var messageViewModel: MessageViewModel @State private var popIn = false @State private var willPopOut = false @@ -43,13 +44,21 @@ public struct ReactionsOverlayView: View { minOriginY: CGFloat = 100, bottomOffset: CGFloat = 0, onBackgroundTap: @escaping () -> Void, - onActionExecuted: @escaping (MessageActionInfo) -> Void + onActionExecuted: @escaping (MessageActionInfo) -> Void, + viewModel: ReactionsOverlayViewModel? = nil, + messageViewModel: MessageViewModel? = nil ) { _viewModel = StateObject( - wrappedValue: ViewModelsFactory.makeReactionsOverlayViewModel( + wrappedValue: viewModel ?? ViewModelsFactory.makeReactionsOverlayViewModel( message: messageDisplayInfo.message ) ) + _messageViewModel = StateObject( + wrappedValue: messageViewModel ?? MessageViewModel( + message: messageDisplayInfo.message, + channel: channel + ) + ) self.channel = channel self.factory = factory self.currentSnapshot = currentSnapshot @@ -225,6 +234,9 @@ public struct ReactionsOverlayView: View { isFirst: messageDisplayInfo.isFirst, scrolledId: .constant(nil) ) + // This is needed for the LinkDetectionTextView to work properly. + // TODO: This should be refactored on v5 so the TextView does not depend directly on the view model. + .environment(\.messageViewModel, messageViewModel) } private func dismissReactionsOverlay(completion: @escaping () -> Void) { diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelListConfig.swift new file mode 100644 index 000000000..6bd0aa495 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelListConfig.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A configuration for channel lists. +public struct ChannelListConfig { + public init(messageRelativeDateFormatEnabled: Bool = false) { + self.messageRelativeDateFormatEnabled = messageRelativeDateFormatEnabled + } + + /// If true, the timestamp format depends on the time passed. + /// + /// Different date formats are used for today, yesterday, last 7 days, and older dates. + public var messageRelativeDateFormatEnabled: Bool +} diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index e36f5de9f..cd14fe253 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -337,7 +337,11 @@ extension ChatChannel { public var timestampText: String { if let lastMessageAt = lastMessageAt { - return InjectedValues[\.utils].dateFormatter.string(from: lastMessageAt) + let utils = InjectedValues[\.utils] + let formatter = utils.channelListConfig.messageRelativeDateFormatEnabled ? + utils.messageRelativeDateFormatter : + utils.dateFormatter + return formatter.string(from: lastMessageAt) } else { return "" } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift index 455f2d8d0..ab4e6750a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift @@ -158,7 +158,10 @@ struct SearchResultItem: View { private var timestampText: String { if let lastMessageAt = searchResult.channel.lastMessageAt { - return utils.dateFormatter.string(from: lastMessageAt) + let formatter = utils.channelListConfig.messageRelativeDateFormatEnabled ? + utils.messageRelativeDateFormatter : + utils.dateFormatter + return formatter.string(from: lastMessageAt) } else { return "" } diff --git a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift index 3e14a3829..514f2fa73 100644 --- a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift @@ -370,7 +370,15 @@ extension ViewFactory { public func makeLastInGroupHeaderView(for message: ChatMessage) -> some View { EmptyView() } - + + public func makeMessageTranslationFooterView( + messageViewModel: MessageViewModel + ) -> some View { + MessageTranslationFooterView( + messageViewModel: messageViewModel + ) + } + public func makeImageAttachmentView( for message: ChatMessage, isFirst: Bool, @@ -452,6 +460,7 @@ extension ViewFactory { options: MediaViewsOptions ) -> some View { GalleryView( + viewFactory: self, mediaAttachments: mediaAttachments, author: message.author, isShown: isShown, @@ -459,6 +468,14 @@ extension ViewFactory { ) } + public func makeGalleryHeaderView( + title: String, + subtitle: String, + shown: Binding + ) -> some View { + GalleryHeaderView(title: title, subtitle: subtitle, isShown: shown) + } + public func makeVideoPlayerView( attachment: ChatMessageVideoAttachment, message: ChatMessage, @@ -466,12 +483,21 @@ extension ViewFactory { options: MediaViewsOptions ) -> some View { VideoPlayerView( + viewFactory: self, attachment: attachment, author: message.author, isShown: isShown ) } + public func makeVideoPlayerHeaderView( + title: String, + subtitle: String, + shown: Binding + ) -> some View { + GalleryHeaderView(title: title, subtitle: subtitle, isShown: shown) + } + public func makeDeletedMessageView( for message: ChatMessage, isFirst: Bool, @@ -986,7 +1012,7 @@ extension ViewFactory { currentUserId: chatClient.currentUserId, message: message ) - let showReadCount = channel.memberCount > 2 + let showReadCount = channel.memberCount > 2 && !message.isLastActionFailed return MessageReadIndicatorView( readUsers: readUsers, showReadCount: showReadCount, diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index 3d1f1baa9..109848843 100644 --- a/Sources/StreamChatSwiftUI/Generated/L10n.swift +++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift @@ -352,6 +352,10 @@ internal enum L10n { internal static var deletedMessagePlaceholder: String { L10n.tr("Localizable", "message.deleted-message-placeholder") } /// Only visible to you internal static var onlyVisibleToYou: String { L10n.tr("Localizable", "message.only-visible-to-you") } + /// Show Original + internal static var showOriginal: String { L10n.tr("Localizable", "message.showOriginal") } + /// Show Translation + internal static var showTranslation: String { L10n.tr("Localizable", "message.showTranslation") } /// Translated to %@ internal static func translatedTo(_ p1: Any) -> String { return L10n.tr("Localizable", "message.translatedTo", String(describing: p1)) diff --git a/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift index 72dcbfde0..19c8a4f33 100644 --- a/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift +++ b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift @@ -7,5 +7,5 @@ import Foundation enum SystemEnvironment { /// A Stream Chat version. - public static let version: String = "4.78.0" + public static let version: String = "4.79.0" } diff --git a/Sources/StreamChatSwiftUI/Info.plist b/Sources/StreamChatSwiftUI/Info.plist index 1bd3d64a3..01fdd70ce 100644 --- a/Sources/StreamChatSwiftUI/Info.plist +++ b/Sources/StreamChatSwiftUI/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.78.0 + 4.79.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPhotoLibraryUsageDescription diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings index 0eab6da4d..3d57da52a 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings @@ -90,6 +90,8 @@ "message.polls.votes" = "%d votes"; "message.translatedTo" = "Translated to %@"; +"message.showOriginal" = "Show Original"; +"message.showTranslation" = "Show Translation"; "alert.actions.cancel" = "Cancel"; "alert.actions.delete" = "Delete"; diff --git a/Sources/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift index c36970e32..f39985f10 100644 --- a/Sources/StreamChatSwiftUI/Utils.swift +++ b/Sources/StreamChatSwiftUI/Utils.swift @@ -8,11 +8,14 @@ import StreamChat /// Class providing implementations of several utilities used in the SDK. /// The default implementations can be replaced in the init method, or directly via the variables. public class Utils { - // TODO: Make it public in future versions. - internal var messagePreviewFormatter = MessagePreviewFormatter() var markdownFormatter = MarkdownFormatter() public var dateFormatter: DateFormatter + + /// Date formatter where the format depends on the time passed. + /// + /// - SeeAlso: ``ChannelListConfig/messageRelativeDateFormatEnabled``. + public var messageRelativeDateFormatter: DateFormatter public var videoPreviewLoader: VideoPreviewLoader public var imageLoader: ImageLoading public var imageCDN: ImageCDN @@ -24,7 +27,9 @@ public class Utils { public var channelAvatarsMerger: ChannelAvatarsMerging public var messageTypeResolver: MessageTypeResolving public var messageActionsResolver: MessageActionsResolving + public var messagePreviewFormatter: MessagePreviewFormatter public var commandsConfig: CommandsConfig + public var channelListConfig: ChannelListConfig public var messageListConfig: MessageListConfig public var composerConfig: ComposerConfig public var pollsConfig: PollsConfig @@ -59,6 +64,8 @@ public class Utils { public lazy var audioSessionFeedbackGenerator: AudioSessionFeedbackGenerator = StreamAudioSessionFeedbackGenerator() + public var originalTranslationsStore = MessageOriginalTranslationsStore() + var messageCachingUtils = MessageCachingUtils() var messageListDateUtils: MessageListDateUtils var channelControllerFactory = ChannelControllerFactory() @@ -69,6 +76,7 @@ public class Utils { public init( dateFormatter: DateFormatter = .makeDefault(), + messageRelativeDateFormatter: DateFormatter = MessageRelativeDateFormatter(), videoPreviewLoader: VideoPreviewLoader = DefaultVideoPreviewLoader(), imageLoader: ImageLoading = NukeImageLoader(), imageCDN: ImageCDN = StreamImageCDN(), @@ -78,7 +86,9 @@ public class Utils { channelAvatarsMerger: ChannelAvatarsMerging = ChannelAvatarsMerger(), messageTypeResolver: MessageTypeResolving = MessageTypeResolver(), messageActionResolver: MessageActionsResolving = MessageActionsResolver(), + messagePreviewFormatter: MessagePreviewFormatter = MessagePreviewFormatter(), commandsConfig: CommandsConfig = DefaultCommandsConfig(), + channelListConfig: ChannelListConfig = ChannelListConfig(), messageListConfig: MessageListConfig = MessageListConfig(), composerConfig: ComposerConfig = ComposerConfig(), pollsConfig: PollsConfig = PollsConfig(), @@ -93,6 +103,7 @@ public class Utils { shouldSyncChannelControllerOnAppear: @escaping (ChatChannelController) -> Bool = { _ in true } ) { self.dateFormatter = dateFormatter + self.messageRelativeDateFormatter = messageRelativeDateFormatter self.videoPreviewLoader = videoPreviewLoader self.imageLoader = imageLoader self.imageCDN = imageCDN @@ -104,7 +115,9 @@ public class Utils { self.channelAvatarsMerger = channelAvatarsMerger self.messageTypeResolver = messageTypeResolver messageActionsResolver = messageActionResolver + self.messagePreviewFormatter = messagePreviewFormatter self.commandsConfig = commandsConfig + self.channelListConfig = channelListConfig self.messageListConfig = messageListConfig self.composerConfig = composerConfig self.snapshotCreator = snapshotCreator diff --git a/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift b/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift index a44103587..41362caed 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift @@ -18,7 +18,13 @@ public extension ChatMessage { /// A boolean value that checks if the last action (`send`, `edit` or `delete`) on the message failed. var isLastActionFailed: Bool { - guard isDeleted == false else { return false } + guard isDeleted == false else { + return false + } + + if isBounced { + return true + } switch localState { case .sendingFailed, .syncingFailed, .deletingFailed: diff --git a/Sources/StreamChatSwiftUI/Utils/Common/MessageRelativeDateFormatter.swift b/Sources/StreamChatSwiftUI/Utils/Common/MessageRelativeDateFormatter.swift new file mode 100644 index 000000000..8c081dbf0 --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/Common/MessageRelativeDateFormatter.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A formatter that converts message timestamps to a format which depends on the time passed. +public final class MessageRelativeDateFormatter: DateFormatter, @unchecked Sendable { + override public init() { + super.init() + locale = .autoupdatingCurrent + dateStyle = .short + timeStyle = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func string(from date: Date) -> String { + if calendar.isDateInToday(date) { + return todayFormatter.string(from: date) + } + if calendar.isDateInYesterday(date) { + return yesterdayFormatter.string(from: date) + } + if calendar.isDateInLastWeek(date) { + return weekdayFormatter.string(from: date) + } + + return super.string(from: date) + } + + var todayFormatter: DateFormatter { + InjectedValues[\.utils].dateFormatter + } + + let yesterdayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.dateStyle = .short + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + return formatter + }() + + let weekdayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("EEEE") + return formatter + }() +} + +extension Calendar { + func isDateInLastWeek(_ date: Date) -> Bool { + guard let dateBefore7days = self.date(byAdding: .day, value: -7, to: Date()) else { + return false + } + return date > dateBefore7days + } +} diff --git a/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift b/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift index f26aeb6bb..40320b29a 100644 --- a/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift +++ b/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift @@ -34,19 +34,22 @@ public struct UserDisplayInfo { public let imageURL: URL? public let role: UserRole? public let size: CGSize? + public let extraData: [String: RawJSON] public init( id: String, name: String, imageURL: URL?, role: UserRole? = nil, - size: CGSize? = nil + size: CGSize? = nil, + extraData: [String: RawJSON] = [:] ) { self.id = id self.name = name self.imageURL = imageURL self.role = role self.size = size + self.extraData = extraData } } @@ -57,7 +60,8 @@ extension ChatMessage { id: author.id, name: author.name ?? author.id, imageURL: author.imageURL, - role: author.userRole + role: author.userRole, + extraData: author.extraData ) } diff --git a/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift b/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift index 2a8d5a6bd..b4dadbdab 100644 --- a/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift +++ b/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift @@ -7,13 +7,13 @@ import SwiftUI /// A formatter that converts a message to a text preview representation. /// By default it is used to show message previews in the Channel List and Thread List. -struct MessagePreviewFormatter { +open class MessagePreviewFormatter { @Injected(\.chatClient) var chatClient - init() {} + public init() {} /// Formats the message including the author's name. - func format(_ previewMessage: ChatMessage, in channel: ChatChannel) -> String { + open func format(_ previewMessage: ChatMessage, in channel: ChatChannel) -> String { if let poll = previewMessage.poll { return formatPoll(poll) } @@ -21,7 +21,7 @@ struct MessagePreviewFormatter { } /// Formats only the content of the message without the author's name. - func formatContent(for previewMessage: ChatMessage, in channel: ChatChannel) -> String { + open func formatContent(for previewMessage: ChatMessage, in channel: ChatChannel) -> String { if let attachmentPreviewText = formatAttachmentContent(for: previewMessage, in: channel) { return attachmentPreviewText } @@ -32,7 +32,7 @@ struct MessagePreviewFormatter { } /// Formats only the attachment content of the message in case it contains attachments. - func formatAttachmentContent(for previewMessage: ChatMessage, in channel: ChatChannel) -> String? { + open func formatAttachmentContent(for previewMessage: ChatMessage, in channel: ChatChannel) -> String? { if let poll = previewMessage.poll { return "📊 \(poll.name)" } diff --git a/Sources/StreamChatSwiftUI/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory.swift index 762eaadc8..0aa9fbebd 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory.swift @@ -359,6 +359,15 @@ public protocol ViewFactory: AnyObject { /// - Returns: view shown in the date and author indicator slot. func makeMessageAuthorAndDateView(for message: ChatMessage) -> MessageAuthorAndDateViewType + associatedtype MessageTranslationFooterViewType: View + /// Creates a view to display translation information below a message if it has been translated. + /// - Parameters: + /// - messageViewModel: The message view model used to display information about the message. + /// - Returns: A view to display translation information of the message. + func makeMessageTranslationFooterView( + messageViewModel: MessageViewModel + ) -> MessageTranslationFooterViewType + associatedtype LastInGroupHeaderView: View /// Creates a view shown as a header of the last message in a group. /// - Parameter message: the chat message for which the header will be displayed. @@ -455,6 +464,19 @@ public protocol ViewFactory: AnyObject { options: MediaViewsOptions ) -> GalleryViewType + associatedtype GalleryHeaderViewType: View + /// Creates the gallery header view presented with a sheet. + /// - Parameters: + /// - title: The title displayed in the header. + /// - subtitle: The subtitle displayed in the header. + /// - shown: Binding controlling whether the gallery is shown. + /// - Returns: View displayed in the gallery header slot. + func makeGalleryHeaderView( + title: String, + subtitle: String, + shown: Binding + ) -> GalleryHeaderViewType + associatedtype VideoPlayerViewType: View /// Creates the video player view. /// - Parameters: @@ -469,6 +491,19 @@ public protocol ViewFactory: AnyObject { isShown: Binding, options: MediaViewsOptions ) -> VideoPlayerViewType + + associatedtype VideoPlayerHeaderViewType: View + /// Creates the video player header view presented with a sheet. + /// - Parameters: + /// - title: The title displayed in the header. + /// - subtitle: The subtitle displayed in the header. + /// - shown: Binding controlling whether the video player is shown. + /// - Returns: View displayed in the video player header slot. + func makeVideoPlayerHeaderView( + title: String, + subtitle: String, + shown: Binding + ) -> VideoPlayerHeaderViewType associatedtype DeletedMessageViewType: View /// Creates the deleted message view. diff --git a/StreamChatSwiftUI-XCFramework.podspec b/StreamChatSwiftUI-XCFramework.podspec index 388996a85..80ce77419 100644 --- a/StreamChatSwiftUI-XCFramework.podspec +++ b/StreamChatSwiftUI-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamChatSwiftUI-XCFramework' - spec.version = '4.78.0' + spec.version = '4.79.0' spec.summary = 'StreamChat SwiftUI Chat Components' spec.description = 'StreamChatSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK.' @@ -19,7 +19,7 @@ Pod::Spec.new do |spec| spec.framework = 'Foundation', 'UIKit', 'SwiftUI' - spec.dependency 'StreamChat-XCFramework', '~> 4.78.0' + spec.dependency 'StreamChat-XCFramework', '~> 4.79.0' spec.cocoapods_version = '>= 1.11.0' end diff --git a/StreamChatSwiftUI.podspec b/StreamChatSwiftUI.podspec index 4de9e323b..272dac1b7 100644 --- a/StreamChatSwiftUI.podspec +++ b/StreamChatSwiftUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamChatSwiftUI' - spec.version = '4.78.0' + spec.version = '4.79.0' spec.summary = 'StreamChat SwiftUI Chat Components' spec.description = 'StreamChatSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK.' @@ -19,5 +19,5 @@ Pod::Spec.new do |spec| spec.framework = 'Foundation', 'UIKit', 'SwiftUI' - spec.dependency 'StreamChat', '~> 4.78.0' + spec.dependency 'StreamChat', '~> 4.79.0' end diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index 582fad6af..8df42398f 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -16,10 +16,13 @@ 4F6D83352C0F05040098C298 /* PollCommentsViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6D83342C0F05040098C298 /* PollCommentsViewModel_Tests.swift */; }; 4F6D83512C1079A00098C298 /* AlertBannerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6D83502C1079A00098C298 /* AlertBannerViewModifier.swift */; }; 4F6D83542C1094220098C298 /* AlertBannerViewModifier_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6D83532C1094220098C298 /* AlertBannerViewModifier_Tests.swift */; }; + 4F7613792DDCB2C900F996E3 /* MessageRelativeDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7613782DDCB2AD00F996E3 /* MessageRelativeDateFormatter.swift */; }; 4F7720AE2C58C45200BAEC02 /* OnLoadViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7720AD2C58C45000BAEC02 /* OnLoadViewModifier.swift */; }; 4F7DD9A02BFC7C6100599AA6 /* ChatClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7DD99F2BFC7C6100599AA6 /* ChatClient+Extensions.swift */; }; 4F7DD9A22BFCB2EF00599AA6 /* ChatClientExtensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7DD9A12BFCB2EF00599AA6 /* ChatClientExtensions_Tests.swift */; }; 4F889C562D7F000700A7BDAF /* ChatMessageExtensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F889C552D7F000700A7BDAF /* ChatMessageExtensions_Tests.swift */; }; + 4F8D64402DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8D643F2DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift */; }; + 4F9173FD2DDDFFE8003C30B5 /* ChannelListConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9173FC2DDDFFE3003C30B5 /* ChannelListConfig.swift */; }; 4FA3741A2D799CA400294721 /* AppConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA374192D799CA400294721 /* AppConfigurationView.swift */; }; 4FA3741D2D799FC300294721 /* AppConfigurationTranslationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA3741C2D799FC300294721 /* AppConfigurationTranslationView.swift */; }; 4FA3741F2D79A64F00294721 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA3741E2D79A64900294721 /* AppConfiguration.swift */; }; @@ -522,6 +525,7 @@ AD3AB65C2CB730090014D4D7 /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65B2CB730090014D4D7 /* Shimmer.swift */; }; AD3AB65E2CB731360014D4D7 /* ChatThreadListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */; }; AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */; }; + AD51D9182DB9543A0068D0B0 /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */; }; AD5C0A5F2D6FDD9700E1E500 /* BouncedMessageActionsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */; }; AD6B7E052D356E8800ADEF39 /* ReactionsUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */; }; ADE0F55E2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */; }; @@ -532,6 +536,7 @@ ADE442EE2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE442ED2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift */; }; ADE442F02CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE442EF2CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift */; }; ADE442F22CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE442F12CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift */; }; + ADF544382DC93C2A0024A0B3 /* MessageTranslationFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF544372DC93C0D0024A0B3 /* MessageTranslationFooterView.swift */; }; C14A465B284665B100EF498E /* SDKIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14A465A284665B100EF498E /* SDKIdentifier.swift */; }; E3A1C01C282BAC66002D1E26 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = E3A1C01B282BAC66002D1E26 /* Sentry */; }; /* End PBXBuildFile section */ @@ -612,10 +617,13 @@ 4F6D83342C0F05040098C298 /* PollCommentsViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollCommentsViewModel_Tests.swift; sourceTree = ""; }; 4F6D83502C1079A00098C298 /* AlertBannerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertBannerViewModifier.swift; sourceTree = ""; }; 4F6D83532C1094220098C298 /* AlertBannerViewModifier_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertBannerViewModifier_Tests.swift; sourceTree = ""; }; + 4F7613782DDCB2AD00F996E3 /* MessageRelativeDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRelativeDateFormatter.swift; sourceTree = ""; }; 4F7720AD2C58C45000BAEC02 /* OnLoadViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnLoadViewModifier.swift; sourceTree = ""; }; 4F7DD99F2BFC7C6100599AA6 /* ChatClient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatClient+Extensions.swift"; sourceTree = ""; }; 4F7DD9A12BFCB2EF00599AA6 /* ChatClientExtensions_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientExtensions_Tests.swift; sourceTree = ""; }; 4F889C552D7F000700A7BDAF /* ChatMessageExtensions_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageExtensions_Tests.swift; sourceTree = ""; }; + 4F8D643F2DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRelativeDateFormatter_Tests.swift; sourceTree = ""; }; + 4F9173FC2DDDFFE3003C30B5 /* ChannelListConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListConfig.swift; sourceTree = ""; }; 4FA374192D799CA400294721 /* AppConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationView.swift; sourceTree = ""; }; 4FA3741C2D799FC300294721 /* AppConfigurationTranslationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationTranslationView.swift; sourceTree = ""; }; 4FA3741E2D79A64900294721 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; @@ -1123,6 +1131,7 @@ AD3AB65B2CB730090014D4D7 /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; }; AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListLoadingView.swift; sourceTree = ""; }; AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListHeaderViewModifier.swift; sourceTree = ""; }; + AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BouncedMessageActionsModifier.swift; sourceTree = ""; }; AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsUsersViewModel.swift; sourceTree = ""; }; ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListErrorBannerView.swift; sourceTree = ""; }; @@ -1133,6 +1142,7 @@ ADE442ED2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListViewModel_Tests.swift; sourceTree = ""; }; ADE442EF2CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListView_Tests.swift; sourceTree = ""; }; ADE442F12CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListItemView_Tests.swift; sourceTree = ""; }; + ADF544372DC93C0D0024A0B3 /* MessageTranslationFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTranslationFooterView.swift; sourceTree = ""; }; C14A465A284665B100EF498E /* SDKIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKIdentifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1786,8 +1796,10 @@ 8465FCFF2746A95600AF091E /* MessageListView.swift */, 8465FD052746A95600AF091E /* MessageContainerView.swift */, 8465FD0E2746A95600AF091E /* MessageView.swift */, + AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */, 84DEC8E02760D24100172876 /* MessageRepliesView.swift */, 8465FD0D2746A95600AF091E /* MessageAvatarView.swift */, + ADF544372DC93C0D0024A0B3 /* MessageTranslationFooterView.swift */, 8465FCFE2746A95600AF091E /* ImageAttachmentView.swift */, 84A75FBA274EA29B00225CE8 /* GiphyAttachmentView.swift */, 8465FD002746A95600AF091E /* FileAttachmentPreview.swift */, @@ -1935,6 +1947,7 @@ 8465FD3D2746A95600AF091E /* ImageCDN.swift */, 8465FD482746A95600AF091E /* ImageMerger.swift */, 8465FD422746A95600AF091E /* InputTextView.swift */, + 4F7613782DDCB2AD00F996E3 /* MessageRelativeDateFormatter.swift */, 8465FD432746A95600AF091E /* NSLayoutConstraint+Extensions.swift */, 8465FD492746A95600AF091E /* NukeImageProcessor.swift */, 4F7720AD2C58C45000BAEC02 /* OnLoadViewModifier.swift */, @@ -1951,22 +1964,23 @@ 8465FD4C2746A95600AF091E /* ChatChannelList */ = { isa = PBXGroup; children = ( + 8465FD4D2746A95600AF091E /* ChannelAvatarsMerger.swift */, + 8465FD572746A95700AF091E /* ChannelHeaderLoader.swift */, + 4F9173FC2DDDFFE3003C30B5 /* ChannelListConfig.swift */, + 8465FD4E2746A95600AF091E /* ChatChannelHelperViews.swift */, + 8465FD512746A95600AF091E /* ChatChannelList.swift */, + 8465FD542746A95700AF091E /* ChatChannelListHeader.swift */, + 8465FD592746A95700AF091E /* ChatChannelListItem.swift */, 8465FD552746A95700AF091E /* ChatChannelListScreen.swift */, 8465FD5C2746A95700AF091E /* ChatChannelListView.swift */, - 8465FD512746A95600AF091E /* ChatChannelList.swift */, 8465FD582746A95700AF091E /* ChatChannelListViewModel.swift */, - 8465FD542746A95700AF091E /* ChatChannelListHeader.swift */, - 8465FD5A2746A95700AF091E /* ChatChannelSwipeableListItem.swift */, 8465FD532746A95600AF091E /* ChatChannelNavigatableListItem.swift */, - 8465FD592746A95700AF091E /* ChatChannelListItem.swift */, - 8465FD4D2746A95600AF091E /* ChannelAvatarsMerger.swift */, - 8465FD4E2746A95600AF091E /* ChatChannelHelperViews.swift */, + 8465FD5A2746A95700AF091E /* ChatChannelSwipeableListItem.swift */, 8465FD502746A95600AF091E /* DefaultChannelActions.swift */, - 8465FD522746A95600AF091E /* NoChannelsView.swift */, - 8465FD572746A95700AF091E /* ChannelHeaderLoader.swift */, + 91B763A3283EB19800B458A9 /* MoreChannelActionsFullScreenWrappingView.swift */, 8465FD4F2746A95600AF091E /* MoreChannelActionsView.swift */, 8465FD5B2746A95700AF091E /* MoreChannelActionsViewModel.swift */, - 91B763A3283EB19800B458A9 /* MoreChannelActionsFullScreenWrappingView.swift */, + 8465FD522746A95600AF091E /* NoChannelsView.swift */, 8421BCEF27A44EAE000F977D /* SearchResultsView.swift */, ); path = ChatChannelList; @@ -2186,6 +2200,7 @@ 91B79FD8284E7E9C005B6E4F /* ChatUserNamer_Tests.swift */, 84C94D53275A1380007FE2B9 /* DateUtils_Tests.swift */, 84C94D5D275A3AA9007FE2B9 /* ImageCDN_Tests.swift */, + 4F8D643F2DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift */, 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */, 84779C762AEBCA6E000A6A68 /* ReactionsIconProvider_Tests.swift */, 84E1D8272976CCAF00060491 /* SortReactions_Tests.swift */, @@ -2675,6 +2690,7 @@ 82D64BEA2AD7E5B700C5C79E /* TaskFetchOriginalImageData.swift in Sources */, 82D64BDB2AD7E5B700C5C79E /* UIImageView.swift in Sources */, 82D64BD02AD7E5B700C5C79E /* NukeVideoPlayerView.swift in Sources */, + ADF544382DC93C2A0024A0B3 /* MessageTranslationFooterView.swift in Sources */, 84EADEB52B28935B0046B50C /* RecordingView.swift in Sources */, 84AB7B262773619F00631A10 /* MentionUsersView.swift in Sources */, 8465FDA82746A95700AF091E /* ImageLoading.swift in Sources */, @@ -2790,9 +2806,11 @@ 82D64C0C2AD7E5B700C5C79E /* ImageEncoders.swift in Sources */, 8465FD832746A95700AF091E /* LinkAttachmentView.swift in Sources */, 4FCD7DA72D632121000EEB0F /* MarkdownFormatter.swift in Sources */, + AD51D9182DB9543A0068D0B0 /* MessageViewModel.swift in Sources */, 82D64C082AD7E5B700C5C79E /* Operation.swift in Sources */, 82D64BEB2AD7E5B700C5C79E /* ImagePipelineTask.swift in Sources */, AD2DDA612CB040EA0040B8D4 /* NoThreadsView.swift in Sources */, + 4F9173FD2DDDFFE8003C30B5 /* ChannelListConfig.swift in Sources */, 82D64BF92AD7E5B700C5C79E /* ImageProcessors+Resize.swift in Sources */, 8465FDC22746A95700AF091E /* ChatChannelNavigatableListItem.swift in Sources */, 8465FDAD2746A95700AF091E /* ImageCDN.swift in Sources */, @@ -2806,6 +2824,7 @@ 8465FDA52746A95700AF091E /* Modifiers.swift in Sources */, 8465FDBB2746A95700AF091E /* LoadingView.swift in Sources */, 84D6E4F62B2CA4E300D0056C /* RecordingTipView.swift in Sources */, + 4F7613792DDCB2C900F996E3 /* MessageRelativeDateFormatter.swift in Sources */, 846608E3278C303800D3D7B3 /* TypingIndicatorView.swift in Sources */, 84A1CACF2816BCF00046595A /* AddUsersView.swift in Sources */, 82D64BF02AD7E5B700C5C79E /* DataLoading.swift in Sources */, @@ -3065,6 +3084,7 @@ 84B2B5CA281947E100479CEE /* ViewFrameUtils.swift in Sources */, 8423C342277CBA280092DCF1 /* TypingSuggester_Tests.swift in Sources */, 84507C9A281ACCD70081DDC2 /* AddUsersView_Tests.swift in Sources */, + 4F8D64402DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift in Sources */, 84C94D0627578BF2007FE2B9 /* UnwrapAsync.swift in Sources */, 84D6B55A27DF6EC7009C6D07 /* LoadingView_Tests.swift in Sources */, 84C94D0427578BF2007FE2B9 /* TestError.swift in Sources */, @@ -3880,7 +3900,7 @@ repositoryURL = "https://github.com/GetStream/stream-chat-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.78.0; + minimumVersion = 4.79.0; }; }; E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { diff --git a/StreamChatSwiftUIArtifacts.json b/StreamChatSwiftUIArtifacts.json index 74ca01551..9c9ffb5b5 100644 --- a/StreamChatSwiftUIArtifacts.json +++ b/StreamChatSwiftUIArtifacts.json @@ -1 +1 @@ -{"4.40.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.40.0/StreamChatSwiftUI.zip","4.41.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.41.0/StreamChatSwiftUI.zip","4.42.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.42.0/StreamChatSwiftUI.zip","4.43.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.43.0/StreamChatSwiftUI.zip","4.44.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.44.0/StreamChatSwiftUI.zip","4.45.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.45.0/StreamChatSwiftUI.zip","4.46.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.46.0/StreamChatSwiftUI.zip","4.47.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.0/StreamChatSwiftUI.zip","4.47.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.1/StreamChatSwiftUI.zip","4.48.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.48.0/StreamChatSwiftUI.zip","4.49.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.49.0/StreamChatSwiftUI.zip","4.50.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.0/StreamChatSwiftUI.zip","4.50.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.1/StreamChatSwiftUI.zip","4.51.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.51.0/StreamChatSwiftUI.zip","4.52.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.52.0/StreamChatSwiftUI.zip","4.53.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.53.0/StreamChatSwiftUI.zip","4.54.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.54.0/StreamChatSwiftUI.zip","4.55.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.55.0/StreamChatSwiftUI.zip","4.56.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.56.0/StreamChatSwiftUI.zip","4.57.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.57.0/StreamChatSwiftUI.zip","4.58.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.58.0/StreamChatSwiftUI.zip","4.59.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.59.0/StreamChatSwiftUI.zip","4.60.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.60.0/StreamChatSwiftUI.zip","4.61.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.61.0/StreamChatSwiftUI.zip","4.62.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.62.0/StreamChatSwiftUI.zip","4.63.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.63.0/StreamChatSwiftUI.zip","4.64.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.64.0/StreamChatSwiftUI.zip","4.65.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.65.0/StreamChatSwiftUI.zip","4.66.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.66.0/StreamChatSwiftUI.zip","4.67.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.67.0/StreamChatSwiftUI.zip","4.68.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.68.0/StreamChatSwiftUI.zip","4.69.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.69.0/StreamChatSwiftUI.zip","4.70.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.70.0/StreamChatSwiftUI.zip","4.71.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.71.0/StreamChatSwiftUI.zip","4.72.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.72.0/StreamChatSwiftUI.zip","4.73.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.73.0/StreamChatSwiftUI.zip","4.74.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.74.0/StreamChatSwiftUI.zip","4.75.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.75.0/StreamChatSwiftUI.zip","4.76.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.76.0/StreamChatSwiftUI.zip","4.77.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.77.0/StreamChatSwiftUI.zip","4.78.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.78.0/StreamChatSwiftUI.zip"} \ No newline at end of file +{"4.40.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.40.0/StreamChatSwiftUI.zip","4.41.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.41.0/StreamChatSwiftUI.zip","4.42.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.42.0/StreamChatSwiftUI.zip","4.43.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.43.0/StreamChatSwiftUI.zip","4.44.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.44.0/StreamChatSwiftUI.zip","4.45.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.45.0/StreamChatSwiftUI.zip","4.46.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.46.0/StreamChatSwiftUI.zip","4.47.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.0/StreamChatSwiftUI.zip","4.47.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.1/StreamChatSwiftUI.zip","4.48.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.48.0/StreamChatSwiftUI.zip","4.49.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.49.0/StreamChatSwiftUI.zip","4.50.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.0/StreamChatSwiftUI.zip","4.50.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.1/StreamChatSwiftUI.zip","4.51.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.51.0/StreamChatSwiftUI.zip","4.52.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.52.0/StreamChatSwiftUI.zip","4.53.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.53.0/StreamChatSwiftUI.zip","4.54.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.54.0/StreamChatSwiftUI.zip","4.55.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.55.0/StreamChatSwiftUI.zip","4.56.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.56.0/StreamChatSwiftUI.zip","4.57.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.57.0/StreamChatSwiftUI.zip","4.58.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.58.0/StreamChatSwiftUI.zip","4.59.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.59.0/StreamChatSwiftUI.zip","4.60.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.60.0/StreamChatSwiftUI.zip","4.61.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.61.0/StreamChatSwiftUI.zip","4.62.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.62.0/StreamChatSwiftUI.zip","4.63.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.63.0/StreamChatSwiftUI.zip","4.64.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.64.0/StreamChatSwiftUI.zip","4.65.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.65.0/StreamChatSwiftUI.zip","4.66.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.66.0/StreamChatSwiftUI.zip","4.67.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.67.0/StreamChatSwiftUI.zip","4.68.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.68.0/StreamChatSwiftUI.zip","4.69.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.69.0/StreamChatSwiftUI.zip","4.70.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.70.0/StreamChatSwiftUI.zip","4.71.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.71.0/StreamChatSwiftUI.zip","4.72.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.72.0/StreamChatSwiftUI.zip","4.73.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.73.0/StreamChatSwiftUI.zip","4.74.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.74.0/StreamChatSwiftUI.zip","4.75.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.75.0/StreamChatSwiftUI.zip","4.76.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.76.0/StreamChatSwiftUI.zip","4.77.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.77.0/StreamChatSwiftUI.zip","4.78.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.78.0/StreamChatSwiftUI.zip","4.79.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.79.0/StreamChatSwiftUI.zip"} \ No newline at end of file diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift index 20e07d4d6..3daff5bf7 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift @@ -20,7 +20,7 @@ class MessageActionsViewModel_Tests: StreamChatTestCase { text: "test", author: .mock(id: .unique) ), - channel: .mockDMChannel(ownCapabilities: [.sendMessage, .uploadFile, .pinMessage]), + channel: .mockDMChannel(ownCapabilities: [.sendMessage, .uploadFile, .pinMessage, .readEvents]), chatClient: chatClient, onFinish: { _ in }, onError: { _ in } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift index 83e10a087..29e5475a0 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift @@ -72,7 +72,38 @@ class MessageActions_Tests: StreamChatTestCase { XCTAssert(messageActions[4].title == "Mark Unread") XCTAssert(messageActions[5].title == "Mute User") } - + + func test_messageActions_otherUserDefaultReadEventsDisabled() { + // Given + let channel = ChatChannel.mockDMChannel(ownCapabilities: [.sendMessage, .uploadFile, .pinMessage]) + let message = ChatMessage.mock( + id: .unique, + cid: channel.cid, + text: "Test", + author: .mock(id: .unique), + isSentByCurrentUser: false + ) + let factory = DefaultViewFactory.shared + + // When + let messageActions = MessageAction.defaultActions( + factory: factory, + for: message, + channel: channel, + chatClient: chatClient, + onFinish: { _ in }, + onError: { _ in } + ) + + // Then + XCTAssert(messageActions.count == 5) + XCTAssert(messageActions[0].title == "Reply") + XCTAssert(messageActions[1].title == "Thread Reply") + XCTAssert(messageActions[2].title == "Pin to conversation") + XCTAssert(messageActions[3].title == "Copy Message") + XCTAssert(messageActions[4].title == "Mute User") + } + func test_messageActions_otherUserDefaultBlockingEnabled() { // Given streamChat = StreamChat( @@ -292,12 +323,64 @@ class MessageActions_Tests: StreamChatTestCase { XCTAssertEqual(messageActions[3].title, "Copy Message") XCTAssertEqual(messageActions[4].title, "Delete Message") } + + func test_messageActions_currentUser_editingDisabledWhenNoUpdateCapabilities() { + // Given + let channel = ChatChannel.mockDMChannel(ownCapabilities: []) + let message = ChatMessage.mock( + id: .unique, + cid: channel.cid, + text: "Test", + author: .mock(id: chatClient.currentUserId!), + isSentByCurrentUser: true + ) + let factory = DefaultViewFactory.shared + + // When + let messageActions = MessageAction.defaultActions( + factory: factory, + for: message, + channel: channel, + chatClient: chatClient, + onFinish: { _ in }, + onError: { _ in } + ) + + // Then + XCTAssertFalse(messageActions.contains(where: { $0.title == "Edit Message" })) + } + + func test_messageActions_otherUser_editingEnabledWhenUpdateAnyMessageCapability() { + // Given + let channel = ChatChannel.mockDMChannel(ownCapabilities: [.updateAnyMessage]) + let message = ChatMessage.mock( + id: .unique, + cid: channel.cid, + text: "Test", + author: .mock(id: .unique), + isSentByCurrentUser: false + ) + let factory = DefaultViewFactory.shared + + // When + let messageActions = MessageAction.defaultActions( + factory: factory, + for: message, + channel: channel, + chatClient: chatClient, + onFinish: { _ in }, + onError: { _ in } + ) + + // Then + XCTAssertTrue(messageActions.contains(where: { $0.title == "Edit Message" })) + } // MARK: - Private private var mockDMChannel: ChatChannel { ChatChannel.mockDMChannel( - ownCapabilities: [.sendMessage, .uploadFile, .pinMessage] + ownCapabilities: [.updateOwnMessage, .sendMessage, .uploadFile, .pinMessage, .readEvents] ) } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift index fc8501058..eb43ea43e 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift @@ -659,6 +659,106 @@ class MessageComposerViewModel_Tests: StreamChatTestCase { XCTAssertFalse(viewModel.canSendPoll) } + func test_showCommandsOverlay() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let viewModel = MessageComposerViewModel( + channelController: channelController, + messageController: messageController + ) + + // When + let channelConfig = ChannelConfig(commands: [.init()]) + channelController.channel_mock = .mock( + cid: .unique, + config: channelConfig + ) + viewModel.composerCommand = .init(id: "test", typingSuggestion: .empty, displayInfo: nil) + + // Then + XCTAssertTrue(viewModel.showCommandsOverlay) + } + + func test_showCommandsOverlay_whenComposerCommandIsNil_returnsFalse() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let viewModel = MessageComposerViewModel( + channelController: channelController, + messageController: messageController + ) + + // When + let channelConfig = ChannelConfig(commands: [.init()]) + channelController.channel_mock = .mock( + cid: .unique, + config: channelConfig + ) + viewModel.composerCommand = nil + + // Then + XCTAssertFalse(viewModel.showCommandsOverlay) + } + + func test_showCommandsOverlay_whenCommandsAreDisabled_returnsFalse() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let viewModel = MessageComposerViewModel( + channelController: channelController, + messageController: messageController + ) + + // When + let channelConfig = ChannelConfig(commands: []) + channelController.channel_mock = .mock( + cid: .unique, + config: channelConfig + ) + viewModel.composerCommand = .init(id: "test", typingSuggestion: .empty, displayInfo: nil) + + // Then + XCTAssertFalse(viewModel.showCommandsOverlay) + } + + func test_showCommandsOverlay_whenCommandsAreDisabledButIsMentions_returnsTrue() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let viewModel = MessageComposerViewModel( + channelController: channelController, + messageController: messageController + ) + + // When + let channelConfig = ChannelConfig(commands: []) + channelController.channel_mock = .mock( + cid: .unique, + config: channelConfig + ) + viewModel.composerCommand = .init(id: "mentions", typingSuggestion: .empty, displayInfo: nil) + + // Then + XCTAssertTrue(viewModel.showCommandsOverlay) + } + func test_addedAsset_extraData() { // Given let image = UIImage(systemName: "person")! diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift index bbd514b6f..2afea3e2a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift @@ -5,6 +5,7 @@ import SnapshotTesting @testable import StreamChat @testable import StreamChatSwiftUI +@testable import StreamChatTestTools import StreamSwiftTestHelpers import SwiftUI import XCTest @@ -123,7 +124,7 @@ class MessageContainerView_Tests: StreamChatTestCase { ) // When - let view = testMessageViewContainer(message: message) + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) @@ -139,7 +140,7 @@ class MessageContainerView_Tests: StreamChatTestCase { ) // When - let view = testMessageViewContainer(message: message) + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) @@ -160,7 +161,43 @@ class MessageContainerView_Tests: StreamChatTestCase { ) // When - let view = testMessageViewContainer(message: message) + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_messageContainerView_sendingFailed_snapshot() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Test message", + author: .mock(id: .unique), + attachments: [], + localState: .sendingFailed + ) + + // When + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_messageContainerView_editingFailed_snapshot() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Test message", + author: .mock(id: .unique), + attachments: [], + localState: .syncingFailed + ) + + // When + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) @@ -274,7 +311,7 @@ class MessageContainerView_Tests: StreamChatTestCase { ) // When - let view = testMessageViewContainer(message: message) + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) @@ -292,7 +329,7 @@ class MessageContainerView_Tests: StreamChatTestCase { ) // When - let view = testMessageViewContainer(message: message) + let view = testMessageViewContainer(message: message, channel: .mockNonDMChannel()) // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) @@ -315,7 +352,7 @@ class MessageContainerView_Tests: StreamChatTestCase { .environment(\.channelTranslationLanguage, .spanish) // Then - AssertSnapshot(view, size: CGSize(width: 375, height: 200)) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } func test_translatedText_myMessageIsNotTranslated_snapshot() { @@ -412,20 +449,9 @@ class MessageContainerView_Tests: StreamChatTestCase { isSentByCurrentUser: true ) - let view = MessageContainerView( - factory: DefaultViewFactory.shared, - channel: .mockDMChannel(config: .mock(repliesEnabled: true)), - message: message, - width: defaultScreenSize.width, - showsAllInfo: true, - isInThread: false, - isLast: false, - scrolledId: .constant(nil), - quotedMessage: .constant(nil), - onLongPress: { _ in } - ) + let viewModel = MessageViewModel(message: message, channel: .mockDMChannel(config: .mock(repliesEnabled: true))) - XCTAssertTrue(view.isSwipeToReplyPossible) + XCTAssertTrue(viewModel.isSwipeToQuoteReplyPossible) } func test_isSwipeToReplyPossible_whenRepliesDisabled_whenMessageInteractable_shouldBeFalse() { @@ -437,20 +463,9 @@ class MessageContainerView_Tests: StreamChatTestCase { isSentByCurrentUser: true ) - let view = MessageContainerView( - factory: DefaultViewFactory.shared, - channel: .mockDMChannel(config: .mock(repliesEnabled: false)), - message: message, - width: defaultScreenSize.width, - showsAllInfo: true, - isInThread: false, - isLast: false, - scrolledId: .constant(nil), - quotedMessage: .constant(nil), - onLongPress: { _ in } - ) + let viewModel = MessageViewModel(message: message, channel: .mockDMChannel(config: .mock(repliesEnabled: false))) - XCTAssertFalse(view.isSwipeToReplyPossible) + XCTAssertFalse(viewModel.isSwipeToQuoteReplyPossible) } func test_isSwipeToReplyPossible_whenRepliesEnabled_whenMessageNotInteractable_shouldBeFalse() { @@ -463,28 +478,125 @@ class MessageContainerView_Tests: StreamChatTestCase { isSentByCurrentUser: true ) - let view = MessageContainerView( - factory: DefaultViewFactory.shared, - channel: .mockDMChannel(config: .mock(repliesEnabled: true)), + let viewModel = MessageViewModel(message: message, channel: .mockDMChannel(config: .mock(repliesEnabled: true))) + + XCTAssertFalse(viewModel.isSwipeToQuoteReplyPossible) + } + + func test_translatedText_showOriginalTranslatedButtonEnabled_originalTextShown_snapshot() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + author: .mock(id: .unique), + translations: [ + .spanish: "Hola" + ] + ) + let utils = Utils( + dateFormatter: EmptyDateFormatter(), + messageListConfig: .init( + messageDisplayOptions: MessageDisplayOptions(showOriginalTranslatedButton: true) + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + // When + let messageViewModel = MessageViewModel_Mock( message: message, - width: defaultScreenSize.width, - showsAllInfo: true, - isInThread: false, - isLast: false, - scrolledId: .constant(nil), - quotedMessage: .constant(nil), - onLongPress: { _ in } + channel: .mock( + cid: .unique, + membership: .mock(id: .unique, language: .spanish) + ) + ) + messageViewModel.mockOriginalTextShown = true + let view = testMessageViewContainer(message: message, messageViewModel: messageViewModel) + .environment(\.channelTranslationLanguage, .spanish) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_translatedText_showOriginalTranslatedButtonEnabled_translatedTextShown_snapshot() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + author: .mock(id: .unique), + translations: [ + .spanish: "Hola" + ] + ) + let utils = Utils( + dateFormatter: EmptyDateFormatter(), + messageListConfig: .init( + messageDisplayOptions: MessageDisplayOptions(showOriginalTranslatedButton: true) + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + // When + let messageViewModel = MessageViewModel_Mock( + message: message, + channel: .mock( + cid: .unique, + membership: .mock(id: .unique, language: .spanish) + ) ) + messageViewModel.mockOriginalTextShown = false + let view = testMessageViewContainer(message: message, messageViewModel: messageViewModel) + .environment(\.channelTranslationLanguage, .spanish) - XCTAssertFalse(view.isSwipeToReplyPossible) + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_translatedText_showOriginalTranslatedButtonDisabled_translatedTextShown_snapshot() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + author: .mock(id: .unique), + translations: [ + .spanish: "Hola" + ] + ) + let utils = Utils( + dateFormatter: EmptyDateFormatter(), + messageListConfig: .init( + messageDisplayOptions: MessageDisplayOptions(showOriginalTranslatedButton: false) + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + // When + let messageViewModel = MessageViewModel_Mock( + message: message, + channel: .mock( + cid: .unique, + membership: .mock(id: .unique, language: .spanish) + ) + ) + let view = testMessageViewContainer(message: message, messageViewModel: messageViewModel) + .environment(\.channelTranslationLanguage, .spanish) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } // MARK: - private - func testMessageViewContainer(message: ChatMessage) -> some View { + func testMessageViewContainer( + message: ChatMessage, + channel: ChatChannel? = nil, + messageViewModel: MessageViewModel? = nil + ) -> some View { MessageContainerView( factory: DefaultViewFactory.shared, - channel: .mockDMChannel(), + channel: channel ?? .mockDMChannel(), message: message, width: defaultScreenSize.width, showsAllInfo: true, @@ -492,8 +604,17 @@ class MessageContainerView_Tests: StreamChatTestCase { isLast: false, scrolledId: .constant(nil), quotedMessage: .constant(nil), - onLongPress: { _ in } + onLongPress: { _ in }, + viewModel: messageViewModel ?? MessageViewModel(message: message, channel: channel) ) .frame(width: 375, height: 200) } } + +class MessageViewModel_Mock: MessageViewModel { + var mockOriginalTextShown: Bool = false + + override var originalTextShown: Bool { + mockOriginalTextShown + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageReadIndicatorView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageReadIndicatorView_Tests.swift index 9d709e433..4773d5662 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageReadIndicatorView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageReadIndicatorView_Tests.swift @@ -72,6 +72,32 @@ class MessageReadIndicatorView_Tests: StreamChatTestCase { assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + func test_messageReadIndicatorView_snapshotSyncing() { + // Given + let view = MessageReadIndicatorView( + readUsers: [], + showReadCount: false, + localState: .syncing + ) + .frame(width: 50, height: 16) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_messageReadIndicatorView_snapshotSyncing_whenShowReadCount() { + // Given + let view = MessageReadIndicatorView( + readUsers: [.mock(id: .unique)], + showReadCount: true, + localState: .syncing + ) + .frame(width: 50, height: 16) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + func test_messageReadIndicatorView_snapshotMessageFailed() { // Given let view = MessageReadIndicatorView( @@ -84,4 +110,17 @@ class MessageReadIndicatorView_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + func test_messageReadIndicatorView_snapshotMessageEditingFailed() { + // Given + let view = MessageReadIndicatorView( + readUsers: [], + showReadCount: false, + localState: .syncingFailed + ) + .frame(width: 50, height: 16) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift index 03c4f6dbd..701ec6ee2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift @@ -136,7 +136,7 @@ class ReactionsOverlayView_Tests: StreamChatTestCase { let view = VerticallyCenteredView { ReactionsOverlayView( factory: DefaultViewFactory.shared, - channel: .mockDMChannel(ownCapabilities: [.sendMessage, .uploadFile, .pinMessage]), + channel: .mockDMChannel(ownCapabilities: [.sendMessage, .uploadFile, .pinMessage, .readEvents]), currentSnapshot: self.overlayImage, messageDisplayInfo: messageDisplayInfo, onBackgroundTap: {}, @@ -241,15 +241,17 @@ class ReactionsOverlayView_Tests: StreamChatTestCase { contentWidth: self.messageDisplayInfo.contentWidth, isFirst: true ) + let channel = ChatChannel.mock(cid: .unique, membership: .mock(id: "test", language: .portuguese)) let view = VerticallyCenteredView { ReactionsOverlayView( factory: DefaultViewFactory.shared, - channel: .mock(cid: .unique, membership: .mock(id: "test", language: .portuguese)), + channel: channel, currentSnapshot: self.overlayImage, messageDisplayInfo: messageDisplayInfo, onBackgroundTap: {}, onActionExecuted: { _ in } ) + .environment(\.messageViewModel, MessageViewModel(message: testMessage, channel: channel)) } // Then diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_messageContainerView_editingFailed_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_messageContainerView_editingFailed_snapshot.1.png new file mode 100644 index 000000000..0ed7c0c69 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_messageContainerView_editingFailed_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_messageContainerView_sendingFailed_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_messageContainerView_sendingFailed_snapshot.1.png new file mode 100644 index 000000000..0ed7c0c69 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_messageContainerView_sendingFailed_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_participant_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_participant_snapshot.1.png new file mode 100644 index 000000000..c16e211b9 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_participant_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonDisabled_translatedTextShown_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonDisabled_translatedTextShown_snapshot.1.png new file mode 100644 index 000000000..418bef28c Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonDisabled_translatedTextShown_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonEnabled_originalTextShown_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonEnabled_originalTextShown_snapshot.1.png new file mode 100644 index 000000000..3bfa6c890 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonEnabled_originalTextShown_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonEnabled_translatedTextShown_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonEnabled_translatedTextShown_snapshot.1.png new file mode 100644 index 000000000..afbc90ca5 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageContainerView_Tests/test_translatedText_showOriginalTranslatedButtonEnabled_translatedTextShown_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotMessageEditingFailed.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotMessageEditingFailed.1.png new file mode 100644 index 000000000..f210a1ecd Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotMessageEditingFailed.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotSyncing.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotSyncing.1.png new file mode 100644 index 000000000..567c11648 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotSyncing.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotSyncing_whenShowReadCount.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotSyncing_whenShowReadCount.1.png new file mode 100644 index 000000000..567c11648 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageReadIndicatorView_Tests/test_messageReadIndicatorView_snapshotSyncing_whenShowReadCount.1.png differ diff --git a/StreamChatSwiftUITests/Tests/Utils/MessageRelativeDateFormatter_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/MessageRelativeDateFormatter_Tests.swift new file mode 100644 index 000000000..af8412a4e --- /dev/null +++ b/StreamChatSwiftUITests/Tests/Utils/MessageRelativeDateFormatter_Tests.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat +@testable import StreamChatSwiftUI +import XCTest + +final class MessageRelativeDateFormatter_Tests: StreamChatTestCase { + private var formatter: MessageRelativeDateFormatter! + + override func setUp() { + super.setUp() + formatter = MessageRelativeDateFormatter() + formatter.locale = Locale(identifier: "en_UK") + formatter.todayFormatter.locale = Locale(identifier: "en_UK") + formatter.yesterdayFormatter.locale = Locale(identifier: "en_UK") + } + + override func tearDown() { + super.tearDown() + formatter = nil + } + + func test_showingTimeOnly() throws { + let date = try XCTUnwrap(Calendar.current.date(bySettingHour: 1, minute: 2, second: 3, of: Date())) + let result = formatter.string(from: date) + let expected = formatter.todayFormatter.string(from: date) + XCTAssertEqual(expected, result) + XCTAssertEqual("01:02", result) + } + + func test_showingYesterday() throws { + let date = try XCTUnwrap(Calendar.current.date(byAdding: .day, value: -1, to: Date())) + let result = formatter.string(from: date) + let expected = formatter.yesterdayFormatter.string(from: date) + XCTAssertEqual(expected, result) + XCTAssertEqual("Yesterday", result) + } + + func test_showingWeekday() throws { + let date = try XCTUnwrap(Calendar.current.date(byAdding: .day, value: -6, to: Date())) + let result = formatter.string(from: date) + let expected = formatter.weekdayFormatter.string(from: date) + XCTAssertEqual(expected, result) + } + + func test_showingShortDate() throws { + let components = DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2025, + month: 1, + day: 15, + hour: 3, + minute: 4, + second: 5 + ) + let date = try XCTUnwrap(Calendar.current.date(from: components)) + let result = formatter.string(from: date) + XCTAssertEqual("15/01/2025", result) + } +} diff --git a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift index 8387b3faa..4a1aa38f5 100644 --- a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift +++ b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift @@ -982,7 +982,22 @@ class ViewFactory_Tests: StreamChatTestCase { ) // Then - XCTAssert(view is GalleryView) + XCTAssert(view is GalleryView) + } + + func test_viewFactory_makeGalleryHeaderView() { + // Given + let viewFactory = DefaultViewFactory.shared + + // When + let view = viewFactory.makeGalleryHeaderView( + title: .unique, + subtitle: .unique, + shown: .constant(true) + ) + + // Then + XCTAssert(view is GalleryHeaderView) } func test_viewFactory_makeVideoPlayerView() { @@ -998,7 +1013,22 @@ class ViewFactory_Tests: StreamChatTestCase { ) // Then - XCTAssert(view is VideoPlayerView) + XCTAssert(view is VideoPlayerView) + } + + func test_viewFactory_makeVideoPlayerHeaderView() { + // Given + let viewFactory = DefaultViewFactory.shared + + // When + let view = viewFactory.makeVideoPlayerHeaderView( + title: .unique, + subtitle: .unique, + shown: .constant(true) + ) + + // Then + XCTAssert(view is GalleryHeaderView) } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index cc59dc920..b5d6b1f12 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,7 +7,7 @@ require 'xcodeproj' import 'Sonarfile' import 'Allurefile' -xcode_version = ENV['XCODE_VERSION'] || '16.2' +xcode_version = ENV['XCODE_VERSION'] || '16.3' xcode_project = 'StreamChatSwiftUI.xcodeproj' sdk_names = ['StreamChatSwiftUI'] github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-swiftui' @@ -23,7 +23,7 @@ before_all do |lane| if is_ci setup_ci setup_git_config - xcversion(version: xcode_version) unless [:sonar_upload, :allure_launch, :allure_upload, :copyright, :pod_lint].include?(lane) + select_xcode(version: xcode_version) unless [:sonar_upload, :allure_launch, :allure_upload, :copyright, :pod_lint].include?(lane) end end @@ -217,15 +217,31 @@ lane :match_me do |options| end desc 'Builds the latest version of Demo app and uploads it to TestFlight' -lane :swiftui_testflight_build do +lane :swiftui_testflight_build do |options| + is_manual_upload = is_localhost || !options[:configuration].to_s.empty? + configuration = options[:configuration].to_s.empty? ? 'Release' : options[:configuration] + match_me + + sdk_version = get_sdk_version_from_environment + app_version = + if is_manual_upload + major, minor, _patch = sdk_version.split('.').map(&:to_i) + minor += 1 + "#{major}.#{minor}.0" + else + sdk_version + end + UI.important("[TestFlight] Uploading DemoApp version: #{app_version}") + testflight_build( api_key: appstore_api_key, xcode_project: xcode_project, sdk_target: 'StreamChatSwiftUI', + app_version: app_version, app_target: 'DemoAppSwiftUI', app_identifier: 'io.getstream.iOS.DemoAppSwiftUI', - app_version: File.read(swift_environment_path).match(/String\s+=\s+"([\d.]+)"/)[1] + configuration: configuration ) end @@ -320,29 +336,24 @@ lane :test_e2e_mock do |options| number_of_retries: 3 } - if is_localhost - scan(scan_options) - else - parallelize_tests_on_ci(scan: scan_options, batch: options[:batch], cron: options[:cron]) + if ENV['MATRIX_SIZE'] && options[:batch] + products_dir = File.expand_path("../#{derived_data_path}/Build/Products") + xctestrun = Dir.glob(File.expand_path("#{products_dir}/*.xctestrun")).first + tests = retrieve_xctest_names(xctestrun: xctestrun).values.flatten + slice_size = (tests.size / ENV['MATRIX_SIZE'].to_f).ceil + only_testing = [] + tests.each_slice(slice_size) { |test| only_testing << test } + only_testing_batch = only_testing[options[:batch].to_i] + scan_options[:only_testing] = only_testing_batch + UI.important("Tests in total: #{only_testing.flatten.size}. Running #{only_testing_batch.size} of them ⌛️") end -end - -private_lane :parallelize_tests_on_ci do |options| - products_dir = File.expand_path("../#{derived_data_path}/Build/Products") - xctestrun = Dir.glob(File.expand_path("#{products_dir}/*.xctestrun")).first - tests = retrieve_xctest_names(xctestrun: xctestrun).values.flatten - slice_size = options[:cron] ? tests.size : (tests.size / ENV['MATRIX_SIZE'].to_f).ceil - only_testing = [] - tests.each_slice(slice_size) { |test| only_testing << test } - only_testing_batch = only_testing[options[:batch].to_i] begin - UI.success("Tests in total: #{only_testing.flatten.size}. Running #{only_testing_batch.size} of them ⌛️") - scan(options[:scan].merge(only_testing: only_testing_batch)) + scan(scan_options) rescue StandardError failed_tests = retreive_failed_tests UI.important("Re-running #{failed_tests.size} failed tests ⌛️") - scan(options[:scan].merge(only_testing: failed_tests)) + scan(scan_options.merge(only_testing: failed_tests)) end end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index 09a35c441..3fc4f8457 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -5,4 +5,4 @@ gem 'fastlane-plugin-versioning' gem 'fastlane-plugin-sonarcloud_metric_kit' gem 'fastlane-plugin-create_xcframework' -gem 'fastlane-plugin-stream_actions', '0.3.77' +gem 'fastlane-plugin-stream_actions', '0.3.79'