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 @@
-
-
+
+
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."