diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cbf1c9110..de6900d6b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### ✅ Added
- Add `FilterKey.disabled` and `ChatChannel.isDisabled` [#3546](https://github.com/GetStream/stream-chat-swift/pull/3546)
- Add `ImageAttachmentPayload.file` for setting `file_size` and `mime_type` for image attachments [#3548](https://github.com/GetStream/stream-chat-swift/pull/3548)
+- Add `ChatMessageController.partialUpdateMessage()` [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531)
### 🐞 Fixed
- Remove the main thread requirement from the `DataStore` [#3541](https://github.com/GetStream/stream-chat-swift/pull/3541)
- Refresh quoted message preview when the quoted message is deleted [#3553](https://github.com/GetStream/stream-chat-swift/pull/3553)
diff --git a/DemoApp/Info.plist b/DemoApp/Info.plist
index 5f0f3e06ea..3ab08bd18c 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 0000000000..c05d24d0c5
--- /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 fde449f275..b6b20cd928 100644
--- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift
+++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift
@@ -56,9 +56,8 @@ class AppConfig {
if StreamRuntimeCheck.isStreamInternalConfiguration {
demoAppConfig.isAtlantisEnabled = true
demoAppConfig.isMessageDebuggerEnabled = true
- demoAppConfig.isLocationAttachmentsEnabled = true
- demoAppConfig.isLocationAttachmentsEnabled = true
demoAppConfig.isHardDeleteEnabled = true
+ demoAppConfig.isLocationAttachmentsEnabled = true
demoAppConfig.shouldShowConnectionBanner = true
demoAppConfig.isPremiumMemberFeatureEnabled = true
StreamRuntimeCheck.assertionsEnabled = true
diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift
index cbe714c798..e234ee8fef 100644
--- a/DemoApp/Screens/DemoAppTabBarController.swift
+++ b/DemoApp/Screens/DemoAppTabBarController.swift
@@ -2,11 +2,15 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+import Combine
+@_spi(ExperimentalLocation)
import StreamChat
import StreamChatUI
import UIKit
class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate {
+ private var locationProvider = LocationProvider.shared
+
let channelListVC: UIViewController
let threadListVC: UIViewController
let currentUserController: CurrentChatUserController
@@ -61,6 +65,14 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele
threadListVC.tabBarItem.badgeColor = .red
viewControllers = [channelListVC, threadListVC]
+
+ locationProvider.didUpdateLocation = { [weak self] location in
+ let newLocation = LocationAttachmentInfo(
+ latitude: location.coordinate.latitude,
+ longitude: location.coordinate.longitude
+ )
+ self?.currentUserController.updateLiveLocation(newLocation)
+ }
}
func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) {
@@ -69,4 +81,35 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele
let totalUnreadBadge = unreadCount.channels + unreadCount.threads
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 locationAttachment = $0.liveLocationAttachments.first else {
+ return nil
+ }
+
+ return "(\(locationAttachment.latitude), \(locationAttachment.longitude))"
+ }
+
+ debugPrint("[Location] Updated live locations to the server: \(locations)")
+ }
}
diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift
index 814bf3da85..926c61b06b 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift
@@ -8,7 +8,9 @@ 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 hasStaticLocationAttachment = message.attachmentCounts.keys.contains(.staticLocation)
+ let hasLiveLocationAttachment = message.attachmentCounts.keys.contains(.liveLocation)
+ let hasLocationAttachment = hasStaticLocationAttachment || hasLiveLocationAttachment
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 18ee301af3..10177ec9b8 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift
@@ -2,55 +2,87 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+import CoreLocation
+@_spi(ExperimentalLocation)
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 {
+ if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && 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)
+ }
+ }
+
+ func sendInstantLiveLocation() {
+ getCurrentLocationInfo { [weak self] location in
+ guard let location = location else { return }
+ self?.channelController?.startLiveLocationSharing(location)
+ }
+ }
- content.attachments.append(AnyAttachmentPayload(payload: locationAttachmentPayload))
+ private func getCurrentLocationInfo(completion: @escaping (LocationAttachmentInfo?) -> Void) {
+ locationProvider.getCurrentLocation { [weak self] result in
+ switch result {
+ case .success(let location):
+ let location = LocationAttachmentInfo(
+ 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 5e5a1ea85f..0acdcf069e 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift
@@ -2,24 +2,26 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+@_spi(ExperimentalLocation)
import StreamChat
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 {
+ if message.staticLocationAttachments.isEmpty == false {
attachmentPreviewView.contentMode = .scaleAspectFit
- attachmentPreviewView.image = UIImage(
- systemName: "mappin.circle.fill",
- withConfiguration: UIImage.SymbolConfiguration(font: .boldSystemFont(ofSize: 12))
- )
+ attachmentPreviewView.image = UIImage(systemName: "mappin.circle.fill")
attachmentPreviewView.tintColor = .systemRed
- textView.text = """
- Location:
- (\(locationPayload.coordinate.latitude),\(locationPayload.coordinate.longitude))
- """
+ textView.text = "Location"
+ return
+ }
+
+ if message.liveLocationAttachments.isEmpty == false {
+ attachmentPreviewView.contentMode = .scaleAspectFit
+ attachmentPreviewView.image = UIImage(systemName: "location.fill")
+ attachmentPreviewView.tintColor = .systemBlue
+ textView.text = "Live Location"
return
}
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 b93f830ec9..0000000000
--- 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 45eb70b04b..0000000000
--- 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 509f370741..6a043804aa 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift
@@ -3,21 +3,47 @@
//
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 messageId: MessageId?
+ var author: ChatUser?
+
+ init(coordinate: CLLocationCoordinate2D, isLive: Bool, isSharingLiveLocation: Bool, messageId: MessageId?, author: ChatUser?) {
+ self.coordinate = coordinate
+ self.isLive = isLive
+ self.isSharingLiveLocation = isSharingLiveLocation
+ self.messageId = messageId
+ 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 +62,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 +103,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 +147,147 @@ 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.isSharingLiveLocation && content.isFromCurrentUser {
+ stopButton.isHidden = false
+ sharingStatusView.isHidden = true
+ sharingStatusView.updateStatus(isSharing: true)
+ } else if content.isLive {
+ stopButton.isHidden = true
+ sharingStatusView.isHidden = false
+ sharingStatusView.updateStatus(isSharing: content.isSharingLiveLocation)
+ } 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.messageId 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 c6a49302f2..c7e62f10f7 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift
@@ -2,18 +2,57 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+@_spi(ExperimentalLocation)
import StreamChat
import StreamChatUI
+import UIKit
protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate {
- func didTapOnLocationAttachment(
- _ attachment: ChatMessageLocationAttachment
+ func didTapOnStaticLocationAttachment(
+ _ attachment: ChatMessageStaticLocationAttachment
+ )
+
+ func didTapOnLiveLocationAttachment(
+ _ attachment: ChatMessageLiveLocationAttachment
+ )
+
+ func didTapOnStopSharingLocation(
+ _ attachment: ChatMessageLiveLocationAttachment
)
}
extension DemoChatMessageListVC: LocationAttachmentViewDelegate {
- func didTapOnLocationAttachment(_ attachment: ChatMessageLocationAttachment) {
- let mapViewController = LocationDetailViewController(locationAttachment: attachment)
+ func didTapOnStaticLocationAttachment(_ attachment: ChatMessageStaticLocationAttachment) {
+ let messageController = client.messageController(
+ cid: attachment.id.cid,
+ messageId: attachment.id.messageId
+ )
+ showDetailViewController(messageController: messageController)
+ }
+
+ func didTapOnLiveLocationAttachment(_ attachment: ChatMessageLiveLocationAttachment) {
+ let messageController = client.messageController(
+ cid: attachment.id.cid,
+ messageId: attachment.id.messageId
+ )
+ showDetailViewController(messageController: messageController)
+ }
+
+ func didTapOnStopSharingLocation(_ attachment: ChatMessageLiveLocationAttachment) {
+ client
+ .channelController(for: attachment.id.cid)
+ .stopLiveLocationSharing()
+ }
+
+ 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 38bb5666a7..aff97f2e70 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift
@@ -2,6 +2,7 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+@_spi(ExperimentalLocation)
import StreamChat
import StreamChatUI
import UIKit
@@ -9,29 +10,58 @@ import UIKit
class LocationAttachmentViewInjector: AttachmentViewInjector {
lazy var locationAttachmentView = LocationAttachmentSnapshotView()
- var locationAttachment: ChatMessageLocationAttachment? {
- attachments(payloadType: LocationAttachmentPayload.self).first
+ var staticLocationAttachment: ChatMessageStaticLocationAttachment? {
+ attachments(payloadType: StaticLocationAttachmentPayload.self).first
}
+ var liveLocationAttachment: ChatMessageLiveLocationAttachment? {
+ attachments(payloadType: LiveLocationAttachmentPayload.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 staticLocation = staticLocationAttachment {
+ locationAttachmentView.content = .init(
+ coordinate: .init(latitude: staticLocation.latitude, longitude: staticLocation.longitude),
+ isLive: false,
+ isSharingLiveLocation: false,
+ messageId: contentView.content?.id,
+ author: contentView.content?.author
+ )
+ } else if let liveLocation = liveLocationAttachment {
+ locationAttachmentView.content = .init(
+ coordinate: .init(latitude: liveLocation.latitude, longitude: liveLocation.longitude),
+ isLive: true,
+ isSharingLiveLocation: liveLocation.stoppedSharing == false,
+ messageId: contentView.content?.id,
+ author: contentView.content?.author
+ )
+ }
}
func handleTapOnLocationAttachment() {
@@ -39,10 +69,22 @@ class LocationAttachmentViewInjector: AttachmentViewInjector {
return
}
- guard let locationAttachment = self.locationAttachment else {
+ if let staticLocationAttachment = self.staticLocationAttachment {
+ locationAttachmentDelegate.didTapOnStaticLocationAttachment(staticLocationAttachment)
+ } else if let liveLocationAttachment = self.liveLocationAttachment {
+ locationAttachmentDelegate.didTapOnLiveLocationAttachment(liveLocationAttachment)
+ }
+ }
+
+ func handleTapOnStopSharingLocation() {
+ guard let locationAttachmentDelegate = contentView.delegate as? LocationAttachmentViewDelegate else {
+ return
+ }
+
+ guard let locationAttachment = liveLocationAttachment else {
return
}
- locationAttachmentDelegate.didTapOnLocationAttachment(locationAttachment)
+ locationAttachmentDelegate.didTapOnStopSharingLocation(locationAttachment)
}
}
diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift
index 5e4df68608..c233d13233 100644
--- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift
+++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift
@@ -3,13 +3,18 @@
//
import MapKit
+@_spi(ExperimentalLocation)
+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 +23,273 @@ class LocationDetailViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
+ private var userAnnotation: UserAnnotation?
+ private let coordinateSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
+
let mapView: MKMapView = {
let view = MKMapView()
+ view.translatesAutoresizingMaskIntoConstraints = false
view.isZoomEnabled = true
return view
}()
+ var isLiveLocationAttachment: Bool {
+ messageController.message?.liveLocationAttachments.first != nil
+ }
+
+ 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()
+ 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)
+
+ 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)
+ ])
+
+ if isLiveLocationAttachment {
+ 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 staticLocationAttachment = messageController.message?.staticLocationAttachments.first {
+ locationCoordinate = CLLocationCoordinate2D(
+ latitude: staticLocationAttachment.latitude,
+ longitude: staticLocationAttachment.longitude
+ )
+ } else if let liveLocationAttachment = messageController.message?.liveLocationAttachments.first {
+ locationCoordinate = CLLocationCoordinate2D(
+ latitude: liveLocationAttachment.latitude,
+ longitude: liveLocationAttachment.longitude
+ )
+ }
+ if let locationCoordinate {
+ mapView.region = .init(
+ center: locationCoordinate,
+ span: coordinateSpan
+ )
+ updateUserLocation(
+ locationCoordinate
+ )
+ }
+
+ updateBannerState()
+ }
+
+ func updateUserLocation(
+ _ coordinate: CLLocationCoordinate2D
+ ) {
+ if let existingAnnotation = userAnnotation {
+ if isLiveLocationAttachment {
+ // 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. This also blocks the user from moving the map.
+ // In a real app, we could have a toggle to enable/disable this behaviour.
+ UIView.animate(withDuration: 5) {
+ existingAnnotation.coordinate = coordinate
+ }
+ UIView.animate(withDuration: 5, delay: 0.2, options: .curveEaseOut) {
+ self.mapView.setCenter(coordinate, animated: true)
+ }
+ } else {
+ existingAnnotation.coordinate = coordinate
+ mapView.setCenter(coordinate, animated: true)
+ }
+ } else if let author = messageController.message?.author, isLiveLocationAttachment {
+ 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 liveLocationAttachment = messageController.message?.liveLocationAttachments.first else {
+ return
+ }
+
+ let isFromCurrentUser = messageController.message?.isSentByCurrentUser == true
+ let dateFormatter = appearance.formatters.channelListMessageTimestamp
+ let updatedAtText = dateFormatter.format(messageController.message?.updatedAt ?? Date())
+ if liveLocationAttachment.stoppedSharing == false {
+ locationControlBanner.configure(
+ state: isFromCurrentUser
+ ? .currentUserSharing
+ : .anotherUserSharing(lastUpdatedAtText: updatedAtText)
+ )
+ } else {
+ locationControlBanner.configure(state: .ended(lastUpdatedAtText: updatedAtText))
+ }
+ }
+}
+
+extension LocationDetailViewController: ChatMessageControllerDelegate {
+ func messageController(
+ _ controller: ChatMessageController,
+ didChangeMessage change: EntityChange
+ ) {
+ guard let liveLocationAttachment = controller.message?.liveLocationAttachments.first else {
+ return
+ }
+
let locationCoordinate = CLLocationCoordinate2D(
- latitude: locationAttachment.coordinate.latitude,
- longitude: locationAttachment.coordinate.longitude
+ latitude: liveLocationAttachment.latitude,
+ longitude: liveLocationAttachment.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 = liveLocationAttachment.stoppedSharing == true
+ 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 liveLocationAttachment = messageController.message?.liveLocationAttachments.first
+ let isSharingLiveLocation = liveLocationAttachment?.stoppedSharing == false
+ if isSharingLiveLocation {
+ 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
+ case anotherUserSharing(lastUpdatedAtText: String)
+ case ended(lastUpdatedAtText: String)
+ }
- view = mapView
+ func configure(state: State) {
+ switch state {
+ case .currentUserSharing:
+ sharingButton.isEnabled = true
+ sharingButton.setTitle("Stop Sharing", for: .normal)
+ sharingButton.setTitleColor(appearance.colorPalette.alert, for: .normal)
+ locationUpdateLabel.text = "Location sharing is active"
+ case .anotherUserSharing(let lastUpdatedAtText):
+ sharingButton.isEnabled = false
+ sharingButton.setTitle("Live Location", for: .normal)
+ sharingButton.setTitleColor(appearance.colorPalette.alert, for: .normal)
+ locationUpdateLabel.text = "Location last updated at \(lastUpdatedAtText)"
+ 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 0000000000..d3b0af9dfc
--- /dev/null
+++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift
@@ -0,0 +1,54 @@
+//
+// 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(isSharing: Bool) {
+ statusLabel.text = isSharing ? "Live location active" : "Live location ended"
+ iconImageView.image = isSharing ? activeSharingImage : inactiveSharingImage
+ iconImageView.tintColor = isSharing
+ ? 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 0000000000..a07ffc6779
--- /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 0000000000..e6ad750d0e
--- /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 6cb0826e32..efeeca3303 100644
--- a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift
+++ b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift
@@ -2,10 +2,30 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+@_spi(ExperimentalLocation)
+import StreamChat
import StreamChatUI
import UIKit
final class DemoChatChannelListItemView: ChatChannelListItemView {
+ override var subtitleText: String? {
+ guard let previewMessage = content?.channel.previewMessage else {
+ return super.subtitleText
+ }
+ if previewMessage.liveLocationAttachments.isEmpty == false {
+ return previewMessage.isSentByCurrentUser
+ ? previewMessageTextForCurrentUser(messageText: "Live location")
+ : previewMessageTextFromAnotherUser(previewMessage.author, messageText: "Live Location")
+ }
+
+ if previewMessage.staticLocationAttachments.isEmpty == false {
+ return previewMessage.isSentByCurrentUser
+ ? previewMessageTextForCurrentUser(messageText: "Static location")
+ : previewMessageTextFromAnotherUser(previewMessage.author, messageText: "Static Location")
+ }
+ 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 6336fa12d6..e18736f1fe 100644
--- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift
+++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift
@@ -3,6 +3,7 @@
//
import Foundation
+@_spi(ExperimentalLocation)
import StreamChat
import StreamChatUI
import UIKit
@@ -26,6 +27,11 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC {
actions.append(messageDebugActionItem())
}
+ let hasLocationAttachments = message?.liveLocationAttachments.isEmpty == false || message?.staticLocationAttachments.isEmpty == false
+ 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 49262cb983..a8da910904 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/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift
index e0a705226e..33c50abb97 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/ChatClient.swift b/Sources/StreamChat/ChatClient.swift
index 3627ed00da..08dcbccd65 100644
--- a/Sources/StreamChat/ChatClient.swift
+++ b/Sources/StreamChat/ChatClient.swift
@@ -61,7 +61,9 @@ public class ChatClient {
.video: VideoAttachmentPayload.self,
.audio: AudioAttachmentPayload.self,
.file: FileAttachmentPayload.self,
- .voiceRecording: VoiceRecordingAttachmentPayload.self
+ .voiceRecording: VoiceRecordingAttachmentPayload.self,
+ .staticLocation: StaticLocationAttachmentPayload.self,
+ .liveLocation: LiveLocationAttachmentPayload.self
]
let connectionRepository: ConnectionRepository
diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
index 7ee1ae60df..a5bc318d3e 100644
--- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
+++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
@@ -68,8 +68,12 @@ 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
+ /// The component responsible to update a message from the channel.
+ private let messageUpdater: MessageUpdater
+
private lazy var eventSender: TypingEventsSender = self.environment.eventSenderBuilder(
client.databaseContainer,
client.apiClient
@@ -227,7 +231,10 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
client.databaseContainer,
client.apiClient
)
- channelMemberUpdater = self.environment.memberUpdaterBuilder(
+ channelMemberUpdater = self.environment.memberUpdaterBuilder(client.databaseContainer, client.apiClient)
+ messageUpdater = self.environment.messageUpdaterBuilder(
+ client.config.isLocalStorageEnabled,
+ client.messageRepository,
client.databaseContainer,
client.apiClient
)
@@ -825,6 +832,182 @@ 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.
+ @_spi(ExperimentalLocation)
+ public func sendStaticLocation(
+ _ location: LocationAttachmentInfo,
+ 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
+ }
+
+ let locationPayload = StaticLocationAttachmentPayload(
+ latitude: location.latitude,
+ longitude: location.longitude,
+ extraData: location.extraData
+ )
+
+ updater.createNewMessage(
+ in: cid,
+ messageId: messageId,
+ text: text ?? "",
+ pinning: nil,
+ isSilent: false,
+ isSystem: false,
+ command: nil,
+ arguments: nil,
+ attachments: [
+ .init(payload: locationPayload)
+ ],
+ mentionedUserIds: [],
+ quotedMessageId: quotedMessageId,
+ skipPush: false,
+ skipEnrichUrl: false,
+ poll: 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.
+ /// - 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.
+ @_spi(ExperimentalLocation)
+ public func startLiveLocationSharing(
+ _ location: LocationAttachmentInfo,
+ 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
+ }
+
+ client.messageRepository.getActiveLiveLocationMessages(for: cid) { [weak self] result in
+ if let message = try? result.get().first {
+ self?.callback {
+ completion?(.failure(
+ ClientError.ActiveLiveLocationAlreadyExists(messageId: message.id)
+ ))
+ }
+ return
+ }
+
+ let liveLocationSharingPayload = LiveLocationAttachmentPayload(
+ latitude: location.latitude,
+ longitude: location.longitude,
+ stoppedSharing: false,
+ extraData: location.extraData
+ )
+
+ self?.updater.createNewMessage(
+ in: cid,
+ messageId: nil,
+ text: text ?? "",
+ pinning: nil,
+ isSilent: false,
+ isSystem: false,
+ command: nil,
+ arguments: nil,
+ attachments: [
+ .init(payload: liveLocationSharingPayload)
+ ],
+ mentionedUserIds: [],
+ quotedMessageId: nil,
+ skipPush: false,
+ skipEnrichUrl: false,
+ poll: nil,
+ extraData: extraData
+ ) { result in
+ if let newMessage = try? result.get() {
+ self?.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage))
+ }
+ self?.callback {
+ completion?(result.map(\.id))
+ }
+ }
+ }
+ }
+
+ /// Stops sharing the live location message in the channel.
+ @_spi(ExperimentalLocation)
+ public func stopLiveLocationSharing(completion: ((Result) -> Void)? = nil) {
+ guard let cid = cid, isChannelAlreadyCreated else {
+ channelModificationFailed { error in
+ completion?(.failure(error ?? ClientError.Unknown()))
+ }
+ return
+ }
+
+ client.messageRepository.getActiveLiveLocationMessages(for: cid) { result in
+ switch result {
+ case let .success(messages):
+ guard let message = messages.first,
+ let liveLocation = message.liveLocationAttachments.first
+ else {
+ self.callback {
+ completion?(.failure(ClientError.MessageDoesNotHaveLiveLocationAttachment()))
+ }
+ return
+ }
+
+ let liveLocationPayload = LiveLocationAttachmentPayload(
+ latitude: liveLocation.latitude,
+ longitude: liveLocation.longitude,
+ stoppedSharing: true
+ )
+
+ self.messageUpdater.updatePartialMessage(
+ messageId: message.id,
+ attachments: [.init(payload: liveLocationPayload)]
+ ) { result in
+ self.callback {
+ completion?(result.map(\.id))
+ }
+ }
+ case let .failure(error):
+ self.callback {
+ completion?(.failure(error))
+ }
+ }
+ }
+ }
+
/// Creates a new poll.
///
/// - Parameters:
@@ -1490,6 +1673,13 @@ extension ChatChannelController {
_ apiClient: APIClient
) -> ChannelMemberUpdater = ChannelMemberUpdater.init
+ var messageUpdaterBuilder: (
+ _ isLocalStorageEnabled: Bool,
+ _ messageRepository: MessageRepository,
+ _ database: DatabaseContainer,
+ _ apiClient: APIClient
+ ) -> MessageUpdater = MessageUpdater.init
+
var eventSenderBuilder: (
_ database: DatabaseContainer,
_ apiClient: APIClient
@@ -1757,7 +1947,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."
@@ -1781,6 +1971,17 @@ extension ClientError {
"You can't specify a value outside the range 1-120 for cooldown duration."
}
}
+
+ final class ActiveLiveLocationAlreadyExists: ClientError {
+ let messageId: MessageId
+
+ init(messageId: MessageId) {
+ self.messageId = messageId
+ super.init(
+ "You can't start a new live location sharing because a message with id:\(messageId) has already one active live location."
+ )
+ }
+ }
}
extension ClientError {
diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift
index 1c1a1ee3db..a25f2fcb4b 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] _ 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()
@@ -230,6 +266,26 @@ public extension CurrentChatUserController {
}
}
+ /// Updates the location of all the active live location messages for the current user.
+ ///
+ /// The updates are throttled to avoid sending too many requests.
+ ///
+ /// - Parameter location: The new location to be updated.
+ @_spi(ExperimentalLocation)
+ func updateLiveLocation(_ location: LocationAttachmentInfo) {
+ guard let messages = activeLiveLocationMessagesObserver?.items, !messages.isEmpty else {
+ return
+ }
+
+ locationUpdatesThrottler.execute { [weak self] in
+ for message in messages {
+ guard let cid = message.cid else { continue }
+ let messageController = self?.client.messageController(cid: cid, messageId: message.id)
+ messageController?.updateLiveLocation(location)
+ }
+ }
+ }
+
/// 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) {
@@ -350,6 +406,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 currentUserUpdaterBuilder = CurrentUserUpdater.init
}
}
@@ -378,6 +449,21 @@ private extension CurrentChatUserController {
NSFetchedResultsController.self
)
}
+
+ func createActiveLiveLocationMessagesObserver() -> BackgroundListDatabaseObserver? {
+ guard let currentUserId = client.currentUserId else {
+ return nil
+ }
+ return environment.currentUserActiveLiveLocationMessagesObserverBuilder(
+ client.databaseContainer,
+ MessageDTO.activeLiveLocationMessagesFetchRequest(
+ currentUserId: currentUserId,
+ channelId: nil
+ ),
+ { try $0.asModel() },
+ NSFetchedResultsController.self
+ )
+ }
}
// MARK: - Delegates
@@ -385,16 +471,60 @@ 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 controller observed a change in the active live location messages.
+ @_spi(ExperimentalLocation)
+ func currentUserController(
+ _ controller: CurrentChatUserController,
+ didChangeActiveLiveLocationMessages messages: [ChatMessage]
+ )
+
+ /// The current user started sharing his location in message attachments.
+ @_spi(ExperimentalLocation)
+ func currentUserControllerDidStartSharingLiveLocation(
+ _ controller: CurrentChatUserController
+ )
+
+ /// The current user has no active live location attachments.
+ @_spi(ExperimentalLocation)
+ func currentUserControllerDidStopSharingLiveLocation(
+ _ controller: CurrentChatUserController
+ )
}
public extension CurrentChatUserControllerDelegate {
- func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) {}
-
- func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUser: EntityChange) {}
+ func currentUserController(
+ _ controller: CurrentChatUserController,
+ didChangeCurrentUserUnreadCount: UnreadCount
+ ) {}
+
+ func currentUserController(
+ _ controller: CurrentChatUserController,
+ didChangeCurrentUser: EntityChange
+ ) {}
+
+ func currentUserController(
+ _ controller: CurrentChatUserController,
+ didChangeActiveLiveLocationMessages messages: [ChatMessage]
+ ) {}
+
+ func currentUserControllerDidStartSharingLiveLocation(
+ _ controller: CurrentChatUserController
+ ) {}
+
+ func currentUserControllerDidStopSharingLiveLocation(
+ _ controller: CurrentChatUserController
+ ) {}
}
public extension CurrentChatUserController {
diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift
index 549d1e597b..0673564caa 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 }
/// Creates a new `MessageControllerGeneric`.
@@ -193,7 +200,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
@@ -243,15 +256,15 @@ 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.
/// - skipEnrichUrl: If true, the url preview won't be attached to the message.
/// - attachments: An array of the attachments for 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,
@@ -272,6 +285,92 @@ 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)
+ }
+ }
+ }
+
+ /// Updates the message's live location attachment if it has one.
+ ///
+ /// This method is for internal use only.
+ ///
+ /// In order to update live location attachments, the `CurrentUserController.updateLiveLocation()` method should be used
+ /// since it will automatically update all attachments with active location sharing of the current user. It also makes
+ /// sure that the requests are throttled while this one is not.
+ ///
+ /// - Parameters:
+ /// - location: The new location for the live location attachment.
+ /// - completion: Called when the server updates the message.
+ internal func updateLiveLocation(
+ _ location: LocationAttachmentInfo,
+ completion: ((Result) -> Void)? = nil
+ ) {
+ guard let locationAttachment = message?.liveLocationAttachments.first else {
+ completion?(.failure(ClientError.MessageDoesNotHaveLiveLocationAttachment()))
+ return
+ }
+
+ guard locationAttachment.stoppedSharing == false else {
+ completion?(.failure(ClientError.MessageLiveLocationAlreadyStopped()))
+ return
+ }
+
+ let liveLocationPayload = LiveLocationAttachmentPayload(
+ latitude: location.latitude,
+ longitude: location.longitude
+ )
+
+ // Optimistic update
+ client.databaseContainer.write { session in
+ let messageDTO = try session.messageEditableByCurrentUser(self.messageId)
+ guard let liveLocationAttachmentDTO = messageDTO.attachments.first(
+ where: { $0.attachmentID == locationAttachment.id }
+ ) else {
+ return
+ }
+
+ liveLocationAttachmentDTO.data = try JSONEncoder.default.encode(liveLocationPayload)
+ }
+
+ messageUpdater.updatePartialMessage(
+ messageId: messageId,
+ text: nil,
+ attachments: [
+ .init(payload: liveLocationPayload)
+ ],
+ extraData: nil
+ ) { result in
+ self.callback {
+ completion?(result)
+ }
+ }
+ }
+
/// Deletes the message this controller manages.
///
/// - Parameters:
@@ -834,6 +933,58 @@ 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.
+ @_spi(ExperimentalLocation)
+ public func stopLiveLocationSharing(completion: ((Result) -> Void)? = nil) {
+ guard let locationAttachment = message?.liveLocationAttachments.first else {
+ callback {
+ completion?(.failure(ClientError.MessageDoesNotHaveLiveLocationAttachment()))
+ }
+ return
+ }
+
+ guard locationAttachment.stoppedSharing == false else {
+ callback {
+ completion?(.failure(ClientError.MessageLiveLocationAlreadyStopped()))
+ }
+ return
+ }
+
+ let liveLocationPayload = LiveLocationAttachmentPayload(
+ latitude: locationAttachment.latitude,
+ longitude: locationAttachment.longitude,
+ stoppedSharing: true
+ )
+
+ // Optimistic update
+ client.databaseContainer.write { session in
+ let messageDTO = try session.messageEditableByCurrentUser(self.messageId)
+ guard let liveLocationAttachmentDTO = messageDTO.attachments.first(
+ where: { $0.attachmentID == locationAttachment.id }
+ ) else {
+ return
+ }
+
+ liveLocationAttachmentDTO.data = try JSONEncoder.default.encode(liveLocationPayload)
+ }
+
+ messageUpdater.updatePartialMessage(
+ messageId: messageId,
+ text: nil,
+ attachments: [
+ .init(payload: liveLocationPayload)
+ ],
+ extraData: nil
+ ) { result in
+ self.callback {
+ completion?(result)
+ }
+ }
+ }
}
// MARK: - Environment
@@ -887,9 +1038,8 @@ private extension ChatMessageController {
func setRepliesObserver() {
let sortAscending = listOrdering == .topToBottom ? false : true
- let deletedMessageVisibility = client.databaseContainer.viewContext
- .deletedMessagesVisibility ?? .visibleForCurrentUser
- let shouldShowShadowedMessages = client.databaseContainer.viewContext.shouldShowShadowedMessages ?? false
+ let deletedMessageVisibility = client.config.deletedMessagesVisibility
+ let shouldShowShadowedMessages = client.config.shouldShowShadowedMessages
let pageSize: Int = repliesPageSize
let observer = environment.repliesObserverBuilder(
@@ -964,10 +1114,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/AttachmentDTO.swift b/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift
index e1e832e698..89a4bd4f5e 100644
--- a/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift
@@ -60,6 +60,9 @@ class AttachmentDTO: NSManagedObject {
/// An attachment raw `Data`.
@NSManaged var data: Data
+ /// A property to easily fetch active location attachments.
+ @NSManaged var isActiveLocationAttachment: Bool
+
func clearLocalState() {
localDownloadState = nil
localRelativePath = nil
@@ -172,6 +175,13 @@ extension NSManagedObjectContext: AttachmentDatabaseSession {
dto.data = try JSONEncoder.default.encode(payload.payload)
dto.message = messageDTO
+ dto.isActiveLocationAttachment = false
+ if payload.type == .liveLocation {
+ let stoppedSharingKey = LiveLocationAttachmentPayload.CodingKeys.stoppedSharing.rawValue
+ let stoppedSharing = payload.payload[stoppedSharingKey]?.boolValue ?? false
+ dto.isActiveLocationAttachment = !stoppedSharing
+ }
+
// Keep local state for downloaded attachments
if dto.localDownloadState == nil {
dto.clearLocalState()
diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift
index 3415a59ae0..ce3a273acd 100644
--- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift
@@ -584,7 +584,41 @@ 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 activeLiveLocationMessagesFetchRequest(
+ 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: "ANY attachments.isActiveLocationAttachment == YES"),
+ .init(format: "user.id == %@", currentUserId)
+ ]
+ if let channelId {
+ predicates.append(.init(format: "channel.cid == %@", channelId.rawValue))
+ }
+ request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
+ return request
+ }
+
+ static func loadActiveLiveLocationMessages(
+ currentUserId: UserId,
+ channelId: ChannelId?,
+ context: NSManagedObjectContext
+ ) throws -> [MessageDTO] {
+ let request = activeLiveLocationMessagesFetchRequest(currentUserId: currentUserId, channelId: channelId)
+ return try load(request, context: context)
+ }
+
static func loadReplies(
from fromIncludingDate: Date,
to toIncludingDate: Date,
@@ -873,7 +907,7 @@ extension NSManagedObjectContext: MessageDatabaseSession {
}
)
dto.attachments = attachments
-
+
if let poll = payload.poll {
let pollDto = try savePoll(payload: poll, cache: cache)
dto.poll = pollDto
diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
index 98617a5329..5d53913562 100644
--- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
+++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
@@ -1,8 +1,9 @@
-
+
+
diff --git a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
index d1941d4cda..f807012b4c 100644
--- a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
+++ b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
@@ -136,6 +136,8 @@ public extension AttachmentType {
static let audio = Self(rawValue: "audio")
static let voiceRecording = Self(rawValue: "voiceRecording")
static let linkPreview = Self(rawValue: "linkPreview")
+ static let staticLocation = Self(rawValue: "static_location")
+ static let liveLocation = Self(rawValue: "live_location")
static let unknown = Self(rawValue: "unknown")
}
diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift
new file mode 100644
index 0000000000..e5ef2f85bf
--- /dev/null
+++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift
@@ -0,0 +1,67 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+/// A type alias for an attachment with `LiveLocationAttachmentPayload` payload type.
+///
+/// Live location attachments are used to represent a live location sharing in a chat message.
+@_spi(ExperimentalLocation)
+public typealias ChatMessageLiveLocationAttachment = ChatMessageAttachment
+
+/// The payload for attachments with `.liveLocation` type.
+@_spi(ExperimentalLocation)
+public struct LiveLocationAttachmentPayload: AttachmentPayload {
+ /// The type used to parse the attachment.
+ public static var type: AttachmentType = .liveLocation
+
+ /// The latitude of the location.
+ public let latitude: Double
+ /// The longitude of the location.
+ public let longitude: Double
+ /// A boolean value indicating whether the live location sharing was stopped.
+ public let stoppedSharing: Bool?
+ /// The extra data for the attachment payload.
+ public var extraData: [String: RawJSON]?
+
+ public init(
+ latitude: Double,
+ longitude: Double,
+ stoppedSharing: Bool? = nil,
+ extraData: [String: RawJSON]? = nil
+ ) {
+ self.latitude = latitude
+ self.longitude = longitude
+ self.stoppedSharing = stoppedSharing
+ self.extraData = extraData
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case latitude
+ case longitude
+ case stoppedSharing = "stopped_sharing"
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(latitude, forKey: .latitude)
+ try container.encode(longitude, forKey: .longitude)
+ try container.encodeIfPresent(stoppedSharing, forKey: .stoppedSharing)
+ try extraData?.encode(to: encoder)
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let latitude = try container.decode(Double.self, forKey: .latitude)
+ let longitude = try container.decode(Double.self, forKey: .longitude)
+ let stoppedSharing = try container.decodeIfPresent(Bool.self, forKey: .stoppedSharing)
+
+ self.init(
+ latitude: latitude,
+ longitude: longitude,
+ stoppedSharing: stoppedSharing ?? false,
+ extraData: try Self.decodeExtraData(from: decoder)
+ )
+ }
+}
diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift
new file mode 100644
index 0000000000..6240a10d83
--- /dev/null
+++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift
@@ -0,0 +1,59 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+/// A type alias for an attachment with `StaticLocationAttachmentPayload` payload type.
+///
+/// Static location attachments represent a location that doesn't change.
+@_spi(ExperimentalLocation)
+public typealias ChatMessageStaticLocationAttachment = ChatMessageAttachment
+
+/// The payload for attachments with `.staticLocation` type.
+@_spi(ExperimentalLocation)
+public struct StaticLocationAttachmentPayload: AttachmentPayload {
+ /// The type used to parse the attachment.
+ public static var type: AttachmentType = .staticLocation
+
+ /// The latitude of the location.
+ public let latitude: Double
+ /// The longitude of the location.
+ public let longitude: Double
+ /// The extra data for the attachment payload.
+ public var extraData: [String: RawJSON]?
+
+ public init(
+ latitude: Double,
+ longitude: Double,
+ extraData: [String: RawJSON]? = nil
+ ) {
+ self.latitude = latitude
+ self.longitude = longitude
+ self.extraData = extraData
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case latitude
+ case longitude
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(latitude, forKey: .latitude)
+ try container.encode(longitude, forKey: .longitude)
+ try extraData?.encode(to: encoder)
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let latitude = try container.decode(Double.self, forKey: .latitude)
+ let longitude = try container.decode(Double.self, forKey: .longitude)
+
+ self.init(
+ latitude: latitude,
+ longitude: longitude,
+ extraData: try Self.decodeExtraData(from: decoder)
+ )
+ }
+}
diff --git a/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift b/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift
new file mode 100644
index 0000000000..74d20ff0b0
--- /dev/null
+++ b/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift
@@ -0,0 +1,22 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+/// The location attachment information.
+public struct LocationAttachmentInfo {
+ public var latitude: Double
+ public var longitude: Double
+ public var extraData: [String: RawJSON]?
+
+ public init(
+ latitude: Double,
+ longitude: Double,
+ extraData: [String: RawJSON]? = nil
+ ) {
+ self.latitude = latitude
+ self.longitude = longitude
+ self.extraData = extraData
+ }
+}
diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift
index d79ee8d96c..cc8dcbd1a8 100644
--- a/Sources/StreamChat/Models/ChatMessage.swift
+++ b/Sources/StreamChat/Models/ChatMessage.swift
@@ -317,6 +317,18 @@ public extension ChatMessage {
attachments(payloadType: VoiceRecordingAttachmentPayload.self)
}
+ /// Returns the attachments of `.staticLocation` type.
+ @_spi(ExperimentalLocation)
+ var staticLocationAttachments: [ChatMessageStaticLocationAttachment] {
+ attachments(payloadType: StaticLocationAttachmentPayload.self)
+ }
+
+ /// Returns the attachments of `.liveLocation` type.
+ @_spi(ExperimentalLocation)
+ var liveLocationAttachments: [ChatMessageLiveLocationAttachment] {
+ attachments(payloadType: LiveLocationAttachmentPayload.self)
+ }
+
/// Returns attachment for the given identifier.
/// - Parameter id: Attachment identifier.
/// - Returns: A type-erased attachment.
diff --git a/Sources/StreamChat/Models/UserInfo.swift b/Sources/StreamChat/Models/UserInfo.swift
index a108ef0327..e55bca163f 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 86f222e92f..7fb174baf3 100644
--- a/Sources/StreamChat/Repositories/MessageRepository.swift
+++ b/Sources/StreamChat/Repositories/MessageRepository.swift
@@ -281,7 +281,32 @@ class MessageRepository {
}
}
}
-
+
+ func getActiveLiveLocationMessages(
+ 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.loadActiveLiveLocationMessages(
+ 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 0aef17b461..d9013642e4 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/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift
index 986017a474..4268b0eb3f 100644
--- a/Sources/StreamChat/Workers/MessageUpdater.swift
+++ b/Sources/StreamChat/Workers/MessageUpdater.swift
@@ -119,12 +119,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
@@ -181,6 +181,78 @@ 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,
+ 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:
@@ -488,7 +560,7 @@ class MessageUpdater: Worker {
messageId: messageId,
request: .init(set: .init(pinned: true))
)
-
+
self?.apiClient.request(endpoint: endpoint) { result in
switch result {
case .success:
@@ -517,7 +589,7 @@ class MessageUpdater: Worker {
messageId: messageId,
request: .init(set: .init(pinned: false))
)
-
+
self?.apiClient.request(endpoint: endpoint) { result in
switch result {
case .success:
@@ -531,7 +603,7 @@ class MessageUpdater: Worker {
}
}
}
-
+
private func pinLocalMessage(
on messageId: MessageId,
pinning: MessagePinning,
@@ -553,7 +625,7 @@ class MessageUpdater: Worker {
}
}
}
-
+
private func unpinLocalMessage(
on messageId: MessageId,
completion: ((Result, MessagePinning) -> Void)? = nil
@@ -576,9 +648,9 @@ class MessageUpdater: Worker {
}
}
}
-
+
static let minSignificantDownloadingProgressChange: Double = 0.01
-
+
func downloadAttachment(
_ attachment: ChatMessageAttachment,
completion: @escaping (Result, Error>) -> Void
@@ -613,7 +685,7 @@ class MessageUpdater: Worker {
}
)
}
-
+
func deleteLocalAttachmentDownload(for attachmentId: AttachmentId, completion: @escaping (Error?) -> Void) {
database.write({ session in
let dto = session.attachment(id: attachmentId)
@@ -627,7 +699,7 @@ class MessageUpdater: Worker {
dto?.clearLocalState()
}, completion: completion)
}
-
+
private func updateDownloadProgress(
for attachmentId: AttachmentId,
payloadType: Payload.Type,
@@ -652,7 +724,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)
@@ -669,7 +741,7 @@ class MessageUpdater: Worker {
}
})
}
-
+
/// Updates local state of attachment with provided `id` to be enqueued by attachment uploader.
/// - Parameters:
/// - id: The attachment identifier.
@@ -712,7 +784,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
@@ -813,7 +885,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 {
@@ -907,7 +979,7 @@ extension MessageUpdater {
struct MessageSearchResults {
let payload: MessageSearchResultsPayload
let models: [ChatMessage]
-
+
var next: String? { payload.next }
}
}
@@ -948,7 +1020,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.
@@ -989,7 +1061,7 @@ extension MessageUpdater {
}
}
}
-
+
func clearSearchResults(for query: MessageSearchQuery) async throws {
try await withCheckedThrowingContinuation { continuation in
clearSearchResults(for: query) { error in
@@ -997,7 +1069,7 @@ extension MessageUpdater {
}
}
}
-
+
func createNewReply(
in cid: ChannelId,
messageId: MessageId?,
@@ -1037,7 +1109,7 @@ extension MessageUpdater {
}
}
}
-
+
func deleteLocalAttachmentDownload(for attachmentId: AttachmentId) async throws {
try await withCheckedThrowingContinuation { continuation in
deleteLocalAttachmentDownload(for: attachmentId) { error in
@@ -1045,7 +1117,7 @@ extension MessageUpdater {
}
}
}
-
+
func deleteMessage(messageId: MessageId, hard: Bool) async throws {
try await withCheckedThrowingContinuation { continuation in
deleteMessage(messageId: messageId, hard: hard) { error in
@@ -1053,7 +1125,7 @@ extension MessageUpdater {
}
}
}
-
+
func deleteReaction(_ type: MessageReactionType, messageId: MessageId) async throws {
try await withCheckedThrowingContinuation { continuation in
deleteReaction(type, messageId: messageId) { error in
@@ -1061,7 +1133,7 @@ extension MessageUpdater {
}
}
}
-
+
func dispatchEphemeralMessageAction(
cid: ChannelId,
messageId: MessageId,
@@ -1077,7 +1149,7 @@ extension MessageUpdater {
}
}
}
-
+
func downloadAttachment(
_ attachment: ChatMessageAttachment
) async throws -> ChatMessageAttachment where Payload: DownloadableAttachmentPayload {
@@ -1087,7 +1159,7 @@ extension MessageUpdater {
}
}
}
-
+
func editMessage(
messageId: MessageId,
text: String,
@@ -1107,7 +1179,7 @@ extension MessageUpdater {
}
}
}
-
+
func flagMessage(
_ flag: Bool,
with messageId: MessageId,
@@ -1127,7 +1199,7 @@ extension MessageUpdater {
}
}
}
-
+
func getMessage(cid: ChannelId, messageId: MessageId) async throws -> ChatMessage {
try await withCheckedThrowingContinuation { continuation in
getMessage(cid: cid, messageId: messageId) { result in
@@ -1135,7 +1207,7 @@ extension MessageUpdater {
}
}
}
-
+
func loadReactions(
cid: ChannelId,
messageId: MessageId,
@@ -1151,7 +1223,7 @@ extension MessageUpdater {
}
}
}
-
+
@discardableResult func loadReplies(
cid: ChannelId,
messageId: MessageId,
@@ -1169,7 +1241,7 @@ extension MessageUpdater {
}
}
}
-
+
func pinMessage(messageId: MessageId, pinning: MessagePinning) async throws -> ChatMessage {
try await withCheckedThrowingContinuation { continuation in
pinMessage(messageId: messageId, pinning: pinning) { result in
@@ -1177,7 +1249,7 @@ extension MessageUpdater {
}
}
}
-
+
func resendAttachment(with id: AttachmentId) async throws {
try await withCheckedThrowingContinuation { continuation in
restartFailedAttachmentUploading(with: id) { error in
@@ -1185,7 +1257,7 @@ extension MessageUpdater {
}
}
}
-
+
func resendMessage(with messageId: MessageId) async throws {
try await withCheckedThrowingContinuation { continuation in
resendMessage(with: messageId) { error in
@@ -1193,7 +1265,7 @@ extension MessageUpdater {
}
}
}
-
+
func search(query: MessageSearchQuery, policy: UpdatePolicy) async throws -> MessageSearchResults {
try await withCheckedThrowingContinuation { continuation in
search(query: query, policy: policy) { result in
@@ -1201,7 +1273,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
@@ -1209,7 +1281,7 @@ extension MessageUpdater {
}
}
}
-
+
func unpinMessage(messageId: MessageId) async throws -> ChatMessage {
try await withCheckedThrowingContinuation { continuation in
unpinMessage(messageId: messageId) { result in
@@ -1217,9 +1289,9 @@ extension MessageUpdater {
}
}
}
-
+
// MARK: -
-
+
func loadReplies(
for parentMessageId: MessageId,
pagination: MessagesPagination,
@@ -1236,7 +1308,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?,
@@ -1258,7 +1330,7 @@ extension MessageUpdater {
paginationStateHandler: paginationStateHandler
)
}
-
+
func loadReplies(
for parentMessageId: MessageId,
after replyId: MessageId?,
@@ -1280,7 +1352,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 94b8a2ed72..19a71caeba 100644
--- a/StreamChat.xcodeproj/project.pbxproj
+++ b/StreamChat.xcodeproj/project.pbxproj
@@ -1368,10 +1368,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 */; };
@@ -1442,6 +1440,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 */; };
@@ -1476,6 +1476,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 */; };
@@ -1548,6 +1549,10 @@
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 */; };
+ AD770B652D09BA15003AC602 /* ChatMessageStaticLocationAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */; };
+ AD770B662D09BA15003AC602 /* ChatMessageStaticLocationAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */; };
+ AD770B682D09E2D5003AC602 /* ChatMessageLiveLocationAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.swift */; };
+ AD770B692D09E2D5003AC602 /* ChatMessageLiveLocationAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.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 */; };
@@ -1771,6 +1776,8 @@
ADDC08142C82A81F00EA0E5F /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADDC08132C82A81F00EA0E5F /* TextFieldView.swift */; };
ADDC08152C82A81F00EA0E5F /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADDC08132C82A81F00EA0E5F /* TextFieldView.swift */; };
ADDFDE2B2779EC8A003B3B07 /* Atlantis in Frameworks */ = {isa = PBXBuildFile; productRef = ADDFDE2A2779EC8A003B3B07 /* Atlantis */; };
+ ADE043672D2C59F900B4250D /* LiveLocationAttachmentPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE043652D2C59F900B4250D /* LiveLocationAttachmentPayload_Tests.swift */; };
+ ADE043682D2C59F900B4250D /* StaticLocationAttachmentPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE043662D2C59F900B4250D /* StaticLocationAttachmentPayload_Tests.swift */; };
ADE2093D29FC022D007D0FF3 /* MessagesPaginationStateHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE2093C29FC022D007D0FF3 /* MessagesPaginationStateHandling.swift */; };
ADE40043291B1A510000C98B /* AttachmentUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE40042291B1A510000C98B /* AttachmentUploader.swift */; };
ADE40044291B1A510000C98B /* AttachmentUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE40042291B1A510000C98B /* AttachmentUploader.swift */; };
@@ -1816,6 +1823,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 /* LocationAttachmentInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B22D121EAF000F515F /* LocationAttachmentInfo.swift */; };
+ ADFCA5B42D121EB8000F515F /* LocationAttachmentInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B22D121EAF000F515F /* LocationAttachmentInfo.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 */; };
BCE4831434E78C9538FA73F8 /* JSONDecoder_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */; };
BCE484BA1EE03FF336034250 /* FilterEncoding_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE483AC99F58A9034EA2ECE /* FilterEncoding_Tests.swift */; };
BCE48639FD7B6B05CD63A6AF /* FilterDecoding_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE4862E2C4943998F0DCBD9 /* FilterDecoding_Tests.swift */; };
@@ -2307,8 +2319,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 */; };
@@ -4173,10 +4183,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 = ""; };
@@ -4223,6 +4231,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 = ""; };
@@ -4246,6 +4256,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 = ""; };
@@ -4293,6 +4304,8 @@
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 = ""; };
+ AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageStaticLocationAttachment.swift; sourceTree = ""; };
+ AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageLiveLocationAttachment.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 = ""; };
@@ -4436,6 +4449,8 @@
ADDC080D2C8290EC00EA0E5F /* PollCreationMultipleVotesFeatureCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollCreationMultipleVotesFeatureCell.swift; sourceTree = ""; };
ADDC08102C82911B00EA0E5F /* PollCreationOptionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollCreationOptionCell.swift; sourceTree = ""; };
ADDC08132C82A81F00EA0E5F /* TextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldView.swift; sourceTree = ""; };
+ ADE043652D2C59F900B4250D /* LiveLocationAttachmentPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationAttachmentPayload_Tests.swift; sourceTree = ""; };
+ ADE043662D2C59F900B4250D /* StaticLocationAttachmentPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationAttachmentPayload_Tests.swift; sourceTree = ""; };
ADE2093C29FC022D007D0FF3 /* MessagesPaginationStateHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationStateHandling.swift; sourceTree = ""; };
ADE40042291B1A510000C98B /* AttachmentUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader.swift; sourceTree = ""; };
ADE57B782C36DB2000DD6B88 /* ChatThreadListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListErrorView.swift; sourceTree = ""; };
@@ -4470,6 +4485,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 /* LocationAttachmentInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationAttachmentInfo.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 = ""; };
BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONDecoder_Tests.swift; sourceTree = ""; };
BCE483AC99F58A9034EA2ECE /* FilterEncoding_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterEncoding_Tests.swift; sourceTree = ""; };
BCE4862E2C4943998F0DCBD9 /* FilterDecoding_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterDecoding_Tests.swift; sourceTree = ""; };
@@ -4507,7 +4525,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 = ""; };
@@ -5033,6 +5050,7 @@
225D7FE125D191400094E555 /* ChatMessageImageAttachment.swift */,
22692C8625D176F4007C41D0 /* ChatMessageLinkAttachment.swift */,
79983C80266633C2000995F6 /* ChatMessageVideoAttachment.swift */,
+ ADFCA5B52D121EE9000F515F /* Location */,
);
path = Attachments;
sourceTree = "";
@@ -5610,6 +5628,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 */,
@@ -5670,6 +5689,7 @@
A3227E7D284A511200EBE6CC /* DemoAppConfiguration.swift */,
AD7110C32B3434F700AFFE28 /* StreamRuntimeCheck+StreamInternal.swift */,
792DDA5B256FB69E001DB91B /* SceneDelegate.swift */,
+ ADFCA5B62D1232A7000F515F /* LocationProvider.swift */,
8440861528FFE85F0027849C /* Shared */,
A3227E56284A47F700EBE6CC /* StreamChat */,
A3227ECA284A607D00EBE6CC /* Screens */,
@@ -6339,7 +6359,6 @@
ACA3C98526CA23F300EB8B07 /* DateUtils.swift */,
79F691B12604C10A000AE89B /* SystemEnvironment.swift */,
CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */,
- C12297D22AC57A3200C5FF04 /* Throttler.swift */,
AD169DEC2C9B112B00F58FAC /* KeyboardHandler */,
AD95FD0F28F9B72200DBDF41 /* Extensions */,
ACCA772826C40C7A007AE2ED /* ImageLoading */,
@@ -7264,6 +7283,8 @@
A364D0A127D0C8930029857A /* Attachments */ = {
isa = PBXGroup;
children = (
+ ADE043652D2C59F900B4250D /* LiveLocationAttachmentPayload_Tests.swift */,
+ ADE043662D2C59F900B4250D /* StaticLocationAttachmentPayload_Tests.swift */,
84CC56EA267B3D5900DF2784 /* AnyAttachmentPayload_Tests.swift */,
ADB951B4291DD30400800554 /* AnyAttachmentUpdater_Tests.swift */,
8875CF8D2587A7F200BBA6AC /* AttachmentId_Tests.swift */,
@@ -8408,12 +8429,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 = "";
@@ -9082,6 +9104,16 @@
path = InputChatMessageView;
sourceTree = "";
};
+ ADFCA5B52D121EE9000F515F /* Location */ = {
+ isa = PBXGroup;
+ children = (
+ ADFCA5B22D121EAF000F515F /* LocationAttachmentInfo.swift */,
+ AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */,
+ AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.swift */,
+ );
+ path = Location;
+ sourceTree = "";
+ };
BD3EA7F4264AD954003AFA09 /* AttachmentViews */ = {
isa = PBXGroup;
children = (
@@ -10774,7 +10806,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 */,
@@ -11072,7 +11103,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 */,
@@ -11081,10 +11114,10 @@
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 */,
- AD053B9D2B3358E2003612B6 /* LocationAttachmentPayload.swift in Sources */,
AD053BA12B3359DD003612B6 /* DemoAttachmentViewCatalog.swift in Sources */,
AD053B9F2B335929003612B6 /* LocationAttachmentViewInjector.swift in Sources */,
);
@@ -11363,6 +11396,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 */,
@@ -11402,6 +11436,7 @@
AD52A21C2804851600D0157E /* CommandDTO.swift in Sources */,
AD37D7CD2BC9937200800D8C /* Thread.swift in Sources */,
792AF91624D812440010097B /* EntityChange.swift in Sources */,
+ AD770B682D09E2D5003AC602 /* ChatMessageLiveLocationAttachment.swift in Sources */,
AD84377B2BB482CF000F3826 /* ThreadEndpoints.swift in Sources */,
404296EB2A011B050089126D /* AudioSessionProtocol.swift in Sources */,
C186BFAF27AADB410099CCA6 /* SyncOperations.swift in Sources */,
@@ -11534,6 +11569,7 @@
792FCB4924A3BF38000290C7 /* OptionSet+Extensions.swift in Sources */,
79A0E9AD2498BD0C00E9BD50 /* ChatClient.swift in Sources */,
F688643624E6DA8700A71361 /* CurrentUserController.swift in Sources */,
+ AD770B662D09BA15003AC602 /* ChatMessageStaticLocationAttachment.swift in Sources */,
4F8E531C2B833D6C008C0F9F /* ChatState.swift in Sources */,
88BEBCD32536FD7600D9E8B7 /* MemberListController+Combine.swift in Sources */,
888E8C36252B2AAF00195E03 /* UserController+SwiftUI.swift in Sources */,
@@ -11654,6 +11690,7 @@
79FC85E724ACCBC500A665ED /* Token.swift in Sources */,
4F4562F62C240FD200675C7F /* DatabaseItemConverter.swift in Sources */,
79877A0E2498E4BC00015F8B /* Channel.swift in Sources */,
+ ADFCA5B32D121EB8000F515F /* LocationAttachmentInfo.swift in Sources */,
DA4AA3B22502718600FAAF6E /* ChannelController+Combine.swift in Sources */,
40789D1D29F6AC500018C2BB /* AudioPlayingDelegate.swift in Sources */,
ADF34F8A25CDC58900AD637C /* ConnectionController.swift in Sources */,
@@ -11916,6 +11953,8 @@
79DDF810249CB92E002F4412 /* RequestDecoder_Tests.swift in Sources */,
A3F65E3A27EB72F6003F6256 /* Event+Equatable.swift in Sources */,
88AA928E254735CF00BFA0C3 /* MessageReactionDTO_Tests.swift in Sources */,
+ ADE043672D2C59F900B4250D /* LiveLocationAttachmentPayload_Tests.swift in Sources */,
+ ADE043682D2C59F900B4250D /* StaticLocationAttachmentPayload_Tests.swift in Sources */,
889B00E5252C972C007709A8 /* ChannelMemberListQuery_Tests.swift in Sources */,
84C11BE127FB2C2B00000A9E /* ChannelReadDTO_Tests.swift in Sources */,
A3960E0D27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift in Sources */,
@@ -12293,6 +12332,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 */,
@@ -12376,6 +12416,7 @@
C121E869274544AF00023E4C /* ConnectionRecoveryHandler.swift in Sources */,
C121E86B274544AF00023E4C /* AttachmentQueueUploader.swift in Sources */,
841BAA552BD26136000C73E4 /* PollOption.swift in Sources */,
+ AD770B652D09BA15003AC602 /* ChatMessageStaticLocationAttachment.swift in Sources */,
4042968A29FACA6A0089126D /* AudioValuePercentageNormaliser.swift in Sources */,
841BAA112BCEADAC000C73E4 /* PollsEvents.swift in Sources */,
C121E86D274544AF00023E4C /* DatabaseContainer.swift in Sources */,
@@ -12466,6 +12507,7 @@
C121E895274544B000023E4C /* MessagePinning.swift in Sources */,
4042968429FACA0E0089126D /* AudioSamplesProcessor.swift in Sources */,
C121E896274544B000023E4C /* UnreadCount.swift in Sources */,
+ ADFCA5B42D121EB8000F515F /* LocationAttachmentInfo.swift in Sources */,
842F9746277A09B10060A489 /* PinnedMessagesQuery.swift in Sources */,
C121E897274544B000023E4C /* User+SwiftUI.swift in Sources */,
C121E898274544B000023E4C /* MessageReaction.swift in Sources */,
@@ -12513,6 +12555,7 @@
C121E8B3274544B000023E4C /* CurrentUserController.swift in Sources */,
4F6AD5E42CABEAB6007E769C /* KeyPath+Extensions.swift in Sources */,
C174E0F7284DFA5A0040B936 /* IdentifiablePayload.swift in Sources */,
+ AD770B692D09E2D5003AC602 /* ChatMessageLiveLocationAttachment.swift in Sources */,
AD0CC0382BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */,
841BAA052BCE94F8000C73E4 /* QueryPollsRequestBody.swift in Sources */,
C121E8B4274544B000023E4C /* CurrentUserController+SwiftUI.swift in Sources */,
@@ -12716,7 +12759,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 */,
diff --git a/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme b/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme
index fe8fca18fa..abce29fc90 100644
--- a/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme
+++ b/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme
@@ -50,6 +50,12 @@
ReferencedContainer = "container:StreamChat.xcodeproj">
+
+
+
+
) -> 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
@@ -247,6 +254,12 @@ 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
}
override func getMessage(cid: ChannelId, messageId: MessageId, completion: ((Result) -> Void)? = nil) {
@@ -516,6 +529,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/TestData/DummyData/MessageAttachmentPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift
index 486abfe9f8..fd12204b87 100644
--- a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift
+++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift
@@ -1,9 +1,9 @@
//
-// Copyright © 2025 Stream.io Inc. All rights reserved.
+// Copyright 2025 Stream.io Inc. All rights reserved.
//
import Foundation
-@testable import StreamChat
+@testable @_spi(ExperimentalLocation) import StreamChat
extension MessageAttachmentPayload {
static func dummy(
@@ -173,4 +173,42 @@ extension MessageAttachmentPayload {
])
)
}
+
+ static func staticLocation(
+ latitude: Double = 51.5074,
+ longitude: Double = -0.1278
+ ) -> Self {
+ .init(
+ type: .staticLocation,
+ payload: .dictionary([
+ "latitude": .number(latitude),
+ "longitude": .number(longitude)
+ ])
+ )
+ }
+
+ static func liveLocation(
+ latitude: Double = 51.5074,
+ longitude: Double = -0.1278,
+ stoppedSharing: Bool = false
+ ) -> Self {
+ .init(
+ type: .liveLocation,
+ payload: .dictionary([
+ "latitude": .number(latitude),
+ "longitude": .number(longitude),
+ "stopped_sharing": .bool(stoppedSharing)
+ ])
+ )
+ }
+
+ var decodedStaticLocationPayload: StaticLocationAttachmentPayload? {
+ let data = try! JSONEncoder.stream.encode(payload)
+ return try? JSONDecoder.stream.decode(StaticLocationAttachmentPayload.self, from: data)
+ }
+
+ var decodedLiveLocationPayload: LiveLocationAttachmentPayload? {
+ let data = try! JSONEncoder.stream.encode(payload)
+ return try? JSONDecoder.stream.decode(LiveLocationAttachmentPayload.self, from: data)
+ }
}
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift
index 35ea330b58..3657dac6af 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/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
index 4f5de48e33..d1401ba136 100644
--- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
@@ -3,6 +3,7 @@
//
import CoreData
+@_spi(ExperimentalLocation)
@testable import StreamChat
@testable import StreamChatTestTools
import XCTest
@@ -43,6 +44,7 @@ final class ChannelController_Tests: XCTestCase {
client?.cleanUp()
env?.channelUpdater?.cleanUp()
env?.memberUpdater?.cleanUp()
+ env?.messageUpdater?.cleanUp()
env?.eventSender?.cleanUp()
env = nil
@@ -5566,6 +5568,198 @@ 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 = LocationAttachmentInfo(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 attachment = env.channelUpdater?.createNewMessage_attachments?.first
+ XCTAssertEqual(attachment?.type, .staticLocation)
+ let payload = attachment?.payload as? StaticLocationAttachmentPayload
+ XCTAssertEqual(payload?.latitude, location.latitude)
+ XCTAssertEqual(payload?.longitude, location.longitude)
+ }
+
+ func test_startLiveLocationSharing_whenActiveLiveLocationExists_fails() throws {
+ // Given
+ let location = LocationAttachmentInfo(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),
+ for: self.channelId,
+ syncOwnReactions: false,
+ cache: nil
+ )
+ try $0.saveAttachment(
+ payload: .liveLocation(
+ latitude: location.latitude,
+ longitude: location.longitude,
+ stoppedSharing: false
+ ),
+ id: .init(cid: self.channelId, messageId: existingMessageId, index: 0)
+ )
+ }
+
+ // When
+ var receivedError: Error?
+ let exp = expectation(description: "startLiveLocationSharing")
+ controller.startLiveLocationSharing(location) { result in
+ if case .failure(let error) = result {
+ receivedError = error
+ }
+ exp.fulfill()
+ }
+
+ env.channelUpdater?.createNewMessage_completion?(.success(.mock()))
+
+ wait(for: [exp], timeout: defaultTimeout)
+
+ // Then
+ XCTAssertTrue(receivedError is ClientError.ActiveLiveLocationAlreadyExists)
+ }
+
+ func test_startLiveLocationSharing_whenNoActiveLiveLocation_callsChannelUpdater() throws {
+ // Given
+ let location = LocationAttachmentInfo(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,
+ 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 attachment = env.channelUpdater?.createNewMessage_attachments?.first
+ XCTAssertEqual(attachment?.type, .liveLocation)
+ let payload = attachment?.payload as? LiveLocationAttachmentPayload
+ XCTAssertEqual(payload?.latitude, location.latitude)
+ XCTAssertEqual(payload?.longitude, location.longitude)
+ XCTAssertEqual(payload?.stoppedSharing, false)
+ }
+
+ func test_stopLiveLocationSharing_whenNoActiveLiveLocation_fails() throws {
+ // Given
+ try client.databaseContainer.createChannel(cid: channelId)
+ try client.databaseContainer.createCurrentUser()
+
+ // When
+ var receivedError: Error?
+ let exp = expectation(description: "stopLiveLocationSharing")
+ controller.stopLiveLocationSharing { result in
+ if case .failure(let error) = result {
+ receivedError = error
+ }
+ exp.fulfill()
+ }
+
+ env.channelUpdater?.createNewMessage_completion?(.success(.mock()))
+
+ wait(for: [exp], timeout: defaultTimeout)
+
+ // Then
+ XCTAssertTrue(receivedError is ClientError.MessageDoesNotHaveLiveLocationAttachment)
+ }
+
+ func test_stopLiveLocationSharing_whenActiveLiveLocationExists_updatesMessage() throws {
+ // Given
+ let location = LocationAttachmentInfo(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),
+ for: self.channelId,
+ syncOwnReactions: false,
+ cache: nil
+ )
+ try $0.saveAttachment(
+ payload: .liveLocation(
+ latitude: location.latitude,
+ longitude: location.longitude,
+ stoppedSharing: false
+ ),
+ id: .init(cid: self.channelId, messageId: existingMessageId, index: 0)
+ )
+ }
+
+ // When
+ let exp = expectation(description: "stopLiveLocationSharing")
+ controller.stopLiveLocationSharing { _ in
+ exp.fulfill()
+ }
+
+ env.messageUpdater?.updatePartialMessage_completion_result = .success(.mock())
+ env.messageUpdater?.updatePartialMessage_completion?(.success(.mock()))
+
+ wait(for: [exp], timeout: defaultTimeout)
+
+ // Then
+ let attachment = env.messageUpdater?.updatePartialMessage_attachments?.first
+ XCTAssertEqual(attachment?.type, .liveLocation)
+ let payload = attachment?.payload as? LiveLocationAttachmentPayload
+ XCTAssertEqual(payload?.latitude, location.latitude)
+ XCTAssertEqual(payload?.longitude, location.longitude)
+ XCTAssertEqual(payload?.stoppedSharing, true)
+ }
}
// MARK: Test Helpers
@@ -5807,6 +6001,7 @@ private class ControllerUpdateWaiter: ChatChannelControllerDelegate {
private class TestEnvironment {
var channelUpdater: ChannelUpdater_Mock?
var memberUpdater: ChannelMemberUpdater_Mock?
+ var messageUpdater: MessageUpdater_Mock?
var eventSender: TypingEventsSender_Mock?
lazy var environment: ChatChannelController.Environment = .init(
@@ -5824,6 +6019,15 @@ private class TestEnvironment {
self.memberUpdater = ChannelMemberUpdater_Mock(database: $0, apiClient: $1)
return self.memberUpdater!
},
+ messageUpdaterBuilder: { [unowned self] in
+ self.messageUpdater = MessageUpdater_Mock(
+ isLocalStorageEnabled: $0,
+ messageRepository: $1,
+ database: $2,
+ apiClient: $3
+ )
+ return self.messageUpdater!
+ },
eventSenderBuilder: { [unowned self] in
self.eventSender = TypingEventsSender_Mock(database: $0, apiClient: $1)
return self.eventSender!
diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift
index c1a298a3b1..97e924086c 100644
--- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift
@@ -3,6 +3,7 @@
//
import CoreData
+@_spi(ExperimentalLocation)
@testable import StreamChat
@testable import StreamChatTestTools
import XCTest
@@ -474,8 +475,10 @@ final class MessageController_Tests: XCTestCase {
let channel = dummyPayload(with: cid)
let truncatedDate = Date.unique
+ var config = ChatClientConfig(apiKey: .init(.anonymous))
+ config.deletedMessagesVisibility = .visibleForCurrentUser
+ client = ChatClient.mock(config: config)
try client.databaseContainer.createCurrentUser(id: currentUserId)
- client.databaseContainer.viewContext.deletedMessagesVisibility = .visibleForCurrentUser
controller = ChatMessageController(client: client, cid: cid, messageId: messageId, replyPaginationHandler: replyPaginationHandler, environment: env.controllerEnvironment)
// Insert own deleted reply
@@ -510,8 +513,10 @@ final class MessageController_Tests: XCTestCase {
let channel = dummyPayload(with: cid)
let truncatedDate = Date.unique
+ var config = ChatClientConfig(apiKey: .init(.anonymous))
+ config.deletedMessagesVisibility = .alwaysHidden
+ client = ChatClient.mock(config: config)
try client.databaseContainer.createCurrentUser(id: currentUserId)
- client.databaseContainer.viewContext.deletedMessagesVisibility = .alwaysHidden
controller = ChatMessageController(client: client, cid: cid, messageId: messageId, replyPaginationHandler: replyPaginationHandler, environment: env.controllerEnvironment)
// Save channel
@@ -601,8 +606,10 @@ final class MessageController_Tests: XCTestCase {
let channel = dummyPayload(with: cid)
let truncatedDate = Date.unique
+ var config = ChatClientConfig(apiKey: .init(.anonymous))
+ config.shouldShowShadowedMessages = true
+ client = ChatClient.mock(config: config)
try client.databaseContainer.createCurrentUser(id: currentUserId)
- client.databaseContainer.viewContext.shouldShowShadowedMessages = true
controller = ChatMessageController(client: client, cid: cid, messageId: messageId, replyPaginationHandler: replyPaginationHandler, environment: env.controllerEnvironment)
// Save channel
@@ -2483,9 +2490,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")
@@ -2493,6 +2498,268 @@ 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: - Live Location
+
+ func test_updateLiveLocation_callsMessageUpdater_withCorrectValues() {
+ // Given
+ let latitude = 51.5074
+ let longitude = -0.1278
+
+ // Save message with live location
+ _ = controller.message
+ env.messageObserver.item_mock = .mock(
+ id: messageId,
+ attachments: [
+ ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: .init(latitude: latitude, longitude: longitude, stoppedSharing: false),
+ downloadingState: nil,
+ uploadingState: nil
+ ).asAnyAttachment
+ ]
+ )
+
+ // When
+ let location = LocationAttachmentInfo(
+ latitude: latitude,
+ longitude: longitude
+ )
+ controller.updateLiveLocation(location)
+
+ // Simulate
+ env.messageUpdater.updatePartialMessage_completion?(.success(.mock(id: messageId)))
+
+ // Then
+ XCTAssertEqual(env.messageUpdater.updatePartialMessage_messageId, messageId)
+ XCTAssertEqual(
+ env.messageUpdater.updatePartialMessage_attachments?.first?.type,
+ AttachmentType.liveLocation
+ )
+
+ let payload = env.messageUpdater.updatePartialMessage_attachments?.first?.payload as? LiveLocationAttachmentPayload
+ XCTAssertEqual(payload?.latitude, latitude)
+ XCTAssertEqual(payload?.longitude, longitude)
+ }
+
+ func test_updateLiveLocation_whenNoLiveLocationAttachment_completesWithError() {
+ // Create a mock message without live location attachment
+ _ = controller.message
+ env.messageObserver.item_mock = .mock(
+ id: messageId,
+ attachments: [
+ ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: .init(latitude: 10, longitude: 30, stoppedSharing: true),
+ downloadingState: nil,
+ uploadingState: nil
+ ).asAnyAttachment
+ ]
+ )
+
+ // Create the location info to update
+ let location = LocationAttachmentInfo(
+ latitude: 1.0,
+ longitude: 1.0
+ )
+
+ // Update live location
+ var receivedError: Error?
+ controller.updateLiveLocation(location) { result in
+ if case let .failure(error) = result {
+ receivedError = error
+ }
+ }
+
+ // Assert error is returned
+ XCTAssertTrue(receivedError is ClientError.MessageLiveLocationAlreadyStopped)
+ }
+
+ func test_updateLiveLocation_whenLiveLocationHasAlreadyStopped_completesWithError() {
+ // Create a mock message without live location attachment
+ _ = controller.message
+ env.messageObserver.item_mock = .mock(
+ id: messageId,
+ attachments: []
+ )
+
+ // Create the location info to update
+ let location = LocationAttachmentInfo(
+ latitude: 1.0,
+ longitude: 1.0
+ )
+
+ // Update live location
+ var receivedError: Error?
+ controller.updateLiveLocation(location) { result in
+ if case let .failure(error) = result {
+ receivedError = error
+ }
+ }
+
+ // Assert error is returned
+ XCTAssertTrue(receivedError is ClientError.MessageDoesNotHaveLiveLocationAttachment)
+ }
+
+ // MARK: - Stop Live Location Tests
+
+ func test_stopLiveLocationSharing_callsMessageUpdater_withCorrectValues() {
+ // Save message with live location
+ _ = controller.message
+ env.messageObserver.item_mock = .mock(
+ id: messageId,
+ attachments: [
+ ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: .init(latitude: 10, longitude: 10, stoppedSharing: false),
+ downloadingState: nil,
+ uploadingState: nil
+ ).asAnyAttachment
+ ]
+ )
+
+ // When
+ controller.stopLiveLocationSharing()
+
+ // Simulate
+ env.messageUpdater.updatePartialMessage_completion?(.success(.mock(id: messageId)))
+
+ // Then
+ XCTAssertEqual(env.messageUpdater.updatePartialMessage_messageId, messageId)
+ XCTAssertEqual(
+ env.messageUpdater.updatePartialMessage_attachments?.first?.type,
+ AttachmentType.liveLocation
+ )
+
+ let payload = env.messageUpdater.updatePartialMessage_attachments?.first?.payload as? LiveLocationAttachmentPayload
+ XCTAssertEqual(payload?.latitude, 10)
+ XCTAssertEqual(payload?.longitude, 10)
+ XCTAssertEqual(payload?.stoppedSharing, true)
+ }
+
+ func test_stopLiveLocationSharing_whenNoLiveLocationAttachment_completesWithError() {
+ // Given
+ // Create a mock message without live location attachment
+ _ = controller.message
+ env.messageObserver.item_mock = .mock(
+ id: messageId,
+ attachments: []
+ )
+
+ // 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
+ // Create a mock message with stopped live location
+ _ = controller.message
+ env.messageObserver.item_mock = .mock(
+ id: messageId,
+ attachments: [
+ ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: .init(latitude: 10, longitude: 30, stoppedSharing: true),
+ downloadingState: nil,
+ uploadingState: nil
+ ).asAnyAttachment
+ ]
+ )
+
+ // When
+ var receivedError: Error?
+ let exp = expectation(description: "stopLiveLocationSharing")
+ 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/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
index ef48cb32be..d1d558e7e1 100644
--- a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
+++ b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
@@ -4098,6 +4098,86 @@ 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 attachments: [MessageAttachmentPayload] = [
+ .liveLocation(
+ latitude: 50,
+ longitude: 10,
+ stoppedSharing: !isActive
+ )
+ ]
+
+ let messagePayload: MessagePayload = .dummy(
+ messageId: id,
+ attachments: attachments,
+ authorUserId: userId
+ )
+
+ 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.loadActiveLiveLocationMessages(
+ 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.loadActiveLiveLocationMessages(
+ currentUserId: currentUserId,
+ channelId: channel1Id,
+ context: database.viewContext
+ )
+ XCTAssertEqual(loadedMessages.count, 1) // Should only get the active message from channel 1
+ }
+ }
+
// MARK: - Helpers:
private func message(with id: MessageId) -> ChatMessage? {
diff --git a/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift
new file mode 100644
index 0000000000..1c2b4626dd
--- /dev/null
+++ b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift
@@ -0,0 +1,78 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+@_spi(ExperimentalLocation)
+@testable import StreamChat
+@testable import StreamChatTestTools
+import XCTest
+
+final class LiveLocationAttachmentPayload_Tests: XCTestCase {
+ func test_decodingDefaultValues() throws {
+ // Create attachment field values
+ let latitude: Double = 51.5074
+ let longitude: Double = -0.1278
+ let stoppedSharing = true
+
+ // Create JSON with the given values
+ let json = """
+ {
+ "latitude": \(latitude),
+ "longitude": \(longitude),
+ "stopped_sharing": \(stoppedSharing)
+ }
+ """.data(using: .utf8)!
+
+ // Decode attachment from JSON
+ let payload = try JSONDecoder.stream.decode(LiveLocationAttachmentPayload.self, from: json)
+
+ // Assert values are decoded correctly
+ XCTAssertEqual(payload.latitude, latitude)
+ XCTAssertEqual(payload.longitude, longitude)
+ XCTAssertEqual(payload.stoppedSharing, stoppedSharing)
+ }
+
+ func test_decodingExtraData() throws {
+ // Create attachment field values
+ let latitude: Double = 51.5074
+ let longitude: Double = -0.1278
+ let locationName: String = .unique
+
+ // Create JSON with the given values
+ let json = """
+ {
+ "latitude": \(latitude),
+ "longitude": \(longitude),
+ "locationName": "\(locationName)"
+ }
+ """.data(using: .utf8)!
+
+ // Decode attachment from JSON
+ let payload = try JSONDecoder.stream.decode(LiveLocationAttachmentPayload.self, from: json)
+
+ // Assert values are decoded correctly
+ XCTAssertEqual(payload.latitude, latitude)
+ XCTAssertEqual(payload.longitude, longitude)
+ XCTAssertEqual(payload.extraData?["locationName"]?.stringValue, locationName)
+ }
+
+ func test_encoding() throws {
+ let payload = LiveLocationAttachmentPayload(
+ latitude: 51.5074,
+ longitude: -0.1278,
+ stoppedSharing: true,
+ extraData: ["locationName": "London"]
+ )
+
+ let json = try JSONEncoder.stream.encode(payload)
+
+ let expectedJsonObject: [String: Any] = [
+ "latitude": 51.5074,
+ "longitude": -0.1278,
+ "stopped_sharing": true,
+ "locationName": "London"
+ ]
+
+ AssertJSONEqual(json, expectedJsonObject)
+ }
+}
diff --git a/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift
new file mode 100644
index 0000000000..8e45688c49
--- /dev/null
+++ b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift
@@ -0,0 +1,73 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+@_spi(ExperimentalLocation)
+@testable import StreamChat
+@testable import StreamChatTestTools
+import XCTest
+
+final class StaticLocationAttachmentPayload_Tests: XCTestCase {
+ func test_decodingDefaultValues() throws {
+ // Create attachment field values
+ let latitude: Double = 51.5074
+ let longitude: Double = -0.1278
+
+ // Create JSON with the given values
+ let json = """
+ {
+ "latitude": \(latitude),
+ "longitude": \(longitude)
+ }
+ """.data(using: .utf8)!
+
+ // Decode attachment from JSON
+ let payload = try JSONDecoder.stream.decode(StaticLocationAttachmentPayload.self, from: json)
+
+ // Assert values are decoded correctly
+ XCTAssertEqual(payload.latitude, latitude)
+ XCTAssertEqual(payload.longitude, longitude)
+ }
+
+ func test_decodingExtraData() throws {
+ // Create attachment field values
+ let latitude: Double = 51.5074
+ let longitude: Double = -0.1278
+ let locationName: String = .unique
+
+ // Create JSON with the given values
+ let json = """
+ {
+ "latitude": \(latitude),
+ "longitude": \(longitude),
+ "locationName": "\(locationName)"
+ }
+ """.data(using: .utf8)!
+
+ // Decode attachment from JSON
+ let payload = try JSONDecoder.stream.decode(StaticLocationAttachmentPayload.self, from: json)
+
+ // Assert values are decoded correctly
+ XCTAssertEqual(payload.latitude, latitude)
+ XCTAssertEqual(payload.longitude, longitude)
+ XCTAssertEqual(payload.extraData?["locationName"]?.stringValue, locationName)
+ }
+
+ func test_encoding() throws {
+ let payload = StaticLocationAttachmentPayload(
+ latitude: 51.5074,
+ longitude: -0.1278,
+ extraData: ["locationName": "London"]
+ )
+
+ let json = try JSONEncoder.stream.encode(payload)
+
+ let expectedJsonObject: [String: Any] = [
+ "latitude": 51.5074,
+ "longitude": -0.1278,
+ "locationName": "London"
+ ]
+
+ AssertJSONEqual(json, expectedJsonObject)
+ }
+}
diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift
index b60cd84054..9435298417 100644
--- a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift
+++ b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift
@@ -2,6 +2,7 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//
+@_spi(ExperimentalLocation)
@testable import StreamChat
@testable import StreamChatTestTools
import XCTest
@@ -392,4 +393,137 @@ final class ChatMessage_Tests: XCTestCase {
XCTAssertEqual(actualIds, expectedIds)
}
+
+ // MARK: - staticLocationAttachments
+
+ func test_staticLocationAttachments_whenNoAttachments_returnsEmpty() {
+ let message: ChatMessage = .mock(
+ id: .unique,
+ cid: .unique,
+ text: .unique,
+ author: .mock(id: .unique),
+ attachments: []
+ )
+
+ XCTAssertTrue(message.staticLocationAttachments.isEmpty)
+ }
+
+ func test_staticLocationAttachments_whenHasLocationAttachments_returnsOnlyStaticLocationAttachments() {
+ let staticLocation1 = ChatMessageStaticLocationAttachment(
+ id: .unique,
+ type: .staticLocation,
+ payload: StaticLocationAttachmentPayload(
+ latitude: 51.5074,
+ longitude: -0.1278
+ ),
+ downloadingState: nil,
+ uploadingState: nil
+ )
+ let staticLocation2 = ChatMessageStaticLocationAttachment(
+ id: .unique,
+ type: .staticLocation,
+ payload: StaticLocationAttachmentPayload(
+ latitude: 40.7128,
+ longitude: -74.0060
+ ),
+ downloadingState: nil,
+ uploadingState: nil
+ )
+ let liveLocation = ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: LiveLocationAttachmentPayload(
+ latitude: 48.8566,
+ longitude: 2.3522,
+ stoppedSharing: false
+ ),
+ downloadingState: nil,
+ uploadingState: nil
+ )
+
+ let message: ChatMessage = .mock(
+ id: .unique,
+ cid: .unique,
+ text: .unique,
+ author: .mock(id: .unique),
+ attachments: [
+ staticLocation1.asAnyAttachment,
+ liveLocation.asAnyAttachment,
+ staticLocation2.asAnyAttachment
+ ]
+ )
+
+ XCTAssertEqual(message.staticLocationAttachments.count, 2)
+ XCTAssertEqual(
+ Set(message.staticLocationAttachments.map(\.id)),
+ Set([staticLocation1.id, staticLocation2.id])
+ )
+ }
+
+ // MARK: - liveLocationAttachments
+
+ func test_liveLocationAttachments_whenNoAttachments_returnsEmpty() {
+ let message: ChatMessage = .mock(
+ id: .unique,
+ cid: .unique,
+ text: .unique,
+ author: .mock(id: .unique),
+ attachments: []
+ )
+
+ XCTAssertTrue(message.liveLocationAttachments.isEmpty)
+ }
+
+ func test_liveLocationAttachments_whenHasLocationAttachments_returnsOnlyLiveLocationAttachments() {
+ let liveLocation1 = ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: LiveLocationAttachmentPayload(
+ latitude: 48.8566,
+ longitude: 2.3522,
+ stoppedSharing: false
+ ),
+ downloadingState: nil,
+ uploadingState: nil
+ )
+ let liveLocation2 = ChatMessageLiveLocationAttachment(
+ id: .unique,
+ type: .liveLocation,
+ payload: LiveLocationAttachmentPayload(
+ latitude: 35.6762,
+ longitude: 139.6503,
+ stoppedSharing: true
+ ),
+ downloadingState: nil,
+ uploadingState: nil
+ )
+ let staticLocation = ChatMessageStaticLocationAttachment(
+ id: .unique,
+ type: .staticLocation,
+ payload: StaticLocationAttachmentPayload(
+ latitude: 51.5074,
+ longitude: -0.1278
+ ),
+ downloadingState: nil,
+ uploadingState: nil
+ )
+
+ let message: ChatMessage = .mock(
+ id: .unique,
+ cid: .unique,
+ text: .unique,
+ author: .mock(id: .unique),
+ attachments: [
+ liveLocation1.asAnyAttachment,
+ staticLocation.asAnyAttachment,
+ liveLocation2.asAnyAttachment
+ ]
+ )
+
+ XCTAssertEqual(message.liveLocationAttachments.count, 2)
+ XCTAssertEqual(
+ Set(message.liveLocationAttachments.map(\.id)),
+ Set([liveLocation1.id, liveLocation2.id])
+ )
+ }
}
diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
index 28abae056a..6851cd307a 100644
--- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
@@ -653,6 +653,67 @@ final class MessageRepositoryTests: XCTestCase {
XCTAssertEqual(reactionState, .deletingFailed)
XCTAssertEqual(reactionScore, 10)
}
+
+ // 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.getActiveLiveLocationMessages(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,
+ attachments: [.dummy(type: .liveLocation)]
+ )
+ try database.createMessage(
+ id: messageId2,
+ authorId: currentUserId,
+ cid: cid,
+ attachments: [.dummy(type: .liveLocation)]
+ )
+
+ let expectation = self.expectation(description: "getActiveLiveLocationMessages completes")
+ var receivedMessages: [ChatMessage]?
+
+ repository.getActiveLiveLocationMessages(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]))
+ }
}
extension MessageRepositoryTests {
diff --git a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift
index a965987772..5d9271a461 100644
--- a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift
+++ b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift
@@ -2974,6 +2974,124 @@ 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: - Helpers