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