From c04b53ebb7d01027293e4e6028bc9244017c3a99 Mon Sep 17 00:00:00 2001 From: Stream Bot Date: Tue, 17 Jun 2025 10:25:04 +0000 Subject: [PATCH 01/10] Update release version to snapshot --- Sources/StreamChat/Generated/SystemEnvironment+Version.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift index f81118ac163..7d778cbe4a3 100644 --- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift +++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift @@ -7,5 +7,5 @@ import Foundation extension SystemEnvironment { /// A Stream Chat version. - public static let version: String = "4.80.0" + public static let version: String = "4.81.0-SNAPSHOT" } From 3e226efa09ca6e8dff0d6a5601401340b937b761 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Tue, 17 Jun 2025 15:57:57 +0100 Subject: [PATCH 02/10] [CI] Fix Sonar issue on develop (#3701) --- .github/workflows/sonar.yml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 9bd2c2eee38..83b4d71feb0 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -19,25 +19,24 @@ jobs: - uses: actions/checkout@v4.1.1 - uses: actions/github-script@v6 - id: get_pr_data + id: get_pr_number with: script: | - return ( - await github.rest.repos.listPullRequestsAssociatedWithCommit({ - commit_sha: context.sha, - owner: context.repo.owner, - repo: context.repo.repo, - }) - ).data[0]; - - - uses: ./.github/actions/bootstrap - env: - INSTALL_SONAR: true - SKIP_MINT_BOOTSTRAP: true + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + commit_sha: context.sha, + owner: context.repo.owner, + repo: context.repo.repo, + }); + return prs.data[0]?.number || ''; - name: Run Sonar analysis run: | - ARTIFACT_NAME="test-coverage-${{ fromJson(steps.get_pr_data.outputs.result).number }}" + if [[ -z "${{ steps.get_pr_number.outputs.result }}" ]]; then + echo "No PR found. Skipping Sonar analysis." + exit 0 + fi + + ARTIFACT_NAME="test-coverage-${{ steps.get_pr_number.outputs.result }}" ARTIFACT=$(gh api repos/${{ github.repository }}/actions/artifacts | jq -r ".artifacts | map(select(.name==\"$ARTIFACT_NAME\")) | first") if [[ "$ARTIFACT" == null || "$ARTIFACT" == "" ]]; then echo "Artifact not found. Skipping Sonar analysis." From 969def667bdbd6b05505e141a7a7d78c0796bb60 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Thu, 19 Jun 2025 08:51:05 +0100 Subject: [PATCH 03/10] [CI] Get rid of a workaround on testflight manual upload (#3702) --- fastlane/Fastfile | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9538f39be02..6750fffb3d5 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -282,22 +282,14 @@ lane :uikit_testflight_build do |options| 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}") + UI.important("[TestFlight] Uploading DemoApp version: #{sdk_version}") testflight_build( api_key: appstore_api_key, xcode_project: xcode_project, sdk_target: 'StreamChat', app_target: 'DemoApp', - app_version: app_version, + app_version: sdk_version, app_identifier: 'io.getstream.iOS.ChatDemoApp', configuration: configuration, extensions: ['DemoShare'], From ad8e9fc14d98ae5b291c0b20b64419f298b0b7d5 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Thu, 19 Jun 2025 09:05:14 +0100 Subject: [PATCH 04/10] [CI] Switch python version in xcmetrics workflow (#3700) --- .github/workflows/sdk-performance-metrics.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/sdk-performance-metrics.yml b/.github/workflows/sdk-performance-metrics.yml index 09cbd9afcfe..d1760a988d3 100644 --- a/.github/workflows/sdk-performance-metrics.yml +++ b/.github/workflows/sdk-performance-metrics.yml @@ -28,6 +28,10 @@ jobs: with: ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + - uses: actions/setup-python@v5 + with: + python-version: 3.12 # gsutil requires Python version 3.8-3.12 + - uses: actions/checkout@v3.1.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} with: From ba80a94585c8f3085beb70c1fcddbde7fbddcd23 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 24 Jun 2025 14:53:01 +0100 Subject: [PATCH 05/10] Add Static Location and Live Location Support (#3531) * Add static and live location payloads * Update the demo app to use static location attachment * Add new `ChannelController.sendStaticLocation()` to instantly send a location message to a channel * Change the Demo App to send the current location instead of dummy ones * Add location background mode to Demo App * Add staticLocation to the attachments register * Create a CurrentUserLocationProvider to make it easier to fetch the current user location * Fix not being able to part live location payload * Update DemoApp to support live location attachments * Add support for partial message update in MessageUpdater * Expose the Throttler (Revert this, and use it internally) * Add `shareLiveLocation()` and `stopLiveLocation()` to `ChannelController` * Refactor logic to fetch current active locations + Improve API * Add extra data to location attachment payloads * Add `LocationAttachmentInfo` to be used as argument * Add `text` to partial message update endpoint * Add `ChatMessageController.updateMessage()` to support partially updating a message * Change location live updates APIs * Improve Demo App Location Provider * Improve API by observing active live locations messages * Fixed starting monitoring location whenever an active location was updated * Make the API even easier and add additional delegate methods to know when the user should start and stop location sharing * Move the Throttler to LLC, just like the Debouncer * Move the Throttling to the current user controller to automatically protect against abusive updates * Some minor cleanups * Simplify currentUserControllerDidStartSharingLiveLocation API * Improve location attachment view * Add live location map when tapping the live location attachmen * Animate the user location tracking * Optimal animation * Optmizate animation smoothness and server spamm * Add CoreData concurrency flag to StreamDevelopers scheme * Fix crash when creating the MessageController from a background thread * Fix snapshot chaching * Fix map detail view controller not showing initial position * Fix avatar view in map view * Add pulse animation when live sharing * Refactor LocationDetailViewController to only use the message controller * Refactor code structure of the map detail view controller * Add bottom sheet to stop location sharing * Add stopLiveLocationSharing() to Message Controller * Fix stop sharing button not working * FIx bottom sheet logic * Add static pin in detail view * Fix sharing location for other users active location messages * Finish logic for location snapshot view when static vs live * Add live location status view in the snapshot view * Minor cleanup * Fix copyright * Fix loading indicator snapshot view * Remove support of mixed attachments to locations * Add MessageEndpoints test coverage * Add test coverage to message updater * Add test coverage to Message Repository * Add test coverage to message attachments extensions * Add test coverage to parsing attachments * Add test coverage to MessageDTO * Add message updater mock * ActiveLiveLocationAlreadyExists init should not be public * Add test coverage to Message Controller * Add test coverage to Channel Controller * Fix concurrency issues when stopping and updating the live location at the same time * Fix Message Controller Tests * Change updateMessage -> partialUpdateMessage * Add unset support for partial update message * Update CHANGELOG.md * Fix tests, not compiling because of unset * Fix test_updatePartialMessage_makesCorrectAPICall * Make `ChatMessageController.updateLiveLocation()` internal * Update CHANGELOG.md * Fix reloading the snapshot when not necesasry * Fix avatar view showing for a split second in the snapshot view for static attachments * Change location attachment to have dynamic height depending on message list size * Extract avatar size in snapshot view * Fix minor typo * Add fixed width to map snapshot and simplify caching logic * Use a banner view instead of a sheet in the map detail view * Present the map instead of pushing when on iPad * Add more documentation on how "Tracking" behaviour works * Enable locations by default in the demo app * Fix quote message for live location * Fix location attachments should not be editable * Do not show location attachment picker when inside thread * Fix preview message for location attachments * Fix detail map show Stop Sharing button for another user * Disable locations feature by default * Add experimental flag * Update CHANGELOG.md * Revert "Disable locations feature by default" This reverts commit 07652720c6bcae382fc1d188d7168c14ef1909c9. * Revert "Add experimental flag" This reverts commit d06eda08903433c3ed2d3e120ef00f54bc0dbab2. * Revert "Update CHANGELOG.md" This reverts commit 37bd1d15282479086487ad2b6f41cb09f398614c. * Fix missing stuff from merge conflicts * Update CHANGELOG.md * Rename LocationAttachmentInfo to LocationInfo * Add LocationDTO * Update Atlantis * Refactor to new SharedLocation object * Demo App new Static Location integration * Fix Merge Conflicts Errors * Add new location endpoints * Implement new stop live location sharing * Remove stopLiveLocationSharing from ChannelController * Implement optimistic stop live location sharing * Add `CurrentUserController.loadLiveLocations()` * Add new updateLiveLocation endpoint instead of using partial update message * Migrate existing unit tests to the new implementation * Fix not possible to open location detail view * Remove unnecessary location optimistic update * Auto-centering feature * Fix sharing location of failed messages * Fix stopping a live location not triggering didStopLiveLocationSharing delegate * Update CHANGELOG.md * Fix parsing active current active location messages * Remove unused message updater in ChannelController * Fix docs typo * Add action sheet to select live location in the demo app * Make atlantis version static * Fix unit tests compilation * Fix removeAllData test * Remove `didChangeActiveLiveLocationMessages` since it is not needed for now * Improve docs of current user controller * Revert "Remove `didChangeActiveLiveLocationMessages` since it is not needed for now" This reverts commit 37f4290d7f649938f738b1398af6e8e6b00b3fa7. # Conflicts: # Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift * Make sure live location is changed to not active when reaches the end * Forward to the current user delegate event if there was an error updating live location * Fix potential memory leak * Use message updater directly in the current user controller * Add option to provide location info from createNewMessage * Only call didStartSharingLiveLocation or didStopSharingLiveLocation if are from the current device * Handle multi device logic when updating live location sharing * Revert Multi-Device logic for now * Fix compilation tests * Add message updater tests * Add test coverage to loadAllActiveLiveLocations * Add test coverage to use device Id when creating new live location message * Update CHANGELOG.md * Fix flaky test_stopLiveLocationSharing_apiFailure_revertsOptimisticUpdate * Render endAt text in the location attachment images * Fix not able to share 1min location * Fix auto center button showing in static locations * Add `channel.config.sharedLocationsEnabled` * Add `canShareLocation` capability * Add `ChatChannel.activeLiveLocations` * Rename LocationDTO -> SharedLocationDTO * Fix Channel.json typo * Add `ActiveLiveLocationsEndTimeTracker` to track when the endAt is reached * Fix concurrency issue in ActiveLiveLocationsEndTimeTracker * Add missing properties to shared location * Optimistically stop active live location when starting a new one * Expose `CurrentUserController.activeLiveLocationMessages` * Remove outdated comment * Add test coverage to ActiveLiveLocationsEndTimeTracker * Fix the ActiveLiveLocationsEndTimeTracker not triggering an UI Update * Try to make test less flaky * Add missing docs to `CurrentUserController.loadActiveLocationsMessages()` --------- Co-authored-by: Alexey Alter-Pesotskiy --- CHANGELOG.md | 19 +- DemoApp/Info.plist | 34 +- DemoApp/LocationProvider.swift | 99 +++++ .../AppConfigViewController.swift | 4 +- DemoApp/Screens/DemoAppTabBarController.swift | 42 ++ .../DemoAttachmentViewCatalog.swift | 2 +- .../CustomAttachments/DemoComposerVC.swift | 123 ++++-- .../DemoQuotedChatMessageView.swift | 31 +- ...chmentPayload+AttachmentViewProvider.swift | 19 - .../LocationAttachmentPayload.swift | 22 -- .../LocationAttachmentSnapshotView.swift | 253 +++++++++--- .../LocationAttachmentViewDelegate.swift | 44 ++- .../LocationAttachmentViewInjector.swift | 47 ++- .../LocationDetailViewController.swift | 315 ++++++++++++++- .../LocationSharingStatusView.swift | 56 +++ .../LocationAttachment/UserAnnotation.swift | 18 + .../UserAnnotationView.swift | 82 ++++ .../DemoChatChannelListItemView.swift | 15 + .../Components/DemoChatMessageActionsVC.swift | 5 + .../StreamChatWrapper+DemoApp.swift | 5 - .../Endpoints/ChannelEndpoints.swift | 7 +- .../EndpointPath+OfflineRequest.swift | 2 +- .../APIClient/Endpoints/EndpointPath.swift | 6 +- .../Endpoints/LocationEndpoints.swift | 43 +++ .../Endpoints/MessageEndpoints.swift | 31 +- .../Payloads/ChannelListPayload.swift | 14 +- .../Payloads/IdentifiableModel.swift | 6 + .../Endpoints/Payloads/LocationPayloads.swift | 77 ++++ .../Endpoints/Payloads/MessagePayloads.swift | 11 +- Sources/StreamChat/ChatClient.swift | 4 + .../ChannelController/ChannelController.swift | 143 ++++++- .../CurrentUserController.swift | 198 +++++++++- .../MessageController/MessageController.swift | 91 ++++- .../Database/DTOs/ChannelConfigDTO.swift | 3 + .../StreamChat/Database/DTOs/ChannelDTO.swift | 9 +- .../StreamChat/Database/DTOs/MessageDTO.swift | 103 ++++- .../Database/DTOs/SharedLocationDTO.swift | 106 +++++ .../StreamChat/Database/DatabaseSession.swift | 10 + .../StreamChatModel.xcdatamodel/contents | 22 ++ Sources/StreamChat/Models/Channel.swift | 18 +- Sources/StreamChat/Models/ChatMessage.swift | 14 +- Sources/StreamChat/Models/DraftMessage.swift | 1 + .../Models/Location/LocationInfo.swift | 21 + .../Models/Location/NewLocationInfo.swift | 15 + .../Models/Location/SharedLocation.swift | 60 +++ Sources/StreamChat/Models/UserInfo.swift | 2 +- .../Repositories/MessageRepository.swift | 27 +- .../Utils/Throttler.swift | 18 +- .../Events/MessageEvents.swift | 3 +- .../ActiveLiveLocationsEndTimeTracker.swift | 98 +++++ .../StreamChat/Workers/ChannelUpdater.swift | 2 + .../Workers/CurrentUserUpdater.swift | 18 + .../StreamChat/Workers/MessageUpdater.swift | 247 ++++++++++-- StreamChat.xcodeproj/project.pbxproj | 104 ++++- .../Unique/ChatMessage+Unique.swift | 3 +- .../Fixtures/JSONs/Channel.json | 14 + .../Fixtures/JSONs/ChannelPayload.json | 1 + .../ChatChannel_Mock.swift | 22 +- .../ChatMessage_Mock.swift | 6 +- .../ChatChannelController_Mock.swift | 1 + .../Database/DatabaseSession_Mock.swift | 7 + .../Workers/ChannelUpdater_Mock.swift | 3 + .../Workers/MessageUpdater_Mock.swift | 57 +++ .../Spy/DatabaseContainer_Spy.swift | 13 +- .../TestData/DummyData/ChannelPayload.swift | 6 +- .../DummyData/MessageAttachmentPayload.swift | 2 +- .../TestData/DummyData/MessagePayload.swift | 6 +- .../DummyData/SharedLocationPayload.swift | 33 ++ .../TestData/DummyData/XCTestCase+Dummy.swift | 6 +- .../Endpoints/MessageEndpoints_Tests.swift | 24 ++ .../Payloads/ChannelListPayload_Tests.swift | 5 +- .../Payloads/IdentifiablePayload_Tests.swift | 3 +- .../ChannelController_Tests.swift | 131 +++++++ .../MessageController_Tests.swift | 164 +++++++- .../Database/DTOs/ChannelDTO_Tests.swift | 24 +- .../Database/DTOs/MessageDTO_Tests.swift | 282 ++++++++++++++ .../Database/DatabaseContainer_Tests.swift | 1 + .../Models/ChatChannel_Tests.swift | 8 + .../Models/ChatMessage_Tests.swift | 2 + .../MessageRepository_Tests.swift | 83 +++- .../ChannelReadUpdaterMiddleware_Tests.swift | 3 +- ...iveLiveLocationsEndTimeTracker_Tests.swift | 236 ++++++++++++ .../Workers/CurrentUserUpdater_Tests.swift | 64 +++ .../Workers/MessageUpdater_Tests.swift | 363 ++++++++++++++++++ 84 files changed, 4022 insertions(+), 320 deletions(-) create mode 100644 DemoApp/LocationProvider.swift delete mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift delete mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift create mode 100644 Sources/StreamChat/APIClient/Endpoints/LocationEndpoints.swift create mode 100644 Sources/StreamChat/APIClient/Endpoints/Payloads/LocationPayloads.swift create mode 100644 Sources/StreamChat/Database/DTOs/SharedLocationDTO.swift create mode 100644 Sources/StreamChat/Models/Location/LocationInfo.swift create mode 100644 Sources/StreamChat/Models/Location/NewLocationInfo.swift create mode 100644 Sources/StreamChat/Models/Location/SharedLocation.swift rename Sources/{StreamChatUI => StreamChat}/Utils/Throttler.swift (83%) create mode 100644 Sources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swift create mode 100644 TestTools/StreamChatTestTools/TestData/DummyData/SharedLocationPayload.swift create mode 100644 Tests/StreamChatTests/Workers/Background/ActiveLiveLocationsEndTimeTracker_Tests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c8abebf74..93868f1ea97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +## StreamChat +### ✅ Added +- Add `ChatMessageController.partialUpdateMessage()` [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) +- Add Location Sharing Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessage.sharedLocation` + - Add `ChatMessageController.stopLiveLocationSharing()` + - Add `ChatChannelController`: + - `sendStaticLocation()` + - `startLiveLocationSharing()` + - Add `CurrentChatUserController`: + - `loadActiveLiveLocationMessages()` + - `updateLiveLocation()` + - Add `CurrentChatUserControllerDelegate`: + - `didStartSharingLiveLocation()` + - `didStopSharingLiveLocation()` + - `didChangeActiveLiveLocationMessages()` + - `didFailToUpdateLiveLocation()` + # [4.80.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.80.0) _June 17, 2025_ diff --git a/DemoApp/Info.plist b/DemoApp/Info.plist index 5f0f3e06ea6..3ab08bd18c6 100644 --- a/DemoApp/Info.plist +++ b/DemoApp/Info.plist @@ -2,16 +2,6 @@ - NSBonjourServices - - _Proxyman._tcp - - NSLocalNetworkUsageDescription - Atlantis would use Bonjour Service to discover Proxyman app from your local network. - NSCameraUsageDescription - We need access to your camera for sending photo attachments. - NSMicrophoneUsageDescription - We need access to your microphone for taking a video. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -30,8 +20,26 @@ $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption + LSApplicationCategoryType + LSRequiresIPhoneOS + NSBonjourServices + + _Proxyman._tcp + + NSCameraUsageDescription + We need access to your camera for sending photo attachments. + NSLocalNetworkUsageDescription + Atlantis would use Bonjour Service to discover Proxyman app from your local network. + NSLocationAlwaysUsageDescription + We need access to your location to share it in the chat. + NSLocationWhenInUseUsageDescription + We need access to your location to share it in the chat. + NSMicrophoneUsageDescription + We need access to your microphone for taking a video. + PushNotification-Configuration + APN-Configuration UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -51,6 +59,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + location + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -70,7 +82,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - PushNotification-Configuration - APN-Configuration diff --git a/DemoApp/LocationProvider.swift b/DemoApp/LocationProvider.swift new file mode 100644 index 00000000000..c05d24d0c5f --- /dev/null +++ b/DemoApp/LocationProvider.swift @@ -0,0 +1,99 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreLocation +import Foundation + +enum LocationPermissionError: Error { + case permissionDenied + case permissionRestricted +} + +class LocationProvider: NSObject { + private let locationManager: CLLocationManager + private var onCurrentLocationFetch: ((Result) -> Void)? + + var didUpdateLocation: ((CLLocation) -> Void)? + var lastLocation: CLLocation? + var onError: ((Error) -> Void)? + + private init(locationManager: CLLocationManager = CLLocationManager()) { + self.locationManager = locationManager + super.init() + } + + static let shared = LocationProvider() + + var isMonitoringLocation: Bool { + locationManager.delegate != nil + } + + func startMonitoringLocation() { + locationManager.allowsBackgroundLocationUpdates = true + locationManager.delegate = self + requestPermission { [weak self] error in + guard let error else { return } + self?.onError?(error) + } + } + + func stopMonitoringLocation() { + locationManager.allowsBackgroundLocationUpdates = false + locationManager.stopUpdatingLocation() + locationManager.delegate = nil + } + + func getCurrentLocation(completion: @escaping (Result) -> Void) { + onCurrentLocationFetch = completion + if let lastLocation = lastLocation { + onCurrentLocationFetch?(.success(lastLocation)) + onCurrentLocationFetch = nil + } else { + requestPermission { [weak self] error in + guard let error else { return } + self?.onCurrentLocationFetch?(.failure(error)) + self?.onCurrentLocationFetch = nil + } + } + } + + func requestPermission(completion: @escaping (Error?) -> Void) { + locationManager.delegate = self + switch locationManager.authorizationStatus { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + completion(nil) + case .authorizedWhenInUse, .authorizedAlways: + locationManager.startUpdatingLocation() + completion(nil) + case .denied: + completion(LocationPermissionError.permissionDenied) + case .restricted: + completion(LocationPermissionError.permissionRestricted) + @unknown default: + break + } + } +} + +extension LocationProvider: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + if status == .authorizedWhenInUse || status == .authorizedAlways { + manager.startUpdatingLocation() + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.first else { return } + didUpdateLocation?(location) + lastLocation = location + onCurrentLocationFetch?(.success(location)) + onCurrentLocationFetch = nil + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { + onError?(error) + } +} diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 66f0b082382..3ac21aa7cbd 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -51,7 +51,7 @@ class AppConfig { isHardDeleteEnabled: false, isAtlantisEnabled: false, isMessageDebuggerEnabled: false, - isLocationAttachmentsEnabled: false, + isLocationAttachmentsEnabled: true, tokenRefreshDetails: nil, shouldShowConnectionBanner: false, isPremiumMemberFeatureEnabled: false, @@ -62,8 +62,6 @@ class AppConfig { if StreamRuntimeCheck.isStreamInternalConfiguration { demoAppConfig.isAtlantisEnabled = true demoAppConfig.isMessageDebuggerEnabled = true - demoAppConfig.isLocationAttachmentsEnabled = true - demoAppConfig.isLocationAttachmentsEnabled = true demoAppConfig.isHardDeleteEnabled = true demoAppConfig.shouldShowConnectionBanner = true demoAppConfig.isPremiumMemberFeatureEnabled = true diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index ec776b4baa5..9c6001ec54f 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -2,11 +2,14 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Combine import StreamChat import StreamChatUI import UIKit class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, MessageReminderListControllerDelegate { + private var locationProvider = LocationProvider.shared + let channelListVC: UIViewController let threadListVC: UIViewController let draftListVC: UIViewController @@ -59,6 +62,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele super.viewDidLoad() currentUserController.delegate = self + currentUserController.loadActiveLiveLocationMessages() unreadCount = currentUserController.unreadCount // Update reminders badge if the feature is enabled. @@ -78,6 +82,13 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele threadListVC.tabBarItem.image = UIImage(systemName: "text.bubble") threadListVC.tabBarItem.badgeColor = .red + locationProvider.didUpdateLocation = { [weak self] location in + let newLocation = LocationInfo( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + self?.currentUserController.updateLiveLocation(newLocation) + } draftListVC.tabBarItem.title = "Drafts" draftListVC.tabBarItem.image = UIImage(systemName: "bubble.and.pencil") @@ -99,6 +110,37 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge } + func currentUserControllerDidStartSharingLiveLocation( + _ controller: CurrentChatUserController + ) { + debugPrint("[Location] Started sharing live location.") + locationProvider.startMonitoringLocation() + } + + func currentUserControllerDidStopSharingLiveLocation(_ controller: CurrentChatUserController) { + debugPrint("[Location] Stopped sharing live location.") + locationProvider.stopMonitoringLocation() + } + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeActiveLiveLocationMessages messages: [ChatMessage] + ) { + guard !messages.isEmpty else { + return + } + + let locations: [String] = messages.compactMap { + guard let location = $0.sharedLocation else { + return nil + } + + return "(lat:\(location.latitude), lon:\(location.longitude), endAt: \(location.endAt?.description ?? "nil"))" + } + + debugPrint("[Location] Updated live locations to the server: \(locations)") + } + func controller( _ controller: MessageReminderListController, didChangeReminders changes: [ListChange] diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift index 814bf3da852..dfcad9e3a8c 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift @@ -8,7 +8,7 @@ import StreamChatUI class DemoAttachmentViewCatalog: AttachmentViewCatalog { override class func attachmentViewInjectorClassFor(message: ChatMessage, components: Components) -> AttachmentViewInjector.Type? { let hasMultipleAttachmentTypes = message.attachmentCounts.keys.count > 1 - let hasLocationAttachment = message.attachmentCounts.keys.contains(.location) + let hasLocationAttachment = message.sharedLocation != nil if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && hasLocationAttachment { if hasMultipleAttachmentTypes { return MixedAttachmentViewInjector.self diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index ec9df8bca71..8e7f85c2d9f 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -2,56 +2,117 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import CoreLocation import StreamChat import StreamChatUI import UIKit class DemoComposerVC: ComposerVC { - /// For demo purposes the locations are hard-coded. - var dummyLocations: [(latitude: Double, longitude: Double)] = [ - (38.708442, -9.136822), // Lisbon, Portugal - (37.983810, 23.727539), // Athens, Greece - (53.149118, -6.079341), // Greystones, Ireland - (41.11722, 20.80194), // Ohrid, Macedonia - (51.5074, -0.1278), // London, United Kingdom - (52.5200, 13.4050), // Berlin, Germany - (40.4168, -3.7038), // Madrid, Spain - (50.4501, 30.5234), // Kyiv, Ukraine - (41.9028, 12.4964), // Rome, Italy - (48.8566, 2.3522), // Paris, France - (44.4268, 26.1025), // Bucharest, Romania - (48.2082, 16.3738), // Vienna, Austria - (47.4979, 19.0402) // Budapest, Hungary - ] + private var locationProvider = LocationProvider.shared override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions - - let alreadyHasLocation = content.attachments.map(\.type).contains(.location) - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && !alreadyHasLocation { + + let isDemoAppLocationsEnabled = AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled + let isLocationEnabled = channelController?.channel?.config.sharedLocationsEnabled == true + if isLocationEnabled && isDemoAppLocationsEnabled && content.isInsideThread == false { let sendLocationAction = UIAlertAction( - title: "Location", + title: "Send Current Location", style: .default, - handler: { [weak self] _ in self?.sendLocation() } + handler: { [weak self] _ in + self?.sendInstantStaticLocation() + } ) actions.append(sendLocationAction) + + let sendLiveLocationAction = UIAlertAction( + title: "Share Live Location", + style: .default, + handler: { [weak self] _ in + self?.sendInstantLiveLocation() + } + ) + actions.append(sendLiveLocationAction) } return actions } - func sendLocation() { - guard let location = dummyLocations.randomElement() else { return } - let locationAttachmentPayload = LocationAttachmentPayload( - coordinate: .init(latitude: location.latitude, longitude: location.longitude) - ) + func sendInstantStaticLocation() { + getCurrentLocationInfo { [weak self] location in + guard let location = location else { return } + self?.channelController?.sendStaticLocation(location) + } + } - content.attachments.append(AnyAttachmentPayload(payload: locationAttachmentPayload)) + func sendInstantLiveLocation() { + getCurrentLocationInfo { [weak self] location in + guard let location = location else { return } + let alertController = UIAlertController( + title: "Share Live Location", + message: "Select the duration for sharing your live location.", + preferredStyle: .actionSheet + ) + let durations: [(String, TimeInterval)] = [ + ("1 minute", 61), + ("10 minutes", 600), + ("1 hour", 3600) + ] + for (title, duration) in durations { + let action = UIAlertAction(title: title, style: .default) { [weak self] _ in + let endDate = Date().addingTimeInterval(duration) + self?.channelController?.startLiveLocationSharing(location, endDate: endDate) { [weak self] result in + switch result { + case .success: + break + case .failure(let error): + self?.presentAlert( + title: "Could not start live location sharing", + message: error.localizedDescription + ) + } + } + } + alertController.addAction(action) + } + alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + self?.present(alertController, animated: true) + } + } + + private func getCurrentLocationInfo(completion: @escaping (LocationInfo?) -> Void) { + locationProvider.getCurrentLocation { [weak self] result in + switch result { + case .success(let location): + let location = LocationInfo( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + completion(location) + case .failure(let error): + if error is LocationPermissionError { + self?.showLocationPermissionAlert() + } + completion(nil) + } + } + } - // In case you would want to send the location directly, without composer preview: -// channelController?.createNewMessage(text: "", attachments: [.init( -// payload: locationAttachmentPayload -// )]) + private func showLocationPermissionAlert() { + let alert = UIAlertController( + title: "Location Access Required", + message: "Please enable location access in Settings to share your location.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + present(alert, animated: true) } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift index 5e5a1ea85f9..17cef3d278e 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift @@ -7,22 +7,21 @@ import StreamChatUI import UIKit class DemoQuotedChatMessageView: QuotedChatMessageView { - override func setAttachmentPreview(for message: ChatMessage) { - let locationAttachments = message.attachments(payloadType: LocationAttachmentPayload.self) - if let locationPayload = locationAttachments.first?.payload { - attachmentPreviewView.contentMode = .scaleAspectFit - attachmentPreviewView.image = UIImage( - systemName: "mappin.circle.fill", - withConfiguration: UIImage.SymbolConfiguration(font: .boldSystemFont(ofSize: 12)) - ) - attachmentPreviewView.tintColor = .systemRed - textView.text = """ - Location: - (\(locationPayload.coordinate.latitude),\(locationPayload.coordinate.longitude)) - """ - return - } + override func updateContent() { + super.updateContent() - super.setAttachmentPreview(for: message) + if let sharedLocation = content?.message.sharedLocation { + if sharedLocation.isLive { + attachmentPreviewView.contentMode = .scaleAspectFit + attachmentPreviewView.image = UIImage(systemName: "location.fill") + attachmentPreviewView.tintColor = .systemBlue + textView.text = "Live Location" + } else { + attachmentPreviewView.contentMode = .scaleAspectFit + attachmentPreviewView.image = UIImage(systemName: "mappin.circle.fill") + attachmentPreviewView.tintColor = .systemRed + textView.text = "Location" + } + } } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift deleted file mode 100644 index b93f830ec96..00000000000 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import StreamChatUI -import UIKit - -/// Location Attachment Composer Preview -extension LocationAttachmentPayload: AttachmentPreviewProvider { - public static let preferredAxis: NSLayoutConstraint.Axis = .vertical - - public func previewView(components: Components) -> UIView { - /// For simplicity, we are using the same view for the Composer preview, - /// but a different one could be provided. - let preview = LocationAttachmentSnapshotView() - preview.coordinate = coordinate - return preview - } -} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift deleted file mode 100644 index 45eb70b04b2..00000000000 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import StreamChat - -public extension AttachmentType { - static let location = Self(rawValue: "custom_location") -} - -struct LocationCoordinate: Codable, Hashable { - let latitude: Double - let longitude: Double -} - -public struct LocationAttachmentPayload: AttachmentPayload { - public static var type: AttachmentType = .location - - var coordinate: LocationCoordinate -} - -public typealias ChatMessageLocationAttachment = ChatMessageAttachment diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 509f370741d..11613303a50 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -3,21 +3,53 @@ // import MapKit +import StreamChat import StreamChatUI import UIKit -class LocationAttachmentSnapshotView: _View { - static var snapshotsCache: NSCache = .init() +class LocationAttachmentSnapshotView: _View, ThemeProvider { + struct Content { + var coordinate: CLLocationCoordinate2D + var isLive: Bool + var isSharingLiveLocation: Bool + var message: ChatMessage? + var author: ChatUser? + + init( + coordinate: CLLocationCoordinate2D, + isLive: Bool, + isSharingLiveLocation: Bool, + message: ChatMessage?, + author: ChatUser? + ) { + self.coordinate = coordinate + self.isLive = isLive + self.isSharingLiveLocation = isSharingLiveLocation + self.message = message + self.author = author + } - var coordinate: LocationCoordinate? { + var isFromCurrentUser: Bool { + author?.id == StreamChatWrapper.shared.client?.currentUserId + } + } + + var content: Content? { didSet { updateContent() } } - var snapshotter: MKMapSnapshotter? - var didTapOnLocation: (() -> Void)? + var didTapOnStopSharingLocation: (() -> Void)? + + let mapHeightRatio: CGFloat = 0.7 + let mapOptions: MKMapSnapshotter.Options = .init() + + let avatarSize: CGFloat = 30 + + static var snapshotsCache: NSCache = .init() + var snapshotter: MKMapSnapshotter? lazy var imageView: UIImageView = { let view = UIImageView() @@ -36,7 +68,36 @@ class LocationAttachmentSnapshotView: _View { return view }() - let mapOptions: MKMapSnapshotter.Options = .init() + lazy var stopButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Stop Sharing", for: .normal) + button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) + button.setTitleColor(appearance.colorPalette.alert, for: .normal) + button.backgroundColor = .clear + button.layer.cornerRadius = 16 + button.addTarget(self, action: #selector(handleStopButtonTap), for: .touchUpInside) + return button + }() + + lazy var avatarView: ChatUserAvatarView = { + let view = ChatUserAvatarView() + view.translatesAutoresizingMaskIntoConstraints = false + view.shouldShowOnlineIndicator = false + view.layer.masksToBounds = true + view.layer.cornerRadius = avatarSize / 2 + view.layer.borderWidth = 2 + view.layer.borderColor = UIColor.white.cgColor + view.isHidden = true + return view + }() + + lazy var sharingStatusView: LocationSharingStatusView = { + let view = LocationSharingStatusView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isHidden = true + return view + }() override func setUp() { super.setUp() @@ -48,19 +109,40 @@ class LocationAttachmentSnapshotView: _View { imageView.addGestureRecognizer(tapGestureRecognizer) } + override func setUpAppearance() { + super.setUpAppearance() + + backgroundColor = appearance.colorPalette.background6 + } + override func setUpLayout() { super.setUpLayout() + stopButton.isHidden = true + activityIndicatorView.hidesWhenStopped = true + addSubview(activityIndicatorView) - addSubview(imageView) + + let container = VContainer(spacing: 0, alignment: .center) { + imageView + sharingStatusView + .height(30) + stopButton + .width(120) + .height(35) + }.embed(in: self) + + addSubview(avatarView) NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), activityIndicatorView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), - activityIndicatorView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor) + activityIndicatorView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + imageView.widthAnchor.constraint(equalTo: container.widthAnchor), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: mapHeightRatio), + avatarView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), + avatarView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + avatarView.widthAnchor.constraint(equalToConstant: avatarSize), + avatarView.heightAnchor.constraint(equalToConstant: avatarSize) ]) } @@ -71,79 +153,154 @@ class LocationAttachmentSnapshotView: _View { override func updateContent() { super.updateContent() - imageView.image = nil - - guard let coordinate = self.coordinate else { + guard let content = self.content else { return } + + avatarView.isHidden = true - configureMapPosition(coordinate: coordinate) - - if imageView.image == nil { - activityIndicatorView.startAnimating() + if content.message?.isLocalOnly == true { + stopButton.isHidden = true + sharingStatusView.isHidden = true + } else if content.isSharingLiveLocation && content.isFromCurrentUser { + stopButton.isHidden = false + sharingStatusView.isHidden = true + if let location = content.message?.sharedLocation { + sharingStatusView.updateStatus(location: location) + } + } else if content.isLive { + stopButton.isHidden = true + sharingStatusView.isHidden = false + if let location = content.message?.sharedLocation { + sharingStatusView.updateStatus(location: location) + } + } else { + stopButton.isHidden = true + sharingStatusView.isHidden = true } - if let snapshotImage = Self.snapshotsCache.object(forKey: coordinate.cachingKey) { - imageView.image = snapshotImage - } else { - loadMapSnapshotImage(coordinate: coordinate) + configureMapPosition() + loadMapSnapshotImage() + } + + override func layoutSubviews() { + super.layoutSubviews() + + if frame.size.width != mapOptions.size.width { + loadMapSnapshotImage() } } - private func configureMapPosition(coordinate: LocationCoordinate) { + private func configureMapPosition() { + guard let content = self.content else { + return + } + mapOptions.region = .init( - center: CLLocationCoordinate2D( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ), + center: content.coordinate, span: MKCoordinateSpan( latitudeDelta: 0.01, longitudeDelta: 0.01 ) ) - mapOptions.size = CGSize(width: 250, height: 150) } - private func loadMapSnapshotImage(coordinate: LocationCoordinate) { + private func loadMapSnapshotImage() { + guard frame.size != .zero else { + return + } + + mapOptions.size = CGSize(width: frame.width, height: frame.width * mapHeightRatio) + + if let cachedSnapshot = getCachedSnapshot() { + imageView.image = cachedSnapshot + updateAnnotationView() + return + } else { + imageView.image = nil + } + + activityIndicatorView.startAnimating() snapshotter?.cancel() snapshotter = MKMapSnapshotter(options: mapOptions) snapshotter?.start { snapshot, _ in guard let snapshot = snapshot else { return } - let image = self.generatePinAnnotation(for: snapshot, with: coordinate) DispatchQueue.main.async { self.activityIndicatorView.stopAnimating() - self.imageView.image = image - Self.snapshotsCache.setObject(image, forKey: coordinate.cachingKey) + + if let content = self.content, !content.isLive { + let image = self.drawPinOnSnapshot(snapshot) + self.imageView.image = image + self.setCachedSnapshot(image: image) + } else { + self.imageView.image = snapshot.image + self.setCachedSnapshot(image: snapshot.image) + } + + self.updateAnnotationView() } } } - private func generatePinAnnotation( - for snapshot: MKMapSnapshotter.Snapshot, - with coordinate: LocationCoordinate - ) -> UIImage { - let image = UIGraphicsImageRenderer(size: mapOptions.size).image { _ in + private func drawPinOnSnapshot(_ snapshot: MKMapSnapshotter.Snapshot) -> UIImage { + UIGraphicsImageRenderer(size: mapOptions.size).image { _ in snapshot.image.draw(at: .zero) + + guard let content = self.content else { return } let pinView = MKPinAnnotationView(annotation: nil, reuseIdentifier: nil) let pinImage = pinView.image - - var point = snapshot.point(for: CLLocationCoordinate2D( - latitude: coordinate.latitude, - longitude: coordinate.longitude - )) + + var point = snapshot.point(for: content.coordinate) point.x -= pinView.bounds.width / 2 point.y -= pinView.bounds.height / 2 point.x += pinView.centerOffset.x point.y += pinView.centerOffset.y + pinImage?.draw(at: point) } - return image } -} -private extension LocationCoordinate { - var cachingKey: NSString { - NSString(string: "\(latitude),\(longitude)") + private func updateAnnotationView() { + guard let content = self.content else { return } + + if content.isLive, let user = content.author { + avatarView.isHidden = false + avatarView.content = user + } else { + avatarView.isHidden = true + } + } + + @objc func handleStopButtonTap() { + didTapOnStopSharingLocation?() + } + + // MARK: Snapshot Caching Management + + func setCachedSnapshot(image: UIImage) { + guard let cachingKey = cachingKey() else { + return + } + + Self.snapshotsCache.setObject(image, forKey: cachingKey) + } + + func getCachedSnapshot() -> UIImage? { + guard let cachingKey = cachingKey() else { + return nil + } + + return Self.snapshotsCache.object(forKey: cachingKey) + } + + private func cachingKey() -> NSString? { + guard let content = self.content else { + return nil + } + guard let messageId = content.message?.id else { + return nil + } + return NSString(string: "\(messageId)") } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index c6a49302f29..8c74ec67026 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -4,16 +4,52 @@ import StreamChat import StreamChatUI +import UIKit protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate { - func didTapOnLocationAttachment( - _ attachment: ChatMessageLocationAttachment + func didTapOnLocation( + _ location: SharedLocation + ) + + func didTapOnStopSharingLocation( + _ location: SharedLocation ) } extension DemoChatMessageListVC: LocationAttachmentViewDelegate { - func didTapOnLocationAttachment(_ attachment: ChatMessageLocationAttachment) { - let mapViewController = LocationDetailViewController(locationAttachment: attachment) + func didTapOnLocation(_ location: SharedLocation) { + let messageController = client.messageController( + cid: location.channelId, + messageId: location.messageId + ) + showDetailViewController(messageController: messageController) + } + + func didTapOnStopSharingLocation(_ location: SharedLocation) { + client + .messageController(cid: location.channelId, messageId: location.messageId) + .stopLiveLocationSharing { result in + switch result { + case .success: + break + case .failure(let error): + self.presentAlert( + title: "Could not stop sharing location", + message: error.localizedDescription + ) + } + } + } + + private func showDetailViewController(messageController: ChatMessageController) { + let mapViewController = LocationDetailViewController( + messageController: messageController + ) + if UIDevice.current.userInterfaceIdiom == .pad { + let nav = UINavigationController(rootViewController: mapViewController) + navigationController?.present(nav, animated: true) + return + } navigationController?.pushViewController(mapViewController, animated: true) } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 38bb5666a7d..df001362f9c 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -9,29 +9,42 @@ import UIKit class LocationAttachmentViewInjector: AttachmentViewInjector { lazy var locationAttachmentView = LocationAttachmentSnapshotView() - var locationAttachment: ChatMessageLocationAttachment? { - attachments(payloadType: LocationAttachmentPayload.self).first - } + let mapWidth: CGFloat = 300 override func contentViewDidLayout(options: ChatMessageLayoutOptions) { super.contentViewDidLayout(options: options) contentView.bubbleContentContainer.insertArrangedSubview(locationAttachmentView, at: 0) - - NSLayoutConstraint.activate([ - locationAttachmentView.widthAnchor.constraint(equalToConstant: 250), - locationAttachmentView.heightAnchor.constraint(equalToConstant: 150) - ]) + contentView.bubbleThreadFootnoteContainer.width(mapWidth) locationAttachmentView.didTapOnLocation = { [weak self] in self?.handleTapOnLocationAttachment() } + locationAttachmentView.didTapOnStopSharingLocation = { [weak self] in + self?.handleTapOnStopSharingLocation() + } + + let isSentByCurrentUser = contentView.content?.isSentByCurrentUser == true + let maskedCorners: CACornerMask = isSentByCurrentUser + ? [.layerMinXMaxYCorner, .layerMinXMinYCorner] + : [.layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMaxXMinYCorner] + locationAttachmentView.layer.maskedCorners = maskedCorners + locationAttachmentView.layer.cornerRadius = 16 + locationAttachmentView.layer.masksToBounds = true } override func contentViewDidUpdateContent() { super.contentViewDidUpdateContent() - locationAttachmentView.coordinate = locationAttachment?.coordinate + if let location = contentView.content?.sharedLocation { + locationAttachmentView.content = .init( + coordinate: .init(latitude: location.latitude, longitude: location.longitude), + isLive: location.isLive, + isSharingLiveLocation: location.isLiveSharingActive, + message: contentView.content, + author: contentView.content?.author + ) + } } func handleTapOnLocationAttachment() { @@ -39,10 +52,22 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { return } - guard let locationAttachment = self.locationAttachment else { + guard let location = contentView.content?.sharedLocation else { + return + } + + locationAttachmentDelegate.didTapOnLocation(location) + } + + func handleTapOnStopSharingLocation() { + guard let locationAttachmentDelegate = contentView.delegate as? LocationAttachmentViewDelegate else { + return + } + + guard let location = contentView.content?.sharedLocation else { return } - locationAttachmentDelegate.didTapOnLocationAttachment(locationAttachment) + locationAttachmentDelegate.didTapOnStopSharingLocation(location) } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 5e4df68608a..c58b3d5a5e0 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -3,13 +3,17 @@ // import MapKit +import StreamChat +import StreamChatUI import UIKit -class LocationDetailViewController: UIViewController { - let locationAttachment: ChatMessageLocationAttachment +class LocationDetailViewController: UIViewController, ThemeProvider { + let messageController: ChatMessageController - init(locationAttachment: ChatMessageLocationAttachment) { - self.locationAttachment = locationAttachment + init( + messageController: ChatMessageController + ) { + self.messageController = messageController super.init(nibName: nil, bundle: nil) } @@ -18,29 +22,314 @@ class LocationDetailViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + private var userAnnotation: UserAnnotation? + private let coordinateSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + private var isAutoCenteringEnabled = false + let mapView: MKMapView = { let view = MKMapView() + view.translatesAutoresizingMaskIntoConstraints = false view.isZoomEnabled = true + view.showsCompass = false return view }() + private lazy var autoCenterButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.backgroundColor = appearance.colorPalette.background + button.layer.cornerRadius = 22 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowOpacity = 0.1 + button.layer.shadowRadius = 4 + button.addTarget(self, action: #selector(autoCenterButtonTapped), for: .touchUpInside) + return button + }() + + var isLiveLocation: Bool { + messageController.message?.sharedLocation?.isLive == true + } + + private lazy var locationControlBanner: LocationControlBannerView = { + let banner = LocationControlBannerView() + banner.translatesAutoresizingMaskIntoConstraints = false + banner.onStopSharingTapped = { [weak self] in + self?.messageController.stopLiveLocationSharing() + } + return banner + }() + override func viewDidLoad() { super.viewDidLoad() + updateAutoCenterButtonAppearance() + + messageController.synchronize() + messageController.delegate = self + + title = "Location" + navigationController?.navigationBar.backgroundColor = appearance.colorPalette.background + + mapView.register( + UserAnnotationView.self, + forAnnotationViewWithReuseIdentifier: UserAnnotationView.reuseIdentifier + ) + mapView.showsUserLocation = false + mapView.delegate = self + + view.backgroundColor = appearance.colorPalette.background + view.addSubview(mapView) + view.addSubview(autoCenterButton) + + NSLayoutConstraint.activate([ + mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + autoCenterButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + autoCenterButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + autoCenterButton.widthAnchor.constraint(equalToConstant: 44), + autoCenterButton.heightAnchor.constraint(equalToConstant: 44) + ]) + + autoCenterButton.isHidden = !isLiveLocation + + if isLiveLocation { + view.addSubview(locationControlBanner) + NSLayoutConstraint.activate([ + locationControlBanner.leadingAnchor.constraint(equalTo: view.leadingAnchor), + locationControlBanner.trailingAnchor.constraint(equalTo: view.trailingAnchor), + locationControlBanner.bottomAnchor.constraint(equalTo: view.bottomAnchor), + locationControlBanner.heightAnchor.constraint(equalToConstant: 90) + ]) + // Make sure the Apple's Map logo is visible + mapView.layoutMargins.bottom = 60 + } + + var locationCoordinate: CLLocationCoordinate2D? + if let location = messageController.message?.sharedLocation { + locationCoordinate = CLLocationCoordinate2D( + latitude: location.latitude, + longitude: location.longitude + ) + } + + if let locationCoordinate { + mapView.region = .init( + center: locationCoordinate, + span: coordinateSpan + ) + updateUserLocation( + locationCoordinate + ) + } + + updateBannerState() + } + + func updateUserLocation( + _ coordinate: CLLocationCoordinate2D + ) { + if let existingAnnotation = userAnnotation { + if isLiveLocation { + // Since we update the location every 3s, by updating the coordinate with 5s animation + // this will make sure the annotation moves smoothly. + // This results in a "Tracking" like behaviour when auto-centering is enabled. + UIView.animate(withDuration: 5, delay: 0, options: .allowUserInteraction) { + existingAnnotation.coordinate = coordinate + } + if isAutoCenteringEnabled { + UIView.animate(withDuration: 5, delay: 0.2, options: [.curveEaseOut, .allowUserInteraction]) { + self.mapView.setCenter(coordinate, animated: true) + } + } + } else { + existingAnnotation.coordinate = coordinate + mapView.setCenter(coordinate, animated: true) + } + } else if let author = messageController.message?.author, isLiveLocation { + let userAnnotation = UserAnnotation( + coordinate: coordinate, + user: author + ) + mapView.addAnnotation(userAnnotation) + self.userAnnotation = userAnnotation + } else { + let annotation = MKPointAnnotation() + annotation.coordinate = coordinate + mapView.addAnnotation(annotation) + } + } + + private func updateBannerState() { + guard let message = messageController.message else { return } + guard let location = message.sharedLocation, location.isLive else { + return + } + + let isFromCurrentUser = message.isSentByCurrentUser + let dateFormatter = appearance.formatters.channelListMessageTimestamp + let endingAtText = dateFormatter.format(messageController.message?.sharedLocation?.endAt ?? Date()) + let updatedAtText = dateFormatter.format(messageController.message?.updatedAt ?? Date()) + if location.isLiveSharingActive && message.isLocalOnly == false { + locationControlBanner.configure( + state: isFromCurrentUser + ? .currentUserSharing(endingAtText: endingAtText) + : .anotherUserSharing(endingAtText: endingAtText) + ) + } else { + locationControlBanner.configure(state: .ended(lastUpdatedAtText: updatedAtText)) + } + } + + @objc private func autoCenterButtonTapped() { + isAutoCenteringEnabled.toggle() + updateAutoCenterButtonAppearance() + if let coordinate = userAnnotation?.coordinate { + mapView.setCenter(coordinate, animated: true) + } + } + + private func updateAutoCenterButtonAppearance() { + let imageName = isAutoCenteringEnabled ? "location.fill" : "location" + let image = UIImage(systemName: imageName) + autoCenterButton.setImage(image, for: .normal) + autoCenterButton.tintColor = isAutoCenteringEnabled + ? appearance.colorPalette.accentPrimary + : appearance.colorPalette.subtitleText + } +} + +extension LocationDetailViewController: ChatMessageControllerDelegate { + func messageController( + _ controller: ChatMessageController, + didChangeMessage change: EntityChange + ) { + guard let location = messageController.message?.sharedLocation, location.isLive else { + return + } + let locationCoordinate = CLLocationCoordinate2D( - latitude: locationAttachment.coordinate.latitude, - longitude: locationAttachment.coordinate.longitude + latitude: location.latitude, + longitude: location.longitude ) - mapView.region = .init( - center: locationCoordinate, - span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5) + updateUserLocation( + locationCoordinate ) - let annotation = MKPointAnnotation() - annotation.coordinate = locationCoordinate - mapView.addAnnotation(annotation) + let isLiveLocationSharingStopped = location.isLiveSharingActive == false + if isLiveLocationSharingStopped, let userAnnotation = self.userAnnotation { + let userAnnotationView = mapView.view(for: userAnnotation) as? UserAnnotationView + userAnnotationView?.stopPulsingAnimation() + } + + updateBannerState() + } +} + +extension LocationDetailViewController: MKMapViewDelegate { + func mapView( + _ mapView: MKMapView, + viewFor annotation: MKAnnotation + ) -> MKAnnotationView? { + guard let userAnnotation = annotation as? UserAnnotation else { + return nil + } + + let annotationView = mapView.dequeueReusableAnnotationView( + withIdentifier: UserAnnotationView.reuseIdentifier, + for: userAnnotation + ) as? UserAnnotationView + + annotationView?.setUser(userAnnotation.user) + + let location = messageController.message?.sharedLocation + if location?.isLiveSharingActive == true { + annotationView?.startPulsingAnimation() + } else { + annotationView?.stopPulsingAnimation() + } + return annotationView + } +} + +class LocationControlBannerView: UIView, ThemeProvider { + var onStopSharingTapped: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private lazy var sharingButton: UIButton = { + let button = UIButton() + button.setTitle("Stop Sharing", for: .normal) + button.setTitleColor(appearance.colorPalette.alert, for: .normal) + button.titleLabel?.font = appearance.fonts.body + button.addTarget(self, action: #selector(stopSharingTapped), for: .touchUpInside) + return button + }() + + private lazy var locationUpdateLabel: UILabel = { + let label = UILabel() + label.font = appearance.fonts.footnote + label.textColor = appearance.colorPalette.subtitleText + return label + }() + + private func setupView() { + backgroundColor = appearance.colorPalette.background6 + layer.cornerRadius = 16 + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + let container = VContainer(spacing: 0, alignment: .center) { + sharingButton + locationUpdateLabel + } + + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor, constant: 8), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } + + @objc private func stopSharingTapped() { + onStopSharingTapped?() + } + + enum State { + case currentUserSharing(endingAtText: String) + case anotherUserSharing(endingAtText: String) + case ended(lastUpdatedAtText: String) + } - view = mapView + func configure(state: State) { + switch state { + case .currentUserSharing(let endingAtText): + sharingButton.isEnabled = true + sharingButton.setTitle("Stop Sharing", for: .normal) + sharingButton.setTitleColor(appearance.colorPalette.alert, for: .normal) + locationUpdateLabel.text = "Live until \(endingAtText)" + case .anotherUserSharing(let endingAtText): + sharingButton.isEnabled = false + sharingButton.setTitle("Live Location", for: .normal) + sharingButton.setTitleColor(appearance.colorPalette.alert, for: .normal) + locationUpdateLabel.text = "Live until \(endingAtText)" + case .ended(let lastUpdatedAtText): + sharingButton.isEnabled = false + sharingButton.setTitle("Live location ended", for: .normal) + sharingButton.setTitleColor(appearance.colorPalette.alert.withAlphaComponent(0.6), for: .normal) + locationUpdateLabel.text = "Location last updated at \(lastUpdatedAtText)" + } } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift new file mode 100644 index 00000000000..fd6b49916ce --- /dev/null +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift @@ -0,0 +1,56 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +class LocationSharingStatusView: _View, ThemeProvider { + private lazy var statusLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = appearance.fonts.footnote + label.textColor = appearance.colorPalette.subtitleText + return label + }() + + private var activeSharingImage: UIImage? = UIImage( + systemName: "location.fill", + withConfiguration: UIImage.SymbolConfiguration(scale: .medium) + ) + + private var inactiveSharingImage: UIImage? = UIImage( + systemName: "location.slash.fill", + withConfiguration: UIImage.SymbolConfiguration(scale: .medium) + ) + + private lazy var iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.image = activeSharingImage + return imageView + }() + + override func setUpLayout() { + super.setUpLayout() + + HContainer(spacing: 4, alignment: .center) { + iconImageView + .width(16) + .height(16) + statusLabel + }.embed(in: self) + } + + func updateStatus(location: SharedLocation) { + guard let endAt = location.endAt else { return } + let endAtText = appearance.formatters.channelListMessageTimestamp.format(endAt) + statusLabel.text = location.isLiveSharingActive ? "Live until \(endAtText)" : "Live location ended" + iconImageView.image = location.isLiveSharingActive ? activeSharingImage : inactiveSharingImage + iconImageView.tintColor = location.isLiveSharingActive + ? appearance.colorPalette.accentPrimary + : appearance.colorPalette.subtitleText + } +} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift new file mode 100644 index 00000000000..a07ffc67793 --- /dev/null +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import MapKit +import StreamChat + +class UserAnnotation: NSObject, MKAnnotation { + dynamic var coordinate: CLLocationCoordinate2D + var user: ChatUser + + init(coordinate: CLLocationCoordinate2D, user: ChatUser) { + self.coordinate = coordinate + self.user = user + super.init() + } +} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift new file mode 100644 index 00000000000..e6ad750d0ef --- /dev/null +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift @@ -0,0 +1,82 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import MapKit +import StreamChat +import StreamChatUI + +class UserAnnotationView: MKAnnotationView { + static let reuseIdentifier = "UserAnnotationView" + + private lazy var avatarView: ChatUserAvatarView = { + let view = ChatUserAvatarView() + view.shouldShowOnlineIndicator = false + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.masksToBounds = true + return view + }() + + private var size: CGSize = .init(width: 40, height: 40) + + private var pulseLayer: CALayer? + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + backgroundColor = .gray + frame = CGRect(x: 0, y: 0, width: size.width, height: size.height) + layer.cornerRadius = 20 + layer.masksToBounds = false + layer.borderWidth = 2 + layer.borderColor = UIColor.white.cgColor + addSubview(avatarView) + avatarView.width(size.width) + avatarView.height(size.height) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + func setUser(_ user: ChatUser) { + avatarView.content = user + } + + func startPulsingAnimation() { + guard pulseLayer == nil else { + return + } + let pulseLayer = CALayer() + pulseLayer.masksToBounds = false + pulseLayer.frame = bounds + pulseLayer.cornerRadius = bounds.width / 2 + pulseLayer.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.5).cgColor + layer.insertSublayer(pulseLayer, below: avatarView.layer) + + let animationScale = CABasicAnimation(keyPath: "transform.scale") + animationScale.fromValue = 1.0 + animationScale.toValue = 1.5 + animationScale.duration = 2.0 + animationScale.timingFunction = CAMediaTimingFunction(name: .easeOut) + animationScale.autoreverses = false + animationScale.repeatCount = .infinity + + let animationOpacity = CABasicAnimation(keyPath: "opacity") + animationOpacity.fromValue = 1.0 + animationOpacity.toValue = 0 + animationOpacity.duration = 2.0 + animationOpacity.timingFunction = CAMediaTimingFunction(name: .easeOut) + animationOpacity.autoreverses = false + animationOpacity.repeatCount = .infinity + + pulseLayer.add(animationScale, forKey: "pulseScale") + pulseLayer.add(animationOpacity, forKey: "pulseOpacity") + self.pulseLayer = pulseLayer + } + + func stopPulsingAnimation() { + pulseLayer?.removeFromSuperlayer() + pulseLayer = nil + } +} diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift index 6cb0826e328..fd5a6504675 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift @@ -6,6 +6,21 @@ import StreamChatUI import UIKit final class DemoChatChannelListItemView: ChatChannelListItemView { + override var subtitleText: String? { + guard let previewMessage = content?.channel.previewMessage else { + return super.subtitleText + } + + if let location = previewMessage.sharedLocation { + let text = location.isLive ? "Live location" : "Static location" + return previewMessage.isSentByCurrentUser + ? previewMessageTextForCurrentUser(messageText: text) + : previewMessageTextFromAnotherUser(previewMessage.author, messageText: text) + } + + return super.subtitleText + } + override var contentBackgroundColor: UIColor { // In case it is a message search, we want to ignore the pinning behaviour. if content?.searchResult?.message != nil { diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index aee4601e71f..eaccfe150f0 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift @@ -33,6 +33,11 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC { actions.append(messageDebugActionItem()) } + let hasLocationAttachments = message?.sharedLocation != nil + if hasLocationAttachments { + actions.removeAll(where: { $0 is EditActionItem }) + } + return actions } diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index 26bdb552bef..86c545ba444 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -9,10 +9,6 @@ import StreamChatUI extension StreamChatWrapper { // Instantiates chat client func setUpChat() { - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { - Components.default.mixedAttachmentInjector.register(.location, with: LocationAttachmentViewInjector.self) - } - // Set the log level LogConfig.level = StreamRuntimeCheck.logLevel ?? .warning LogConfig.formatters = [ @@ -26,7 +22,6 @@ extension StreamChatWrapper { if client == nil { client = ChatClient(config: config) } - client?.registerAttachment(LocationAttachmentPayload.self) // L10N let localizationProvider = Appearance.default.localizationProvider diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift index ce531036df7..74da7d1c0c6 100644 --- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift @@ -135,7 +135,12 @@ extension Endpoint { ) } - static func sendMessage(cid: ChannelId, messagePayload: MessageRequestBody, skipPush: Bool, skipEnrichUrl: Bool) + static func sendMessage( + cid: ChannelId, + messagePayload: MessageRequestBody, + skipPush: Bool, + skipEnrichUrl: Bool + ) -> Endpoint { let body: [String: AnyEncodable] = [ "message": AnyEncodable(messagePayload), diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index 8aa9624a78b..fc60e82be57 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -15,7 +15,7 @@ extension EndpointPath { .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, .callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, .polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote, - .unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder: + .unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder, .liveLocations: return false } } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index 66f902cd124..708790dda51 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -73,7 +73,9 @@ enum EndpointPath: Codable { case deleteImage(String) case appSettings - + + case liveLocations + case polls case pollsQuery case poll(pollId: String) @@ -107,6 +109,8 @@ enum EndpointPath: Codable { case let .markThreadUnread(cid): return "channels/\(cid.apiPath)/unread" + case .liveLocations: return "users/live_locations" + case .channels: return "channels" case let .createChannel(queryString): return "channels/\(queryString)/query" case let .updateChannel(queryString): return "channels/\(queryString)/query" diff --git a/Sources/StreamChat/APIClient/Endpoints/LocationEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/LocationEndpoints.swift new file mode 100644 index 00000000000..b2627ff2e63 --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/LocationEndpoints.swift @@ -0,0 +1,43 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// The endpoints related to live location services. +/// +/// - Note: Creating new location is part of MessageEndpoints.swift file, since it is done when creating a message. +extension Endpoint { + static func updateLiveLocation(request: LiveLocationUpdateRequestPayload) -> Endpoint { + .init( + path: .liveLocations, + method: .put, + queryItems: nil, + requiresConnectionId: false, + requiresToken: true, + body: request + ) + } + + static func stopLiveLocation(request: StopLiveLocationRequestPayload) -> Endpoint { + .init( + path: .liveLocations, + method: .put, + queryItems: nil, + requiresConnectionId: false, + requiresToken: true, + body: request + ) + } + + static func currentUserActiveLiveLocations() -> Endpoint { + .init( + path: .liveLocations, + method: .get, + queryItems: nil, + requiresConnectionId: false, + requiresToken: true, + body: nil + ) + } +} diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index e0a705226e2..33c50abb97b 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -50,6 +50,17 @@ extension Endpoint { ) } + static func partialUpdateMessage(messageId: MessageId, request: MessagePartialUpdateRequest) + -> Endpoint { + .init( + path: .editMessage(messageId), + method: .put, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + } + static func loadReplies(messageId: MessageId, pagination: MessagesPagination) -> Endpoint { .init( @@ -98,7 +109,7 @@ extension Endpoint { struct MessagePartialUpdateRequest: Encodable { var set: SetProperties? - var unset: [String]? = [] + var unset: [String]? = nil var skipEnrichUrl: Bool? var userId: String? var user: UserRequestBody? @@ -106,6 +117,24 @@ struct MessagePartialUpdateRequest: Encodable { /// The available message properties that can be updated. struct SetProperties: Encodable { var pinned: Bool? + var text: String? + var extraData: [String: RawJSON]? + var attachments: [MessageAttachmentPayload]? + + enum CodingKeys: String, CodingKey { + case text + case pinned + case extraData + case attachments + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(pinned, forKey: .pinned) + try container.encodeIfPresent(attachments, forKey: .attachments) + try extraData?.encode(to: encoder) + } } func encode(to encoder: Encoder) throws { diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index ea0a43a87c1..e9cc7d91614 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -47,6 +47,8 @@ struct ChannelPayload { let isHidden: Bool? let draft: DraftPayload? + + let activeLiveLocations: [SharedLocationPayload] } extension ChannelPayload { @@ -71,6 +73,7 @@ extension ChannelPayload: Decodable { case watcherCount = "watcher_count" case hidden case draft + case activeLiveLocations = "active_live_locations" } init(from decoder: Decoder) throws { @@ -87,7 +90,8 @@ extension ChannelPayload: Decodable { pinnedMessages: try container.decodeArrayIgnoringFailures([MessagePayload].self, forKey: .pinnedMessages), channelReads: try container.decodeArrayIfPresentIgnoringFailures([ChannelReadPayload].self, forKey: .channelReads) ?? [], isHidden: try container.decodeIfPresent(Bool.self, forKey: .hidden), - draft: try container.decodeIfPresent(DraftPayload.self, forKey: .draft) + draft: try container.decodeIfPresent(DraftPayload.self, forKey: .draft), + activeLiveLocations: try container.decodeArrayIfPresentIgnoringFailures([SharedLocationPayload].self, forKey: .activeLiveLocations) ?? [] ) } } @@ -233,13 +237,14 @@ public class ChannelConfig: Codable { case updatedAt = "updated_at" case skipLastMsgAtUpdateForSystemMsg = "skip_last_msg_update_for_system_msgs" case messageRemindersEnabled = "user_message_reminders" + case sharedLocationsEnabled = "shared_locations" } /// If users are allowed to add reactions to messages. Enabled by default. public let reactionsEnabled: Bool /// Controls if typing indicators are shown. Enabled by default. public let typingEventsEnabled: Bool - /// Controls whether the chat shows how far you’ve read. Enabled by default. + /// Controls whether the chat shows how far you've read. Enabled by default. public let readEventsEnabled: Bool /// Determines if events are fired for connecting and disconnecting to a chat. Enabled by default. public let connectEventsEnabled: Bool @@ -271,6 +276,8 @@ public class ChannelConfig: Codable { public let skipLastMsgAtUpdateForSystemMsg: Bool /// Determines if user message reminders are enabled. public let messageRemindersEnabled: Bool + /// Determines if shared locations are enabled. + public let sharedLocationsEnabled: Bool public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -297,6 +304,7 @@ public class ChannelConfig: Codable { pollsEnabled = try container.decodeIfPresent(Bool.self, forKey: .pollsEnabled) ?? false skipLastMsgAtUpdateForSystemMsg = try container.decodeIfPresent(Bool.self, forKey: .skipLastMsgAtUpdateForSystemMsg) ?? false messageRemindersEnabled = try container.decodeIfPresent(Bool.self, forKey: .messageRemindersEnabled) ?? false + sharedLocationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .sharedLocationsEnabled) ?? false } internal required init( @@ -313,6 +321,7 @@ public class ChannelConfig: Codable { urlEnrichmentEnabled: Bool = false, skipLastMsgAtUpdateForSystemMsg: Bool = false, messageRemindersEnabled: Bool = false, + sharedLocationsEnabled: Bool = false, messageRetention: String = "", maxMessageLength: Int = 0, commands: [Command] = [], @@ -337,5 +346,6 @@ public class ChannelConfig: Codable { self.pollsEnabled = pollsEnabled self.skipLastMsgAtUpdateForSystemMsg = skipLastMsgAtUpdateForSystemMsg self.messageRemindersEnabled = messageRemindersEnabled + self.sharedLocationsEnabled = sharedLocationsEnabled } } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/IdentifiableModel.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/IdentifiableModel.swift index 4bbc6ab1b52..37a6585576f 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/IdentifiableModel.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/IdentifiableModel.swift @@ -98,3 +98,9 @@ extension PollVoteDTO: IdentifiableModel { static var idKeyPath: String? { #keyPath(PollVoteDTO.id) } static func id(for model: NSManagedObject) -> DatabaseId? { (model as? Self)?.id } } + +extension SharedLocationDTO: IdentifiableModel { + static var className: DatabaseType { _className } + static var idKeyPath: String? { #keyPath(SharedLocationDTO.messageId) } + static func id(for model: NSManagedObject) -> DatabaseId? { (model as? Self)?.messageId } +} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/LocationPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/LocationPayloads.swift new file mode 100644 index 00000000000..38d70500829 --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/LocationPayloads.swift @@ -0,0 +1,77 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +struct SharedLocationPayload: Decodable { + let channelId: String + let messageId: String + let userId: String + let latitude: Double + let longitude: Double + let createdAt: Date + let updatedAt: Date + let endAt: Date? + let createdByDeviceId: String + + enum CodingKeys: String, CodingKey { + case channelId = "channel_cid" + case messageId = "message_id" + case userId = "user_id" + case latitude + case longitude + case createdAt = "created_at" + case updatedAt = "updated_at" + case createdByDeviceId = "created_by_device_id" + case endAt = "end_at" + } +} + +struct NewLocationRequestPayload: Encodable { + let latitude: Double + let longitude: Double + let endAt: Date? + let createdByDeviceId: String + + enum CodingKeys: String, CodingKey { + case latitude + case longitude + case endAt = "end_at" + case createdByDeviceId = "created_by_device_id" + } +} + +struct LiveLocationUpdateRequestPayload: Encodable { + let messageId: String + let latitude: Double + let longitude: Double + let createdByDeviceId: String + + enum CodingKeys: String, CodingKey { + case messageId = "message_id" + case latitude + case longitude + case createdByDeviceId = "created_by_device_id" + } +} + +struct StopLiveLocationRequestPayload: Encodable { + let messageId: String + let endAt: Date = Date() + let createdByDeviceId: String + + enum CodingKeys: String, CodingKey { + case messageId = "message_id" + case endAt = "end_at" + case createdByDeviceId = "created_by_device_id" + } +} + +struct ActiveLiveLocationsResponsePayload: Decodable { + let locations: [SharedLocationPayload] + + enum CodingKeys: String, CodingKey { + case locations = "active_live_locations" + } +} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 8dc929e6f86..324508b9868 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -54,6 +54,7 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable { case skipEnrichUrl = "skip_enrich_url" case restrictedVisibility = "restricted_visibility" case draft + case location = "shared_location" case reminder } @@ -114,6 +115,7 @@ class MessagePayload: Decodable { var poll: PollPayload? var draft: DraftPayload? + var location: SharedLocationPayload? var reminder: ReminderPayload? /// Only message payload from `getMessage` endpoint contains channel data. It's a convenience workaround for having to @@ -182,6 +184,7 @@ class MessagePayload: Decodable { messageTextUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .messageTextUpdatedAt) poll = try container.decodeIfPresent(PollPayload.self, forKey: .poll) draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) + location = try container.decodeIfPresent(SharedLocationPayload.self, forKey: .location) reminder = try container.decodeIfPresent(ReminderPayload.self, forKey: .reminder) } @@ -225,7 +228,8 @@ class MessagePayload: Decodable { messageTextUpdatedAt: Date? = nil, poll: PollPayload? = nil, draft: DraftPayload? = nil, - reminder: ReminderPayload? = nil + reminder: ReminderPayload? = nil, + location: SharedLocationPayload? = nil ) { self.id = id self.cid = cid @@ -266,6 +270,7 @@ class MessagePayload: Decodable { self.messageTextUpdatedAt = messageTextUpdatedAt self.poll = poll self.draft = draft + self.location = location self.reminder = reminder } } @@ -290,6 +295,7 @@ struct MessageRequestBody: Encodable { var pinned: Bool var pinExpires: Date? var pollId: String? + var location: NewLocationRequestPayload? var restrictedVisibility: [UserId]? let extraData: [String: RawJSON] @@ -310,6 +316,7 @@ struct MessageRequestBody: Encodable { pinExpires: Date? = nil, pollId: String? = nil, restrictedVisibility: [UserId]? = nil, + location: NewLocationRequestPayload? = nil, extraData: [String: RawJSON] ) { self.id = id @@ -328,6 +335,7 @@ struct MessageRequestBody: Encodable { self.pinExpires = pinExpires self.pollId = pollId self.restrictedVisibility = restrictedVisibility + self.location = location self.extraData = extraData } @@ -346,6 +354,7 @@ struct MessageRequestBody: Encodable { try container.encodeIfPresent(pollId, forKey: .pollId) try container.encodeIfPresent(type, forKey: .type) try container.encodeIfPresent(restrictedVisibility, forKey: .restrictedVisibility) + try container.encodeIfPresent(location, forKey: .location) if !attachments.isEmpty { try container.encode(attachments, forKey: .attachments) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 49333754134..e59935cf10c 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -640,6 +640,10 @@ public class ChatClient { database: databaseContainer, apiClient: apiClient, attachmentPostProcessor: config.uploadedAttachmentPostProcessor + ), + ActiveLiveLocationsEndTimeTracker( + database: databaseContainer, + apiClient: apiClient ) ] try? backgroundWorker(of: AttachmentQueueUploader.self) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 396c055a51f..623c390f39b 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -68,8 +68,9 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// The worker used to fetch the remote data and communicate with servers. private let updater: ChannelUpdater + /// The component responsible to update a channel member. private let channelMemberUpdater: ChannelMemberUpdater - + private lazy var eventSender: TypingEventsSender = self.environment.eventSenderBuilder( client.databaseContainer, client.apiClient @@ -229,10 +230,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP client.databaseContainer, client.apiClient ) - channelMemberUpdater = self.environment.memberUpdaterBuilder( - client.databaseContainer, - client.apiClient - ) + channelMemberUpdater = self.environment.memberUpdaterBuilder(client.databaseContainer, client.apiClient) pollsRepository = client.pollsRepository draftsRepository = client.draftMessagesRepository @@ -751,6 +749,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - skipPush: If true, skips sending push notification to channel members. /// - skipEnrichUrl: If true, the url preview won't be attached to the message. /// - restrictedVisibility: The list of user ids that should be able to see the message. + /// - location: The new location information of the message. /// - extraData: Additional extra data of the message object. /// - completion: Called when saving the message to the local DB finishes. /// @@ -765,6 +764,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP skipPush: Bool = false, skipEnrichUrl: Bool = false, restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil ) { @@ -788,6 +788,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, restrictedVisibility: restrictedVisibility, + location: location, extraData: transformableInfo.extraData, poll: nil, completion: completion @@ -843,6 +844,132 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } + /// Sends a static location message to the channel. + /// + /// - Parameters: + /// - location: The location information. + /// - text: The text of the message. + /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. + /// - quotedMessageId: The id of the quoted message, in case the location is an inline reply. + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes, not when the message reaches the server. + public func sendStaticLocation( + _ location: LocationInfo, + text: String? = nil, + messageId: MessageId? = nil, + quotedMessageId: MessageId? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + completion?(.failure(error ?? ClientError.Unknown())) + } + return + } + + updater.createNewMessage( + in: cid, + messageId: messageId, + text: text ?? "", + pinning: nil, + isSilent: false, + isSystem: false, + command: nil, + arguments: nil, + attachments: [], + mentionedUserIds: [], + quotedMessageId: quotedMessageId, + skipPush: false, + skipEnrichUrl: false, + poll: nil, + location: .init( + latitude: location.latitude, + longitude: location.longitude, + endAt: nil + ), + extraData: extraData + ) { result in + if let newMessage = try? result.get() { + self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + } + self.callback { + completion?(result.map(\.id)) + } + } + } + + /// Starts a live location sharing message in the channel. + /// + /// If there is already an active live location sharing message in the this channel, + /// it will fail with an error. + /// + /// - Parameters: + /// - location: The location information. + /// - endDate: The date when the location sharing ends. + /// - text: The text of the message. + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes, + /// not when the message reaches the server. + public func startLiveLocationSharing( + _ location: LocationInfo, + endDate: Date, + text: String? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + self.callback { + completion?(.failure(error ?? ClientError.Unknown())) + } + } + return + } + + // Stop existing live location sharing message if it exists. + // It is an optimistic update, since the backend will also stop the existing live location message. + client.messageRepository.getCurrentUserActiveLiveLocationMessages(for: cid) { [weak self] result in + if let message = try? result.get().first { + self?.client.databaseContainer.write { session in + let existingLiveLocation = session.message(id: message.id) + existingLiveLocation?.isActiveLiveLocation = false + existingLiveLocation?.location?.endAt = DBDate() + } + } + } + + updater.createNewMessage( + in: cid, + messageId: nil, + text: text ?? "", + pinning: nil, + isSilent: false, + isSystem: false, + command: nil, + arguments: nil, + attachments: [], + mentionedUserIds: [], + quotedMessageId: nil, + skipPush: false, + skipEnrichUrl: false, + poll: nil, + location: NewLocationInfo( + latitude: location.latitude, + longitude: location.longitude, + endAt: endDate + ), + extraData: extraData + ) { result in + if let newMessage = try? result.get() { + self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + } + self.callback { + completion?(result.map(\.id)) + } + } + } + /// Updates the draft message of this channel. /// /// If there is no draft message, a new draft message will be created. @@ -872,7 +999,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } return } - + draftsRepository.updateDraft( for: cid, threadId: nil, @@ -1577,6 +1704,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP skipPush: Bool = false, skipEnrichUrl: Bool = false, restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], poll: PollPayload?, completion: ((Result) -> Void)? = nil @@ -1608,6 +1736,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP skipEnrichUrl: skipEnrichUrl, restrictedVisibility: restrictedVisibility, poll: poll, + location: location, extraData: extraData ) { result in if let newMessage = try? result.get() { @@ -1916,7 +2045,7 @@ private extension ChatChannelController { // MARK: - Errors -extension ClientError { +public extension ClientError { final class ChannelNotCreatedYet: ClientError { override public var localizedDescription: String { "You can't modify the channel because the channel hasn't been created yet. Call `synchronize()` to create the channel and wait for the completion block to finish. Alternatively, you can observe the `state` changes of the controller and wait for the `remoteDataFetched` state." diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 610b13b9f38..a41295cd19e 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -37,6 +37,9 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt return _basePublishers as? BasePublishers ?? .init(controller: self) } + /// The observer for the active live location messages. + private var activeLiveLocationMessagesObserver: BackgroundListDatabaseObserver? + /// Used for observing the current user changes in a database. private lazy var currentUserObserver = createUserObserver() .onChange { [weak self] change in @@ -47,6 +50,22 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } $0.currentUserController(self, didChangeCurrentUser: change) } + + /// Only when we have access to the currentUserId is when we should + /// create the observer for the active live location messages. + if self?.activeLiveLocationMessagesObserver == nil { + let observer = self?.createActiveLiveLocationMessagesObserver() + self?.activeLiveLocationMessagesObserver = observer + try? observer?.startObserving() + observer?.onDidChange = { [weak self, weak observer] _ in + self?.delegateCallback { [weak self] in + guard let self = self else { return } + let messages = Array(observer?.items ?? []) + self.isSharingLiveLocation = !messages.isEmpty + $0.currentUserController(self, didChangeActiveLiveLocationMessages: messages) + } + } + } } .onFieldChange(\.unreadCount) { [weak self] change in self?.delegateCallback { [weak self] in @@ -58,6 +77,23 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } } + /// A flag to indicate whether the current user is sharing his live location. + private var isSharingLiveLocation = false { + didSet { + if isSharingLiveLocation == oldValue { + return + } + if isSharingLiveLocation { + delegate?.currentUserControllerDidStartSharingLiveLocation(self) + } else { + delegate?.currentUserControllerDidStopSharingLiveLocation(self) + } + } + } + + /// The throttler for limiting the frequency of live location updates. + private var locationUpdatesThrottler = Throttler(interval: 3, broadcastLatestEvent: true) + /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() @@ -76,6 +112,14 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt currentUser?.unreadCount ?? .noUnread } + /// The active live location messages for the current user. + /// + /// To observe changes of the active live locations, set your class as + /// a delegate of this controller. + public var activeLiveLocationMessages: [ChatMessage] { + Array(activeLiveLocationMessagesObserver?.items ?? []) + } + /// The worker used to update the current user. private lazy var currentUserUpdater = environment.currentUserUpdaterBuilder( client.databaseContainer, @@ -85,6 +129,9 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt /// The worker used to update the current user member for a given channel. private lazy var currentMemberUpdater = createMemberUpdater() + /// The worker used to update messages. Used for updating live locations of messages. + private lazy var messageUpdater = createMessageUpdater() + // MARK: - Drafts Properties /// The query used for fetching the draft messages. @@ -264,6 +311,48 @@ public extension CurrentChatUserController { } } + /// Loads all active live location messages for the current user. + /// This method is only needed to be called once to make sure all live location messages are tracked. + func loadActiveLiveLocationMessages(completion: ((Result<[SharedLocation], Error>) -> Void)? = nil) { + currentUserUpdater.loadActiveLiveLocations { result in + self.callback { + completion?(result) + } + } + } + + /// Updates the location of all the active live location messages + /// for the current user that were generated by the this device. + /// + /// The updates are throttled to avoid sending too many requests. + /// + /// - Parameter location: The new location to be updated. + func updateLiveLocation(_ location: LocationInfo) { + guard let messages = activeLiveLocationMessagesObserver?.items, !messages.isEmpty else { + return + } + + locationUpdatesThrottler.execute { [weak self] in + for message in messages { + self?.messageUpdater.updateLiveLocation( + messageId: message.id, + locationInfo: location + ) { result in + guard let self = self else { return } + guard let error = result.error else { return } + guard let location = message.sharedLocation else { return } + self.delegateCallback { delegate in + delegate.currentUserController( + self, + didFailToUpdateLiveLocation: location, + with: error + ) + } + } + } + } + } + /// Fetches the most updated devices and syncs with the local database. /// - Parameter completion: Called when the devices are synced successfully, or with error. func synchronizeDevices(completion: ((Error?) -> Void)? = nil) { @@ -455,6 +544,21 @@ extension CurrentChatUserController { _ fetchedResultsControllerType: NSFetchedResultsController.Type ) -> BackgroundEntityDatabaseObserver = BackgroundEntityDatabaseObserver.init + var currentUserActiveLiveLocationMessagesObserverBuilder: ( + _ database: DatabaseContainer, + _ fetchRequest: NSFetchRequest, + _ itemCreator: @escaping (MessageDTO) throws -> ChatMessage, + _ fetchedResultsControllerType: NSFetchedResultsController.Type + ) -> BackgroundListDatabaseObserver = { + .init( + database: $0, + fetchRequest: $1, + itemCreator: $2, + itemReuseKeyPaths: (\ChatMessage.id, \MessageDTO.id), + fetchedResultsControllerType: $3 + ) + } + var draftMessagesObserverBuilder: ( _ database: DatabaseContainer, _ fetchRequest: NSFetchRequest, @@ -492,10 +596,34 @@ private extension CurrentChatUserController { ) } + func createActiveLiveLocationMessagesObserver() -> BackgroundListDatabaseObserver? { + guard let currentUserId = client.currentUserId else { + return nil + } + return environment.currentUserActiveLiveLocationMessagesObserverBuilder( + client.databaseContainer, + MessageDTO.currentUserActiveLiveLocationMessagesFetchRequest( + currentUserId: currentUserId, + channelId: nil + ), + { try $0.asModel() }, + NSFetchedResultsController.self + ) + } + private func createMemberUpdater() -> ChannelMemberUpdater { .init(database: client.databaseContainer, apiClient: client.apiClient) } + private func createMessageUpdater() -> MessageUpdater { + .init( + isLocalStorageEnabled: client.config.isLocalStorageEnabled, + messageRepository: client.messageRepository, + database: client.databaseContainer, + apiClient: client.apiClient + ) + } + @discardableResult private func createDraftMessagesObserver(query: DraftListQuery) -> BackgroundListDatabaseObserver { let observer = environment.draftMessagesObserverBuilder( @@ -520,10 +648,47 @@ private extension CurrentChatUserController { /// `CurrentChatUserController` uses this protocol to communicate changes to its delegate. public protocol CurrentChatUserControllerDelegate: AnyObject { /// The controller observed a change in the `UnreadCount`. - func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) + func currentUserController( + _ controller: CurrentChatUserController, + didChangeCurrentUserUnreadCount: UnreadCount + ) /// The controller observed a change in the `CurrentChatUser` entity. - func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUser: EntityChange) + func currentUserController( + _ controller: CurrentChatUserController, + didChangeCurrentUser: EntityChange + ) + + /// The current user has currently active live location attachments. + func currentUserControllerDidStartSharingLiveLocation( + _ controller: CurrentChatUserController + ) + + /// The current user has no active live location attachments. + func currentUserControllerDidStopSharingLiveLocation( + _ controller: CurrentChatUserController + ) + + /// The current user active location messages have changed. + /// + /// Whenever there are changes of the current user's live location messages, this method is called. + /// + /// - If a new live location message is added, the array will contain the new message. + /// - If a live location message is removed, the array will not contain it anymore. + /// - If all live location messages are removed, the array will be empty. + /// + /// - Parameter messages: The currently active live location messages for the current user. + func currentUserController( + _ controller: CurrentChatUserController, + didChangeActiveLiveLocationMessages messages: [ChatMessage] + ) + + /// There was an error when updating one of the live location messages. + func currentUserController( + _ controller: CurrentChatUserController, + didFailToUpdateLiveLocation location: SharedLocation, + with error: Error + ) /// The controller observed a change in the draft messages. func currentUserController( @@ -533,9 +698,34 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { } public extension CurrentChatUserControllerDelegate { - func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) {} + func currentUserController( + _ controller: CurrentChatUserController, + didChangeCurrentUserUnreadCount: UnreadCount + ) {} + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeCurrentUser: EntityChange + ) {} + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeActiveLiveLocationMessages messages: [ChatMessage] + ) {} + + func currentUserControllerDidStartSharingLiveLocation( + _ controller: CurrentChatUserController + ) {} - func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUser: EntityChange) {} + func currentUserControllerDidStopSharingLiveLocation( + _ controller: CurrentChatUserController + ) {} + + func currentUserController( + _ controller: CurrentChatUserController, + didFailToUpdateLiveLocation location: SharedLocation, + with error: Error + ) {} func currentUserController( _ controller: CurrentChatUserController, diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index c592a2c2813..f19336abdf2 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Combine import CoreData import Foundation @@ -183,8 +184,14 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// The worker used to fetch the remote data and communicate with servers. private let messageUpdater: MessageUpdater + + /// The polls repository to fetch polls data. private let pollsRepository: PollsRepository + + /// The replies pagination handler. private let replyPaginationHandler: MessagesPaginationStateHandling + + /// The current state of the pagination state. private var replyPaginationState: MessagesPaginationState { replyPaginationHandler.state } /// The drafts repository. @@ -199,7 +206,13 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// - cid: The channel identifier the message belongs to. /// - messageId: The message identifier. /// - environment: The source of internal dependencies. - init(client: ChatClient, cid: ChannelId, messageId: MessageId, replyPaginationHandler: MessagesPaginationStateHandling, environment: Environment = .init()) { + init( + client: ChatClient, + cid: ChannelId, + messageId: MessageId, + replyPaginationHandler: MessagesPaginationStateHandling, + environment: Environment = .init() + ) { self.client = client self.cid = cid self.messageId = messageId @@ -251,7 +264,8 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP // MARK: - Actions - /// Edits the message this controller manages with the provided values. + /// Edits the message locally, changes the message state to pending and + /// schedules it to eventually be published to the server. /// /// - Parameters: /// - text: The updated message text. @@ -259,8 +273,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// - attachments: An array of the attachments for the message. /// - restrictedVisibility: The list of user ids that can see the message. /// - extraData: Custom extra data. When `nil` is passed the message custom fields stay the same. Equals `nil` by default. - /// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished. - /// If request fails, the completion will be called with an error. + /// - completion: Called when the message is edited locally. public func editMessage( text: String, skipEnrichUrl: Bool = false, @@ -292,6 +305,36 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP } } + /// Updates the message partially and submits the changes directly to the server. + /// + /// **Note:** The `message.localState` is not changed in this method call. + /// + /// - Parameters: + /// - text: The text in case the message + /// - attachments: The attachments to be updated. + /// - extraData: The additional data to be updated. + /// - unsetProperties: Properties from the message to be cleared/unset. + /// - completion: Called when the server updates the message. + public func partialUpdateMessage( + text: String? = nil, + attachments: [AnyAttachmentPayload]? = nil, + extraData: [String: RawJSON]? = nil, + unsetProperties: [String]? = nil, + completion: ((Result) -> Void)? = nil + ) { + messageUpdater.updatePartialMessage( + messageId: messageId, + text: text, + attachments: attachments, + extraData: extraData, + unset: unsetProperties + ) { result in + self.callback { + completion?(result) + } + } + } + /// Deletes the message this controller manages. /// /// - Parameters: @@ -866,6 +909,32 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP } } + /// Stops sharing the live location for this message if it has an active location sharing attachment. + /// + /// - Parameters: + /// - completion: Called when the server updates the message. + public func stopLiveLocationSharing(completion: ((Result) -> Void)? = nil) { + guard let location = message?.sharedLocation else { + callback { + completion?(.failure(ClientError.MessageDoesNotHaveLiveLocationAttachment())) + } + return + } + + guard location.isLiveSharingActive else { + callback { + completion?(.failure(ClientError.MessageLiveLocationAlreadyStopped())) + } + return + } + + messageUpdater.stopLiveLocationSharing(messageId: messageId) { result in + self.callback { + completion?(result) + } + } + } + /// Updates the draft message for this thread. /// /// If there is no draft message, a new draft message will be created. @@ -1124,10 +1193,22 @@ public extension ChatMessageController { } } -extension ClientError { +public extension ClientError { final class MessageEmptyReplies: ClientError { override public var localizedDescription: String { "You can't load previous replies when there is no replies for the message." } } + + final class MessageDoesNotHaveLiveLocationAttachment: ClientError { + override public var localizedDescription: String { + "The message does not have a live location attachment." + } + } + + final class MessageLiveLocationAlreadyStopped: ClientError { + override public var localizedDescription: String { + "The live location sharing has already been stopped." + } + } } diff --git a/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift index 258d9d81629..687364ea7c1 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift @@ -25,6 +25,7 @@ final class ChannelConfigDTO: NSManagedObject { @NSManaged var createdAt: DBDate @NSManaged var updatedAt: DBDate @NSManaged var commands: NSOrderedSet + @NSManaged var sharedLocationsEnabled: Bool func asModel() throws -> ChannelConfig { try isNotDeleted() @@ -42,6 +43,7 @@ final class ChannelConfigDTO: NSManagedObject { urlEnrichmentEnabled: urlEnrichmentEnabled, skipLastMsgAtUpdateForSystemMsg: skipLastMsgAtUpdateForSystemMsg, messageRemindersEnabled: messageRemindersEnabled, + sharedLocationsEnabled: sharedLocationsEnabled, messageRetention: messageRetention, maxMessageLength: Int(maxMessageLength), commands: Array(Set( @@ -83,6 +85,7 @@ extension ChannelConfig { dto.pollsEnabled = pollsEnabled dto.skipLastMsgAtUpdateForSystemMsg = skipLastMsgAtUpdateForSystemMsg dto.messageRemindersEnabled = messageRemindersEnabled + dto.sharedLocationsEnabled = sharedLocationsEnabled return dto } } diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 521b7a88d7f..b7b19e07641 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -74,6 +74,7 @@ class ChannelDTO: NSManagedObject { @NSManaged var memberListQueries: Set @NSManaged var previewMessage: MessageDTO? @NSManaged var draftMessage: MessageDTO? + @NSManaged var activeLiveLocations: Set /// If the current channel is muted by the current user, `mute` contains details. @NSManaged var mute: ChannelMuteDTO? @@ -358,6 +359,10 @@ extension NSManagedObjectContext { } } + dto.activeLiveLocations = Set(try payload.activeLiveLocations.map { + try saveLocation(payload: $0, cache: cache) + }) + try payload.pinnedMessages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) } @@ -595,6 +600,7 @@ extension ChatChannel { let previewMessage = try? dto.previewMessage?.relationshipAsModel(depth: depth) let draftMessage = try? dto.draftMessage?.relationshipAsModel(depth: depth) let typingUsers = Set(dto.currentlyTypingUsers.compactMap { try? $0.asModel() }) + let activeLiveLocations = try dto.activeLiveLocations.map { try $0.asModel() } let channel = try ChatChannel( cid: cid, @@ -628,7 +634,8 @@ extension ChatChannel { pinnedMessages: pinnedMessages, muteDetails: muteDetails, previewMessage: previewMessage, - draftMessage: draftMessage.map(DraftMessage.init) + draftMessage: draftMessage.map(DraftMessage.init), + activeLiveLocations: activeLiveLocations ) if let transformer = clientConfig.modelsTransformer { diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 7061a9b1673..82cc29e39af 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -88,6 +88,9 @@ class MessageDTO: NSManagedObject { @NSManaged var draftReply: MessageDTO? @NSManaged var isDraft: Bool + @NSManaged var location: SharedLocationDTO? + @NSManaged var isActiveLiveLocation: Bool + @NSManaged var reminder: MessageReminderDTO? /// If the message is sent by the current user, this field @@ -125,6 +128,13 @@ class MessageDTO: NSManagedObject { self.cid = channel.cid } + if let locationEndAt = location?.endAt?.bridgeDate { + let isActiveLiveLocation = locationEndAt > Date() + if isActiveLiveLocation != self.isActiveLiveLocation { + self.isActiveLiveLocation = isActiveLiveLocation + } + } + // Manually mark the channel as dirty to trigger the entity update and give the UI a chance // to reload the channel cell to reflect the updated preview. if let channel = previewOfChannel, !channel.hasChanges, !channel.isDeleted { @@ -617,7 +627,59 @@ class MessageDTO: NSManagedObject { ]) return try load(request, context: context) } - + + /// Fetches all active location messages in a channel or all channels of the current user. + /// If `channelId` is nil, it will fetch all messages independent of the channel. + static func currentUserActiveLiveLocationMessagesFetchRequest( + currentUserId: UserId, + channelId: ChannelId? + ) -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageDTO.entityName) + MessageDTO.applyPrefetchingState(to: request) + // Hard coded limit for now. 10 live locations messages at the same should be more than enough. + request.fetchLimit = 10 + request.sortDescriptors = [NSSortDescriptor( + keyPath: \MessageDTO.createdAt, + ascending: true + )] + var predicates: [NSPredicate] = [ + .init(format: "isActiveLiveLocation == YES"), + .init(format: "user.id == %@", currentUserId), + .init(format: "localMessageStateRaw == nil") + ] + if let channelId { + predicates.append(.init(format: "channel.cid == %@", channelId.rawValue)) + } + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + return request + } + + /// Fetches all active location messages in any given channel and from every user. + static func activeLiveLocationMessagesFetchRequest() -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageDTO.entityName) + MessageDTO.applyPrefetchingState(to: request) + request.fetchLimit = 25 + request.sortDescriptors = [NSSortDescriptor( + keyPath: \MessageDTO.createdAt, + ascending: true + )] + var predicates: [NSPredicate] = [ + .init(format: "isActiveLiveLocation == YES"), + .init(format: "localMessageStateRaw == nil") + ] + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + return request + } + + static func loadCurrentUserActiveLiveLocationMessages( + currentUserId: UserId, + channelId: ChannelId?, + context: NSManagedObjectContext + ) throws -> [MessageDTO] { + let request = currentUserActiveLiveLocationMessagesFetchRequest(currentUserId: currentUserId, channelId: channelId) + return try load(request, context: context) + } + static func loadReplies( from fromIncludingDate: Date, to toIncludingDate: Date, @@ -680,6 +742,7 @@ extension NSManagedObjectContext: MessageDatabaseSession { skipPush: Bool, skipEnrichUrl: Bool, poll: PollPayload?, + location: NewLocationInfo?, restrictedVisibility: [UserId], extraData: [String: RawJSON] ) throws -> MessageDTO { @@ -691,7 +754,8 @@ extension NSManagedObjectContext: MessageDatabaseSession { throw ClientError.ChannelDoesNotExist(cid: cid) } - let message = MessageDTO.loadOrCreate(id: messageId ?? .newUniqueId, context: self, cache: nil) + let id = messageId ?? .newUniqueId + let message = MessageDTO.loadOrCreate(id: id, context: self, cache: nil) // We make `createdDate` 0.1 second bigger than Channel's most recent message // so if the local time is not in sync, the message will still appear in the correct position @@ -735,6 +799,23 @@ extension NSManagedObjectContext: MessageDatabaseSession { message.poll = try? savePoll(payload: poll, cache: nil) } + if let location, let currentUser, let deviceId = currentUser.currentDevice?.id { + message.location = try? saveLocation( + payload: .init( + channelId: cid.rawValue, + messageId: id, + userId: currentUser.user.id, + latitude: location.latitude, + longitude: location.longitude, + createdAt: Date(), + updatedAt: Date(), + endAt: location.endAt, + createdByDeviceId: deviceId + ), + cache: nil + ) + } + message.attachments = Set( try attachments.enumerated().map { index, attachment in let id = AttachmentId(cid: cid, messageId: message.id, index: index) @@ -949,6 +1030,10 @@ extension NSManagedObjectContext: MessageDatabaseSession { } } + if let location = payload.location { + dto.location = try saveLocation(payload: location, cache: cache) + } + let user = try saveUser(payload: payload.user) dto.user = user @@ -1005,7 +1090,7 @@ extension NSManagedObjectContext: MessageDatabaseSession { } ) dto.attachments = attachments - + if let poll = payload.poll { let pollDto = try savePoll(payload: poll, cache: cache) dto.poll = pollDto @@ -1591,6 +1676,14 @@ extension MessageDTO { pinExpires: pinExpires?.bridgeDate, pollId: poll?.id, restrictedVisibility: restrictedVisibilityArray, + location: location.map { + .init( + latitude: $0.latitude, + longitude: $0.longitude, + endAt: $0.endAt?.bridgeDate, + createdByDeviceId: $0.deviceId + ) + }, extraData: extraData ) } @@ -1706,6 +1799,7 @@ private extension ChatMessage { } let poll = try? dto.poll?.asModel() + let location = try? dto.location?.asModel() let currentUserReactions: Set let isSentByCurrentUser: Bool @@ -1803,7 +1897,8 @@ private extension ChatMessage { remindAt: $0.remindAt?.bridgeDate, createdAt: $0.createdAt.bridgeDate, updatedAt: $0.updatedAt.bridgeDate - ) } + ) }, + sharedLocation: location ) if let transformer = chatClientConfig?.modelsTransformer { diff --git a/Sources/StreamChat/Database/DTOs/SharedLocationDTO.swift b/Sources/StreamChat/Database/DTOs/SharedLocationDTO.swift new file mode 100644 index 00000000000..96e7b31667b --- /dev/null +++ b/Sources/StreamChat/Database/DTOs/SharedLocationDTO.swift @@ -0,0 +1,106 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation + +@objc(SharedLocationDTO) +class SharedLocationDTO: NSManagedObject { + @NSManaged var messageId: String + @NSManaged var channelId: String + @NSManaged var userId: String + @NSManaged var deviceId: String + @NSManaged var latitude: Double + @NSManaged var longitude: Double + @NSManaged var updatedAt: DBDate + @NSManaged var createdAt: DBDate + @NSManaged var endAt: DBDate? + @NSManaged var message: MessageDTO + + override func willSave() { + super.willSave() + + guard !isDeleted && !message.isDeleted else { + return + } + + // When location changed, we need to propagate this change up to holding message + if hasPersistentChangedValues, !message.hasChanges { + // this will not change object, but mark it as dirty, triggering updates + message.id = message.id + } + } + + static func loadOrCreate( + messageId: String, + context: NSManagedObjectContext, + cache: PreWarmedCache? + ) -> SharedLocationDTO { + if let cachedObject = cache?.model(for: messageId, context: context, type: SharedLocationDTO.self) { + return cachedObject + } + + let request = fetchRequest(for: messageId) + if let existing = load(by: request, context: context).first { + return existing + } + + let new = NSEntityDescription.insertNewObject(into: context, for: request) + new.messageId = messageId + return new + } + + static func load(messageId: String, context: NSManagedObjectContext) -> SharedLocationDTO? { + let request = fetchRequest(for: messageId) + return load(by: request, context: context).first + } + + static func fetchRequest(for messageId: String) -> NSFetchRequest { + let request = NSFetchRequest(entityName: SharedLocationDTO.entityName) + SharedLocationDTO.applyPrefetchingState(to: request) + request.sortDescriptors = [NSSortDescriptor(keyPath: \SharedLocationDTO.message.createdAt, ascending: false)] + request.predicate = NSPredicate(format: "messageId == %@", messageId) + return request + } +} + +extension SharedLocationDTO { + func asModel() throws -> SharedLocation { + try isNotDeleted() + + return SharedLocation( + messageId: messageId, + channelId: try ChannelId(cid: channelId), + userId: userId, + createdByDeviceId: deviceId, + latitude: latitude, + longitude: longitude, + updatedAt: updatedAt.bridgeDate, + createdAt: createdAt.bridgeDate, + endAt: endAt?.bridgeDate + ) + } +} + +extension NSManagedObjectContext { + @discardableResult + func saveLocation(payload: SharedLocationPayload, cache: PreWarmedCache?) throws -> SharedLocationDTO { + let locationDTO = SharedLocationDTO.loadOrCreate( + messageId: payload.messageId, + context: self, + cache: cache + ) + + locationDTO.messageId = payload.messageId + locationDTO.channelId = payload.channelId + locationDTO.deviceId = payload.createdByDeviceId + locationDTO.latitude = payload.latitude + locationDTO.longitude = payload.longitude + locationDTO.endAt = payload.endAt?.bridgeDate + locationDTO.userId = payload.userId + locationDTO.updatedAt = payload.updatedAt.bridgeDate + locationDTO.createdAt = payload.createdAt.bridgeDate + return locationDTO + } +} diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 71ff789b263..72bfb73fa15 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -104,6 +104,7 @@ protocol MessageDatabaseSession { skipPush: Bool, skipEnrichUrl: Bool, poll: PollPayload?, + location: NewLocationInfo?, restrictedVisibility: [UserId], extraData: [String: RawJSON] ) throws -> MessageDTO @@ -301,6 +302,7 @@ extension MessageDatabaseSession { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: pollPayload, + location: nil, restrictedVisibility: restrictedVisibility, extraData: extraData ) @@ -673,6 +675,12 @@ protocol PollDatabaseSession { func delete(pollVote: PollVoteDTO) } +protocol LocationDatabaseSession { + /// Saves the provided location payload to the DB. + @discardableResult + func saveLocation(payload: SharedLocationPayload, cache: PreWarmedCache?) throws -> SharedLocationDTO +} + protocol DatabaseSession: UserDatabaseSession, CurrentUserDatabaseSession, MessageDatabaseSession, @@ -686,7 +694,9 @@ protocol DatabaseSession: UserDatabaseSession, QueuedRequestDatabaseSession, ThreadDatabaseSession, ThreadReadDatabaseSession, + PollDatabaseSession, ReminderDatabaseSession, + LocationDatabaseSession, PollDatabaseSession {} extension DatabaseSession { diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 84628c805c4..37225f38d61 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -29,6 +29,7 @@ + @@ -63,6 +64,7 @@ + @@ -214,6 +216,7 @@ + @@ -250,6 +253,7 @@ + @@ -438,6 +442,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 77f811bba60..d8ccb8736d1 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -160,6 +160,9 @@ public struct ChatChannel { /// The draft message in the channel. public let draftMessage: DraftMessage? + /// A list of active live locations in the channel. + public let activeLiveLocations: [SharedLocation] + // MARK: - Internal var hasUnread: Bool { @@ -198,7 +201,8 @@ public struct ChatChannel { pinnedMessages: [ChatMessage], muteDetails: MuteDetails?, previewMessage: ChatMessage?, - draftMessage: DraftMessage? + draftMessage: DraftMessage?, + activeLiveLocations: [SharedLocation] ) { self.cid = cid self.name = name @@ -232,6 +236,7 @@ public struct ChatChannel { self.muteDetails = muteDetails self.previewMessage = previewMessage self.draftMessage = draftMessage + self.activeLiveLocations = activeLiveLocations } /// Returns a new `ChatChannel` with the provided data replaced. @@ -272,7 +277,8 @@ public struct ChatChannel { pinnedMessages: pinnedMessages, muteDetails: muteDetails, previewMessage: previewMessage, - draftMessage: draftMessage + draftMessage: draftMessage, + activeLiveLocations: activeLiveLocations ) } } @@ -322,6 +328,7 @@ extension ChatChannel: Hashable { guard lhs.truncatedAt == rhs.truncatedAt else { return false } guard lhs.ownCapabilities == rhs.ownCapabilities else { return false } guard lhs.draftMessage == rhs.draftMessage else { return false } + guard lhs.activeLiveLocations.count == rhs.activeLiveLocations.count else { return false } return true } @@ -430,6 +437,8 @@ public struct ChannelCapability: RawRepresentable, ExpressibleByStringLiteral, H public static let sendPoll: Self = "send-poll" /// Ability to cast a poll vote. public static let castPollVote: Self = "cast-poll-vote" + /// Ability to share location. + public static let shareLocation: Self = "share-location" } public extension ChatChannel { @@ -587,4 +596,9 @@ public extension ChatChannel { var canCastPollVote: Bool { ownCapabilities.contains(.castPollVote) } + + /// Can the current user share location in this channel. + var canShareLocation: Bool { + ownCapabilities.contains(.shareLocation) + } } diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index df634576534..5f8b11e7c8e 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -181,6 +181,9 @@ public struct ChatMessage { /// Optional poll that is part of the message. public let poll: Poll? + /// The location information of the message. + public let sharedLocation: SharedLocation? + init( id: MessageId, cid: ChannelId?, @@ -221,7 +224,8 @@ public struct ChatMessage { poll: Poll?, textUpdatedAt: Date?, draftReply: DraftMessage?, - reminder: MessageReminderInfo? + reminder: MessageReminderInfo?, + sharedLocation: SharedLocation? ) { self.id = id self.cid = cid @@ -263,6 +267,7 @@ public struct ChatMessage { _attachments = attachments _quotedMessage = { quotedMessage } self.draftReply = draftReply + self.sharedLocation = sharedLocation self.reminder = reminder } @@ -321,7 +326,8 @@ public struct ChatMessage { poll: poll, textUpdatedAt: textUpdatedAt, draftReply: draftReply, - reminder: reminder + reminder: reminder, + sharedLocation: sharedLocation ) } @@ -434,7 +440,8 @@ public struct ChatMessage { poll: poll, textUpdatedAt: textUpdatedAt, draftReply: draftReply, - reminder: reminder + reminder: reminder, + sharedLocation: sharedLocation ) } } @@ -566,6 +573,7 @@ extension ChatMessage: Hashable { guard lhs.translations == rhs.translations else { return false } guard lhs.type == rhs.type else { return false } guard lhs.draftReply == rhs.draftReply else { return false } + guard lhs.sharedLocation == rhs.sharedLocation else { return false } guard lhs.reminder == rhs.reminder else { return false } return true } diff --git a/Sources/StreamChat/Models/DraftMessage.swift b/Sources/StreamChat/Models/DraftMessage.swift index fb909bbf8ea..90074f50380 100644 --- a/Sources/StreamChat/Models/DraftMessage.swift +++ b/Sources/StreamChat/Models/DraftMessage.swift @@ -163,5 +163,6 @@ extension ChatMessage { textUpdatedAt = nil draftReply = nil reminder = nil + sharedLocation = nil } } diff --git a/Sources/StreamChat/Models/Location/LocationInfo.swift b/Sources/StreamChat/Models/Location/LocationInfo.swift new file mode 100644 index 00000000000..3bc7de2bea0 --- /dev/null +++ b/Sources/StreamChat/Models/Location/LocationInfo.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// The location information. +public struct LocationInfo { + /// The location latitude. + public var latitude: Double + /// The location longitude. + public var longitude: Double + + public init( + latitude: Double, + longitude: Double + ) { + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/Sources/StreamChat/Models/Location/NewLocationInfo.swift b/Sources/StreamChat/Models/Location/NewLocationInfo.swift new file mode 100644 index 00000000000..e9b4f009773 --- /dev/null +++ b/Sources/StreamChat/Models/Location/NewLocationInfo.swift @@ -0,0 +1,15 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Data model for the new location info used in `createNewMessage`. +public struct NewLocationInfo { + /// The initial latitude of the location. + public let latitude: Double + /// The initial longitude of the location. + public let longitude: Double + /// The end date of the location sharing if it is a live location. + public let endAt: Date? +} diff --git a/Sources/StreamChat/Models/Location/SharedLocation.swift b/Sources/StreamChat/Models/Location/SharedLocation.swift new file mode 100644 index 00000000000..d420ccbdbd5 --- /dev/null +++ b/Sources/StreamChat/Models/Location/SharedLocation.swift @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +public struct SharedLocation: Equatable { + /// The ID of the message that the location is attached to. + public let messageId: MessageId + /// The ID of the channel that the location is attached to. + public let channelId: ChannelId + /// The ID of the user that created the location. + public let userId: UserId + /// The ID of the device that created the location. + public let createdByDeviceId: DeviceId + /// The latitude of the location. + public let latitude: Double + /// The longitude of the location. + public let longitude: Double + /// The date when the location was updated. + public let updatedAt: Date + /// The date when the location was created. + public let createdAt: Date + /// The date when the location sharing ends. + /// If it's `nil`, it means the location sharing is static instead of live. + public let endAt: Date? + + /// Whether the location sharing is live or not. + public var isLive: Bool { + endAt != nil + } + + /// Whether the live location sharing is currently active. + public var isLiveSharingActive: Bool { + guard let endAt else { return false } + return endAt > Date() + } + + public init( + messageId: MessageId, + channelId: ChannelId, + userId: UserId, + createdByDeviceId: DeviceId, + latitude: Double, + longitude: Double, + updatedAt: Date, + createdAt: Date, + endAt: Date? + ) { + self.messageId = messageId + self.channelId = channelId + self.userId = userId + self.createdByDeviceId = createdByDeviceId + self.latitude = latitude + self.longitude = longitude + self.updatedAt = updatedAt + self.createdAt = createdAt + self.endAt = endAt + } +} diff --git a/Sources/StreamChat/Models/UserInfo.swift b/Sources/StreamChat/Models/UserInfo.swift index a108ef0327a..e55bca163f8 100644 --- a/Sources/StreamChat/Models/UserInfo.swift +++ b/Sources/StreamChat/Models/UserInfo.swift @@ -4,7 +4,7 @@ import Foundation -/// A model containing user info that's used to connect to chat's backend +/// The user information used to connect the user to chat. public struct UserInfo { /// The id of the user. public let id: UserId diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 4d11eda2838..59071803a3e 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -361,7 +361,32 @@ class MessageRepository { } } } - + + func getCurrentUserActiveLiveLocationMessages( + for channelId: ChannelId, + completion: @escaping (Result<[ChatMessage], Error>) -> Void + ) { + let context = database.backgroundReadOnlyContext + context.perform { + do { + guard let currentUserId = context.currentUser?.user.id else { + return completion(.failure(ClientError.CurrentUserDoesNotExist())) + } + let messages = try MessageDTO.loadCurrentUserActiveLiveLocationMessages( + currentUserId: currentUserId, + channelId: channelId, + context: context + ) + .map { + try $0.asModel() + } + completion(.success(messages)) + } catch { + completion(.failure(error)) + } + } + } + func updateMessage(withID id: MessageId, localState: LocalMessageState?, completion: @escaping (Result) -> Void) { var message: ChatMessage? database.write({ diff --git a/Sources/StreamChatUI/Utils/Throttler.swift b/Sources/StreamChat/Utils/Throttler.swift similarity index 83% rename from Sources/StreamChatUI/Utils/Throttler.swift rename to Sources/StreamChat/Utils/Throttler.swift index 0aef17b461d..d9013642e43 100644 --- a/Sources/StreamChatUI/Utils/Throttler.swift +++ b/Sources/StreamChat/Utils/Throttler.swift @@ -5,20 +5,24 @@ import Foundation /// A throttler implementation. The action provided will only be executed if the last action executed has passed an amount of time. -/// Based on the implementation from Apple: https://developer.apple.com/documentation/combine/anypublisher/throttle(for:scheduler:latest:) -class Throttler { +/// +/// The API is based on the implementation from Apple: +/// https://developer.apple.com/documentation/combine/anypublisher/throttle(for:scheduler:latest:) +public class Throttler { private var workItem: DispatchWorkItem? private let queue: DispatchQueue private var previousRun: Date = Date.distantPast - let interval: TimeInterval - let broadcastLatestEvent: Bool + private let broadcastLatestEvent: Bool + + /// The current interval that an action can be executed. + public var interval: TimeInterval /// - Parameters: /// - interval: The interval that an action can be executed. /// - broadcastLatestEvent: A Boolean value that indicates whether we should be using the first or last event of the ones that are being throttled. /// - queue: The queue where the work will be executed. /// This last action will have a delay of the provided interval until it is executed. - init( + public init( interval: TimeInterval, broadcastLatestEvent: Bool = true, queue: DispatchQueue = .init(label: "com.stream.throttler", qos: .utility) @@ -31,7 +35,7 @@ class Throttler { /// Throttle an action. It will cancel the previous action if exists, and it will execute the action immediately /// if the last action executed was past the interval provided. If not, it will only be executed after a delay. /// - Parameter action: The closure to be performed. - func execute(_ action: @escaping () -> Void) { + public func execute(_ action: @escaping () -> Void) { workItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -53,7 +57,7 @@ class Throttler { } /// Cancel any active action. - func cancel() { + public func cancel() { workItem?.cancel() workItem = nil } diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index a90e7adb296..48afe3b92b7 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -295,7 +295,8 @@ private extension MessagePayload { poll: nil, textUpdatedAt: messageTextUpdatedAt, draftReply: nil, - reminder: nil + reminder: nil, + sharedLocation: nil ) } } diff --git a/Sources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swift b/Sources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swift new file mode 100644 index 00000000000..8b8565de876 --- /dev/null +++ b/Sources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swift @@ -0,0 +1,98 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// An observer type that observes all active live locations in the database. +typealias ActiveLiveLocationsObserver = StateLayerDatabaseObserver + +/// A worker that is responsible for tracking when the end time of active locations is reached. +class ActiveLiveLocationsEndTimeTracker: Worker { + private let activeLiveLocationsObserver: ActiveLiveLocationsObserver + internal var workItems: [String: DispatchWorkItem] = [:] + private let queue = DispatchQueue(label: "io.getstream.ActiveLiveLocationsEndTimeTracker") + + override init( + database: DatabaseContainer, + apiClient: APIClient + ) { + activeLiveLocationsObserver = ActiveLiveLocationsObserver( + context: database.writableContext, + fetchRequest: MessageDTO.activeLiveLocationMessagesFetchRequest() + ) + super.init(database: database, apiClient: apiClient) + startObserving() + } + + private func startObserving() { + do { + let items = try activeLiveLocationsObserver.startObserving( + onContextDidChange: { [weak self] _, changes in + self?.handle(changes: changes) + } + ) + let changes = items.map { ListChange.insert($0, index: .init(item: 0, section: 0)) } + handle(changes: changes) + } catch { + log.error("Failed to start AttachmentUploader worker. \(error)") + } + } + + private func handle(changes: [ListChange]) { + guard !changes.isEmpty else { + return + } + + database.write { _ in + for change in changes { + switch change { + case .insert(let message, _): + guard let endAt = message.location?.endAt?.bridgeDate else { continue } + self.scheduleInactiveLocation(for: message.id, at: endAt) + case .remove(let message, _): + self.setInactiveLocation(for: message.id) + self.cancelWorkItem(for: message.id) + case .move, .update: + break + } + } + } + } + + private func scheduleInactiveLocation(for messageId: String, at endAt: Date) { + // Cancel any existing work item for the same messageId + cancelWorkItem(for: messageId) + + let workItem = DispatchWorkItem { [weak self] in + self?.setInactiveLocation(for: messageId) + } + workItems[messageId] = workItem + + let endAtTime = endAt.timeIntervalSinceNow + queue.asyncAfter(deadline: .now() + endAtTime, execute: workItem) + } + + /// It sets the location as inactive in the database and removes it from the active live locations. + /// + /// The update of `updatedAt` is needed for the UI to be updated. + /// The reason is because otherwise the `Equatable` of `SharedLocation` won't have any effect. + /// Since the `endAt` is not changed, and the `isLiveSharingActive` property is a computed one. + /// Which means, that when the `Equatable` is checked, it will return `true` and the UI won't update. + private func setInactiveLocation(for messageId: String) { + database.write { session in + let message = session.message(id: messageId) + message?.isActiveLiveLocation = false + message?.location?.updatedAt = DBDate() // This is need for the UI to be updated. + if let location = message?.location { + message?.channel?.activeLiveLocations.remove(location) + } + } + cancelWorkItem(for: messageId) + } + + private func cancelWorkItem(for messageId: String) { + workItems[messageId]?.cancel() + workItems.removeValue(forKey: messageId) + } +} diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 78c4eb22617..50aca0fd49c 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -378,6 +378,7 @@ class ChannelUpdater: Worker { skipEnrichUrl: Bool, restrictedVisibility: [UserId] = [], poll: PollPayload? = nil, + location: NewLocationInfo? = nil, extraData: [String: RawJSON], completion: ((Result) -> Void)? = nil ) { @@ -401,6 +402,7 @@ class ChannelUpdater: Worker { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: poll, + location: location, restrictedVisibility: restrictedVisibility, extraData: extraData ) diff --git a/Sources/StreamChat/Workers/CurrentUserUpdater.swift b/Sources/StreamChat/Workers/CurrentUserUpdater.swift index bb47067f1af..d33b3fb8f71 100644 --- a/Sources/StreamChat/Workers/CurrentUserUpdater.swift +++ b/Sources/StreamChat/Workers/CurrentUserUpdater.swift @@ -228,6 +228,24 @@ class CurrentUserUpdater: Worker { } } } + + func loadActiveLiveLocations(completion: @escaping (Result<[SharedLocation], Error>) -> Void) { + apiClient.request(endpoint: .currentUserActiveLiveLocations()) { result in + switch result { + case let .success(payload): + self.database.write { session in + try payload.locations.map { + try session.saveLocation(payload: $0, cache: nil).asModel() + } + } completion: { result in + completion(result) + } + + case let .failure(error): + completion(.failure(error)) + } + } + } } extension CurrentUserUpdater { diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 3e23af74596..4b77c01ca96 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -120,12 +120,12 @@ class MessageUpdater: Worker { func updateMessage(localState: LocalMessageState) throws { let newUpdatedAt = DBDate() - + if messageDTO.text != text { messageDTO.textUpdatedAt = newUpdatedAt } messageDTO.updatedAt = newUpdatedAt - + messageDTO.text = text let encodedExtraData = extraData.map { try? JSONEncoder.default.encode($0) } ?? messageDTO.extraData messageDTO.extraData = encodedExtraData @@ -183,6 +183,79 @@ class MessageUpdater: Worker { }) } + func updatePartialMessage( + messageId: MessageId, + text: String? = nil, + attachments: [AnyAttachmentPayload]? = nil, + extraData: [String: RawJSON]? = nil, + unset: [String]? = nil, + completion: ((Result) -> Void)? = nil + ) { + let attachmentPayloads: [MessageAttachmentPayload]? = attachments?.compactMap { attachment in + guard let payloadData = try? JSONEncoder.default.encode(attachment.payload) else { + return nil + } + guard let payloadRawJSON = try? JSONDecoder.default.decode(RawJSON.self, from: payloadData) else { + return nil + } + return MessageAttachmentPayload( + type: attachment.type, + payload: payloadRawJSON + ) + } + + apiClient.request( + endpoint: .partialUpdateMessage( + messageId: messageId, + request: .init( + set: .init( + text: text, + extraData: extraData, + attachments: attachmentPayloads + ), + unset: unset + ) + ) + ) { [weak self] result in + switch result { + case .success(let messagePayloadBoxed): + let messagePayload = messagePayloadBoxed.message + self?.database.write { session in + let cid: ChannelId? + + if let payloadCid = messagePayloadBoxed.message.cid { + cid = payloadCid + } else if let cidFromLocal = session.message(id: messageId)?.cid, + let localCid = try? ChannelId(cid: cidFromLocal) { + cid = localCid + } else { + cid = nil + } + + guard let cid = cid else { + completion?(.failure(ClientError.ChannelNotCreatedYet())) + return + } + + let messageDTO = try session.saveMessage( + payload: messagePayload, + for: cid, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: nil + ) + let message = try messageDTO.asModel() + completion?(.success(message)) + } completion: { error in + guard let error else { return } + completion?(.failure(error)) + } + case .failure(let error): + completion?(.failure(error)) + } + } + } + /// Creates a new reply message in the local DB and sets its local state to `.pendingSend`. /// /// - Parameters: @@ -238,6 +311,7 @@ class MessageUpdater: Worker { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: nil, + location: nil, restrictedVisibility: [], extraData: extraData ) @@ -255,6 +329,101 @@ class MessageUpdater: Worker { } } + /// Updates live location sharing for the given message. + func updateLiveLocation( + messageId: MessageId, + locationInfo: LocationInfo, + completion: @escaping ((Result) -> Void) + ) { + database.write { [weak self] session in + // Update the location locally first so that the observers + // can report if active live location are not available anymore. + let messageDTO = try session.messageEditableByCurrentUser(messageId) + messageDTO.location?.latitude = locationInfo.latitude + messageDTO.location?.longitude = locationInfo.longitude + + guard messageDTO.isActiveLiveLocation else { + completion(.failure(ClientError.MessageDoesNotHaveLiveLocationAttachment())) + return + } + + guard let currentUser = session.currentUser, + let currentDeviceId = currentUser.currentDevice?.id else { + completion(.failure(ClientError.CurrentUserDoesNotExist())) + return + } + + let request = LiveLocationUpdateRequestPayload( + messageId: messageId, + latitude: locationInfo.latitude, + longitude: locationInfo.longitude, + createdByDeviceId: currentDeviceId + ) + + let endpoint = Endpoint.updateLiveLocation(request: request) + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case let .success(payload): + self?.database.write { session in + let sharedLocation = try session.saveLocation(payload: payload, cache: nil) + return try sharedLocation.asModel() + } completion: { result in + completion(result) + } + case let .failure(error): + completion(.failure(error)) + } + } + } + } + + /// Stops live location sharing for the given message. + func stopLiveLocationSharing( + messageId: MessageId, + completion: @escaping ((Result) -> Void) + ) { + // Optimistic update + var previousEndAt: DBDate? + database.write { session in + let messageDTO = try session.messageEditableByCurrentUser(messageId) + previousEndAt = messageDTO.location?.endAt + messageDTO.location?.endAt = Date().bridgeDate + } + + database.backgroundReadOnlyContext.perform { [weak self] in + guard let currentUser = self?.database.backgroundReadOnlyContext.currentUser, + let currentDeviceId = currentUser.currentDevice?.id else { + completion(.failure(ClientError.CurrentUserDoesNotExist())) + return + } + let request = StopLiveLocationRequestPayload( + messageId: messageId, + createdByDeviceId: currentDeviceId + ) + let endpoint = Endpoint.stopLiveLocation( + request: request + ) + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case let .success(payload): + self?.database.write { session in + let sharedLocation = try session.saveLocation(payload: payload, cache: nil) + return try sharedLocation.asModel() + } completion: { result in + completion(result) + } + case let .failure(error): + self?.database.write { session in + // If the request fails, we revert the optimistic update. + guard let messageDTO = session.message(id: messageId) else { return } + messageDTO.location?.endAt = previousEndAt + } + completion(.failure(error)) + } + } + } + } + /// Loads replies for the given message. /// /// - Parameters: @@ -491,7 +660,7 @@ class MessageUpdater: Worker { messageId: messageId, request: .init(set: .init(pinned: true)) ) - + self?.apiClient.request(endpoint: endpoint) { result in switch result { case .success: @@ -520,7 +689,7 @@ class MessageUpdater: Worker { messageId: messageId, request: .init(set: .init(pinned: false)) ) - + self?.apiClient.request(endpoint: endpoint) { result in switch result { case .success: @@ -534,7 +703,7 @@ class MessageUpdater: Worker { } } } - + private func pinLocalMessage( on messageId: MessageId, pinning: MessagePinning, @@ -556,7 +725,7 @@ class MessageUpdater: Worker { } } } - + private func unpinLocalMessage( on messageId: MessageId, completion: ((Result, MessagePinning) -> Void)? = nil @@ -579,9 +748,9 @@ class MessageUpdater: Worker { } } } - + static let minSignificantDownloadingProgressChange: Double = 0.01 - + func downloadAttachment( _ attachment: ChatMessageAttachment, completion: @escaping (Result, Error>) -> Void @@ -616,7 +785,7 @@ class MessageUpdater: Worker { } ) } - + func deleteLocalAttachmentDownload(for attachmentId: AttachmentId, completion: @escaping (Error?) -> Void) { database.write({ session in let dto = session.attachment(id: attachmentId) @@ -630,7 +799,7 @@ class MessageUpdater: Worker { dto?.clearLocalState() }, completion: completion) } - + private func updateDownloadProgress( for attachmentId: AttachmentId, payloadType: Payload.Type, @@ -655,7 +824,7 @@ class MessageUpdater: Worker { attachmentDTO.localDownloadState = newState // Store only the relative path because sandboxed base URL can change between app launchs attachmentDTO.localRelativePath = localURL.relativePath - + guard completion != nil else { return } guard let attachmentAnyModel = attachmentDTO.asAnyModel() else { throw ClientError.AttachmentDoesNotExist(id: attachmentId) @@ -672,7 +841,7 @@ class MessageUpdater: Worker { } }) } - + /// Updates local state of attachment with provided `id` to be enqueued by attachment uploader. /// - Parameters: /// - id: The attachment identifier. @@ -715,7 +884,7 @@ class MessageUpdater: Worker { reason: "only failed or bounced messages can be resent." ) } - + let failedAttachments = messageDTO.attachments.filter { $0.localState == .uploadingFailed } failedAttachments.forEach { $0.localState = .pendingUpload @@ -822,7 +991,7 @@ class MessageUpdater: Worker { completion?(error) } } - + func translate(messageId: MessageId, to language: TranslationLanguage, completion: ((Result) -> Void)? = nil) { apiClient.request(endpoint: .translate(messageId: messageId, to: language), completion: { result in switch result { @@ -917,7 +1086,7 @@ extension MessageUpdater { struct MessageSearchResults { let payload: MessageSearchResultsPayload let models: [ChatMessage] - + var next: String? { payload.next } } } @@ -958,7 +1127,7 @@ extension ClientError { } } -private extension DatabaseSession { +extension DatabaseSession { /// This helper return the message if it can be edited by the current user. /// The message entity will be returned if it exists and authored by the current user. /// If any of the requirements is not met the error will be thrown. @@ -999,7 +1168,7 @@ extension MessageUpdater { } } } - + func clearSearchResults(for query: MessageSearchQuery) async throws { try await withCheckedThrowingContinuation { continuation in clearSearchResults(for: query) { error in @@ -1007,7 +1176,7 @@ extension MessageUpdater { } } } - + func createNewReply( in cid: ChannelId, messageId: MessageId?, @@ -1047,7 +1216,7 @@ extension MessageUpdater { } } } - + func deleteLocalAttachmentDownload(for attachmentId: AttachmentId) async throws { try await withCheckedThrowingContinuation { continuation in deleteLocalAttachmentDownload(for: attachmentId) { error in @@ -1055,7 +1224,7 @@ extension MessageUpdater { } } } - + func deleteMessage(messageId: MessageId, hard: Bool) async throws { try await withCheckedThrowingContinuation { continuation in deleteMessage(messageId: messageId, hard: hard) { error in @@ -1063,7 +1232,7 @@ extension MessageUpdater { } } } - + func deleteReaction(_ type: MessageReactionType, messageId: MessageId) async throws { try await withCheckedThrowingContinuation { continuation in deleteReaction(type, messageId: messageId) { error in @@ -1071,7 +1240,7 @@ extension MessageUpdater { } } } - + func dispatchEphemeralMessageAction( cid: ChannelId, messageId: MessageId, @@ -1087,7 +1256,7 @@ extension MessageUpdater { } } } - + func downloadAttachment( _ attachment: ChatMessageAttachment ) async throws -> ChatMessageAttachment where Payload: DownloadableAttachmentPayload { @@ -1097,7 +1266,7 @@ extension MessageUpdater { } } } - + func editMessage( messageId: MessageId, text: String, @@ -1119,7 +1288,7 @@ extension MessageUpdater { } } } - + func flagMessage( _ flag: Bool, with messageId: MessageId, @@ -1139,7 +1308,7 @@ extension MessageUpdater { } } } - + func getMessage(cid: ChannelId, messageId: MessageId) async throws -> ChatMessage { try await withCheckedThrowingContinuation { continuation in getMessage(cid: cid, messageId: messageId) { result in @@ -1147,7 +1316,7 @@ extension MessageUpdater { } } } - + func loadReactions( cid: ChannelId, messageId: MessageId, @@ -1163,7 +1332,7 @@ extension MessageUpdater { } } } - + @discardableResult func loadReplies( cid: ChannelId, messageId: MessageId, @@ -1181,7 +1350,7 @@ extension MessageUpdater { } } } - + func pinMessage(messageId: MessageId, pinning: MessagePinning) async throws -> ChatMessage { try await withCheckedThrowingContinuation { continuation in pinMessage(messageId: messageId, pinning: pinning) { result in @@ -1189,7 +1358,7 @@ extension MessageUpdater { } } } - + func resendAttachment(with id: AttachmentId) async throws { try await withCheckedThrowingContinuation { continuation in restartFailedAttachmentUploading(with: id) { error in @@ -1197,7 +1366,7 @@ extension MessageUpdater { } } } - + func resendMessage(with messageId: MessageId) async throws { try await withCheckedThrowingContinuation { continuation in resendMessage(with: messageId) { error in @@ -1205,7 +1374,7 @@ extension MessageUpdater { } } } - + func search(query: MessageSearchQuery, policy: UpdatePolicy) async throws -> MessageSearchResults { try await withCheckedThrowingContinuation { continuation in search(query: query, policy: policy) { result in @@ -1213,7 +1382,7 @@ extension MessageUpdater { } } } - + func translate(messageId: MessageId, to language: TranslationLanguage) async throws -> ChatMessage { try await withCheckedThrowingContinuation { continuation in translate(messageId: messageId, to: language) { result in @@ -1221,7 +1390,7 @@ extension MessageUpdater { } } } - + func unpinMessage(messageId: MessageId) async throws -> ChatMessage { try await withCheckedThrowingContinuation { continuation in unpinMessage(messageId: messageId) { result in @@ -1229,9 +1398,9 @@ extension MessageUpdater { } } } - + // MARK: - - + func loadReplies( for parentMessageId: MessageId, pagination: MessagesPagination, @@ -1248,7 +1417,7 @@ extension MessageUpdater { guard let toDate = payload.messages.last?.createdAt else { return [] } return try await repository.replies(from: fromDate, to: toDate, in: parentMessageId) } - + func loadReplies( for parentMessageId: MessageId, before replyId: MessageId?, @@ -1270,7 +1439,7 @@ extension MessageUpdater { paginationStateHandler: paginationStateHandler ) } - + func loadReplies( for parentMessageId: MessageId, after replyId: MessageId?, @@ -1292,7 +1461,7 @@ extension MessageUpdater { paginationStateHandler: paginationStateHandler ) } - + func loadReplies( for parentMessageId: MessageId, around replyId: MessageId, diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index f694bd64e7b..e3552fab177 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1356,10 +1356,8 @@ AD050B9E265D5E12006649A5 /* QuotedChatMessageView+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD050B8C265D5E09006649A5 /* QuotedChatMessageView+SwiftUI.swift */; }; AD050BA8265D600B006649A5 /* QuotedChatMessageView+SwiftUI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD050BA7265D600B006649A5 /* QuotedChatMessageView+SwiftUI_Tests.swift */; }; AD053B9A2B335854003612B6 /* DemoComposerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053B992B335854003612B6 /* DemoComposerVC.swift */; }; - AD053B9D2B3358E2003612B6 /* LocationAttachmentPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053B9C2B3358E2003612B6 /* LocationAttachmentPayload.swift */; }; AD053B9F2B335929003612B6 /* LocationAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053B9E2B335929003612B6 /* LocationAttachmentViewInjector.swift */; }; AD053BA12B3359DD003612B6 /* DemoAttachmentViewCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BA02B3359DD003612B6 /* DemoAttachmentViewCatalog.swift */; }; - AD053BA32B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BA22B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift */; }; AD053BA52B335A63003612B6 /* DemoQuotedChatMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BA42B335A63003612B6 /* DemoQuotedChatMessageView.swift */; }; AD053BA72B33624C003612B6 /* LocationAttachmentViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BA62B33624C003612B6 /* LocationAttachmentViewDelegate.swift */; }; AD053BA92B336331003612B6 /* LocationDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BA82B336331003612B6 /* LocationDetailViewController.swift */; }; @@ -1419,6 +1417,9 @@ AD169E032C9F969C00F58FAC /* DefaultScrollViewKeyboardHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD169E012C9F969C00F58FAC /* DefaultScrollViewKeyboardHandler.swift */; }; AD17CDF927E4DB2700E0D092 /* PushProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17CDF827E4DB2700E0D092 /* PushProvider.swift */; }; AD17CDFA27E4DB2700E0D092 /* PushProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17CDF827E4DB2700E0D092 /* PushProvider.swift */; }; + AD17E1212E00985B001AF308 /* SharedLocationPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1202E009853001AF308 /* SharedLocationPayload.swift */; }; + AD17E1232E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; + AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */; }; AD1D7A8526A2131D00494CA5 /* ChatChannelVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */; }; AD25070D272C0C8D00BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */; }; AD2525212ACB3C0800F1433C /* ChatClientFactory_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */; }; @@ -1430,6 +1431,8 @@ AD2DDA552CAA2B600040B8D4 /* PollAllOptionsListItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA542CAA2B600040B8D4 /* PollAllOptionsListItemCell.swift */; }; AD2DDA562CAA2B600040B8D4 /* PollAllOptionsListItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA542CAA2B600040B8D4 /* PollAllOptionsListItemCell.swift */; }; AD2DDA5A2CAAB7B50040B8D4 /* PollAllOptionsListVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA572CAAB7AC0040B8D4 /* PollAllOptionsListVC_Tests.swift */; }; + AD2F2D992D271B07006ED24B /* UserAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F2D982D271B07006ED24B /* UserAnnotation.swift */; }; + AD2F2D9B2D271B36006ED24B /* UserAnnotationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F2D9A2D271B36006ED24B /* UserAnnotationView.swift */; }; AD3331702A30DB2E00ABF38F /* SwipeToReplyGestureHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD33316F2A30DB2E00ABF38F /* SwipeToReplyGestureHandler_Mock.swift */; }; AD37D7C42BC979B000800D8C /* ThreadDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD37D7C32BC979B000800D8C /* ThreadDTO.swift */; }; AD37D7C52BC979B000800D8C /* ThreadDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD37D7C32BC979B000800D8C /* ThreadDTO.swift */; }; @@ -1466,6 +1469,7 @@ AD470C9E26C6D9030090759A /* ChatMessageListVCDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD470C9D26C6D9030090759A /* ChatMessageListVCDelegate.swift */; }; AD483B962A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD483B952A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift */; }; AD483B972A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD483B952A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift */; }; + AD48F6922D2849B5007CCF3A /* LocationSharingStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD48F6912D2849B5007CCF3A /* LocationSharingStatusView.swift */; }; AD4C15562A55874700A32955 /* ImageLoading_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C15552A55874700A32955 /* ImageLoading_Tests.swift */; }; AD4C8C222C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */; }; AD4C8C232C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */; }; @@ -1563,6 +1567,8 @@ AD76CE342A5F112D003CA182 /* ChatChannelSearchVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD76CE312A5F1104003CA182 /* ChatChannelSearchVC.swift */; }; AD76CE352A5F1133003CA182 /* ChatChannelSearchVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD76CE312A5F1104003CA182 /* ChatChannelSearchVC.swift */; }; AD76CE362A5F1138003CA182 /* ChatMessageSearchVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD76CE2F2A5F10F2003CA182 /* ChatMessageSearchVC.swift */; }; + AD770B682D09E2D5003AC602 /* SharedLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD770B672D09E2CB003AC602 /* SharedLocation.swift */; }; + AD770B692D09E2D5003AC602 /* SharedLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD770B672D09E2CB003AC602 /* SharedLocation.swift */; }; AD78568C298B268F00C2FEAD /* ChannelControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD78568B298B268F00C2FEAD /* ChannelControllerDelegate.swift */; }; AD78568D298B268F00C2FEAD /* ChannelControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD78568B298B268F00C2FEAD /* ChannelControllerDelegate.swift */; }; AD78568F298B273900C2FEAD /* ChatClient+ChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD78568E298B273900C2FEAD /* ChatClient+ChannelController.swift */; }; @@ -1576,6 +1582,10 @@ AD793F49270B767500B05456 /* ChatMessageReactionAuthorsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD793F48270B767500B05456 /* ChatMessageReactionAuthorsVC.swift */; }; AD793F4B270B769E00B05456 /* ChatMessageReactionAuthorViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD793F4A270B769E00B05456 /* ChatMessageReactionAuthorViewCell.swift */; }; AD7977BA2936D9450008B5FB /* Token_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7977B92936D9450008B5FB /* Token_Tests.swift */; }; + AD7A11902DEE008C00B8F963 /* SharedLocationDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926C2DD3A3900013A7E6 /* SharedLocationDTO.swift */; }; + AD7A11912DEE00A000B8F963 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; }; + AD7A11CB2DEE091400B8F963 /* LocationEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7A11CA2DEE090B00B8F963 /* LocationEndpoints.swift */; }; + AD7A11CC2DEE091400B8F963 /* LocationEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7A11CA2DEE090B00B8F963 /* LocationEndpoints.swift */; }; AD7AC99B260A9572004AADA5 /* MessagePinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */; }; AD7B51D327EDECA80068CBD1 /* MixedAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7B51D227EDECA80068CBD1 /* MixedAttachmentViewInjector.swift */; }; AD7B51D427EDECA80068CBD1 /* MixedAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7B51D227EDECA80068CBD1 /* MixedAttachmentViewInjector.swift */; }; @@ -1628,6 +1638,8 @@ AD8C7C662BA46A4A00260715 /* AppEndpoints_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8C7C652BA46A4A00260715 /* AppEndpoints_Tests.swift */; }; AD8D1809268F7290004E3A5C /* TypingSuggester.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8D1808268F7290004E3A5C /* TypingSuggester.swift */; }; AD8D180B268F8ED4004E3A5C /* SlackComposerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8D180A268F8ED4004E3A5C /* SlackComposerVC.swift */; }; + AD8E75E62E04963200AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8E75E52E04953C00AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift */; }; + AD8E75E72E04963200AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8E75E52E04953C00AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift */; }; AD8FEE582AA8E1A100273F88 /* ChatClient+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8FEE572AA8E1A100273F88 /* ChatClient+Environment.swift */; }; AD8FEE592AA8E1A100273F88 /* ChatClient+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8FEE572AA8E1A100273F88 /* ChatClient+Environment.swift */; }; AD8FEE5B2AA8E1E400273F88 /* ChatClientFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8FEE5A2AA8E1E400273F88 /* ChatClientFactory.swift */; }; @@ -1665,8 +1677,8 @@ AD99C909279B0E9D009DD9C5 /* MessageDateSeparatorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */; }; AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; AD99C90D279B136D009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; + AD9C926D2DD3A3950013A7E6 /* SharedLocationDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926C2DD3A3900013A7E6 /* SharedLocationDTO.swift */; }; AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; }; - AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; }; ADA03A222D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; }; ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; }; ADA03A252D65041B00DFE048 /* DraftMessage_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */; }; @@ -1771,6 +1783,7 @@ ADCB578C28A42D7700B81AE8 /* Differentiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCB577428A42D7700B81AE8 /* Differentiable.swift */; }; ADCBBFD526D66A560023FCB2 /* iMessageChatMessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCBBFD426D66A560023FCB2 /* iMessageChatMessageListViewController.swift */; }; ADCBBFD726D66ADC0023FCB2 /* SlackChatMessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCBBFD626D66ADC0023FCB2 /* SlackChatMessageListViewController.swift */; }; + ADCC179F2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */; }; ADCD5E4327987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */; }; ADCD5E4427987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */; }; ADCE32F72A055A9200B52559 /* MessagesPaginationStateHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCE32F62A055A9200B52559 /* MessagesPaginationStateHandler_Mock.swift */; }; @@ -1846,6 +1859,8 @@ ADEEB7F52BD168D500C76602 /* MessageReactionGroupDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEEB7F42BD168D500C76602 /* MessageReactionGroupDTO.swift */; }; ADEEB7F62BD168D500C76602 /* MessageReactionGroupDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEEB7F42BD168D500C76602 /* MessageReactionGroupDTO.swift */; }; ADEED08127F202C100A42B52 /* yoda_with_long_file_name.txt in Resources */ = {isa = PBXBuildFile; fileRef = ADEED08027F202C100A42B52 /* yoda_with_long_file_name.txt */; }; + ADF0473A2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF047392DE4DADC001C23D2 /* LocationPayloads.swift */; }; + ADF0473B2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF047392DE4DADC001C23D2 /* LocationPayloads.swift */; }; ADF2BBE82B9B61E30069D467 /* AppEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF2BBE72B9B61E30069D467 /* AppEndpoints.swift */; }; ADF2BBE92B9B61E30069D467 /* AppEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF2BBE72B9B61E30069D467 /* AppEndpoints.swift */; }; ADF2BBEB2B9B622B0069D467 /* AppSettingsPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF2BBEA2B9B622B0069D467 /* AppSettingsPayload.swift */; }; @@ -1865,6 +1880,11 @@ ADF617692A09927000E70307 /* MessagesPaginationStateHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF617672A09926900E70307 /* MessagesPaginationStateHandler_Tests.swift */; }; ADF9E1F72A03E7E400109108 /* MessagesPaginationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF9E1F62A03E7E400109108 /* MessagesPaginationState.swift */; }; ADFA09C926A99E0A002A6EFA /* ChatThreadHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFA09C726A99C71002A6EFA /* ChatThreadHeaderView.swift */; }; + ADFCA5B32D121EB8000F515F /* LocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B22D121EAF000F515F /* LocationInfo.swift */; }; + ADFCA5B42D121EB8000F515F /* LocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B22D121EAF000F515F /* LocationInfo.swift */; }; + ADFCA5B72D1232B3000F515F /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B62D1232A7000F515F /* LocationProvider.swift */; }; + ADFCA5B92D1378E2000F515F /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B82D1378E2000F515F /* Throttler.swift */; }; + ADFCA5BA2D1378E2000F515F /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B82D1378E2000F515F /* Throttler.swift */; }; ADFD391D2D47D07C00F8E1B1 /* DraftEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */; }; ADFD391E2D47D07C00F8E1B1 /* DraftEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */; }; BCE4831434E78C9538FA73F8 /* JSONDecoder_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */; }; @@ -2357,8 +2377,6 @@ C121EC612746AC8C00023E4C /* StreamChatUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 790881FD25432B7200896F03 /* StreamChatUI.framework */; platformFilter = ios; }; C121EC622746AC8C00023E4C /* StreamChatUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 790881FD25432B7200896F03 /* StreamChatUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C121EC662746AD0E00023E4C /* StreamChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 799C941B247D2F80001F1104 /* StreamChat.framework */; }; - C12297D32AC57A3200C5FF04 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12297D22AC57A3200C5FF04 /* Throttler.swift */; }; - C12297D42AC57A3200C5FF04 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12297D22AC57A3200C5FF04 /* Throttler.swift */; }; C12297D62AC57F7C00C5FF04 /* ChatMessage+Equatable_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12297D52AC57F7C00C5FF04 /* ChatMessage+Equatable_Tests.swift */; }; C122B8812A02645200D27F41 /* ChannelReadPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C122B8802A02645200D27F41 /* ChannelReadPayload_Tests.swift */; }; C12D0A6028FD59B60099895A /* AuthenticationRepository_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12D0A5F28FD59B60099895A /* AuthenticationRepository_Mock.swift */; }; @@ -4208,10 +4226,8 @@ AD050B8C265D5E09006649A5 /* QuotedChatMessageView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QuotedChatMessageView+SwiftUI.swift"; sourceTree = ""; }; AD050BA7265D600B006649A5 /* QuotedChatMessageView+SwiftUI_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QuotedChatMessageView+SwiftUI_Tests.swift"; sourceTree = ""; }; AD053B992B335854003612B6 /* DemoComposerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoComposerVC.swift; sourceTree = ""; }; - AD053B9C2B3358E2003612B6 /* LocationAttachmentPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationAttachmentPayload.swift; sourceTree = ""; }; AD053B9E2B335929003612B6 /* LocationAttachmentViewInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationAttachmentViewInjector.swift; sourceTree = ""; }; AD053BA02B3359DD003612B6 /* DemoAttachmentViewCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAttachmentViewCatalog.swift; sourceTree = ""; }; - AD053BA22B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocationAttachmentPayload+AttachmentViewProvider.swift"; sourceTree = ""; }; AD053BA42B335A63003612B6 /* DemoQuotedChatMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoQuotedChatMessageView.swift; sourceTree = ""; }; AD053BA62B33624C003612B6 /* LocationAttachmentViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationAttachmentViewDelegate.swift; sourceTree = ""; }; AD053BA82B336331003612B6 /* LocationDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDetailViewController.swift; sourceTree = ""; }; @@ -4250,6 +4266,8 @@ AD169DFD2C9F512900F58FAC /* PollCreationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollCreationVC.swift; sourceTree = ""; }; AD169E012C9F969C00F58FAC /* DefaultScrollViewKeyboardHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultScrollViewKeyboardHandler.swift; sourceTree = ""; }; AD17CDF827E4DB2700E0D092 /* PushProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushProvider.swift; sourceTree = ""; }; + AD17E1202E009853001AF308 /* SharedLocationPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationPayload.swift; sourceTree = ""; }; + AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLocationInfo.swift; sourceTree = ""; }; AD1D7A8326A212D000494CA5 /* ChatChannelVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelVC.swift; sourceTree = ""; }; AD25070B272C0C8800BC14C4 /* ChatMessageReactionAuthorsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC_Tests.swift; sourceTree = ""; }; AD2525202ACB3C0800F1433C /* ChatClientFactory_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory_Tests.swift; sourceTree = ""; }; @@ -4258,6 +4276,8 @@ AD2C94DE29CB93C40096DCA1 /* FailingChannelListPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FailingChannelListPayload.json; sourceTree = ""; }; AD2DDA542CAA2B600040B8D4 /* PollAllOptionsListItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollAllOptionsListItemCell.swift; sourceTree = ""; }; AD2DDA572CAAB7AC0040B8D4 /* PollAllOptionsListVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollAllOptionsListVC_Tests.swift; sourceTree = ""; }; + AD2F2D982D271B07006ED24B /* UserAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAnnotation.swift; sourceTree = ""; }; + AD2F2D9A2D271B36006ED24B /* UserAnnotationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAnnotationView.swift; sourceTree = ""; }; AD33316F2A30DB2E00ABF38F /* SwipeToReplyGestureHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyGestureHandler_Mock.swift; sourceTree = ""; }; AD37D7C32BC979B000800D8C /* ThreadDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDTO.swift; sourceTree = ""; }; AD37D7C62BC98A4400800D8C /* ThreadParticipantDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadParticipantDTO.swift; sourceTree = ""; }; @@ -4282,6 +4302,7 @@ AD470C9B26C6D8C60090759A /* ChatMessageListVCDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListVCDataSource.swift; sourceTree = ""; }; AD470C9D26C6D9030090759A /* ChatMessageListVCDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListVCDelegate.swift; sourceTree = ""; }; AD483B952A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMemberUnbanRequestPayload.swift; sourceTree = ""; }; + AD48F6912D2849B5007CCF3A /* LocationSharingStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingStatusView.swift; sourceTree = ""; }; AD4C15552A55874700A32955 /* ImageLoading_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoading_Tests.swift; sourceTree = ""; }; AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedUserAvatarsView.swift; sourceTree = ""; }; AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; @@ -4349,12 +4370,14 @@ AD75CB6A27886746005F5FF7 /* OptionsSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsSelectorViewController.swift; sourceTree = ""; }; AD76CE2F2A5F10F2003CA182 /* ChatMessageSearchVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageSearchVC.swift; sourceTree = ""; }; AD76CE312A5F1104003CA182 /* ChatChannelSearchVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelSearchVC.swift; sourceTree = ""; }; + AD770B672D09E2CB003AC602 /* SharedLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocation.swift; sourceTree = ""; }; AD78568B298B268F00C2FEAD /* ChannelControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelControllerDelegate.swift; sourceTree = ""; }; AD78568E298B273900C2FEAD /* ChatClient+ChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatClient+ChannelController.swift"; sourceTree = ""; }; AD7909902811CBCB0013C434 /* ChatMessageReactionsView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionsView_Tests.swift; sourceTree = ""; }; AD793F48270B767500B05456 /* ChatMessageReactionAuthorsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorsVC.swift; sourceTree = ""; }; AD793F4A270B769E00B05456 /* ChatMessageReactionAuthorViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorViewCell.swift; sourceTree = ""; }; AD7977B92936D9450008B5FB /* Token_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token_Tests.swift; sourceTree = ""; }; + AD7A11CA2DEE090B00B8F963 /* LocationEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationEndpoints.swift; sourceTree = ""; }; AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePinning.swift; sourceTree = ""; }; AD7B51D227EDECA80068CBD1 /* MixedAttachmentViewInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixedAttachmentViewInjector.swift; sourceTree = ""; }; AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResultsMapper.swift; sourceTree = ""; }; @@ -4392,6 +4415,7 @@ AD8C7C652BA46A4A00260715 /* AppEndpoints_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEndpoints_Tests.swift; sourceTree = ""; }; AD8D1808268F7290004E3A5C /* TypingSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingSuggester.swift; sourceTree = ""; }; AD8D180A268F8ED4004E3A5C /* SlackComposerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackComposerVC.swift; sourceTree = ""; }; + AD8E75E52E04953C00AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLiveLocationsEndTimeTracker.swift; sourceTree = ""; }; AD8FEE572AA8E1A100273F88 /* ChatClient+Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatClient+Environment.swift"; sourceTree = ""; }; AD8FEE5A2AA8E1E400273F88 /* ChatClientFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientFactory.swift; sourceTree = ""; }; AD90D18425D56196001D03BB /* CurrentUserUpdater_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserUpdater_Tests.swift; sourceTree = ""; }; @@ -4420,6 +4444,7 @@ AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDateSeparatorFormatter.swift; sourceTree = ""; }; AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLastActivityFormatter.swift; sourceTree = ""; }; AD9BE32526680E4200A6D284 /* Stream.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Stream.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + AD9C926C2DD3A3900013A7E6 /* SharedLocationDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocationDTO.swift; sourceTree = ""; }; AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageInterceptor.swift; sourceTree = ""; }; ADA03A212D64EFE900DFE048 /* DraftMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage.swift; sourceTree = ""; }; ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage_Mock.swift; sourceTree = ""; }; @@ -4494,6 +4519,7 @@ ADCB577428A42D7700B81AE8 /* Differentiable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Differentiable.swift; sourceTree = ""; }; ADCBBFD426D66A560023FCB2 /* iMessageChatMessageListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iMessageChatMessageListViewController.swift; sourceTree = ""; }; ADCBBFD626D66ADC0023FCB2 /* SlackChatMessageListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackChatMessageListViewController.swift; sourceTree = ""; }; + ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLiveLocationsEndTimeTracker_Tests.swift; sourceTree = ""; }; ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamModalTransitioningDelegate.swift; sourceTree = ""; }; ADCDDCC425AE1293004E15FB /* UserUpdateResponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = UserUpdateResponse.json; sourceTree = ""; }; ADCE32F62A055A9200B52559 /* MessagesPaginationStateHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationStateHandler_Mock.swift; sourceTree = ""; }; @@ -4544,6 +4570,7 @@ ADEEB7F12BD1368900C76602 /* MessageReactionGroupPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReactionGroupPayload.swift; sourceTree = ""; }; ADEEB7F42BD168D500C76602 /* MessageReactionGroupDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReactionGroupDTO.swift; sourceTree = ""; }; ADEED08027F202C100A42B52 /* yoda_with_long_file_name.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = yoda_with_long_file_name.txt; sourceTree = ""; }; + ADF047392DE4DADC001C23D2 /* LocationPayloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPayloads.swift; sourceTree = ""; }; ADF2BBE72B9B61E30069D467 /* AppEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEndpoints.swift; sourceTree = ""; }; ADF2BBEA2B9B622B0069D467 /* AppSettingsPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsPayload.swift; sourceTree = ""; }; ADF34F6A25CD6A1D00AD637C /* ConnectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionController.swift; sourceTree = ""; }; @@ -4557,6 +4584,9 @@ ADF617672A09926900E70307 /* MessagesPaginationStateHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationStateHandler_Tests.swift; sourceTree = ""; }; ADF9E1F62A03E7E400109108 /* MessagesPaginationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationState.swift; sourceTree = ""; }; ADFA09C726A99C71002A6EFA /* ChatThreadHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadHeaderView.swift; sourceTree = ""; }; + ADFCA5B22D121EAF000F515F /* LocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationInfo.swift; sourceTree = ""; }; + ADFCA5B62D1232A7000F515F /* LocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = ""; }; + ADFCA5B82D1378E2000F515F /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftEndpoints.swift; sourceTree = ""; }; BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONDecoder_Tests.swift; sourceTree = ""; }; BCE483AC99F58A9034EA2ECE /* FilterEncoding_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterEncoding_Tests.swift; sourceTree = ""; }; @@ -4595,7 +4625,6 @@ C11BAA4C2907EC7B004C5EA4 /* AuthenticationRepository_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationRepository_Tests.swift; sourceTree = ""; }; C121E758274543D000023E4C /* libStreamChat.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libStreamChat.a; sourceTree = BUILT_PRODUCTS_DIR; }; C121EA2F2746A19400023E4C /* libStreamChatUI.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libStreamChatUI.a; sourceTree = BUILT_PRODUCTS_DIR; }; - C12297D22AC57A3200C5FF04 /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; C12297D52AC57F7C00C5FF04 /* ChatMessage+Equatable_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatMessage+Equatable_Tests.swift"; sourceTree = ""; }; C122B8802A02645200D27F41 /* ChannelReadPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelReadPayload_Tests.swift; sourceTree = ""; }; C12D0A5F28FD59B60099895A /* AuthenticationRepository_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationRepository_Mock.swift; sourceTree = ""; }; @@ -5627,6 +5656,7 @@ 79280F4524850ECC00CDEB89 /* Background */ = { isa = PBXGroup; children = ( + AD8E75E52E04953C00AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift */, 88E26D6D2580F34B00F55AB5 /* AttachmentQueueUploader.swift */, F6ED5F7325023EB4005D7327 /* ConnectionRecoveryHandler.swift */, F670B50E24FE6EA900003B1A /* MessageEditor.swift */, @@ -5667,6 +5697,7 @@ ADEEB7F42BD168D500C76602 /* MessageReactionGroupDTO.swift */, 7978FBBB26E16295002CA2DF /* MessageSearchQueryDTO.swift */, C1B49B3F2822C01C00F4E89E /* NSManagedObject+Validation.swift */, + AD9C926C2DD3A3900013A7E6 /* SharedLocationDTO.swift */, 841BAA502BD1CD81000C73E4 /* PollDTO.swift */, 841BAA4D2BD1CD76000C73E4 /* PollOptionDTO.swift */, 841BAA4A2BD1CCC0000C73E4 /* PollVoteDTO.swift */, @@ -5697,6 +5728,7 @@ 792A4F3D247FFDE700EAF71D /* Codable+Extensions.swift */, CF6E489E282341F2008416DC /* CountdownTracker.swift */, 792A4F3E247FFDE700EAF71D /* Data+Gzip.swift */, + ADFCA5B82D1378E2000F515F /* Throttler.swift */, 40789D3B29F6AD9C0018C2BB /* Debouncer.swift */, 88EA9AD725470F6A007EE76B /* Dictionary+Extensions.swift */, 84CF9C72274D473D00BCDE2D /* EventBatcher.swift */, @@ -5759,6 +5791,7 @@ A3227E7D284A511200EBE6CC /* DemoAppConfiguration.swift */, AD7110C32B3434F700AFFE28 /* StreamRuntimeCheck+StreamInternal.swift */, 792DDA5B256FB69E001DB91B /* SceneDelegate.swift */, + ADFCA5B62D1232A7000F515F /* LocationProvider.swift */, 8440861528FFE85F0027849C /* Shared */, A3227E56284A47F700EBE6CC /* StreamChat */, A3227ECA284A607D00EBE6CC /* Screens */, @@ -5813,7 +5846,6 @@ isa = PBXGroup; children = ( ADB8B90E2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift */, - AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */, 79896D63250A62EE00BA8F1C /* ChannelReadUpdaterMiddleware.swift */, 79158CF325F133FB00186102 /* ChannelTruncatedEventMiddleware.swift */, 79AF43B32632AF1B00E75CDA /* ChannelVisibilityEventMiddleware.swift */, @@ -5835,6 +5867,7 @@ 79682C4724BF37550071578E /* Payloads */ = { isa = PBXGroup; children = ( + ADF047392DE4DADC001C23D2 /* LocationPayloads.swift */, ADA83B4F2D978050003B3928 /* ReminderPayloads.swift */, ADF2BBEA2B9B622B0069D467 /* AppSettingsPayload.swift */, DA9985ED24E175AA000E9885 /* ChannelCodingKeys.swift */, @@ -5918,6 +5951,7 @@ 84355D872AB2FCAC00FD5838 /* FilesEndpoints.swift */, ADF2BBE72B9B61E30069D467 /* AppEndpoints.swift */, 841BA9F42BCE8089000C73E4 /* PollsEndpoints.swift */, + AD7A11CA2DEE090B00B8F963 /* LocationEndpoints.swift */, 79682C4724BF37550071578E /* Payloads */, DAEAF4B624DADA990015FB28 /* Requests */, ); @@ -5991,6 +6025,7 @@ isa = PBXGroup; children = ( 225D807625D316B10094E555 /* Attachments */, + ADFCA5B52D121EE9000F515F /* Location */, AD8C7C5C2BA3BE1E00260715 /* AppSettings.swift */, 8A62706D24BF45360040BFD6 /* BanEnabling.swift */, 82BE0ACC2C009A17008DA9DC /* BlockedUserDetails.swift */, @@ -6438,7 +6473,6 @@ ACA3C98526CA23F300EB8B07 /* DateUtils.swift */, 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, - C12297D22AC57A3200C5FF04 /* Throttler.swift */, AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */, AD169DEC2C9B112B00F58FAC /* KeyboardHandler */, AD95FD0F28F9B72200DBDF41 /* Extensions */, @@ -6931,6 +6965,7 @@ A344076127D753530044F150 /* DummyData */ = { isa = PBXGroup; children = ( + AD17E1202E009853001AF308 /* SharedLocationPayload.swift */, AD545E7A2D5BC1DC008FD399 /* DraftPayload.swift */, AD94905B2BF630D200E69224 /* ThreadPayload.swift */, 84C11BDE27FB2B4600000A9E /* ChannelPayload.swift */, @@ -7072,7 +7107,6 @@ isa = PBXGroup; children = ( ADB8B9112D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift */, - AD545E8D2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift */, 79896D65250A6D1500BA8F1C /* ChannelReadUpdaterMiddleware_Tests.swift */, 79158CEA25F0EADF00186102 /* ChannelTruncatedEventMiddleware_Tests.swift */, 79342EEB2632C7770018F0F7 /* ChannelVisibilityEventMiddleware_Tests.swift */, @@ -7278,6 +7312,7 @@ A364D09927D0C5D80029857A /* Background */ = { isa = PBXGroup; children = ( + ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */, 88F7692A25837EE600BD36B0 /* AttachmentQueueUploader_Tests.swift */, A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */, F61D7C3624FFE17200188A0E /* MessageEditor_Tests.swift */, @@ -8509,12 +8544,13 @@ AD053B9B2B33589C003612B6 /* LocationAttachment */ = { isa = PBXGroup; children = ( - AD053B9C2B3358E2003612B6 /* LocationAttachmentPayload.swift */, - AD053BA22B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift */, AD053B9E2B335929003612B6 /* LocationAttachmentViewInjector.swift */, AD053BA62B33624C003612B6 /* LocationAttachmentViewDelegate.swift */, AD053BAA2B33638B003612B6 /* LocationAttachmentSnapshotView.swift */, AD053BA82B336331003612B6 /* LocationDetailViewController.swift */, + AD48F6912D2849B5007CCF3A /* LocationSharingStatusView.swift */, + AD2F2D9A2D271B36006ED24B /* UserAnnotationView.swift */, + AD2F2D982D271B07006ED24B /* UserAnnotation.swift */, ); path = LocationAttachment; sourceTree = ""; @@ -9221,6 +9257,16 @@ path = InputChatMessageView; sourceTree = ""; }; + ADFCA5B52D121EE9000F515F /* Location */ = { + isa = PBXGroup; + children = ( + AD770B672D09E2CB003AC602 /* SharedLocation.swift */, + ADFCA5B22D121EAF000F515F /* LocationInfo.swift */, + AD17E1222E01CAAF001AF308 /* NewLocationInfo.swift */, + ); + path = Location; + sourceTree = ""; + }; BD3EA7F4264AD954003AFA09 /* AttachmentViews */ = { isa = PBXGroup; children = ( @@ -10919,7 +10965,6 @@ C1FC2F7D27416E150062530F /* ImageRequest.swift in Sources */, 79205857264C2D6C002B145B /* TitleContainerView.swift in Sources */, AD793F49270B767500B05456 /* ChatMessageReactionAuthorsVC.swift in Sources */, - C12297D32AC57A3200C5FF04 /* Throttler.swift in Sources */, C1FC2F7527416E150062530F /* Operation.swift in Sources */, AD447443263AC6A10030E583 /* ChatMentionSuggestionView.swift in Sources */, ADCB578728A42D7700B81AE8 /* DifferentiableSection.swift in Sources */, @@ -11214,7 +11259,9 @@ 794E20F52577DF4D00790DAB /* NameGroupViewController.swift in Sources */, A3227EC9284A52EE00EBE6CC /* PushNotifications.swift in Sources */, A3227E65284A4A5C00EBE6CC /* StreamChatWrapper.swift in Sources */, + AD48F6922D2849B5007CCF3A /* LocationSharingStatusView.swift in Sources */, A3227E78284A4CAD00EBE6CC /* DemoChatMessageContentView.swift in Sources */, + AD2F2D992D271B07006ED24B /* UserAnnotation.swift in Sources */, 7933060B256FF94800FBB586 /* DemoChatChannelListRouter.swift in Sources */, AD82903D2A7C5A8F00396782 /* DemoChatChannelListItemView.swift in Sources */, A3227E69284A4AE800EBE6CC /* AvatarView.swift in Sources */, @@ -11223,11 +11270,11 @@ A3227E6D284A4B6A00EBE6CC /* UserCredentialsCell.swift in Sources */, 84A33ABA28F86B8500CEC8FD /* StreamChatWrapper+DemoApp.swift in Sources */, AD053BA92B336331003612B6 /* LocationDetailViewController.swift in Sources */, + ADFCA5B72D1232B3000F515F /* LocationProvider.swift in Sources */, + AD2F2D9B2D271B36006ED24B /* UserAnnotationView.swift in Sources */, A3227E59284A484300EBE6CC /* UIImage+Resized.swift in Sources */, 79B8B64B285CBDC00059FB2D /* DemoChatMessageLayoutOptionsResolver.swift in Sources */, - AD053BA32B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift in Sources */, ADB8B8F52D8ADC9400549C95 /* DemoReminderListVC.swift in Sources */, - AD053B9D2B3358E2003612B6 /* LocationAttachmentPayload.swift in Sources */, AD053BA12B3359DD003612B6 /* DemoAttachmentViewCatalog.swift in Sources */, AD053B9F2B335929003612B6 /* LocationAttachmentViewInjector.swift in Sources */, AD545E692D5531BA008FD399 /* DemoDraftMessageListVC.swift in Sources */, @@ -11318,6 +11365,7 @@ 40D484062A1264F1009E4134 /* MockAudioPlayer.swift in Sources */, A3C3BC6527E8AA0A00224761 /* Int+Unique.swift in Sources */, 4F862F9A2C38001000062502 /* FileManager+Extensions.swift in Sources */, + AD17E1212E00985B001AF308 /* SharedLocationPayload.swift in Sources */, A3C3BC6327E8AA0A00224761 /* AttachmentId+Unique.swift in Sources */, A3C3BC3827E87F5100224761 /* BackgroundTaskScheduler_Mock.swift in Sources */, 84EE53B12BBC32AD00FD2A13 /* Chat_Mock.swift in Sources */, @@ -11514,6 +11562,7 @@ 40789D1329F6AC500018C2BB /* AudioPlaybackContext.swift in Sources */, 22692C9725D1841E007C41D0 /* ChatMessageFileAttachment.swift in Sources */, DA8407062524F84F005A0F62 /* UserListQuery.swift in Sources */, + ADFCA5BA2D1378E2000F515F /* Throttler.swift in Sources */, 4F97F2702BA86491001C4D66 /* UserSearchState.swift in Sources */, DBF17AE825D48865004517B3 /* BackgroundTaskScheduler.swift in Sources */, 79280F4F2485308100CDEB89 /* DataController.swift in Sources */, @@ -11554,6 +11603,7 @@ AD52A21C2804851600D0157E /* CommandDTO.swift in Sources */, AD37D7CD2BC9937200800D8C /* Thread.swift in Sources */, 792AF91624D812440010097B /* EntityChange.swift in Sources */, + AD770B682D09E2D5003AC602 /* SharedLocation.swift in Sources */, AD84377B2BB482CF000F3826 /* ThreadEndpoints.swift in Sources */, 404296EB2A011B050089126D /* AudioSessionProtocol.swift in Sources */, C186BFAF27AADB410099CCA6 /* SyncOperations.swift in Sources */, @@ -11628,6 +11678,7 @@ 792A4F3F247FFDE700EAF71D /* Codable+Extensions.swift in Sources */, 8819DFCF2525F3C600FD1A50 /* UserUpdater.swift in Sources */, 79C750BB248FC4100023F0B7 /* ErrorPayload.swift in Sources */, + AD17E1232E01CAAF001AF308 /* NewLocationInfo.swift in Sources */, 799BE2EA248A8C9D00DAC8A0 /* RetryStrategy.swift in Sources */, DA84070C25250581005A0F62 /* UserListPayload.swift in Sources */, 79877A272498E50D00015F8B /* MemberModelDTO.swift in Sources */, @@ -11671,7 +11722,6 @@ AD52A2192804850700D0157E /* ChannelConfigDTO.swift in Sources */, 797A756824814F0D003CF16D /* Bundle+Extensions.swift in Sources */, 7908829C2546D95A00896F03 /* FlagMessagePayload.swift in Sources */, - AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */, DA0BB1612513B5F200CAEFBD /* StringInterpolation+Extensions.swift in Sources */, 64C8C86E26934C6100329F82 /* UserInfo.swift in Sources */, C1FFD9F927ECC7C7008A6848 /* Filter+predicate.swift in Sources */, @@ -11710,11 +11760,13 @@ 7978FBBC26E16295002CA2DF /* MessageSearchQueryDTO.swift in Sources */, DA640FBE2535CF9200D32944 /* UserListSortingKey.swift in Sources */, 79877A182498E4EE00015F8B /* ChannelEndpoints.swift in Sources */, + AD7A11902DEE008C00B8F963 /* SharedLocationDTO.swift in Sources */, 40789D2D29F6AC500018C2BB /* AudioRecordingState.swift in Sources */, 40789D3529F6AC500018C2BB /* AudioAnalysisEngine.swift in Sources */, 7964F3BA249A314D002A09EC /* LogDestination.swift in Sources */, 4F427F692BA2F52100D92238 /* ConnectedUserState.swift in Sources */, F63CC37324E592D30052844D /* MemberEventObserver.swift in Sources */, + AD7A11912DEE00A000B8F963 /* SendMessageInterceptor.swift in Sources */, 4FF2A80D2B8E011000941A64 /* ChatState+Observer.swift in Sources */, 88EA9AD825470F6A007EE76B /* Dictionary+Extensions.swift in Sources */, 8AE335A824FCF999002B6677 /* Reachability_Vendor.swift in Sources */, @@ -11766,6 +11818,7 @@ 792A4F482480107A00EAF71D /* Pagination.swift in Sources */, 79AF43B42632AF1C00E75CDA /* ChannelVisibilityEventMiddleware.swift in Sources */, DAF1BED525066114003CEDC0 /* MessageController+Combine.swift in Sources */, + AD8E75E62E04963200AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift in Sources */, ADB8B8EA2D8890B900549C95 /* MessageReminderDTO.swift in Sources */, A36C39F52860680A0004EB7E /* URL+EnrichedURL.swift in Sources */, 8A0C3BBC24C0947400CAFD19 /* UserEvents.swift in Sources */, @@ -11816,6 +11869,7 @@ 79FC85E724ACCBC500A665ED /* Token.swift in Sources */, 4F4562F62C240FD200675C7F /* DatabaseItemConverter.swift in Sources */, 79877A0E2498E4BC00015F8B /* Channel.swift in Sources */, + ADFCA5B32D121EB8000F515F /* LocationInfo.swift in Sources */, DA4AA3B22502718600FAAF6E /* ChannelController+Combine.swift in Sources */, 40789D1D29F6AC500018C2BB /* AudioPlayingDelegate.swift in Sources */, ADF34F8A25CDC58900AD637C /* ConnectionController.swift in Sources */, @@ -11848,6 +11902,7 @@ AD94906E2BF68BAE00E69224 /* ThreadListController+SwiftUI.swift in Sources */, AD81FEED2D3977AC00765FD4 /* StreamModelsTransformer.swift in Sources */, AD78568C298B268F00C2FEAD /* ChannelControllerDelegate.swift in Sources */, + AD7A11CB2DEE091400B8F963 /* LocationEndpoints.swift in Sources */, 841BAA072BCE9A49000C73E4 /* UpdatePartialRequestBody.swift in Sources */, 79896D5C2506593E00BA8F1C /* ChannelReadDTO.swift in Sources */, 8819DFD52525F49D00FD1A50 /* UserController.swift in Sources */, @@ -11883,6 +11938,7 @@ 79D6CF1825FA671C00BE2EEC /* MemberEventMiddleware.swift in Sources */, C189D7782AEBC6CD00D4B966 /* BackgroundDatabaseObserver.swift in Sources */, 649968D5264E66EB000515AB /* CDNClient.swift in Sources */, + ADF0473B2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */, 40A458ED2A03AC7C00C198F7 /* AVAsset+TotalAudioSamples.swift in Sources */, 8899BC53254318CC003CB98B /* MessageReaction.swift in Sources */, 8A0175F02501174000570345 /* TypingEventsSender.swift in Sources */, @@ -12149,6 +12205,7 @@ 88EA9AFC25472269007EE76B /* MessageReactionType_Tests.swift in Sources */, 4042968F29FBCE1D0089126D /* AudioSamplesExtractor_Tests.swift in Sources */, 40789D3F29F6AFC40018C2BB /* Debouncer_Tests.swift in Sources */, + ADCC179F2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift in Sources */, 796610BB248E687000761629 /* EventMiddleware_Tests.swift in Sources */, 4F1BEE7F2BE38B5500B6685C /* ReactionList_Tests.swift in Sources */, AD9490602BF65DE400E69224 /* ChatThreadListController_Tests.swift in Sources */, @@ -12390,6 +12447,7 @@ files = ( 4F1BEE7D2BE3851200B6685C /* ReactionListState+Observer.swift in Sources */, 82BE0ACE2C009A17008DA9DC /* BlockedUserDetails.swift in Sources */, + AD9C926D2DD3A3950013A7E6 /* SharedLocationDTO.swift in Sources */, 40789D1C29F6AC500018C2BB /* AudioPlaybackState.swift in Sources */, AD8C7C612BA3DF2800260715 /* AppSettings.json in Sources */, C121E804274544AC00023E4C /* ChatClient.swift in Sources */, @@ -12479,6 +12537,7 @@ C121E834274544AD00023E4C /* UserPayloads.swift in Sources */, C121E835274544AD00023E4C /* CurrentUserPayloads.swift in Sources */, C121E836274544AD00023E4C /* ChannelCodingKeys.swift in Sources */, + ADFCA5B92D1378E2000F515F /* Throttler.swift in Sources */, 4042969629FC092F0089126D /* StreamAudioWaveformAnalyser_Tests.swift in Sources */, 404296EA2A011AC20089126D /* AudioSessionProtocol.swift in Sources */, 40789D2C29F6AC500018C2BB /* AudioRecordingContext.swift in Sources */, @@ -12606,6 +12665,7 @@ 841BAA522BD1CD81000C73E4 /* PollDTO.swift in Sources */, C121E87B274544AF00023E4C /* MessageSearchQueryDTO.swift in Sources */, 4042967E29FAC9DA0089126D /* AudioAnalysisContext.swift in Sources */, + AD17E1242E01CAAF001AF308 /* NewLocationInfo.swift in Sources */, 40789D4929F6C1DC0018C2BB /* StreamAppStateObserver_Tests.swift in Sources */, C1E8AD5F278EF5F40041B775 /* AsyncOperation.swift in Sources */, 8413D2F62BDDAAFF005ADA4E /* PollVoteListController+SwiftUI.swift in Sources */, @@ -12656,6 +12716,7 @@ C121E895274544B000023E4C /* MessagePinning.swift in Sources */, 4042968429FACA0E0089126D /* AudioSamplesProcessor.swift in Sources */, C121E896274544B000023E4C /* UnreadCount.swift in Sources */, + ADFCA5B42D121EB8000F515F /* LocationInfo.swift in Sources */, 842F9746277A09B10060A489 /* PinnedMessagesQuery.swift in Sources */, C121E897274544B000023E4C /* User+SwiftUI.swift in Sources */, C121E898274544B000023E4C /* MessageReaction.swift in Sources */, @@ -12673,6 +12734,7 @@ 40789D3229F6AC500018C2BB /* AudioRecording.swift in Sources */, C121E89F274544B000023E4C /* MessageSearchController+Combine.swift in Sources */, C121E8A0274544B000023E4C /* MessageSearchController+SwiftUI.swift in Sources */, + AD8E75E72E04963200AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift in Sources */, C121E8A1274544B000023E4C /* UserController.swift in Sources */, AD0CC01D2BDBD22D005E2C66 /* ReactionEndpoints.swift in Sources */, C121E8A2274544B000023E4C /* UserController+Combine.swift in Sources */, @@ -12706,6 +12768,7 @@ C121E8B3274544B000023E4C /* CurrentUserController.swift in Sources */, 4F6AD5E42CABEAB6007E769C /* KeyPath+Extensions.swift in Sources */, C174E0F7284DFA5A0040B936 /* IdentifiablePayload.swift in Sources */, + AD770B692D09E2D5003AC602 /* SharedLocation.swift in Sources */, AD0CC0382BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */, 841BAA052BCE94F8000C73E4 /* QueryPollsRequestBody.swift in Sources */, C121E8B4274544B000023E4C /* CurrentUserController+SwiftUI.swift in Sources */, @@ -12793,7 +12856,9 @@ C121E8E3274544B200023E4C /* Data+Gzip.swift in Sources */, C121E8E4274544B200023E4C /* LazyCachedMapCollection.swift in Sources */, 40789D3D29F6AD9C0018C2BB /* Debouncer.swift in Sources */, + AD7A11CC2DEE091400B8F963 /* LocationEndpoints.swift in Sources */, AD6E32A22BBC50110073831B /* ThreadListQuery.swift in Sources */, + ADF0473A2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */, C121E8E5274544B200023E4C /* Timers.swift in Sources */, C121E8E6274544B200023E4C /* SystemEnvironment.swift in Sources */, 4F97F2712BA86491001C4D66 /* UserSearchState.swift in Sources */, @@ -12913,7 +12978,6 @@ C121EB942746A1E800023E4C /* ChatCommandSuggestionCollectionViewCell.swift in Sources */, AD7EFDAC2C78C0B900625FC5 /* PollCommentListItemCell.swift in Sources */, 40824D4A2A1271EF003B61FD /* PlayPauseButton_Tests.swift in Sources */, - C12297D42AC57A3200C5FF04 /* Throttler.swift in Sources */, C121EB952746A1E800023E4C /* AttachmentsPreviewVC.swift in Sources */, AD7BE1712C234798000A5756 /* ChatThreadListLoadingView.swift in Sources */, C121EB962746A1E800023E4C /* AttachmentPreviewContainer.swift in Sources */, @@ -15567,8 +15631,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ProxymanApp/atlantis"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.28.0; + kind = exactVersion; + version = 1.28.0; }; }; C1B49B39282283C100F4E89E /* XCRemoteSwiftPackageReference "GDPerformanceView-Swift" */ = { diff --git a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift index 228f021f63c..eb098446803 100644 --- a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift +++ b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift @@ -54,7 +54,8 @@ extension ChatMessage { poll: nil, textUpdatedAt: nil, draftReply: nil, - reminder: nil + reminder: nil, + sharedLocation: nil ) } } diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json index 32460f2261e..708c04bcd8b 100644 --- a/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json @@ -1320,6 +1320,7 @@ "automod" : "AI", "read_events" : true, "skip_last_msg_update_for_system_msgs": true, + "shared_locations": true, "commands" : [ { "set" : "fun_set", @@ -1590,5 +1591,18 @@ "last_active" : "2021-03-04T15:32:36.509639Z", "name" : "Vishal" } + ], + "active_live_locations": [ + { + "channel_cid": "messaging:da57d796-c37f-4fdb-8340-05313e4f4dca", + "created_at": "2025-06-06T17:08:57.37039Z", + "created_by_device_id": "801f108a7521af9233f66e763339a5faff36e534ef24bce87154544ae1b4f7add0b077ce763b0449ba774b8e59e0362fee7fb1e5416081c835060aa013ea22bd04e7fd775cfbbe7b4fbff5be9dfde484", + "end_at": "2025-06-06T17:10:07.199Z", + "latitude": 15, + "longitude": 15, + "message_id": "9596f6bd-c322-407e-b9e1-8fd10ee824e2", + "updated_at": "2025-06-06T17:08:57.37039Z", + "user_id": "leia_organa" + } ] } diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/ChannelPayload.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/ChannelPayload.json index 3d81dcbe133..a2fb5bf6f2a 100644 --- a/TestTools/StreamChatTestTools/Fixtures/JSONs/ChannelPayload.json +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/ChannelPayload.json @@ -20,6 +20,7 @@ "member_count":4, "updated_at":"2019-05-10T14:03:49.505006Z", "config":{ + "shared_locations": true, "automod_behavior":"flag", "reactions":true, "typing_events":true, diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift index 326c3bc94c7..5317951c8dc 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift @@ -19,6 +19,8 @@ public extension ChannelConfig { mutesEnabled: Bool = true, urlEnrichmentEnabled: Bool = true, skipLastMsgAtUpdateForSystemMsg: Bool = false, + messageRemindersEnabled: Bool = false, + sharedLocationsEnabled: Bool = false, messageRetention: String = "", maxMessageLength: Int = 0, commands: [Command] = [Command(name: "Giphy", description: "", set: "", args: "")], @@ -37,6 +39,8 @@ public extension ChannelConfig { mutesEnabled: mutesEnabled, urlEnrichmentEnabled: urlEnrichmentEnabled, skipLastMsgAtUpdateForSystemMsg: skipLastMsgAtUpdateForSystemMsg, + messageRemindersEnabled: messageRemindersEnabled, + sharedLocationsEnabled: sharedLocationsEnabled, messageRetention: messageRetention, maxMessageLength: maxMessageLength, commands: commands, @@ -92,7 +96,8 @@ public extension ChatChannel { pinnedMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, previewMessage: ChatMessage? = nil, - draftMessage: DraftMessage? = nil + draftMessage: DraftMessage? = nil, + activeLiveLocations: [SharedLocation] = [] ) -> Self { self.init( cid: cid, @@ -122,7 +127,8 @@ public extension ChatChannel { pinnedMessages: pinnedMessages, muteDetails: muteDetails, previewMessage: previewMessage, - draftMessage: draftMessage + draftMessage: draftMessage, + activeLiveLocations: activeLiveLocations ) } @@ -151,7 +157,8 @@ public extension ChatChannel { pinnedMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, previewMessage: ChatMessage? = nil, - draftMessage: DraftMessage? = nil + draftMessage: DraftMessage? = nil, + activeLiveLocations: [SharedLocation] = [] ) -> Self { self.init( cid: .init(type: .messaging, id: "!members" + .newUniqueId), @@ -179,7 +186,8 @@ public extension ChatChannel { pinnedMessages: pinnedMessages, muteDetails: muteDetails, previewMessage: previewMessage, - draftMessage: draftMessage + draftMessage: draftMessage, + activeLiveLocations: activeLiveLocations ) } @@ -207,7 +215,8 @@ public extension ChatChannel { pinnedMessages: [ChatMessage] = [], muteDetails: MuteDetails? = nil, previewMessage: ChatMessage? = nil, - draftMessage: DraftMessage? = nil + draftMessage: DraftMessage? = nil, + activeLiveLocations: [SharedLocation] = [] ) -> Self { self.init( cid: .init(type: .messaging, id: .newUniqueId), @@ -234,7 +243,8 @@ public extension ChatChannel { pinnedMessages: pinnedMessages, muteDetails: muteDetails, previewMessage: previewMessage, - draftMessage: draftMessage + draftMessage: draftMessage, + activeLiveLocations: activeLiveLocations ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift index ac865de9882..ab7168567cb 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift @@ -51,7 +51,8 @@ public extension ChatMessage { textUpdatedAt: Date? = nil, poll: Poll? = nil, draftReply: DraftMessage? = nil, - reminder: MessageReminderInfo? = nil + reminder: MessageReminderInfo? = nil, + sharedLocation: SharedLocation? = nil ) -> Self { .init( id: id, @@ -93,7 +94,8 @@ public extension ChatMessage { poll: poll, textUpdatedAt: textUpdatedAt, draftReply: draftReply, - reminder: reminder + reminder: reminder, + sharedLocation: sharedLocation ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift index 3b77bcf6395..f7b0d5b7b1c 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelController_Mock.swift @@ -66,6 +66,7 @@ class ChatChannelController_Mock: ChatChannelController { skipPush: Bool = false, skipEnrichUrl: Bool = false, restrictedVisibility: [UserId] = [], + location: NewLocationInfo? = nil, extraData: [String : RawJSON] = [:], completion: ((Result) -> Void)? = nil ) { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index b4bf9c799e0..6daa5e8e5ea 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -7,6 +7,7 @@ import Foundation /// This class allows you to wrap an existing `DatabaseSession` and adjust the behavior of its methods. class DatabaseSession_Mock: DatabaseSession { + /// The wrapped session let underlyingSession: DatabaseSession @@ -147,6 +148,7 @@ class DatabaseSession_Mock: DatabaseSession { skipPush: Bool, skipEnrichUrl: Bool, poll: PollPayload?, + location: NewLocationInfo? = nil, restrictedVisibility: [UserId] = [], extraData: [String: RawJSON] ) throws -> MessageDTO { @@ -170,6 +172,7 @@ class DatabaseSession_Mock: DatabaseSession { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, poll: poll, + location: location, restrictedVisibility: restrictedVisibility, extraData: extraData ) @@ -269,6 +272,10 @@ class DatabaseSession_Mock: DatabaseSession { return underlyingSession.saveMessageSearch(payload: payload, for: query) } + func saveLocation(payload: SharedLocationPayload, cache: PreWarmedCache?) throws -> SharedLocationDTO { + try underlyingSession.saveLocation(payload: payload, cache: cache) + } + func pin(message: MessageDTO, pinning: MessagePinning) throws { try throwErrorIfNeeded() return try underlyingSession.pin(message: message, pinning: pinning) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift index 7f95c41bb53..d86fb1d3d15 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift @@ -87,6 +87,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { @Atomic var createNewMessage_mentionedUserIds: [UserId]? @Atomic var createNewMessage_quotedMessageId: MessageId? @Atomic var createNewMessage_pinning: MessagePinning? + @Atomic var createNewMessage_location: NewLocationInfo? @Atomic var createNewMessage_extraData: [String: RawJSON]? @Atomic var createNewMessage_completion: ((Result) -> Void)? @Atomic var createNewMessage_completion_result: Result? @@ -367,6 +368,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { skipEnrichUrl: Bool, restrictedVisibility: [UserId] = [], poll: PollPayload?, + location: NewLocationInfo? = nil, extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil ) { @@ -383,6 +385,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { createNewMessage_quotedMessageId = quotedMessageId createNewMessage_pinning = pinning createNewMessage_extraData = extraData + createNewMessage_location = location createNewMessage_completion = completion createNewMessage_completion_result?.invoke(with: completion) } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift index aa29637c24b..3f417a78826 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift @@ -30,6 +30,13 @@ final class MessageUpdater_Mock: MessageUpdater { @Atomic var editMessage_completion: ((Result) -> Void)? @Atomic var editMessage_extraData: [String: RawJSON]? + @Atomic var updateLiveLocation_messageId: MessageId? + @Atomic var updateLiveLocation_locationInfo: LocationInfo? + @Atomic var updateLiveLocation_completion: ((Result) -> Void)? + + @Atomic var stopLiveLocationSharing_messageId: MessageId? + @Atomic var stopLiveLocationSharing_completion: ((Result) -> Void)? + @Atomic var createNewReply_cid: ChannelId? @Atomic var createNewReply_text: String? @Atomic var createNewReply_command: String? @@ -115,6 +122,13 @@ final class MessageUpdater_Mock: MessageUpdater { @Atomic var translate_completion: ((Result) -> Void)? @Atomic var translate_completion_result: Result? + @Atomic var updatePartialMessage_messageId: MessageId? + @Atomic var updatePartialMessage_text: String? + @Atomic var updatePartialMessage_attachments: [AnyAttachmentPayload]? + @Atomic var updatePartialMessage_extraData: [String: RawJSON]? + @Atomic var updatePartialMessage_completion: ((Result) -> Void)? + @Atomic var updatePartialMessage_completion_result: Result? + var markThreadRead_threadId: MessageId? var markThreadRead_cid: ChannelId? var markThreadRead_callCount = 0 @@ -248,6 +262,15 @@ final class MessageUpdater_Mock: MessageUpdater { loadThread_query = nil loadThread_completion = nil + + updatePartialMessage_messageId = nil + updatePartialMessage_text = nil + updatePartialMessage_attachments = nil + updatePartialMessage_extraData = nil + updatePartialMessage_completion = nil + + stopLiveLocationSharing_completion = nil + updateLiveLocation_completion = nil } override func getMessage(cid: ChannelId, messageId: MessageId, completion: ((Result) -> Void)? = nil) { @@ -313,6 +336,24 @@ final class MessageUpdater_Mock: MessageUpdater { resendMessage_completion_result?.invoke(with: completion) } + override func updateLiveLocation( + messageId: MessageId, + locationInfo: LocationInfo, + completion: @escaping ((Result) -> Void) + ) { + updateLiveLocation_messageId = messageId + updateLiveLocation_locationInfo = locationInfo + updateLiveLocation_completion = completion + } + + override func stopLiveLocationSharing( + messageId: MessageId, + completion: @escaping ((Result) -> Void) + ) { + stopLiveLocationSharing_messageId = messageId + stopLiveLocationSharing_completion = completion + } + override func createNewReply( in cid: ChannelId, messageId: MessageId?, @@ -519,6 +560,22 @@ final class MessageUpdater_Mock: MessageUpdater { loadThread_query = query loadThread_completion = completion } + + override func updatePartialMessage( + messageId: MessageId, + text: String? = nil, + attachments: [AnyAttachmentPayload]? = nil, + extraData: [String: RawJSON]? = nil, + unset: [String]? = nil, + completion: ((Result) -> Void)? = nil + ) { + updatePartialMessage_messageId = messageId + updatePartialMessage_text = text + updatePartialMessage_attachments = attachments + updatePartialMessage_extraData = extraData + updatePartialMessage_completion = completion + updatePartialMessage_completion_result?.invoke(with: completion) + } } extension MessageUpdater.MessageSearchResults { diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift index 48e5b32ddd6..c82d548a8dd 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift @@ -189,7 +189,11 @@ extension DatabaseContainer { } /// Synchronously creates a new CurrentUserDTO in the DB with the given id. - func createCurrentUser(id: UserId = .unique, name: String = .unique) throws { + func createCurrentUser( + id: UserId = .unique, + currentDeviceId: DeviceId? = nil, + name: String = .unique + ) throws { try writeSynchronously { session in let payload: CurrentUserPayload = .dummy( userId: id, @@ -198,6 +202,9 @@ extension DatabaseContainer { extraData: [:] ) try session.saveCurrentUser(payload: payload) + if let currentDeviceId = currentDeviceId { + try session.saveCurrentDevice(currentDeviceId) + } } } @@ -291,6 +298,7 @@ extension DatabaseContainer { reactionScores: [MessageReactionType: Int] = [:], reactionCounts: [MessageReactionType: Int] = [:], reactionGroups: [MessageReactionType: MessageReactionGroupPayload] = [:], + location: SharedLocationPayload? = nil, localState: LocalMessageState? = nil, type: MessageType? = nil, numberOfReplies: Int = 0, @@ -319,7 +327,8 @@ extension DatabaseContainer { pinnedAt: pinnedAt, pinExpires: pinExpires, reactionScores: reactionScores, - reactionCounts: reactionCounts + reactionCounts: reactionCounts, + sharedLocation: location ) let messageDTO = try session.saveMessage( diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift index 7dd7c0a72e2..8f25e5bb4a9 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelPayload.swift @@ -18,7 +18,8 @@ extension ChannelPayload { pinnedMessages: [MessagePayload] = [], channelReads: [ChannelReadPayload] = [], isHidden: Bool? = nil, - draft: DraftPayload? = nil + draft: DraftPayload? = nil, + activeLiveLocations: [SharedLocationPayload] = [] ) -> Self { .init( channel: channel, @@ -31,7 +32,8 @@ extension ChannelPayload { pinnedMessages: pinnedMessages, channelReads: channelReads, isHidden: isHidden, - draft: draft + draft: draft, + activeLiveLocations: activeLiveLocations ) } } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift index 486abfe9f85..9131cedf86f 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift @@ -1,5 +1,5 @@ // -// Copyright © 2025 Stream.io Inc. All rights reserved. +// Copyright 2025 Stream.io Inc. All rights reserved. // import Foundation diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift index d2c347488e1..04b6d982bf9 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift @@ -51,7 +51,8 @@ extension MessagePayload { mentionedUsers: [UserPayload] = [.dummy(userId: .unique)], messageTextUpdatedAt: Date? = nil, poll: PollPayload? = nil, - draft: DraftPayload? = nil + draft: DraftPayload? = nil, + sharedLocation: SharedLocationPayload? = nil ) -> MessagePayload { .init( id: messageId, @@ -93,7 +94,8 @@ extension MessagePayload { moderationDetails: moderationDetails, messageTextUpdatedAt: messageTextUpdatedAt, poll: poll, - draft: draft + draft: draft, + location: sharedLocation ) } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/SharedLocationPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/SharedLocationPayload.swift new file mode 100644 index 00000000000..0d513c629b1 --- /dev/null +++ b/TestTools/StreamChatTestTools/TestData/DummyData/SharedLocationPayload.swift @@ -0,0 +1,33 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat + +extension SharedLocationPayload { + /// Returns dummy draft payload with the given values. + static func dummy( + channelId: ChannelId = .unique, + messageId: String = .unique, + userId: String = .unique, + latitude: Double, + longitude: Double, + createdAt: Date = .unique, + updatedAt: Date = .unique, + endAt: Date? = nil, + createdByDeviceId: DeviceId = .unique + ) -> SharedLocationPayload { + .init( + channelId: channelId.rawValue, + messageId: messageId, + userId: userId, + latitude: latitude, + longitude: longitude, + createdAt: createdAt, + updatedAt: updatedAt, + endAt: endAt, + createdByDeviceId: createdByDeviceId + ) + } +} diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift index c03a8f3bc0f..0a766fc3371 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift @@ -205,7 +205,8 @@ extension XCTestCase { pinnedMessages: pinnedMessages, channelReads: channelReads ?? [dummyChannelRead], isHidden: false, - draft: nil + draft: nil, + activeLiveLocations: [] ) return payload @@ -322,7 +323,8 @@ extension XCTestCase { pinnedMessages: [dummyMessageWithNoExtraData], channelReads: [dummyChannelReadWithNoExtraData], isHidden: nil, - draft: nil + draft: nil, + activeLiveLocations: [] ) return payload diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index 35ea330b58f..3657dac6afd 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -189,4 +189,28 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) XCTAssertEqual("messages/\(messageId)/translate", endpoint.path.value) } + + func test_partialUpdateMessage_buildsCorrectly() { + let messageId: MessageId = .unique + let request = MessagePartialUpdateRequest( + set: .init(pinned: false, text: .unique), + unset: ["custom_text"], + skipEnrichUrl: true + ) + + let expectedEndpoint = Endpoint( + path: .editMessage(messageId), + method: .put, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + + // Build endpoint + let endpoint: Endpoint = .partialUpdateMessage(messageId: messageId, request: request) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)", endpoint.path.value) + } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 50dc214ca9c..62a4c445e22 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -248,7 +248,8 @@ final class ChannelListPayload_Tests: XCTestCase { ) }, isHidden: false, - draft: nil + draft: nil, + activeLiveLocations: [] ) } @@ -332,6 +333,7 @@ final class ChannelPayload_Tests: XCTestCase { XCTAssertEqual(config.messageRetention, "infinite") XCTAssertEqual(config.maxMessageLength, 5000) XCTAssertEqual(config.skipLastMsgAtUpdateForSystemMsg, true) + XCTAssertEqual(config.sharedLocationsEnabled, true) XCTAssertEqual( config.commands, [.init(name: "giphy", description: "Post a random gif to the channel", set: "fun_set", args: "[text]")] @@ -341,6 +343,7 @@ final class ChannelPayload_Tests: XCTestCase { XCTAssertEqual(payload.membership?.user?.id, "broken-waterfall-5") XCTAssertEqual(payload.channel.ownCapabilities?.count, 27) + XCTAssertEqual(payload.activeLiveLocations.count, 1) } func test_newestMessage_whenMessagesAreSortedDesc() throws { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift index 12e07939c48..77fa9e84d09 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift @@ -404,7 +404,8 @@ final class IdentifiablePayload_Tests: XCTestCase { ) }, isHidden: false, - draft: nil + draft: nil, + activeLiveLocations: [] ) } diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index f33319b52b9..8ec633efa97 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -954,6 +954,7 @@ final class ChannelController_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -5723,6 +5724,136 @@ final class ChannelController_Tests: XCTestCase { XCTAssertEqual(channelId, controller.cid) XCTAssertEqual(0, controller.messages.count) } + + // MARK: - Location Tests + + func test_sendStaticLocation_callsChannelUpdater() throws { + // Given + let location = LocationInfo(latitude: 123.45, longitude: 67.89) + let messageId = MessageId.unique + let text = "Custom message" + let extraData: [String: RawJSON] = ["key": .string("value")] + let quotedMessageId = MessageId.unique + + try client.databaseContainer.createChannel(cid: channelId) + + // When + let exp = expectation(description: "sendStaticLocation") + controller.sendStaticLocation( + location, + text: text, + messageId: messageId, + quotedMessageId: quotedMessageId, + extraData: extraData + ) { _ in + exp.fulfill() + } + + env.channelUpdater?.createNewMessage_completion?(.success(.mock())) + + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertEqual(env.channelUpdater?.createNewMessage_cid, channelId) + XCTAssertEqual(env.channelUpdater?.createNewMessage_text, text) + XCTAssertEqual(env.channelUpdater?.createNewMessage_isSilent, false) + XCTAssertEqual(env.channelUpdater?.createNewMessage_quotedMessageId, quotedMessageId) + XCTAssertEqual(env.channelUpdater?.createNewMessage_extraData, extraData) + + let messageLocation = env.channelUpdater?.createNewMessage_location + XCTAssertEqual(messageLocation?.latitude, location.latitude) + XCTAssertEqual(messageLocation?.longitude, location.longitude) + } + + func test_startLiveLocationSharing_whenActiveLiveLocationExists_shouldStopActiveLocation() throws { + // Given + let location = LocationInfo(latitude: 123.45, longitude: 67.89) + let existingMessageId = MessageId.unique + try client.databaseContainer.createChannel(cid: channelId) + let userId: UserId = .unique + try client.databaseContainer.createCurrentUser(id: userId) + + // Simulate existing live location message + try client.databaseContainer.writeSynchronously { + try $0.saveMessage( + payload: .dummy( + messageId: existingMessageId, + authorUserId: userId, + sharedLocation: .init( + channelId: self.channelId.rawValue, + messageId: existingMessageId, + userId: .unique, + latitude: location.latitude, + longitude: location.longitude, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, + createdByDeviceId: .unique + ) + ), + for: self.channelId, + syncOwnReactions: false, + cache: nil + ) + } + + var existingMessage: MessageDTO? { + client.databaseContainer.viewContext.message(id: existingMessageId) + } + + XCTAssertEqual(existingMessage?.isActiveLiveLocation, true) + + // When + let exp = expectation(description: "startLiveLocationSharing") + controller.startLiveLocationSharing(location, endDate: .distantFuture) { result in + XCTAssertNil(result.error) + exp.fulfill() + } + + env.channelUpdater?.createNewMessage_completion?(.success(.mock())) + + wait(for: [exp], timeout: defaultTimeout) + + // Then + AssertAsync { + Assert.willBeEqual(existingMessage?.isActiveLiveLocation, false) + } + } + + func test_startLiveLocationSharing_whenNoActiveLiveLocation_callsChannelUpdater() throws { + // Given + let location = LocationInfo(latitude: 123.45, longitude: 67.89) + let text = "Custom message" + let extraData: [String: RawJSON] = ["key": .string("value")] + try client.databaseContainer.createChannel(cid: channelId) + let userId: UserId = .unique + try client.databaseContainer.createCurrentUser(id: userId) + + // When + let exp = expectation(description: "startLiveLocationSharing") + controller.startLiveLocationSharing( + location, + endDate: .distantFuture, + text: text, + extraData: extraData + ) { _ in + exp.fulfill() + } + + env.channelUpdater?.createNewMessage_completion_result = .success(.mock()) + env.channelUpdater?.createNewMessage_completion?(.success(.mock())) + + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertEqual(env.channelUpdater?.createNewMessage_cid, channelId) + XCTAssertEqual(env.channelUpdater?.createNewMessage_text, text) + XCTAssertEqual(env.channelUpdater?.createNewMessage_extraData, extraData) + + let messageLocation = env.channelUpdater?.createNewMessage_location + XCTAssertEqual(messageLocation?.latitude, location.latitude) + XCTAssertEqual(messageLocation?.longitude, location.longitude) + } } // MARK: Test Helpers diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index 6156818365c..58b0c975e52 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -2573,9 +2573,7 @@ final class MessageController_Tests: XCTestCase { return replies } - - // MARK: - - + func waitForRepliesChange(count: Int) throws { let delegate = try XCTUnwrap(controller.delegate as? TestDelegate) let expectation = XCTestExpectation(description: "RepliesChange") @@ -2583,6 +2581,166 @@ final class MessageController_Tests: XCTestCase { delegate.didChangeRepliesExpectedCount = count wait(for: [expectation], timeout: defaultTimeout) } + + // MARK: - Update Message + + func test_partialUpdateMessage_callsMessageUpdater_withCorrectValues() { + // Given + let text: String = .unique + let attachments = [AnyAttachmentPayload.mockFile] + let extraData: [String: RawJSON] = ["key": .string("value")] + + // When + controller.partialUpdateMessage(text: text, attachments: attachments, extraData: extraData) + + // Then + XCTAssertEqual(env.messageUpdater.updatePartialMessage_messageId, messageId) + XCTAssertEqual(env.messageUpdater.updatePartialMessage_text, text) + XCTAssertEqual(env.messageUpdater.updatePartialMessage_attachments, attachments) + XCTAssertEqual(env.messageUpdater.updatePartialMessage_extraData, extraData) + } + + func test_partialUpdateMessage_propagatesError() { + // Given + let error = TestError() + var completionError: Error? + + // When + let exp = expectation(description: "Completion is called") + controller.partialUpdateMessage(text: .unique) { [callbackQueueID] result in + AssertTestQueue(withId: callbackQueueID) + if case let .failure(error) = result { + completionError = error + } + exp.fulfill() + } + + env.messageUpdater.updatePartialMessage_completion?(.failure(error)) + + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertEqual(completionError as? TestError, error) + } + + func test_partialUpdateMessage_propagatesSuccess() { + // Given + var completionMessage: ChatMessage? + + // When + let exp = expectation(description: "Completion is called") + controller.partialUpdateMessage(text: .unique) { [callbackQueueID] result in + AssertTestQueue(withId: callbackQueueID) + if case let .success(message) = result { + completionMessage = message + } + exp.fulfill() + } + + env.messageUpdater.updatePartialMessage_completion?(.success(.unique)) + + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertNotNil(completionMessage) + } + + // MARK: - Stop Live Location Tests + + func test_stopLiveLocationSharing_callsMessageUpdater_withCorrectValues() { + // Save message with live location + let latitude = 51.5074 + let longitude = -0.1278 + let sharedLocation = SharedLocation( + messageId: messageId, + channelId: .unique, + userId: .unique, + createdByDeviceId: .unique, + latitude: latitude, + longitude: longitude, + updatedAt: .unique, + createdAt: .unique, + endAt: .distantFuture + ) + _ = controller.message + env.messageObserver.item_mock = .mock( + id: messageId, + sharedLocation: sharedLocation + ) + + // When + controller.stopLiveLocationSharing() + + // Simulate + env.messageUpdater.stopLiveLocationSharing_completion?(.success(sharedLocation)) + + // Then + XCTAssertEqual(env.messageUpdater.stopLiveLocationSharing_messageId, messageId) + } + + func test_stopLiveLocationSharing_whenNoLiveLocationAttachment_completesWithError() { + // Given + // Create a mock message without live location attachment + _ = controller.message + env.messageObserver.item_mock = .mock( + id: messageId, + sharedLocation: nil + ) + + // When + let exp = expectation(description: "stopLiveLocationSharing") + var receivedError: Error? + controller.stopLiveLocationSharing { result in + if case let .failure(error) = result { + receivedError = error + } + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertTrue(receivedError is ClientError.MessageDoesNotHaveLiveLocationAttachment) + } + + func test_stopLiveLocationSharing_whenLiveLocationAlreadyStopped_completesWithError() { + // Given + let latitude = 51.5074 + let longitude = -0.1278 + let sharedLocation = SharedLocation( + messageId: messageId, + channelId: .unique, + userId: .unique, + createdByDeviceId: .unique, + latitude: latitude, + longitude: longitude, + updatedAt: .unique, + createdAt: .unique, + endAt: .distantPast + ) + + // Save message with live location + _ = controller.message + env.messageObserver.item_mock = .mock( + id: messageId, + sharedLocation: sharedLocation + ) + + // When + let exp = expectation(description: "stopLiveLocationSharing") + var receivedError: Error? + controller.stopLiveLocationSharing { result in + if case let .failure(error) = result { + receivedError = error + } + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertTrue(receivedError is ClientError.MessageLiveLocationAlreadyStopped) + } } private class TestDelegate: QueueAwareDelegate, ChatMessageControllerDelegate { diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift index 3fbadf897fa..1a62755af6f 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift @@ -397,6 +397,7 @@ final class ChannelDTO_Tests: XCTestCase { Assert.willBeEqual(payload.channel.config.createdAt, loadedChannel.config.createdAt) Assert.willBeEqual(payload.channel.config.updatedAt, loadedChannel.config.updatedAt) Assert.willBeEqual(payload.channel.config.messageRemindersEnabled, loadedChannel.config.messageRemindersEnabled) + Assert.willBeEqual(payload.channel.config.sharedLocationsEnabled, loadedChannel.config.sharedLocationsEnabled) // Own Capabilities Assert.willBeEqual(payload.channel.ownCapabilities, ["join-channel", "delete-channel"]) @@ -1112,7 +1113,8 @@ final class ChannelDTO_Tests: XCTestCase { pinnedMessages: [], channelReads: [currentUserChannelReadPayload], isHidden: false, - draft: nil + draft: nil, + activeLiveLocations: [] ) let unreadMessages = 5 @@ -1169,7 +1171,8 @@ final class ChannelDTO_Tests: XCTestCase { pinnedMessages: [], channelReads: [], isHidden: nil, - draft: nil + draft: nil, + activeLiveLocations: [] ) try database.writeSynchronously { session in try session.saveChannel(payload: channelPayload) @@ -1537,7 +1540,8 @@ final class ChannelDTO_Tests: XCTestCase { quotedMessage: nil, parentId: nil, parentMessage: nil - ) + ), + activeLiveLocations: [.dummy(latitude: 10, longitude: 10)] ) // WHEN @@ -1552,6 +1556,8 @@ final class ChannelDTO_Tests: XCTestCase { XCTAssertEqual(draftMessage.id, draftMessagePayload.id) XCTAssertEqual(draftMessage.text, draftMessagePayload.text) XCTAssertEqual(draftMessage.extraData, draftMessagePayload.extraData) + XCTAssertEqual(channel.activeLiveLocations.first?.latitude, 10) + XCTAssertEqual(channel.activeLiveLocations.first?.longitude, 10) } func test_saveChannel_whenDraftMessageIsNil_removesExistingDraft() throws { @@ -1588,7 +1594,8 @@ final class ChannelDTO_Tests: XCTestCase { quotedMessage: nil, parentId: nil, parentMessage: nil - ) + ), + activeLiveLocations: [] ) try database.writeSynchronously { session in @@ -1613,7 +1620,8 @@ final class ChannelDTO_Tests: XCTestCase { pinnedMessages: [], channelReads: [], isHidden: nil, - draft: nil + draft: nil, + activeLiveLocations: [] ) try database.writeSynchronously { session in @@ -1664,7 +1672,8 @@ final class ChannelDTO_Tests: XCTestCase { quotedMessage: quotedMessagePayload, parentId: nil, parentMessage: nil - ) + ), + activeLiveLocations: [] ) // WHEN @@ -1720,7 +1729,8 @@ final class ChannelDTO_Tests: XCTestCase { quotedMessage: nil, parentId: parentMessagePayload.id, parentMessage: parentMessagePayload - ) + ), + activeLiveLocations: [] ) // WHEN diff --git a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift index e2fe560cad1..39acf8d2058 100644 --- a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift @@ -1201,6 +1201,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: messageExtraData ) @@ -1291,6 +1292,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1396,6 +1398,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1421,6 +1424,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1565,6 +1569,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: true, skipEnrichUrl: true, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1637,6 +1642,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: true, skipEnrichUrl: true, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1688,6 +1694,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1736,6 +1743,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1797,6 +1805,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1827,6 +1836,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1871,6 +1881,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -1925,6 +1936,66 @@ final class MessageDTO_Tests: XCTestCase { XCTAssertEqual(loadedMessage.channel!.defaultSortingAt, loadedMessage.createdAt) } + func test_createNewMessage_whenIsLiveLocation() throws { + // Prepare the current user and channel first + let cid: ChannelId = .unique + let currentUserId: UserId = .unique + let deviceId: DeviceId = .unique + + try database.writeSynchronously { session in + let currentUserPayload: CurrentUserPayload = .dummy( + userId: currentUserId, + role: .admin, + extraData: [:] + ) + + try session.saveCurrentUser(payload: currentUserPayload) + try session.saveCurrentDevice(deviceId) + try session.saveChannel(payload: self.dummyPayload(with: cid)) + } + + // Create a new message + var newMessageId: MessageId! + let newMessageText: String = .unique + try database.writeSynchronously { session in + let messageDTO = try session.createNewMessage( + in: cid, + messageId: .unique, + text: newMessageText, + pinning: nil, + command: nil, + arguments: nil, + parentMessageId: nil, + attachments: [], + mentionedUserIds: [], + showReplyInChannel: true, + isSilent: false, + isSystem: true, + quotedMessageId: nil, + createdAt: nil, + skipPush: true, + skipEnrichUrl: true, + poll: nil, + location: .init(latitude: 10, longitude: 10, endAt: .distantFuture), + restrictedVisibility: [], + extraData: [:] + ) + newMessageId = messageDTO.id + } + + let messageDTO: MessageDTO = try XCTUnwrap(database.viewContext.message(id: newMessageId)) + XCTAssertEqual(messageDTO.isActiveLiveLocation, true) + XCTAssertEqual(messageDTO.location?.latitude, 10) + XCTAssertEqual(messageDTO.location?.longitude, 10) + + let loadedMessage: ChatMessage = try messageDTO.asModel() + XCTAssertEqual(loadedMessage.sharedLocation?.isLiveSharingActive, true) + XCTAssertEqual(loadedMessage.sharedLocation?.endAt, .distantFuture) + XCTAssertEqual(loadedMessage.sharedLocation?.latitude, 10) + XCTAssertEqual(loadedMessage.sharedLocation?.longitude, 10) + XCTAssertEqual(loadedMessage.sharedLocation?.createdByDeviceId, deviceId) + } + func test_replies_linkedToParentMessage_onCreatingNewMessage() throws { // Create current user try database.createCurrentUser() @@ -1961,6 +2032,7 @@ final class MessageDTO_Tests: XCTestCase { skipPush: false, skipEnrichUrl: false, poll: nil, + location: nil, restrictedVisibility: [], extraData: [:] ) @@ -4192,6 +4264,216 @@ final class MessageDTO_Tests: XCTestCase { XCTAssertNil(quoted3Message) } + // MARK: - loadActiveLiveLocationMessages + + func test_loadActiveLiveLocationMessages() throws { + // GIVEN + let currentUserId: UserId = .unique + let otherUserId: UserId = .unique + let channel1Id: ChannelId = .unique + let channel2Id: ChannelId = .unique + + let currentUser: CurrentUserPayload = .dummy(userId: currentUserId) + let otherUser: UserPayload = .dummy(userId: otherUserId) + let channel1Payload: ChannelPayload = .dummy(channel: .dummy(cid: channel1Id)) + let channel2Payload: ChannelPayload = .dummy(channel: .dummy(cid: channel2Id)) + + // Create messages with different combinations: + // - Current user's active live location in channel 1 + // - Current user's inactive live location in channel 1 + // - Current user's active live location in channel 2 + // - Other user's active live location in channel 1 + // - Current user's non-location message in channel 1 + let messages: [(MessageId, UserId, ChannelId, Bool)] = [ + (.unique, currentUserId, channel1Id, true), // Current user, channel 1, active + (.unique, currentUserId, channel1Id, false), // Current user, channel 1, inactive + (.unique, currentUserId, channel2Id, true), // Current user, channel 2, active + (.unique, otherUserId, channel1Id, true), // Other user, channel 1, active + (.unique, currentUserId, channel1Id, false) // Current user, channel 1, no location + ] + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + try session.saveUser(payload: otherUser) + try session.saveChannel(payload: channel1Payload) + try session.saveChannel(payload: channel2Payload) + + // Save all test messages + for (id, userId, channelId, isActive) in messages { + let messagePayload: MessagePayload = .dummy( + messageId: id, + authorUserId: userId, + sharedLocation: .init( + channelId: channelId.rawValue, + messageId: id, + userId: .unique, + latitude: 50, + longitude: 10, + createdAt: .unique, + updatedAt: .unique, + endAt: isActive ? .distantFuture : .distantPast, + createdByDeviceId: .unique + ) + ) + + try session.saveMessage( + payload: messagePayload, + for: channelId, + syncOwnReactions: false, + cache: nil + ) + } + } + + // Test 1: Load all active live location messages for current user + do { + let loadedMessages = try MessageDTO.loadCurrentUserActiveLiveLocationMessages( + currentUserId: currentUserId, + channelId: nil, + context: database.viewContext + ) + XCTAssertEqual(loadedMessages.count, 2) // Should get both active messages from channel 1 and 2 + } + + // Test 2: Load active live location messages for current user in channel 1 + do { + let loadedMessages = try MessageDTO.loadCurrentUserActiveLiveLocationMessages( + currentUserId: currentUserId, + channelId: channel1Id, + context: database.viewContext + ) + XCTAssertEqual(loadedMessages.count, 1) // Should only get the active message from channel 1 + } + } + + func test_loadActiveLiveLocationMessages_excludesMessagesWithLocalState() throws { + // GIVEN + let currentUserId: UserId = .unique + let channelId: ChannelId = .unique + + let currentUser: CurrentUserPayload = .dummy(userId: currentUserId) + let channelPayload: ChannelPayload = .dummy(channel: .dummy(cid: channelId)) + + try database.writeSynchronously { session in + try session.saveCurrentUser(payload: currentUser) + try session.saveChannel(payload: channelPayload) + + // Create test messages with different local states: + // 1. Message with no local state (successfully sent) - should be included + let sentMessagePayload: MessagePayload = .dummy( + messageId: .unique, + authorUserId: currentUserId, + sharedLocation: .init( + channelId: channelId.rawValue, + messageId: .unique, + userId: .unique, + latitude: 50, + longitude: 10, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, // Active location + createdByDeviceId: .unique + ) + ) + + let sentMessage = try session.saveMessage( + payload: sentMessagePayload, + for: channelId, + syncOwnReactions: false, + cache: nil + ) + // Successfully sent message has no local state + sentMessage.localMessageState = nil + + // 2. Message with pendingSend state - should be excluded + let pendingSendMessagePayload: MessagePayload = .dummy( + messageId: .unique, + authorUserId: currentUserId, + sharedLocation: .init( + channelId: channelId.rawValue, + messageId: .unique, + userId: .unique, + latitude: 51, + longitude: 11, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, + createdByDeviceId: .unique + ) + ) + + let pendingSendMessage = try session.saveMessage( + payload: pendingSendMessagePayload, + for: channelId, + syncOwnReactions: false, + cache: nil + ) + pendingSendMessage.localMessageState = .pendingSend + + // 3. Message with sendingFailed state - should be excluded + let sendingFailedMessagePayload: MessagePayload = .dummy( + messageId: .unique, + authorUserId: currentUserId, + sharedLocation: .init( + channelId: channelId.rawValue, + messageId: .unique, + userId: .unique, + latitude: 52, + longitude: 12, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, + createdByDeviceId: .unique + ) + ) + + let sendingFailedMessage = try session.saveMessage( + payload: sendingFailedMessagePayload, + for: channelId, + syncOwnReactions: false, + cache: nil + ) + sendingFailedMessage.localMessageState = .sendingFailed + + // 4. Message with pendingSync state - should be excluded + let pendingSyncMessagePayload: MessagePayload = .dummy( + messageId: .unique, + authorUserId: currentUserId, + sharedLocation: .init( + channelId: channelId.rawValue, + messageId: .unique, + userId: .unique, + latitude: 53, + longitude: 13, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, + createdByDeviceId: .unique + ) + ) + + let pendingSyncMessage = try session.saveMessage( + payload: pendingSyncMessagePayload, + for: channelId, + syncOwnReactions: false, + cache: nil + ) + pendingSyncMessage.localMessageState = .pendingSync + } + + // WHEN + let loadedMessages = try MessageDTO.loadCurrentUserActiveLiveLocationMessages( + currentUserId: currentUserId, + channelId: channelId, + context: database.viewContext + ) + + // THEN + // Only the successfully sent message (with no local state) should be loaded + XCTAssertEqual(loadedMessages.count, 1, "Only messages with no local state should be included") + XCTAssertNil(loadedMessages.first?.localMessageState, "Loaded message should have no local state") + } + func test_asModel_whenModelTransformerProvided_transformsValues() throws { class CustomMessageTransformer: StreamModelsTransformer { var mockTransformedMessage: ChatMessage = .mock( diff --git a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift index 66d33823109..af05b64034e 100644 --- a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift @@ -379,6 +379,7 @@ final class DatabaseContainer_Tests: XCTestCase { try session.saveChannel(payload: self.dummyPayload(with: .unique), query: nil, cache: nil) try session.saveMember(payload: .dummy(), channelId: cid, query: .init(cid: cid), cache: nil) try session.saveCurrentUser(payload: .dummy(userId: currentUserId, role: .admin)) + try session.saveLocation(payload: .dummy(latitude: 10, longitude: 10), cache: nil) try session.saveCurrentDevice("123") try session.saveChannelMute(payload: .init( mutedChannel: .dummy(cid: cid), diff --git a/Tests/StreamChatTests/Models/ChatChannel_Tests.swift b/Tests/StreamChatTests/Models/ChatChannel_Tests.swift index a8a61073a57..1bbe3bb90a6 100644 --- a/Tests/StreamChatTests/Models/ChatChannel_Tests.swift +++ b/Tests/StreamChatTests/Models/ChatChannel_Tests.swift @@ -301,6 +301,14 @@ final class ChatChannel_Tests: XCTestCase { XCTAssertEqual(channelWithoutCapability.canCastPollVote, false) } + func test_canShareLocation() throws { + let channel = setupChannel(withCapabilities: [.shareLocation]) + XCTAssertEqual(channel.canShareLocation, true) + + let channelWithoutCapability = setupChannel(withCapabilities: []) + XCTAssertEqual(channelWithoutCapability.canShareLocation, false) + } + func test_lastReadMessageId_readsDontContainUser() { let userId: UserId = "current" let channel = ChatChannel.mock(cid: .unique, reads: [ diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift index 8dadbb8760b..bd0dc05ef16 100644 --- a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift +++ b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift @@ -393,6 +393,8 @@ final class ChatMessage_Tests: XCTestCase { XCTAssertEqual(actualIds, expectedIds) } + // MARK: - replacing + func test_replacing() { let originalMessage = ChatMessage.mock( id: .unique, diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift index 99f62668d79..64be1a7a50e 100644 --- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift @@ -701,8 +701,89 @@ final class MessageRepositoryTests: XCTestCase { XCTAssertEqual(reactionScore, 10) } - // MARK: - Interceptor Tests + // MARK: - getActiveLiveLocationMessages + func test_getActiveLiveLocationMessages_whenCurrentUserDoesNotExist_failsWithError() throws { + // Create channel but no current user + try database.createChannel(cid: cid) + + let expectation = self.expectation(description: "getActiveLiveLocationMessages completes") + var receivedError: Error? + + repository.getCurrentUserActiveLiveLocationMessages(for: cid) { result in + if case .failure(let error) = result { + receivedError = error + } + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertTrue(receivedError is ClientError) + XCTAssertTrue(receivedError is ClientError.CurrentUserDoesNotExist) + } + + func test_getActiveLiveLocationMessages_returnsMessagesForChannel() throws { + let currentUserId: UserId = .unique + let messageId1: MessageId = .unique + let messageId2: MessageId = .unique + + // Create current user and channel + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + // Create messages with live location attachments + try database.createMessage( + id: messageId1, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId1, + userId: .unique, + latitude: 1, + longitude: 1, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, + createdByDeviceId: .unique + ) + ) + try database.createMessage( + id: messageId2, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId2, + userId: .unique, + latitude: 1, + longitude: 1, + createdAt: .unique, + updatedAt: .unique, + endAt: .distantFuture, + createdByDeviceId: .unique + ) + ) + + let expectation = self.expectation(description: "getActiveLiveLocationMessages completes") + var receivedMessages: [ChatMessage]? + + repository.getCurrentUserActiveLiveLocationMessages(for: cid) { result in + if case .success(let messages) = result { + receivedMessages = messages + } + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertEqual(receivedMessages?.count, 2) + XCTAssertEqual(Set(receivedMessages?.map(\.id) ?? []), Set([messageId1, messageId2])) + } + + // MARK: - Interceptor Tests + final class MockSendMessageInterceptor: SendMessageInterceptor { var sendMessageCalled = false var receivedMessage: ChatMessage? diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift index f2df67f2a43..829de046bf8 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift @@ -52,7 +52,8 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase { pinnedMessages: [], channelReads: [currentUserReadPayload], isHidden: false, - draft: nil + draft: nil, + activeLiveLocations: [] ) try! database.writeSynchronously { session in diff --git a/Tests/StreamChatTests/Workers/Background/ActiveLiveLocationsEndTimeTracker_Tests.swift b/Tests/StreamChatTests/Workers/Background/ActiveLiveLocationsEndTimeTracker_Tests.swift new file mode 100644 index 00000000000..f5a4fc66cc9 --- /dev/null +++ b/Tests/StreamChatTests/Workers/Background/ActiveLiveLocationsEndTimeTracker_Tests.swift @@ -0,0 +1,236 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ActiveLiveLocationsEndTimeTracker_Tests: XCTestCase { + var apiClient: APIClient_Spy! + var database: DatabaseContainer_Spy! + var tracker: ActiveLiveLocationsEndTimeTracker! + + override func setUp() { + super.setUp() + apiClient = APIClient_Spy() + database = DatabaseContainer_Spy() + tracker = ActiveLiveLocationsEndTimeTracker(database: database, apiClient: apiClient) + } + + override func tearDown() { + AssertAsync.canBeReleased(&tracker) + AssertAsync.canBeReleased(&apiClient) + AssertAsync.canBeReleased(&database) + tracker = nil + apiClient = nil + database = nil + super.tearDown() + } + + // MARK: - Tests + + func test_trackerSchedulesWorkItem_whenActiveLiveLocationIsInserted() throws { + let cid: ChannelId = .unique + let messageId: MessageId = .unique + let currentUserId: UserId = .unique + let endAt = Date().addingTimeInterval(100) // 100 seconds in the future + + // Create current user and channel + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + // Initially no work items should be scheduled + XCTAssertTrue(tracker.workItems.isEmpty) + + // Create message with active live location + try database.createMessage( + id: messageId, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId, + userId: currentUserId, + latitude: 10.0, + longitude: 20.0, + createdAt: Date(), + updatedAt: Date(), + endAt: endAt, + createdByDeviceId: .unique + ) + ) + + // Verify work item is scheduled + AssertAsync.willBeEqual(tracker.workItems.count, 1) + XCTAssertNotNil(tracker.workItems[messageId]) + XCTAssertFalse(tracker.workItems[messageId]?.isCancelled ?? true) + } + + func test_trackerSchedulesMultipleWorkItems_forMultipleActiveLiveLocations() throws { + let cid: ChannelId = .unique + let messageId1: MessageId = .unique + let messageId2: MessageId = .unique + let currentUserId: UserId = .unique + let endAt = Date().addingTimeInterval(100) + + // Create current user and channel + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + // Create first message with active live location + try database.createMessage( + id: messageId1, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId1, + userId: currentUserId, + latitude: 10.0, + longitude: 20.0, + createdAt: Date(), + updatedAt: Date(), + endAt: endAt, + createdByDeviceId: .unique + ) + ) + + // Create second message with active live location + try database.createMessage( + id: messageId2, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId2, + userId: currentUserId, + latitude: 30.0, + longitude: 40.0, + createdAt: Date(), + updatedAt: Date(), + endAt: endAt, + createdByDeviceId: .unique + ) + ) + + // Verify both work items are scheduled + AssertAsync.willBeEqual(tracker.workItems.count, 2) + XCTAssertNotNil(tracker.workItems[messageId1]) + XCTAssertNotNil(tracker.workItems[messageId2]) + XCTAssertFalse(tracker.workItems[messageId1]?.isCancelled ?? true) + XCTAssertFalse(tracker.workItems[messageId2]?.isCancelled ?? true) + } + + func test_trackerCancelsWorkItem_whenActiveLiveLocationIsInactive() throws { + let cid: ChannelId = .unique + let messageId: MessageId = .unique + let currentUserId: UserId = .unique + let endAt = Date().addingTimeInterval(100) + let updatedAt = Date.unique + + // Create current user and channel + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + // Create message with active live location + try database.createMessage( + id: messageId, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId, + userId: currentUserId, + latitude: 10.0, + longitude: 20.0, + createdAt: Date(), + updatedAt: updatedAt, + endAt: endAt, + createdByDeviceId: .unique + ) + ) + + // Verify work item is scheduled + AssertAsync.willBeEqual(tracker.workItems.count, 1) + + // Delete the message + try database.writeSynchronously { session in + let message = session.message(id: messageId) + message?.location?.endAt = .distantPast.bridgeDate + message?.isActiveLiveLocation = false + } + + // Verify work item is removed + AssertAsync.willBeEqual(tracker.workItems.count, 0) + let newUpdatedAt = try XCTUnwrap(database.viewContext.message(id: messageId)?.updatedAt) + // The updateAt should be updated especially to trigger an UI Update. + XCTAssertNotEqual(String(newUpdatedAt.timeIntervalSince1970), String(updatedAt.timeIntervalSince1970)) + } + + func test_trackerDoesNotScheduleWorkItem_forMessageWithoutEndTime() throws { + let cid: ChannelId = .unique + let messageId: MessageId = .unique + let currentUserId: UserId = .unique + + // Create current user and channel + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + // Create message with location but no endAt (static location) + try database.createMessage( + id: messageId, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId, + userId: currentUserId, + latitude: 10.0, + longitude: 20.0, + createdAt: Date(), + updatedAt: Date(), + endAt: nil, // No end time + createdByDeviceId: .unique + ) + ) + + // No work item should be scheduled since there's no endAt + AssertAsync.staysTrue(tracker.workItems.isEmpty) + } + + func test_trackerDoesNotRetainItself() throws { + let cid: ChannelId = .unique + let messageId: MessageId = .unique + let currentUserId: UserId = .unique + let endAt = Date().addingTimeInterval(100) + + // Create current user and channel + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + // Create message with active live location + try database.createMessage( + id: messageId, + authorId: currentUserId, + cid: cid, + location: .init( + channelId: cid.rawValue, + messageId: messageId, + userId: currentUserId, + latitude: 10.0, + longitude: 20.0, + createdAt: Date(), + updatedAt: Date(), + endAt: endAt, + createdByDeviceId: .unique + ) + ) + + // Verify work item is scheduled + AssertAsync.willBeEqual(tracker.workItems.count, 1) + + // Assert tracker can be released even though work items are scheduled + AssertAsync.canBeReleased(&tracker) + } +} diff --git a/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift b/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift index 7771b46b5d2..18b6ec7101a 100644 --- a/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/CurrentUserUpdater_Tests.swift @@ -799,6 +799,70 @@ final class CurrentUserUpdater_Tests: XCTestCase { XCTAssertEqual(receivedError as? TestError, expectedError) } + // MARK: - Load Active Live Locations + + func test_loadActiveLiveLocations_makesCorrectAPICall() { + // WHEN + currentUserUpdater.loadActiveLiveLocations { _ in } + + // THEN + let endpoint = apiClient.request_endpoint + XCTAssertEqual(endpoint?.path.value, "users/live_locations") + XCTAssertEqual(endpoint?.method, .get) + } + + func test_loadActiveLiveLocations_successfulResponse_savesToDBAndReturnsModels() throws { + // GIVEN + let payloads = [ + SharedLocationPayload.dummy(latitude: 10, longitude: 20, endAt: Date().addingTimeInterval(100)), + SharedLocationPayload.dummy(latitude: 30, longitude: 40, endAt: Date().addingTimeInterval(200)) + ] + let response = ActiveLiveLocationsResponsePayload(locations: payloads) + var result: Result<[SharedLocation], Error>? + + // WHEN + let expectation = self.expectation(description: "loadActiveLiveLocations") + currentUserUpdater.loadActiveLiveLocations { + result = $0 + expectation.fulfill() + } + apiClient.test_simulateResponse(.success(response)) + + waitForExpectations(timeout: defaultTimeout) + + // THEN + let sharedLocations = try result?.get() + XCTAssertEqual(sharedLocations?.count, payloads.count) + for (model, payload) in zip(sharedLocations ?? [], payloads) { + XCTAssertEqual(model.messageId, payload.messageId) + XCTAssertEqual(model.channelId.rawValue, payload.channelId) + XCTAssertEqual(model.latitude, payload.latitude) + XCTAssertEqual(model.longitude, payload.longitude) + XCTAssertEqual(model.endAt?.timeIntervalSince1970, payload.endAt?.timeIntervalSince1970) + XCTAssertEqual(model.createdByDeviceId, payload.createdByDeviceId) + } + } + + func test_loadActiveLiveLocations_propagatesNetworkError() { + // GIVEN + let expectedError = TestError() + var result: Result<[SharedLocation], Error>? + + // WHEN + currentUserUpdater.loadActiveLiveLocations { + result = $0 + } + apiClient.test_simulateResponse(Result.failure(expectedError)) + + // THEN + switch result { + case .failure(let error as TestError): + XCTAssertEqual(error, expectedError) + default: + XCTFail("Expected TestError") + } + } + // MARK: - private func setUpDownloadedAttachment(with payload: AnyAttachmentPayload, messageId: MessageId = .unique, cid: ChannelId = .unique) throws -> AttachmentId { diff --git a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift index 503642ebbe6..13abbae37ff 100644 --- a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift @@ -2977,6 +2977,369 @@ final class MessageUpdater_Tests: XCTestCase { wait(for: [exp], timeout: defaultTimeout) } + + // MARK: - Update Partial Message Tests + + func test_updatePartialMessage_makesCorrectAPICall() throws { + let messageId: MessageId = .unique + let text: String = .unique + let extraData: [String: RawJSON] = ["custom": .number(1)] + let attachments: [AnyAttachmentPayload] = [.mockImage] + + // Convert attachments to expected format + let expectedAttachmentPayloads: [MessageAttachmentPayload] = attachments.compactMap { attachment in + guard let payloadData = try? JSONEncoder.default.encode(attachment.payload), + let payloadRawJSON = try? JSONDecoder.default.decode(RawJSON.self, from: payloadData) else { + return nil + } + return MessageAttachmentPayload( + type: attachment.type, + payload: payloadRawJSON + ) + } + + let exp = expectation(description: "updatePartialMessage completes") + + // Call updatePartialMessage + messageUpdater.updatePartialMessage( + messageId: messageId, + text: text, + attachments: attachments, + extraData: extraData + ) { _ in + exp.fulfill() + } + + // Simulate successful API response + apiClient.test_simulateResponse( + .success(MessagePayload.Boxed(message: .dummy(messageId: messageId))) + ) + + // Assert correct endpoint is called + let expectedEndpoint: Endpoint = .partialUpdateMessage( + messageId: messageId, + request: .init( + set: .init( + text: text, + extraData: extraData, + attachments: expectedAttachmentPayloads + ) + ) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + + wait(for: [exp], timeout: defaultTimeout) + } + + func test_updatePartialMessage_propagatesNetworkError() throws { + let messageId: MessageId = .unique + let networkError = TestError() + + let exp = expectation(description: "updatePartialMessage completes") + + // Call updatePartialMessage and store result + var completionCalledError: Error? + messageUpdater.updatePartialMessage(messageId: messageId) { result in + if case let .failure(error) = result { + completionCalledError = error + } + exp.fulfill() + } + + // Simulate API response with error + apiClient.test_simulateResponse(Result.failure(networkError)) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert error is propagated + XCTAssertEqual(completionCalledError as? TestError, networkError) + } + + func test_updatePartialMessage_savesMessageToDatabase() throws { + let currentUserId: UserId = .unique + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let text: String = .unique + + try database.createCurrentUser(id: currentUserId) + try database.createChannel(cid: cid) + + let exp = expectation(description: "updatePartialMessage completes") + + // Call updatePartialMessage + var receivedMessage: ChatMessage? + messageUpdater.updatePartialMessage( + messageId: messageId, + text: text + ) { result in + if case let .success(message) = result { + receivedMessage = message + } + exp.fulfill() + } + + // Simulate successful API response + let messagePayload = MessagePayload.dummy( + messageId: messageId, + authorUserId: currentUserId, + text: text, + cid: cid + ) + apiClient.test_simulateResponse(Result.success(.init(message: messagePayload))) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert message is saved and returned correctly + XCTAssertNotNil(receivedMessage) + XCTAssertEqual(receivedMessage?.id, messageId) + XCTAssertEqual(receivedMessage?.text, text) + XCTAssertEqual(receivedMessage?.author.id, currentUserId) + } + + // MARK: - Live Location + + func test_updateLiveLocation_success() throws { + let currentUserId: UserId = .unique + let deviceId = "device-123" + let messageId: MessageId = .unique + let userId: UserId = .unique + let cid: ChannelId = .unique + let initialLatitude = 10.0 + let initialLongitude = 20.0 + let updatedLatitude = 30.0 + let updatedLongitude = 40.0 + + // Create current user and device + try database.createCurrentUser(id: currentUserId, currentDeviceId: deviceId) + try database.createChannel(cid: cid) + // Create a message with a live location + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: initialLatitude, + longitude: initialLongitude, + endAt: Date().addingTimeInterval(1000) + ) + ) + + // Prepare API response + let payload = SharedLocationPayload( + channelId: cid.rawValue, + messageId: messageId, + userId: userId, + latitude: updatedLatitude, + longitude: updatedLongitude, + createdAt: .unique, + updatedAt: .unique, + endAt: Date().addingTimeInterval(1000), + createdByDeviceId: deviceId + ) + apiClient.test_mockResponseResult(.success(payload)) + + let exp = expectation(description: "updateLiveLocation completes") + var result: Result? + messageUpdater.updateLiveLocation( + messageId: messageId, + locationInfo: .init(latitude: updatedLatitude, longitude: updatedLongitude) + ) { + result = $0 + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + let sharedLocation = try result?.get() + XCTAssertEqual(sharedLocation?.latitude, updatedLatitude) + XCTAssertEqual(sharedLocation?.longitude, updatedLongitude) + XCTAssertEqual(sharedLocation?.messageId, messageId) + XCTAssertEqual(sharedLocation?.channelId, cid) + XCTAssertEqual(sharedLocation?.userId, userId) + XCTAssertEqual(sharedLocation?.createdByDeviceId, deviceId) + } + + func test_updateLiveLocation_liveLocationExpired_propagatesError() throws { + let currentUserId: UserId = .unique + let deviceId = "device-123" + let messageId: MessageId = .unique + let cid: ChannelId = .unique + try database.createCurrentUser(id: currentUserId, currentDeviceId: deviceId) + try database.createChannel(cid: cid) + // Create a message with a static location (no endAt) + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: 1, + longitude: 1, + endAt: .distantPast + ) + ) + let result = try waitFor { + messageUpdater.updateLiveLocation( + messageId: messageId, + locationInfo: .init(latitude: 3, longitude: 4), + completion: $0 + ) + } + XCTAssertTrue(result.error is ClientError.MessageDoesNotHaveLiveLocationAttachment) + } + + func test_updateLiveLocation_noCurrentUserDevice_propagatesError() throws { + let currentUserId: UserId = .unique + let messageId: MessageId = .unique + let cid: ChannelId = .unique + try database.createCurrentUser(id: currentUserId, currentDeviceId: nil) + try database.createChannel(cid: cid) + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: 2, + longitude: 3, + endAt: Date().addingTimeInterval(1000) + ) + ) + let result = try waitFor { + messageUpdater.updateLiveLocation( + messageId: messageId, + locationInfo: .init(latitude: 1, longitude: 2), + completion: $0 + ) + } + XCTAssertTrue(result.error is ClientError.CurrentUserDoesNotExist) + } + + func test_updateLiveLocation_apiFailure_propagatesError() throws { + let currentUserId: UserId = .unique + let deviceId = "device-123" + let messageId: MessageId = .unique + let cid: ChannelId = .unique + try database.createCurrentUser(id: currentUserId, currentDeviceId: deviceId) + try database.createChannel(cid: cid) + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: 2, + longitude: 3, + endAt: Date().addingTimeInterval(1000) + ) + ) + + let testError = TestError() + apiClient.test_mockResponseResult(Result.failure(testError)) + + let result = try waitFor { + messageUpdater.updateLiveLocation( + messageId: messageId, + locationInfo: .init(latitude: 1, longitude: 2), + completion: $0 + ) + } + XCTAssertEqual(result.error as? TestError, testError) + } + + func test_stopLiveLocationSharing_success() throws { + let currentUserId: UserId = .unique + let deviceId = "device-123" + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let latitude = 10.0 + let longitude = 20.0 + let endAt = Date().addingTimeInterval(1000) + try database.createCurrentUser(id: currentUserId, currentDeviceId: deviceId) + try database.createChannel(cid: cid) + // Create a message with a live location + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: latitude, + longitude: longitude, + endAt: Date().addingTimeInterval(1000) + ) + ) + + // Prepare API response + let payload = SharedLocationPayload( + channelId: cid.rawValue, + messageId: messageId, + userId: .unique, + latitude: latitude, + longitude: longitude, + createdAt: .unique, + updatedAt: .unique, + endAt: Date(), + createdByDeviceId: deviceId + ) + apiClient.test_mockResponseResult(.success(payload)) + + let exp = expectation(description: "stopLiveLocationSharing completes") + var result: Result? + messageUpdater.stopLiveLocationSharing(messageId: messageId) { + result = $0 + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + + let sharedLocation = try result?.get() + XCTAssertEqual(sharedLocation?.latitude, latitude) + XCTAssertEqual(sharedLocation?.longitude, longitude) + XCTAssertEqual(sharedLocation?.messageId, messageId) + XCTAssertEqual(sharedLocation?.channelId, cid) + XCTAssertEqual(sharedLocation?.createdByDeviceId, deviceId) + XCTAssertEqual(sharedLocation?.isLiveSharingActive, false) + } + + func test_stopLiveLocationSharing_noCurrentUserDevice_propagatesError() throws { + let currentUserId: UserId = .unique + let messageId: MessageId = .unique + let cid: ChannelId = .unique + try database.createCurrentUser(id: currentUserId, currentDeviceId: nil) + try database.createChannel(cid: cid) + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: 2, + longitude: 3, + endAt: Date().addingTimeInterval(1000) + ) + ) + let result = try waitFor { + messageUpdater.stopLiveLocationSharing(messageId: messageId, completion: $0) + } + XCTAssertTrue(result.error is ClientError.CurrentUserDoesNotExist) + } + + func test_stopLiveLocationSharing_apiFailure_revertsOptimisticUpdate() throws { + let currentUserId: UserId = .unique + let deviceId = "device-123" + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let originalEndAt = Date.distantFuture + try database.createCurrentUser(id: currentUserId, currentDeviceId: deviceId) + try database.createChannel(cid: cid) + // Create a message with a live location + try database.createMessage( + id: messageId, + cid: cid, + location: .dummy( + latitude: 2, + longitude: 3, + endAt: originalEndAt + ) + ) + let testError = TestError() + apiClient.test_mockResponseResult(Result.failure(testError)) + let result = try waitFor { + messageUpdater.stopLiveLocationSharing(messageId: messageId, completion: $0) + } + XCTAssertEqual(result.error as? TestError, testError) + // The optimistic update should be reverted + let message = try XCTUnwrap(database.viewContext.message(id: messageId)) + let newEndAt = message.location?.endAt?.bridgeDate + AssertAsync.willBeEqual(newEndAt?.timeIntervalSince1970, originalEndAt.timeIntervalSince1970) + } } // MARK: - Helpers From 9be4edda24676305f02f88df1187def568845dcc Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 27 Jun 2025 15:52:37 +0100 Subject: [PATCH 06/10] Improve the UX of the Location Attachments in the Demo App (#3704) * Throw error when creating a message without device registered * Add a location selection view controller to the demo app * Show the current user location in the detail location view if the location sharing is not from the current user * Fix typo in location attachment snapshot view * Move Location Provider to the location folder * Remove unecessary dispatch * Remove demo app location config * Simplify location snapshot view content --- .../AppConfigViewController.swift | 8 - .../DemoAttachmentViewCatalog.swift | 7 +- .../CustomAttachments/DemoComposerVC.swift | 100 +----- .../LocationAttachmentSnapshotView.swift | 60 ++-- .../LocationAttachmentViewInjector.swift | 9 +- .../LocationDetailViewController.swift | 8 +- .../LocationProvider.swift | 0 .../LocationSelectionViewController.swift | 328 ++++++++++++++++++ .../StreamChat/Database/DTOs/MessageDTO.swift | 14 +- StreamChat.xcodeproj/project.pbxproj | 6 +- 10 files changed, 395 insertions(+), 145 deletions(-) rename DemoApp/{ => StreamChat/Components/CustomAttachments/LocationAttachment}/LocationProvider.swift (100%) create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSelectionViewController.swift diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 3ac21aa7cbd..8378450f827 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -14,8 +14,6 @@ struct DemoAppConfig { var isAtlantisEnabled: Bool /// A Boolean value to define if an additional message debugger action will be added. var isMessageDebuggerEnabled: Bool - /// A Boolean value to define if custom location attachments are enabled. - var isLocationAttachmentsEnabled: Bool /// Set this value to define if we should mimic token refresh scenarios. var tokenRefreshDetails: TokenRefreshDetails? /// A Boolean value that determines if a connection banner UI should be shown. @@ -51,7 +49,6 @@ class AppConfig { isHardDeleteEnabled: false, isAtlantisEnabled: false, isMessageDebuggerEnabled: false, - isLocationAttachmentsEnabled: true, tokenRefreshDetails: nil, shouldShowConnectionBanner: false, isPremiumMemberFeatureEnabled: false, @@ -175,7 +172,6 @@ class AppConfigViewController: UITableViewController { case isHardDeleteEnabled case isAtlantisEnabled case isMessageDebuggerEnabled - case isLocationAttachmentsEnabled case tokenRefreshDetails case shouldShowConnectionBanner case isPremiumMemberFeatureEnabled @@ -326,10 +322,6 @@ class AppConfigViewController: UITableViewController { cell.accessoryView = makeSwitchButton(demoAppConfig.isMessageDebuggerEnabled) { [weak self] newValue in self?.demoAppConfig.isMessageDebuggerEnabled = newValue } - case .isLocationAttachmentsEnabled: - cell.accessoryView = makeSwitchButton(demoAppConfig.isLocationAttachmentsEnabled) { [weak self] newValue in - self?.demoAppConfig.isLocationAttachmentsEnabled = newValue - } case .tokenRefreshDetails: if let tokenRefreshDuration = demoAppConfig.tokenRefreshDetails?.expirationDuration { cell.detailTextLabel?.text = "Duration before expired: \(tokenRefreshDuration)s" diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift index dfcad9e3a8c..009bb316ce8 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift @@ -7,15 +7,10 @@ import StreamChatUI class DemoAttachmentViewCatalog: AttachmentViewCatalog { override class func attachmentViewInjectorClassFor(message: ChatMessage, components: Components) -> AttachmentViewInjector.Type? { - let hasMultipleAttachmentTypes = message.attachmentCounts.keys.count > 1 let hasLocationAttachment = message.sharedLocation != nil - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && hasLocationAttachment { - if hasMultipleAttachmentTypes { - return MixedAttachmentViewInjector.self - } + if hasLocationAttachment { return LocationAttachmentViewInjector.self } - return super.attachmentViewInjectorClassFor(message: message, components: components) } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 8e7f85c2d9f..512d9caa4a2 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -8,111 +8,31 @@ import StreamChatUI import UIKit class DemoComposerVC: ComposerVC { - private var locationProvider = LocationProvider.shared - override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions - let isDemoAppLocationsEnabled = AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled let isLocationEnabled = channelController?.channel?.config.sharedLocationsEnabled == true - if isLocationEnabled && isDemoAppLocationsEnabled && content.isInsideThread == false { - let sendLocationAction = UIAlertAction( - title: "Send Current Location", + if isLocationEnabled && content.isInsideThread == false { + let locationAction = UIAlertAction( + title: "Location", style: .default, handler: { [weak self] _ in - self?.sendInstantStaticLocation() + self?.presentLocationSelection() } ) - actions.append(sendLocationAction) - - let sendLiveLocationAction = UIAlertAction( - title: "Share Live Location", - style: .default, - handler: { [weak self] _ in - self?.sendInstantLiveLocation() - } - ) - actions.append(sendLiveLocationAction) + actions.append(locationAction) } return actions } - func sendInstantStaticLocation() { - getCurrentLocationInfo { [weak self] location in - guard let location = location else { return } - self?.channelController?.sendStaticLocation(location) - } - } - - func sendInstantLiveLocation() { - getCurrentLocationInfo { [weak self] location in - guard let location = location else { return } - let alertController = UIAlertController( - title: "Share Live Location", - message: "Select the duration for sharing your live location.", - preferredStyle: .actionSheet - ) - let durations: [(String, TimeInterval)] = [ - ("1 minute", 61), - ("10 minutes", 600), - ("1 hour", 3600) - ] - for (title, duration) in durations { - let action = UIAlertAction(title: title, style: .default) { [weak self] _ in - let endDate = Date().addingTimeInterval(duration) - self?.channelController?.startLiveLocationSharing(location, endDate: endDate) { [weak self] result in - switch result { - case .success: - break - case .failure(let error): - self?.presentAlert( - title: "Could not start live location sharing", - message: error.localizedDescription - ) - } - } - } - alertController.addAction(action) - } - alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - self?.present(alertController, animated: true) - } - } - - private func getCurrentLocationInfo(completion: @escaping (LocationInfo?) -> Void) { - locationProvider.getCurrentLocation { [weak self] result in - switch result { - case .success(let location): - let location = LocationInfo( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - completion(location) - case .failure(let error): - if error is LocationPermissionError { - self?.showLocationPermissionAlert() - } - completion(nil) - } - } - } - - private func showLocationPermissionAlert() { - let alert = UIAlertController( - title: "Location Access Required", - message: "Please enable location access in Settings to share your location.", - preferredStyle: .alert - ) + func presentLocationSelection() { + guard let channelController = channelController else { return } - alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + let locationSelectionVC = LocationSelectionViewController(channelController: channelController) + let navigationController = UINavigationController(rootViewController: locationSelectionVC) - present(alert, animated: true) + present(navigationController, animated: true) } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 11613303a50..e37288caa9a 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -9,28 +9,35 @@ import UIKit class LocationAttachmentSnapshotView: _View, ThemeProvider { struct Content { - var coordinate: CLLocationCoordinate2D - var isLive: Bool - var isSharingLiveLocation: Bool - var message: ChatMessage? - var author: ChatUser? + var message: ChatMessage + var location: SharedLocation init( - coordinate: CLLocationCoordinate2D, - isLive: Bool, - isSharingLiveLocation: Bool, - message: ChatMessage?, - author: ChatUser? + message: ChatMessage, + location: SharedLocation ) { - self.coordinate = coordinate - self.isLive = isLive - self.isSharingLiveLocation = isSharingLiveLocation self.message = message - self.author = author + self.location = location } var isFromCurrentUser: Bool { - author?.id == StreamChatWrapper.shared.client?.currentUserId + message.isSentByCurrentUser + } + + var isLive: Bool { + location.isLive + } + + var isSharingLiveLocation: Bool { + location.isLiveSharingActive + } + + var author: ChatUser { + message.author + } + + var coordinate: CLLocationCoordinate2D { + .init(latitude: location.latitude, longitude: location.longitude) } } @@ -104,7 +111,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { let tapGestureRecognizer = UITapGestureRecognizer( target: self, - action: #selector(handleTapOnWorkoutAttachment) + action: #selector(handleTapOnLocationAttachment) ) imageView.addGestureRecognizer(tapGestureRecognizer) } @@ -146,7 +153,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { ]) } - @objc func handleTapOnWorkoutAttachment() { + @objc func handleTapOnLocationAttachment() { didTapOnLocation?() } @@ -159,21 +166,17 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { avatarView.isHidden = true - if content.message?.isLocalOnly == true { + if content.message.isLocalOnly { stopButton.isHidden = true sharingStatusView.isHidden = true } else if content.isSharingLiveLocation && content.isFromCurrentUser { stopButton.isHidden = false sharingStatusView.isHidden = true - if let location = content.message?.sharedLocation { - sharingStatusView.updateStatus(location: location) - } + sharingStatusView.updateStatus(location: content.location) } else if content.isLive { stopButton.isHidden = true sharingStatusView.isHidden = false - if let location = content.message?.sharedLocation { - sharingStatusView.updateStatus(location: location) - } + sharingStatusView.updateStatus(location: content.location) } else { stopButton.isHidden = true sharingStatusView.isHidden = true @@ -264,9 +267,9 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { private func updateAnnotationView() { guard let content = self.content else { return } - if content.isLive, let user = content.author { + if content.isLive { avatarView.isHidden = false - avatarView.content = user + avatarView.content = content.author } else { avatarView.isHidden = true } @@ -298,9 +301,6 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { guard let content = self.content else { return nil } - guard let messageId = content.message?.id else { - return nil - } - return NSString(string: "\(messageId)") + return NSString(string: "\(content.message.id)") } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index df001362f9c..3e841a77d2d 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -36,13 +36,10 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { override func contentViewDidUpdateContent() { super.contentViewDidUpdateContent() - if let location = contentView.content?.sharedLocation { + if let message = contentView.content, let location = message.sharedLocation { locationAttachmentView.content = .init( - coordinate: .init(latitude: location.latitude, longitude: location.longitude), - isLive: location.isLive, - isSharingLiveLocation: location.isLiveSharingActive, - message: contentView.content, - author: contentView.content?.author + message: message, + location: location ) } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index c58b3d5a5e0..a00b446d54d 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -51,6 +51,10 @@ class LocationDetailViewController: UIViewController, ThemeProvider { messageController.message?.sharedLocation?.isLive == true } + var isFromCurrentUser: Bool { + messageController.message?.isSentByCurrentUser == true + } + private lazy var locationControlBanner: LocationControlBannerView = { let banner = LocationControlBannerView() banner.translatesAutoresizingMaskIntoConstraints = false @@ -75,7 +79,7 @@ class LocationDetailViewController: UIViewController, ThemeProvider { UserAnnotationView.self, forAnnotationViewWithReuseIdentifier: UserAnnotationView.reuseIdentifier ) - mapView.showsUserLocation = false + mapView.showsUserLocation = !isFromCurrentUser mapView.delegate = self view.backgroundColor = appearance.colorPalette.background @@ -286,7 +290,7 @@ class LocationControlBannerView: UIView, ThemeProvider { }() private func setupView() { - backgroundColor = appearance.colorPalette.background6 + backgroundColor = appearance.colorPalette.background layer.cornerRadius = 16 layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] diff --git a/DemoApp/LocationProvider.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationProvider.swift similarity index 100% rename from DemoApp/LocationProvider.swift rename to DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationProvider.swift diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSelectionViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSelectionViewController.swift new file mode 100644 index 00000000000..df1cf2773b6 --- /dev/null +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSelectionViewController.swift @@ -0,0 +1,328 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreLocation +import MapKit +import StreamChat +import StreamChatUI +import UIKit + +class LocationSelectionViewController: UIViewController, ThemeProvider { + private let channelController: ChatChannelController + private let locationProvider = LocationProvider.shared + private var currentLocation: CLLocation? + + init(channelController: ChatChannelController) { + self.channelController = channelController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private lazy var mapView: MKMapView = { + let view = MKMapView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isZoomEnabled = true + view.showsCompass = false + view.showsUserLocation = true + return view + }() + + private lazy var staticLocationButton: LocationOptionButton = { + let button = LocationOptionButton() + button.configure( + icon: UIImage(systemName: "mappin.circle"), + title: "Send Current Location", + subtitle: "Share your current location once" + ) + button.addTarget(self, action: #selector(sendStaticLocationTapped), for: .touchUpInside) + button.isEnabled = false + return button + }() + + private lazy var liveLocationButton: LocationOptionButton = { + let button = LocationOptionButton() + button.configure( + icon: UIImage(systemName: "location.circle"), + title: "Share Live Location", + subtitle: "Share your location in real-time" + ) + button.addTarget(self, action: #selector(shareLiveLocationTapped), for: .touchUpInside) + button.isEnabled = false + button.layer.borderColor = appearance.colorPalette.accentPrimary.cgColor + return button + }() + + private lazy var bottomContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = appearance.colorPalette.background + view.layer.cornerRadius = 16 + view.clipsToBounds = true + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOffset = CGSize(width: 0, height: -2) + view.layer.shadowOpacity = 0.1 + view.layer.shadowRadius = 4 + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + setupConstraints() + getCurrentLocation() + + title = "Share Location" + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelTapped) + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if currentLocation == nil { + getCurrentLocation() + } + } + + private func setupView() { + view.backgroundColor = appearance.colorPalette.background + + view.addSubview(mapView) + view.addSubview(bottomContainer) + + VContainer(spacing: 8) { + liveLocationButton + staticLocationButton + } + .padding(top: 20, leading: 16, bottom: 20, trailing: 16) + .embed(in: bottomContainer) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mapView.bottomAnchor.constraint(equalTo: bottomContainer.topAnchor), + bottomContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bottomContainer.heightAnchor.constraint(equalToConstant: 220) + ]) + } + + private func getCurrentLocation() { + locationProvider.getCurrentLocation { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let location): + self?.handleLocationReceived(location) + case .failure: + self?.showLocationPermissionAlert() + } + } + } + } + + private func handleLocationReceived(_ location: CLLocation) { + currentLocation = location + + let coordinate = location.coordinate + let region = MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + mapView.setRegion(region, animated: true) + mapView.userTrackingMode = .follow + + staticLocationButton.isEnabled = true + liveLocationButton.isEnabled = true + } + + private func showLocationPermissionAlert() { + let alert = UIAlertController( + title: "Location Access Required", + message: "Please enable location access in Settings to share your location.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + present(alert, animated: true) + } + + @objc private func cancelTapped() { + dismiss(animated: true) + } + + @objc private func sendStaticLocationTapped() { + guard let location = currentLocation else { return } + + let locationInfo = LocationInfo( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + + channelController.sendStaticLocation(locationInfo) + dismiss(animated: true) + } + + @objc private func shareLiveLocationTapped() { + guard let location = currentLocation else { return } + + let alertController = UIAlertController( + title: "Share Live Location", + message: "Select the duration for sharing your live location.", + preferredStyle: .actionSheet + ) + + let durations: [(String, TimeInterval)] = [ + ("1 minute", 61), + ("10 minutes", 600), + ("1 hour", 3600) + ] + + for (title, duration) in durations { + let action = UIAlertAction(title: title, style: .default) { [weak self] _ in + guard let self = self, let location = self.currentLocation else { return } + + let locationInfo = LocationInfo( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + let endDate = Date().addingTimeInterval(duration) + + self.channelController.startLiveLocationSharing(locationInfo, endDate: endDate) { [weak self] result in + switch result { + case .success: + self?.dismiss(animated: true) + case .failure(let error): + self?.presentAlert( + title: "Could not start live location sharing", + message: error.localizedDescription + ) + } + } + } + alertController.addAction(action) + } + + alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alertController, animated: true) + } + + private func presentAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + +class LocationOptionButton: UIButton, ThemeProvider { + private let iconImageView = UIImageView() + private let textLabel = UILabel() + private let descriptionLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupButton() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupButton() + } + + private func setupButton() { + backgroundColor = appearance.colorPalette.background + layer.cornerRadius = 12 + layer.borderWidth = 1 + layer.borderColor = appearance.colorPalette.border.cgColor + translatesAutoresizingMaskIntoConstraints = false + + iconImageView.contentMode = .scaleAspectFit + iconImageView.tintColor = appearance.colorPalette.accentPrimary + + textLabel.font = appearance.fonts.bodyBold + textLabel.textColor = appearance.colorPalette.text + textLabel.numberOfLines = 1 + + descriptionLabel.font = appearance.fonts.footnote + descriptionLabel.textColor = appearance.colorPalette.subtitleText + descriptionLabel.numberOfLines = 1 + + let container = HContainer(spacing: 16, alignment: .center) { + iconImageView + .width(24) + .height(24) + VContainer(spacing: 2, alignment: .leading) { + textLabel + descriptionLabel + } + } + .padding(top: 12, leading: 16, bottom: 12, trailing: 16) + .height(68) + .embed(in: self) + + container.isUserInteractionEnabled = false + } + + func configure(icon: UIImage?, title: String, subtitle: String) { + iconImageView.image = icon + textLabel.text = title + descriptionLabel.text = subtitle + } + + override var isEnabled: Bool { + didSet { + alpha = isEnabled ? 1.0 : 0.5 + } + } + + override var isHighlighted: Bool { + didSet { + updateBackgroundColor() + } + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + updateBackgroundColor() + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + updateBackgroundColor() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + updateBackgroundColor() + } + + private func updateBackgroundColor() { + UIView.animate(withDuration: 0.1) { + if self.isHighlighted { + self.backgroundColor = self.appearance.colorPalette.background6 + } else { + self.backgroundColor = self.appearance.colorPalette.background + } + } + } +} diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 82cc29e39af..f049a846fbf 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -799,12 +799,15 @@ extension NSManagedObjectContext: MessageDatabaseSession { message.poll = try? savePoll(payload: poll, cache: nil) } - if let location, let currentUser, let deviceId = currentUser.currentDevice?.id { + if let location { + guard let deviceId = currentUserDTO.currentDevice?.id else { + throw ClientError.CurrentUserDoesNotHaveDeviceRegistered() + } message.location = try? saveLocation( payload: .init( channelId: cid.rawValue, messageId: id, - userId: currentUser.user.id, + userId: currentUserDTO.user.id, latitude: location.latitude, longitude: location.longitude, createdAt: Date(), @@ -1918,6 +1921,13 @@ extension ClientError { } } + final class CurrentUserDoesNotHaveDeviceRegistered: ClientError { + override var localizedDescription: String { + "There is no `DeviceDTO` instance in the DB." + + "Make sure to call `client.currentUserController.addDevice()`" + } + } + final class MessagePayloadSavingFailure: ClientError {} final class ChannelDoesNotExist: ClientError { diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index e3552fab177..7d40874ef01 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1784,6 +1784,7 @@ ADCBBFD526D66A560023FCB2 /* iMessageChatMessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCBBFD426D66A560023FCB2 /* iMessageChatMessageListViewController.swift */; }; ADCBBFD726D66ADC0023FCB2 /* SlackChatMessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCBBFD626D66ADC0023FCB2 /* SlackChatMessageListViewController.swift */; }; ADCC179F2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */; }; + ADCC17A72E0C70BC00510415 /* LocationSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCC17A62E0C70BC00510415 /* LocationSelectionViewController.swift */; }; ADCD5E4327987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */; }; ADCD5E4427987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */; }; ADCE32F72A055A9200B52559 /* MessagesPaginationStateHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCE32F62A055A9200B52559 /* MessagesPaginationStateHandler_Mock.swift */; }; @@ -4520,6 +4521,7 @@ ADCBBFD426D66A560023FCB2 /* iMessageChatMessageListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iMessageChatMessageListViewController.swift; sourceTree = ""; }; ADCBBFD626D66ADC0023FCB2 /* SlackChatMessageListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackChatMessageListViewController.swift; sourceTree = ""; }; ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLiveLocationsEndTimeTracker_Tests.swift; sourceTree = ""; }; + ADCC17A62E0C70BC00510415 /* LocationSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSelectionViewController.swift; sourceTree = ""; }; ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamModalTransitioningDelegate.swift; sourceTree = ""; }; ADCDDCC425AE1293004E15FB /* UserUpdateResponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = UserUpdateResponse.json; sourceTree = ""; }; ADCE32F62A055A9200B52559 /* MessagesPaginationStateHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationStateHandler_Mock.swift; sourceTree = ""; }; @@ -5791,7 +5793,6 @@ A3227E7D284A511200EBE6CC /* DemoAppConfiguration.swift */, AD7110C32B3434F700AFFE28 /* StreamRuntimeCheck+StreamInternal.swift */, 792DDA5B256FB69E001DB91B /* SceneDelegate.swift */, - ADFCA5B62D1232A7000F515F /* LocationProvider.swift */, 8440861528FFE85F0027849C /* Shared */, A3227E56284A47F700EBE6CC /* StreamChat */, A3227ECA284A607D00EBE6CC /* Screens */, @@ -8544,6 +8545,8 @@ AD053B9B2B33589C003612B6 /* LocationAttachment */ = { isa = PBXGroup; children = ( + ADFCA5B62D1232A7000F515F /* LocationProvider.swift */, + ADCC17A62E0C70BC00510415 /* LocationSelectionViewController.swift */, AD053B9E2B335929003612B6 /* LocationAttachmentViewInjector.swift */, AD053BA62B33624C003612B6 /* LocationAttachmentViewDelegate.swift */, AD053BAA2B33638B003612B6 /* LocationAttachmentSnapshotView.swift */, @@ -11253,6 +11256,7 @@ C1CEF9072A1BC4E800414931 /* UserProfileViewController.swift in Sources */, A3227E62284A499500EBE6CC /* SearchUserCell.swift in Sources */, A3227E76284A4C6400EBE6CC /* MessageReactionType+Position.swift in Sources */, + ADCC17A72E0C70BC00510415 /* LocationSelectionViewController.swift in Sources */, A3227E74284A4C3300EBE6CC /* DemoChatMessageActionsVC.swift in Sources */, AD053BA72B33624C003612B6 /* LocationAttachmentViewDelegate.swift in Sources */, 43016E1626B734410054E805 /* ChatUser+CustomFields.swift in Sources */, From 09db730ae7e188c67434c559d73423750842d408 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 1 Jul 2025 13:50:31 +0100 Subject: [PATCH 07/10] Fix message actions view with flag action when user has no capability (#3705) * Fix message actions view with flag action when user has no capability * Update CHANGELOG.md * Update CHANGELOG.md * Fix UI Tests * Fix popup tests --- CHANGELOG.md | 3 +++ .../ChatMessageActionsVC.swift | 3 ++- .../ChatMessageActionsVC_Tests.swift | 21 +++++++++++++++---- .../ChatMessagePopupVC_Tests.swift | 4 ++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93868f1ea97..4afa9ee3210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `didChangeActiveLiveLocationMessages()` - `didFailToUpdateLiveLocation()` +## StreamChatUI +### 🐞 Fixed +- Fix message actions view with flag action when user has no capability [#3705](https://github.com/GetStream/stream-chat-swift/pull/3705) # [4.80.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.80.0) _June 17, 2025_ diff --git a/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift b/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift index 771015fab91..1c6a4c5e173 100644 --- a/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift +++ b/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift @@ -114,6 +114,7 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { let canUpdateOwnMessage = channel?.canUpdateOwnMessage ?? true let canDeleteAnyMessage = channel?.canDeleteAnyMessage ?? false let canDeleteOwnMessage = channel?.canDeleteOwnMessage ?? true + let canFlagMessage = channel?.canFlagMessage ?? false let isSentByCurrentUser = message.isSentByCurrentUser if canQuoteMessage { @@ -152,7 +153,7 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { actions.append(deleteActionItem()) } - if !isSentByCurrentUser { + if !isSentByCurrentUser && canFlagMessage { actions.append(flagActionItem()) } diff --git a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift index 892a25ee412..cee82662286 100644 --- a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift @@ -18,7 +18,7 @@ final class ChatMessageActionsVC_Tests: XCTestCase { chatMessageController = .mock() vc = ChatMessageActionsVC() vc.messageController = chatMessageController - vc.channel = .mock(cid: .unique, config: .mock(), ownCapabilities: [.sendReply, .quoteMessage, .readEvents]) + vc.channel = .mock(cid: .unique, config: .mock(), ownCapabilities: [.sendReply, .quoteMessage, .readEvents, .flagMessage]) chatMessageController.simulateInitial( message: ChatMessage.mock(id: .unique, cid: .unique, text: "test", author: ChatUser.mock(id: .unique)), @@ -62,7 +62,7 @@ final class ChatMessageActionsVC_Tests: XCTestCase { let vc = TestView() vc.messageController = chatMessageController - vc.channel = .mock(cid: .unique, config: .mock(), ownCapabilities: [.sendReply, .quoteMessage, .readEvents]) + vc.channel = .mock(cid: .unique, config: .mock(), ownCapabilities: [.sendReply, .quoteMessage, .readEvents, .flagMessage]) AssertSnapshot(vc.embedded()) } @@ -336,11 +336,23 @@ final class ChatMessageActionsVC_Tests: XCTestCase { state: .remoteDataFetched ) - vc.channel = .mock(cid: .unique, ownCapabilities: []) + vc.channel = .mock(cid: .unique, ownCapabilities: [.flagMessage]) XCTAssertTrue(vc.messageActions.contains(where: { $0 is FlagActionItem })) } + func test_messageActions_whenMessageIsSentByAnotherUser_whenNoCapability_doesNotContainFlagAction() { + chatMessageController.simulateInitial( + message: ChatMessage.mock(isSentByCurrentUser: false), + replies: [], + state: .remoteDataFetched + ) + + vc.channel = .mock(cid: .unique, ownCapabilities: []) + + XCTAssertFalse(vc.messageActions.contains(where: { $0 is FlagActionItem })) + } + func test_messageActions_whenSendingFailed_thenContainsResendActionEditActionDeleteAction() { chatMessageController.simulateInitial( message: .mock(localState: .sendingFailed, isSentByCurrentUser: false), @@ -419,7 +431,8 @@ final class ChatMessageActionsVC_Tests: XCTestCase { .sendReply, .readEvents, .updateAnyMessage, - .deleteAnyMessage + .deleteAnyMessage, + .flagMessage ] ) diff --git a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift index 55265afabb5..f2172420baa 100644 --- a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift @@ -72,6 +72,10 @@ final class ChatMessagePopupVC_Tests: XCTestCase { vc.reactionsController = reactionsController actionsController = ChatMessageActionsVC() actionsController.messageController = chatMessageController + actionsController.channel = .mock( + cid: .unique, + ownCapabilities: [.flagMessage, .sendReply, .quoteMessage, .readEvents] + ) actionsController.channelConfig = .mock() vc.actionsController = actionsController } From 1c9c7def536f34ebdae417b5bfed77745df9a971 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Jul 2025 12:14:59 +0100 Subject: [PATCH 08/10] Fix reaction picker in `ChatMessagePopupVC` below notch in rare scenarios (#3707) * Fix reaction picker in message actions pop up view below notch in rare scenarios * Update CHANGELOG.md --- CHANGELOG.md | 1 + .../StreamChatUI/MessageActionsPopup/ChatMessagePopupVC.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4afa9ee3210..cc643fe31e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChatUI ### 🐞 Fixed - Fix message actions view with flag action when user has no capability [#3705](https://github.com/GetStream/stream-chat-swift/pull/3705) +- Fix reaction picker in `ChatMessagePopupVC` below the notch in rare scenarios [#3707](https://github.com/GetStream/stream-chat-swift/pull/3707) # [4.80.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.80.0) _June 17, 2025_ diff --git a/Sources/StreamChatUI/MessageActionsPopup/ChatMessagePopupVC.swift b/Sources/StreamChatUI/MessageActionsPopup/ChatMessagePopupVC.swift index 3fc6b53691d..84b5b5ce440 100644 --- a/Sources/StreamChatUI/MessageActionsPopup/ChatMessagePopupVC.swift +++ b/Sources/StreamChatUI/MessageActionsPopup/ChatMessagePopupVC.swift @@ -129,6 +129,7 @@ open class ChatMessagePopupVC: _ViewController, ComponentsProvider { /// Add the scroll view to the view hierarchy. open func addScrollView() { view.addSubview(scrollView) + scrollView.contentInsetAdjustmentBehavior = .always NSLayoutConstraint.activate([ scrollView.widthAnchor.pin(equalTo: view.widthAnchor), From 81c3ed100eecf97c268bcad136eb2b374ca87566 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 3 Jul 2025 12:02:04 +0100 Subject: [PATCH 09/10] Add new Push v3 notification types (#3708) * Add new Push v3 notification types * Update CHANGELOG.md * Make it more flexible * Remove isKnown logic --- CHANGELOG.md | 1 + DemoAppPush/NotificationService.swift | 2 +- .../ChatRemoteNotificationHandler.swift | 39 +++++++-------- .../ChatRemoteNotificationHandler_Tests.swift | 48 +++++++++++++++++-- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc643fe31e9..8b2292e909e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ✅ Added +- Add support for Push v3 notification types [#3708](https://github.com/GetStream/stream-chat-swift/pull/3708) - Add `ChatMessageController.partialUpdateMessage()` [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) - Add Location Sharing Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) - Add `ChatMessage.sharedLocation` diff --git a/DemoAppPush/NotificationService.swift b/DemoAppPush/NotificationService.swift index 0b0b0488236..c1b69b1e415 100644 --- a/DemoAppPush/NotificationService.swift +++ b/DemoAppPush/NotificationService.swift @@ -96,7 +96,7 @@ class NotificationService: UNNotificationServiceExtension { let chatNotification = chatHandler.handleNotification { chatContent in switch chatContent { case let .message(messageNotification): - if messageNotification.type == .reminderDue { + if messageNotification.type == .messageReminderDue || messageNotification.type == .reactionNew { return contentHandler(content) } diff --git a/Sources/StreamChat/APIClient/ChatRemoteNotificationHandler.swift b/Sources/StreamChat/APIClient/ChatRemoteNotificationHandler.swift index d3f0abd57a1..4fd2a319934 100644 --- a/Sources/StreamChat/APIClient/ChatRemoteNotificationHandler.swift +++ b/Sources/StreamChat/APIClient/ChatRemoteNotificationHandler.swift @@ -22,24 +22,22 @@ public class MessageNotificationContent { } } +/// The type of push notifications supported by the Stream Chat SDK. public struct PushNotificationType: Equatable { public var name: String - init(name: String) { - self.name = name + init(eventType: EventType) { + name = eventType.rawValue } - init?(eventType: EventType) { - switch eventType { - case .messageNew, .messageReminderDue: - self.init(name: eventType.rawValue) - default: - return nil - } - } - - public static var newMessage: PushNotificationType = .init(name: EventType.messageNew.rawValue) - public static var reminderDue: PushNotificationType = .init(name: EventType.messageReminderDue.rawValue) + /// When the push notification is for a new message. + public static var messageNew: PushNotificationType = .init(eventType: .messageNew) + /// When the push notification is for a message reminder that is overdue. + public static var messageReminderDue: PushNotificationType = .init(eventType: .messageReminderDue) + /// When the push notification is for a message that has been updated. + public static var messageUpdated: PushNotificationType = .init(eventType: .messageUpdated) + /// When the push notification is for a new reaction. + public static var reactionNew: PushNotificationType = .init(eventType: .reactionNew) } public class UnknownNotificationContent { @@ -120,24 +118,23 @@ public class ChatRemoteNotificationHandler { return completion(.unknown(UnknownNotificationContent(content: content))) } - guard let type = dict["type"] else { - return completion(.unknown(UnknownNotificationContent(content: content))) - } - - guard let pushType = PushNotificationType(eventType: EventType(rawValue: type)) else { - return completion(.unknown(UnknownNotificationContent(content: content))) - } - guard let cid = dict["cid"], let id = dict["id"], let channelId = try? ChannelId(cid: cid) else { completion(.unknown(UnknownNotificationContent(content: content))) return } + guard let type = dict["type"] else { + return completion(.unknown(UnknownNotificationContent(content: content))) + } + + let pushType = PushNotificationType(eventType: EventType(rawValue: type)) + getContent(cid: channelId, messageId: id) { message, channel in guard let message = message else { completion(.unknown(UnknownNotificationContent(content: self.content))) return } + let pushType = PushNotificationType(eventType: EventType(rawValue: type)) let content = MessageNotificationContent( message: message, channel: channel, diff --git a/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift b/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift index 75883ec4fc4..bca7b6d1703 100644 --- a/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift +++ b/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift @@ -246,13 +246,55 @@ final class ChatRemoteNotificationHandler_Tests: XCTestCase { XCTAssertEqual(false, channelRepository.getChannel_store) XCTAssertEqual(false, messageRepository.getMessage_store) } - + + func test_handleNotification_supportedPushNotificationTypes() throws { + let cid = ChannelId.unique + let expectation = XCTestExpectation() + let expectedChannel = ChatChannel.mock(cid: cid) + let expectedMessage = ChatMessage.mock() + channelRepository.getChannel_result = .success(expectedChannel) + messageRepository.getMessageResult = .success(expectedMessage) + + let notificationTypes: [String: PushNotificationType] = [ + "message.new": .messageNew, + "reaction.new": .reactionNew, + "notification.reminder_due": .messageReminderDue, + "message.updated": .messageUpdated + ] + + var assertions: [Bool] = [] + expectation.expectedFulfillmentCount = notificationTypes.count + + for notificationType in notificationTypes { + let content = createNotificationContent( + cid: expectedChannel.cid, + messageId: expectedMessage.id, + type: notificationType.key + ) + let handler = ChatRemoteNotificationHandler(client: clientWithOffline, content: content) + let canHandle = handler.handleNotification { pushNotificationContent in + switch pushNotificationContent { + case .message(let messageNotificationContent): + assertions.append(messageNotificationContent.type == notificationType.value) + case .unknown(let unknownNotificationContent): + XCTFail(unknownNotificationContent.content.debugDescription) + } + expectation.fulfill() + } + + XCTAssertEqual(true, canHandle) + } + + wait(for: [expectation], timeout: defaultTimeout) + XCTAssertEqual(assertions, Array(repeatElement(true, count: notificationTypes.count))) + } + // MARK: - - func createNotificationContent(cid: ChannelId, messageId: MessageId) -> UNNotificationContent { + func createNotificationContent(cid: ChannelId, messageId: MessageId, type: String = "message.new") -> UNNotificationContent { let content = UNMutableNotificationContent() let payload: [String: String] = [ - "type": "message.new", + "type": type, "cid": cid.rawValue, "id": messageId ] From 3ea8ab7fb8367cf53255ab0f1effcb239efd8fa1 Mon Sep 17 00:00:00 2001 From: Stream Bot Date: Thu, 3 Jul 2025 11:19:06 +0000 Subject: [PATCH 10/10] Bump 4.81.0 --- CHANGELOG.md | 5 +++++ README.md | 4 ++-- Sources/StreamChat/Generated/SystemEnvironment+Version.swift | 2 +- Sources/StreamChat/Info.plist | 2 +- Sources/StreamChatUI/Info.plist | 2 +- StreamChat-XCFramework.podspec | 2 +- StreamChat.podspec | 2 +- StreamChatArtifacts.json | 2 +- StreamChatUI-XCFramework.podspec | 2 +- StreamChatUI.podspec | 2 +- 10 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2292e909e..59fb35a3ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### 🔄 Changed + +# [4.81.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.81.0) +_July 03, 2025_ + ## StreamChat ### ✅ Added - Add support for Push v3 notification types [#3708](https://github.com/GetStream/stream-chat-swift/pull/3708) diff --git a/README.md b/README.md index 6bdf9706d68..02fdeae4ae5 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@

- StreamChat - StreamChatUI + StreamChat + StreamChatUI

This is the official iOS SDK for [Stream Chat](https://getstream.io/chat/sdk/ios/), a service for building chat and messaging applications. This library includes both a low-level SDK and a set of reusable UI components. diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift index 7d778cbe4a3..9439ad26bb7 100644 --- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift +++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift @@ -7,5 +7,5 @@ import Foundation extension SystemEnvironment { /// A Stream Chat version. - public static let version: String = "4.81.0-SNAPSHOT" + public static let version: String = "4.81.0" } diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist index 5681ded7134..bfb3d927763 100644 --- a/Sources/StreamChat/Info.plist +++ b/Sources/StreamChat/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.80.0 + 4.81.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist index 5681ded7134..bfb3d927763 100644 --- a/Sources/StreamChatUI/Info.plist +++ b/Sources/StreamChatUI/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.80.0 + 4.81.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/StreamChat-XCFramework.podspec b/StreamChat-XCFramework.podspec index 86521e3b526..d9b6da57654 100644 --- a/StreamChat-XCFramework.podspec +++ b/StreamChat-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChat-XCFramework" - spec.version = "4.80.0" + spec.version = "4.81.0" spec.summary = "StreamChat iOS Client" spec.description = "stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications." diff --git a/StreamChat.podspec b/StreamChat.podspec index b496781b456..13107d16631 100644 --- a/StreamChat.podspec +++ b/StreamChat.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChat" - spec.version = "4.80.0" + spec.version = "4.81.0" spec.summary = "StreamChat iOS Chat Client" spec.description = "stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications." diff --git a/StreamChatArtifacts.json b/StreamChatArtifacts.json index de534696919..3c637f03e3e 100644 --- a/StreamChatArtifacts.json +++ b/StreamChatArtifacts.json @@ -1 +1 @@ -{"4.7.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.7.0/StreamChat-All.zip","4.8.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.8.0/StreamChat-All.zip","4.9.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.9.0/StreamChat-All.zip","4.10.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.0/StreamChat-All.zip","4.10.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.1/StreamChat-All.zip","4.11.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.11.0/StreamChat-All.zip","4.12.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.12.0/StreamChat-All.zip","4.13.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.0/StreamChat-All.zip","4.13.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.1/StreamChat-All.zip","4.14.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.14.0/StreamChat-All.zip","4.15.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.0/StreamChat-All.zip","4.15.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.1/StreamChat-All.zip","4.16.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.16.0/StreamChat-All.zip","4.17.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.17.0/StreamChat-All.zip","4.18.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.18.0/StreamChat-All.zip","4.19.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.19.0/StreamChat-All.zip","4.20.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.20.0/StreamChat-All.zip","4.21.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.0/StreamChat-All.zip","4.21.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.1/StreamChat-All.zip","4.21.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.2/StreamChat-All.zip","4.22.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.22.0/StreamChat-All.zip","4.23.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.23.0/StreamChat-All.zip","4.24.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.0/StreamChat-All.zip","4.24.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.1/StreamChat-All.zip","4.25.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.0/StreamChat-All.zip","4.25.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.1/StreamChat-All.zip","4.26.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.26.0/StreamChat-All.zip","4.27.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.0/StreamChat-All.zip","4.27.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.1/StreamChat-All.zip","4.28.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.28.0/StreamChat-All.zip","4.29.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.29.0/StreamChat-All.zip","4.30.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.30.0/StreamChat-All.zip","4.31.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.31.0/StreamChat-All.zip","4.32.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.32.0/StreamChat-All.zip","4.33.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.33.0/StreamChat-All.zip","4.34.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.34.0/StreamChat-All.zip","4.35.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.0/StreamChat-All.zip","4.35.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.1/StreamChat-All.zip","4.35.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.2/StreamChat-All.zip","4.36.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.36.0/StreamChat-All.zip","4.37.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.0/StreamChat-All.zip","4.37.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.1/StreamChat-All.zip","4.38.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.38.0/StreamChat-All.zip","4.39.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.39.0/StreamChat-All.zip","4.40.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.40.0/StreamChat-All.zip","4.41.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.41.0/StreamChat-All.zip","4.42.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.42.0/StreamChat-All.zip","4.43.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.43.0/StreamChat-All.zip","4.44.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.44.0/StreamChat-All.zip","4.45.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.45.0/StreamChat-All.zip","4.46.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.46.0/StreamChat-All.zip","4.47.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.0/StreamChat-All.zip","4.47.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.1/StreamChat-All.zip","4.48.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.0/StreamChat-All.zip","4.48.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.1/StreamChat-All.zip","4.49.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.49.0/StreamChat-All.zip","4.50.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.50.0/StreamChat-All.zip","4.51.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.51.0/StreamChat-All.zip","4.52.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.52.0/StreamChat-All.zip","4.53.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.53.0/StreamChat-All.zip","4.54.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.54.0/StreamChat-All.zip","4.55.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.55.0/StreamChat-All.zip","4.56.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.0/StreamChat-All.zip","4.56.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.1/StreamChat-All.zip","4.57.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.57.0/StreamChat-All.zip","4.58.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.58.0/StreamChat-All.zip","4.59.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.59.0/StreamChat-All.zip","4.60.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.60.0/StreamChat-All.zip","4.61.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.61.0/StreamChat-All.zip","4.62.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.62.0/StreamChat-All.zip","4.63.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.63.0/StreamChat-All.zip","4.64.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.64.0/StreamChat-All.zip","4.65.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.65.0/StreamChat-All.zip","4.66.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.66.0/StreamChat-All.zip","4.67.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.67.0/StreamChat-All.zip","4.68.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.68.0/StreamChat-All.zip","4.69.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.69.0/StreamChat-All.zip","4.70.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.70.0/StreamChat-All.zip","4.71.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.71.0/StreamChat-All.zip","4.72.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.72.0/StreamChat-All.zip","4.73.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.73.0/StreamChat-All.zip","4.74.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.74.0/StreamChat-All.zip","4.75.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.75.0/StreamChat-All.zip","4.76.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.76.0/StreamChat-All.zip","4.77.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.77.0/StreamChat-All.zip","4.78.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.78.0/StreamChat-All.zip","4.79.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.0/StreamChat-All.zip","4.79.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.1/StreamChat-All.zip","4.80.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.80.0/StreamChat-All.zip"} \ No newline at end of file +{"4.7.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.7.0/StreamChat-All.zip","4.8.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.8.0/StreamChat-All.zip","4.9.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.9.0/StreamChat-All.zip","4.10.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.0/StreamChat-All.zip","4.10.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.1/StreamChat-All.zip","4.11.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.11.0/StreamChat-All.zip","4.12.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.12.0/StreamChat-All.zip","4.13.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.0/StreamChat-All.zip","4.13.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.1/StreamChat-All.zip","4.14.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.14.0/StreamChat-All.zip","4.15.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.0/StreamChat-All.zip","4.15.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.1/StreamChat-All.zip","4.16.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.16.0/StreamChat-All.zip","4.17.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.17.0/StreamChat-All.zip","4.18.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.18.0/StreamChat-All.zip","4.19.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.19.0/StreamChat-All.zip","4.20.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.20.0/StreamChat-All.zip","4.21.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.0/StreamChat-All.zip","4.21.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.1/StreamChat-All.zip","4.21.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.2/StreamChat-All.zip","4.22.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.22.0/StreamChat-All.zip","4.23.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.23.0/StreamChat-All.zip","4.24.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.0/StreamChat-All.zip","4.24.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.1/StreamChat-All.zip","4.25.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.0/StreamChat-All.zip","4.25.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.1/StreamChat-All.zip","4.26.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.26.0/StreamChat-All.zip","4.27.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.0/StreamChat-All.zip","4.27.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.1/StreamChat-All.zip","4.28.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.28.0/StreamChat-All.zip","4.29.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.29.0/StreamChat-All.zip","4.30.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.30.0/StreamChat-All.zip","4.31.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.31.0/StreamChat-All.zip","4.32.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.32.0/StreamChat-All.zip","4.33.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.33.0/StreamChat-All.zip","4.34.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.34.0/StreamChat-All.zip","4.35.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.0/StreamChat-All.zip","4.35.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.1/StreamChat-All.zip","4.35.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.2/StreamChat-All.zip","4.36.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.36.0/StreamChat-All.zip","4.37.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.0/StreamChat-All.zip","4.37.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.1/StreamChat-All.zip","4.38.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.38.0/StreamChat-All.zip","4.39.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.39.0/StreamChat-All.zip","4.40.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.40.0/StreamChat-All.zip","4.41.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.41.0/StreamChat-All.zip","4.42.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.42.0/StreamChat-All.zip","4.43.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.43.0/StreamChat-All.zip","4.44.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.44.0/StreamChat-All.zip","4.45.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.45.0/StreamChat-All.zip","4.46.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.46.0/StreamChat-All.zip","4.47.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.0/StreamChat-All.zip","4.47.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.1/StreamChat-All.zip","4.48.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.0/StreamChat-All.zip","4.48.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.1/StreamChat-All.zip","4.49.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.49.0/StreamChat-All.zip","4.50.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.50.0/StreamChat-All.zip","4.51.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.51.0/StreamChat-All.zip","4.52.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.52.0/StreamChat-All.zip","4.53.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.53.0/StreamChat-All.zip","4.54.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.54.0/StreamChat-All.zip","4.55.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.55.0/StreamChat-All.zip","4.56.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.0/StreamChat-All.zip","4.56.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.1/StreamChat-All.zip","4.57.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.57.0/StreamChat-All.zip","4.58.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.58.0/StreamChat-All.zip","4.59.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.59.0/StreamChat-All.zip","4.60.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.60.0/StreamChat-All.zip","4.61.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.61.0/StreamChat-All.zip","4.62.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.62.0/StreamChat-All.zip","4.63.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.63.0/StreamChat-All.zip","4.64.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.64.0/StreamChat-All.zip","4.65.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.65.0/StreamChat-All.zip","4.66.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.66.0/StreamChat-All.zip","4.67.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.67.0/StreamChat-All.zip","4.68.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.68.0/StreamChat-All.zip","4.69.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.69.0/StreamChat-All.zip","4.70.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.70.0/StreamChat-All.zip","4.71.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.71.0/StreamChat-All.zip","4.72.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.72.0/StreamChat-All.zip","4.73.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.73.0/StreamChat-All.zip","4.74.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.74.0/StreamChat-All.zip","4.75.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.75.0/StreamChat-All.zip","4.76.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.76.0/StreamChat-All.zip","4.77.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.77.0/StreamChat-All.zip","4.78.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.78.0/StreamChat-All.zip","4.79.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.0/StreamChat-All.zip","4.79.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.1/StreamChat-All.zip","4.80.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.80.0/StreamChat-All.zip","4.81.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.81.0/StreamChat-All.zip"} \ No newline at end of file diff --git a/StreamChatUI-XCFramework.podspec b/StreamChatUI-XCFramework.podspec index bc7a2201df9..3a22f3aac21 100644 --- a/StreamChatUI-XCFramework.podspec +++ b/StreamChatUI-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChatUI-XCFramework" - spec.version = "4.80.0" + spec.version = "4.81.0" spec.summary = "StreamChat UI Components" spec.description = "StreamChatUI SDK offers flexible UI components able to display data provided by StreamChat SDK." diff --git a/StreamChatUI.podspec b/StreamChatUI.podspec index 1aeb45bc939..761a12139d9 100644 --- a/StreamChatUI.podspec +++ b/StreamChatUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChatUI" - spec.version = "4.80.0" + spec.version = "4.81.0" spec.summary = "StreamChat UI Components" spec.description = "StreamChatUI SDK offers flexible UI components able to display data provided by StreamChat SDK."