From 340f10c7abed603c815f966ad07ef6148bfe00ad Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 11 Dec 2024 15:33:34 +0000 Subject: [PATCH 01/94] Add static and live location payloads --- .../Models/Attachments/AttachmentTypes.swift | 2 ++ .../ChatMessageLiveLocationAttachment.swift | 35 +++++++++++++++++++ .../ChatMessageStaticLocationAttachment.swift | 31 ++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 16 ++++++--- 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift create mode 100644 Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift diff --git a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift index d1941d4cdab..f807012b4c8 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/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift new file mode 100644 index 00000000000..6a2bf2fb45e --- /dev/null +++ b/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift @@ -0,0 +1,35 @@ +// +// Copyright © 2024 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. +public typealias ChatMessageLiveLocationAttachment = ChatMessageAttachment + +/// The payload for attachments with `.liveLocation` type. +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 + + public init(latitude: Double, longitude: Double, stoppedSharing: Bool) { + self.latitude = latitude + self.longitude = longitude + self.stoppedSharing = stoppedSharing + } + + enum CodingKeys: String, CodingKey { + case latitude + case longitude + case stoppedSharing = "stopped_sharing" + } +} diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift new file mode 100644 index 00000000000..9fd62a1c01c --- /dev/null +++ b/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift @@ -0,0 +1,31 @@ +// +// Copyright © 2024 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. +public typealias ChatMessageStaticLocationAttachment = ChatMessageAttachment + +/// The payload for attachments with `.staticLocation` type. +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 + + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + enum CodingKeys: String, CodingKey { + case latitude + case longitude + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 94b8a2ed72a..2170357518b 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1368,7 +1368,6 @@ 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 */; }; @@ -1548,6 +1547,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 */; }; @@ -4173,7 +4176,6 @@ 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 = ""; }; @@ -4293,6 +4295,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 = ""; }; @@ -5033,6 +5037,8 @@ 225D7FE125D191400094E555 /* ChatMessageImageAttachment.swift */, 22692C8625D176F4007C41D0 /* ChatMessageLinkAttachment.swift */, 79983C80266633C2000995F6 /* ChatMessageVideoAttachment.swift */, + AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */, + AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.swift */, ); path = Attachments; sourceTree = ""; @@ -8408,7 +8414,6 @@ AD053B9B2B33589C003612B6 /* LocationAttachment */ = { isa = PBXGroup; children = ( - AD053B9C2B3358E2003612B6 /* LocationAttachmentPayload.swift */, AD053BA22B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift */, AD053B9E2B335929003612B6 /* LocationAttachmentViewInjector.swift */, AD053BA62B33624C003612B6 /* LocationAttachmentViewDelegate.swift */, @@ -11084,7 +11089,6 @@ 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 */, ); @@ -11402,6 +11406,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 +11539,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 */, @@ -12376,6 +12382,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 */, @@ -12513,6 +12520,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 */, From 92d15acf1df35530d32af2376c02a78b1eabf980 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 11 Dec 2024 15:35:11 +0000 Subject: [PATCH 02/94] Update the demo app to use static location attachment --- .../DemoAttachmentViewCatalog.swift | 2 +- .../CustomAttachments/DemoComposerVC.swift | 7 +++--- .../DemoQuotedChatMessageView.swift | 4 ++-- ...chmentPayload+AttachmentViewProvider.swift | 5 +++-- .../LocationAttachmentPayload.swift | 22 ------------------- .../LocationAttachmentSnapshotView.swift | 5 +++++ .../LocationAttachmentViewDelegate.swift | 4 ++-- .../LocationAttachmentViewInjector.swift | 10 ++++++--- .../LocationDetailViewController.swift | 9 ++++---- .../StreamChatWrapper+DemoApp.swift | 7 ++++-- 10 files changed, 34 insertions(+), 41 deletions(-) delete mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift index 814bf3da852..d65f4f2d0f2 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift @@ -8,7 +8,7 @@ import StreamChatUI class DemoAttachmentViewCatalog: AttachmentViewCatalog { override class func attachmentViewInjectorClassFor(message: ChatMessage, components: Components) -> AttachmentViewInjector.Type? { let hasMultipleAttachmentTypes = message.attachmentCounts.keys.count > 1 - let hasLocationAttachment = message.attachmentCounts.keys.contains(.location) + let hasLocationAttachment = message.attachmentCounts.keys.contains(.staticLocation) 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 18ee301af3c..bf3c7ac5292 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -27,7 +27,7 @@ class DemoComposerVC: ComposerVC { override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions - let alreadyHasLocation = content.attachments.map(\.type).contains(.location) + let alreadyHasLocation = content.attachments.map(\.type).contains(.staticLocation) if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && !alreadyHasLocation { let sendLocationAction = UIAlertAction( title: "Location", @@ -42,8 +42,9 @@ class DemoComposerVC: ComposerVC { func sendLocation() { guard let location = dummyLocations.randomElement() else { return } - let locationAttachmentPayload = LocationAttachmentPayload( - coordinate: .init(latitude: location.latitude, longitude: location.longitude) + let locationAttachmentPayload = StaticLocationAttachmentPayload( + latitude: location.latitude, + longitude: location.longitude ) content.attachments.append(AnyAttachmentPayload(payload: locationAttachmentPayload)) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift index 5e5a1ea85f9..a47e71991b7 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift @@ -8,7 +8,7 @@ import UIKit class DemoQuotedChatMessageView: QuotedChatMessageView { override func setAttachmentPreview(for message: ChatMessage) { - let locationAttachments = message.attachments(payloadType: LocationAttachmentPayload.self) + let locationAttachments = message.attachments(payloadType: StaticLocationAttachmentPayload.self) if let locationPayload = locationAttachments.first?.payload { attachmentPreviewView.contentMode = .scaleAspectFit attachmentPreviewView.image = UIImage( @@ -18,7 +18,7 @@ class DemoQuotedChatMessageView: QuotedChatMessageView { attachmentPreviewView.tintColor = .systemRed textView.text = """ Location: - (\(locationPayload.coordinate.latitude),\(locationPayload.coordinate.longitude)) + (\(locationPayload.latitude),\(locationPayload.longitude)) """ return } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift index b93f830ec96..00a6efce321 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift @@ -2,18 +2,19 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import StreamChat import StreamChatUI import UIKit /// Location Attachment Composer Preview -extension LocationAttachmentPayload: AttachmentPreviewProvider { +extension StaticLocationAttachmentPayload: 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 + preview.coordinate = .init(latitude: latitude, longitude: longitude) return preview } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift deleted file mode 100644 index 45eb70b04b2..00000000000 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import StreamChat - -public extension AttachmentType { - static let location = Self(rawValue: "custom_location") -} - -struct LocationCoordinate: Codable, Hashable { - let latitude: Double - let longitude: Double -} - -public struct LocationAttachmentPayload: AttachmentPayload { - public static var type: AttachmentType = .location - - var coordinate: LocationCoordinate -} - -public typealias ChatMessageLocationAttachment = ChatMessageAttachment diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 509f370741d..390069bdd2a 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -6,6 +6,11 @@ import MapKit import StreamChatUI import UIKit +struct LocationCoordinate { + let latitude: CLLocationDegrees + let longitude: CLLocationDegrees +} + class LocationAttachmentSnapshotView: _View { static var snapshotsCache: NSCache = .init() diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index c6a49302f29..cb09206ba88 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -7,12 +7,12 @@ import StreamChatUI protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate { func didTapOnLocationAttachment( - _ attachment: ChatMessageLocationAttachment + _ attachment: ChatMessageStaticLocationAttachment ) } extension DemoChatMessageListVC: LocationAttachmentViewDelegate { - func didTapOnLocationAttachment(_ attachment: ChatMessageLocationAttachment) { + func didTapOnLocationAttachment(_ attachment: ChatMessageStaticLocationAttachment) { let mapViewController = LocationDetailViewController(locationAttachment: attachment) navigationController?.pushViewController(mapViewController, animated: true) } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 38bb5666a7d..6b1d3ea9843 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -9,8 +9,8 @@ import UIKit class LocationAttachmentViewInjector: AttachmentViewInjector { lazy var locationAttachmentView = LocationAttachmentSnapshotView() - var locationAttachment: ChatMessageLocationAttachment? { - attachments(payloadType: LocationAttachmentPayload.self).first + var locationAttachment: ChatMessageStaticLocationAttachment? { + attachments(payloadType: StaticLocationAttachmentPayload.self).first } override func contentViewDidLayout(options: ChatMessageLayoutOptions) { @@ -31,7 +31,11 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { override func contentViewDidUpdateContent() { super.contentViewDidUpdateContent() - locationAttachmentView.coordinate = locationAttachment?.coordinate + guard let location = locationAttachment else { + return + } + + locationAttachmentView.coordinate = .init(latitude: location.latitude, longitude: location.longitude) } func handleTapOnLocationAttachment() { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 5e4df68608a..33d47dc91cf 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -3,12 +3,13 @@ // import MapKit +import StreamChat import UIKit class LocationDetailViewController: UIViewController { - let locationAttachment: ChatMessageLocationAttachment + let locationAttachment: ChatMessageStaticLocationAttachment - init(locationAttachment: ChatMessageLocationAttachment) { + init(locationAttachment: ChatMessageStaticLocationAttachment) { self.locationAttachment = locationAttachment super.init(nibName: nil, bundle: nil) } @@ -28,8 +29,8 @@ class LocationDetailViewController: UIViewController { super.viewDidLoad() let locationCoordinate = CLLocationCoordinate2D( - latitude: locationAttachment.coordinate.latitude, - longitude: locationAttachment.coordinate.longitude + latitude: locationAttachment.latitude, + longitude: locationAttachment.longitude ) mapView.region = .init( diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index 49262cb9832..59e35a0103a 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -10,7 +10,10 @@ extension StreamChatWrapper { // Instantiates chat client func setUpChat() { if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { - Components.default.mixedAttachmentInjector.register(.location, with: LocationAttachmentViewInjector.self) + Components.default.mixedAttachmentInjector.register( + .staticLocation, + with: LocationAttachmentViewInjector.self + ) } // Set the log level @@ -26,7 +29,7 @@ extension StreamChatWrapper { if client == nil { client = ChatClient(config: config) } - client?.registerAttachment(LocationAttachmentPayload.self) + client?.registerAttachment(StaticLocationAttachmentPayload.self) // L10N let localizationProvider = Appearance.default.localizationProvider From 962ffb42a5c5644216f8513c9cc77d4114e51312 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 11 Dec 2024 16:07:56 +0000 Subject: [PATCH 03/94] Add new `ChannelController.sendStaticLocation()` to instantly send a location message to a channel --- .../CustomAttachments/DemoComposerVC.swift | 6 +-- .../ChannelController/ChannelController.swift | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index bf3c7ac5292..000f7966705 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -49,9 +49,7 @@ class DemoComposerVC: ComposerVC { content.attachments.append(AnyAttachmentPayload(payload: locationAttachmentPayload)) - // In case you would want to send the location directly, without composer preview: -// channelController?.createNewMessage(text: "", attachments: [.init( -// payload: locationAttachmentPayload -// )]) +// // In case you would want to send the location directly, without composer preview: +// channelController?.sendStaticLocation(locationAttachmentPayload) } } diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 7ee1ae60df4..04009a520c9 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -825,6 +825,58 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } + /// Sends a static location message to the channel. + /// + /// - Parameters: + /// - location: The static location payload. + /// - text: The text of the message. + /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. + /// - quotedMessageId: The id of the quoted message, in case the location is an inline reply. + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes, not when the message reaches the server. + public func sendStaticLocation( + _ location: StaticLocationAttachmentPayload, + text: String? = nil, + messageId: MessageId? = nil, + quotedMessageId: MessageId? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + completion?(.failure(error ?? ClientError.Unknown())) + } + return + } + + updater.createNewMessage( + in: cid, + messageId: messageId, + text: text ?? "", + pinning: nil, + isSilent: false, + isSystem: false, + command: nil, + arguments: nil, + attachments: [ + .init(payload: location) + ], + 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)) + } + } + } + /// Creates a new poll. /// /// - Parameters: From cb365fc4ba559fb8043dd1f6ea7a129d60f50a87 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 11 Dec 2024 17:20:40 +0000 Subject: [PATCH 04/94] Change the Demo App to send the current location instead of dummy ones --- DemoApp/Info.plist | 6 + .../CustomAttachments/DemoComposerVC.swift | 118 ++++++++++++++---- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/DemoApp/Info.plist b/DemoApp/Info.plist index 5f0f3e06ea6..030330bd561 100644 --- a/DemoApp/Info.plist +++ b/DemoApp/Info.plist @@ -12,6 +12,12 @@ We need access to your camera for sending photo attachments. NSMicrophoneUsageDescription We need access to your microphone for taking a video. + NSLocationWhenInUseUsageDescription + We need access to your location to share it in the chat. + NSLocationAlwaysUsageDescription + We need access to your location to share it in the chat. + LSApplicationCategoryType + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 000f7966705..59c20b33a75 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -2,37 +2,43 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import CoreLocation import StreamChat import StreamChatUI import UIKit class DemoComposerVC: ComposerVC { - /// For demo purposes the locations are hard-coded. - var dummyLocations: [(latitude: Double, longitude: Double)] = [ - (38.708442, -9.136822), // Lisbon, Portugal - (37.983810, 23.727539), // Athens, Greece - (53.149118, -6.079341), // Greystones, Ireland - (41.11722, 20.80194), // Ohrid, Macedonia - (51.5074, -0.1278), // London, United Kingdom - (52.5200, 13.4050), // Berlin, Germany - (40.4168, -3.7038), // Madrid, Spain - (50.4501, 30.5234), // Kyiv, Ukraine - (41.9028, 12.4964), // Rome, Italy - (48.8566, 2.3522), // Paris, France - (44.4268, 26.1025), // Bucharest, Romania - (48.2082, 16.3738), // Vienna, Austria - (47.4979, 19.0402) // Budapest, Hungary - ] + private lazy var locationManager = CLLocationManager() + + var sharingType: LocationSharingType = .addToAttachments + + enum LocationSharingType { + case addToAttachments + case instant + } override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions let alreadyHasLocation = content.attachments.map(\.type).contains(.staticLocation) if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && !alreadyHasLocation { + let addLocationAction = UIAlertAction( + title: "Add Current Location", + style: .default, + handler: { [weak self] _ in + self?.sharingType = .addToAttachments + self?.requestLocationPermission() + } + ) + actions.append(addLocationAction) + let sendLocationAction = UIAlertAction( - title: "Location", + title: "Send Current Location", style: .default, - handler: { [weak self] _ in self?.sendLocation() } + handler: { [weak self] _ in + self?.sharingType = .instant + self?.requestLocationPermission() + } ) actions.append(sendLocationAction) } @@ -40,16 +46,76 @@ class DemoComposerVC: ComposerVC { return actions } - func sendLocation() { - guard let location = dummyLocations.randomElement() else { return } - let locationAttachmentPayload = StaticLocationAttachmentPayload( - latitude: location.latitude, - longitude: location.longitude + override func setUp() { + super.setUp() + + locationManager = CLLocationManager() + } + + private func requestLocationPermission() { + locationManager.delegate = self + + switch locationManager.authorizationStatus { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + case .authorizedWhenInUse, .authorizedAlways: + locationManager.startUpdatingLocation() + case .denied, .restricted: + showLocationPermissionAlert() + @unknown default: + break + } + } + + 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) + } +} - content.attachments.append(AnyAttachmentPayload(payload: locationAttachmentPayload)) +// MARK: - CLLocationManagerDelegate + +extension DemoComposerVC: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + if status == .authorizedWhenInUse || status == .authorizedAlways { + manager.startUpdatingLocation() + } + } -// // In case you would want to send the location directly, without composer preview: -// channelController?.sendStaticLocation(locationAttachmentPayload) + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.first else { return } + + let locationPayload = StaticLocationAttachmentPayload( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + + switch sharingType { + case .addToAttachments: + content.attachments.append(AnyAttachmentPayload(payload: locationPayload)) + case .instant: + channelController?.sendStaticLocation(locationPayload) + } + + // We only send the location once so we can stop updating the location. + locationManager.stopUpdatingLocation() + locationManager.delegate = nil + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("Location manager error: \(error.localizedDescription)") } } From 8d3b6625e50d7c0c454fb465b644c92c68e081dd Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 11 Dec 2024 23:28:36 +0000 Subject: [PATCH 05/94] Add location background mode to Demo App --- DemoApp/Info.plist | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/DemoApp/Info.plist b/DemoApp/Info.plist index 030330bd561..3ab08bd18c6 100644 --- a/DemoApp/Info.plist +++ b/DemoApp/Info.plist @@ -2,22 +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. - NSLocationWhenInUseUsageDescription - We need access to your location to share it in the chat. - NSLocationAlwaysUsageDescription - We need access to your location to share it in the chat. - LSApplicationCategoryType - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -36,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 @@ -57,6 +59,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + location + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -76,7 +82,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - PushNotification-Configuration - APN-Configuration From b35f54e9bfe1b35283e128bc56592de235f7f70f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 12 Dec 2024 16:24:47 +0000 Subject: [PATCH 06/94] Add staticLocation to the attachments register --- Sources/StreamChat/ChatClient.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 3627ed00dab..b57982d8253 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -61,7 +61,8 @@ public class ChatClient { .video: VideoAttachmentPayload.self, .audio: AudioAttachmentPayload.self, .file: FileAttachmentPayload.self, - .voiceRecording: VoiceRecordingAttachmentPayload.self + .voiceRecording: VoiceRecordingAttachmentPayload.self, + .staticLocation: StaticLocationAttachmentPayload.self ] let connectionRepository: ConnectionRepository From afc3f681b8412c684281408c0dff4eb1be00ddaa Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 12 Dec 2024 16:26:34 +0000 Subject: [PATCH 07/94] Create a CurrentUserLocationProvider to make it easier to fetch the current user location --- .../CustomAttachments/DemoComposerVC.swift | 185 +++++++++++++----- 1 file changed, 138 insertions(+), 47 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 59c20b33a75..a932492e262 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -8,14 +8,7 @@ import StreamChatUI import UIKit class DemoComposerVC: ComposerVC { - private lazy var locationManager = CLLocationManager() - - var sharingType: LocationSharingType = .addToAttachments - - enum LocationSharingType { - case addToAttachments - case instant - } + private lazy var currentUserLocationProvider = CurrentUserLocationProvider() override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions @@ -26,8 +19,7 @@ class DemoComposerVC: ComposerVC { title: "Add Current Location", style: .default, handler: { [weak self] _ in - self?.sharingType = .addToAttachments - self?.requestLocationPermission() + self?.addStaticLocationToAttachments() } ) actions.append(addLocationAction) @@ -36,34 +28,81 @@ class DemoComposerVC: ComposerVC { title: "Send Current Location", style: .default, handler: { [weak self] _ in - self?.sharingType = .instant - self?.requestLocationPermission() + 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 } - override func setUp() { - super.setUp() + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) - locationManager = CLLocationManager() + currentUserLocationProvider.stopMonitoringLocation() } - private func requestLocationPermission() { - locationManager.delegate = self + func addStaticLocationToAttachments() { + currentUserLocationProvider.getCurrentLocation { [weak self] result in + switch result { + case .success(let location): + let staticLocationPayload = StaticLocationAttachmentPayload( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + self?.content.attachments.append(AnyAttachmentPayload(payload: staticLocationPayload)) + case .failure(let error): + if error is LocationPermissionError { + self?.showLocationPermissionAlert() + } + } + } + } - switch locationManager.authorizationStatus { - case .notDetermined: - locationManager.requestWhenInUseAuthorization() - case .authorizedWhenInUse, .authorizedAlways: - locationManager.startUpdatingLocation() - case .denied, .restricted: - showLocationPermissionAlert() - @unknown default: - break + func sendInstantStaticLocation() { + currentUserLocationProvider.getCurrentLocation { [weak self] result in + switch result { + case .success(let location): + let staticLocationPayload = StaticLocationAttachmentPayload( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + self?.channelController?.sendStaticLocation(staticLocationPayload) + case .failure(let error): + if error is LocationPermissionError { + self?.showLocationPermissionAlert() + } + } + } + } + + var throttler = Throttler(interval: 5) + var messageId: MessageId? + + func sendInstantLiveLocation() { + currentUserLocationProvider.startMonitoringLocation() + currentUserLocationProvider.didUpdateLocation = { [weak self] location in + self?.throttler.execute { + let liveLocation = LiveLocationAttachmentPayload( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + stoppedSharing: false + ) + debugPrint("newLiveLocation: \(liveLocation)") + self?.channelController?.updateLiveLocation(liveLocation, messageId: self?.messageId) { + self?.messageId = try? $0.get() + } + } } } @@ -85,9 +124,73 @@ class DemoComposerVC: ComposerVC { } } -// MARK: - CLLocationManagerDelegate +enum LocationPermissionError: Error { + case permissionDenied + case permissionRestricted +} -extension DemoComposerVC: CLLocationManagerDelegate { +class CurrentUserLocationProvider: NSObject { + private let locationManager: CLLocationManager + private var onCurrentLocationFetch: ((Result) -> Void)? + + var didUpdateLocation: ((CLLocation) -> Void)? + var lastLocation: CLLocation? + var onError: ((Error) -> Void)? + + init(locationManager: CLLocationManager = CLLocationManager()) { + self.locationManager = locationManager + super.init() + self.locationManager.delegate = self + } + + func startMonitoringLocation() { + locationManager.allowsBackgroundLocationUpdates = true + locationManager.startMonitoringSignificantLocationChanges() + requestPermission { [weak self] error in + guard let error else { return } + self?.onError?(error) + } + } + + func stopMonitoringLocation() { + locationManager.allowsBackgroundLocationUpdates = false + locationManager.stopUpdatingLocation() + locationManager.stopMonitoringSignificantLocationChanges() + } + + 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) { + 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 CurrentUserLocationProvider: CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus if status == .authorizedWhenInUse || status == .authorizedAlways { @@ -97,25 +200,13 @@ extension DemoComposerVC: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.first else { return } - - let locationPayload = StaticLocationAttachmentPayload( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - - switch sharingType { - case .addToAttachments: - content.attachments.append(AnyAttachmentPayload(payload: locationPayload)) - case .instant: - channelController?.sendStaticLocation(locationPayload) - } - - // We only send the location once so we can stop updating the location. - locationManager.stopUpdatingLocation() - locationManager.delegate = nil + didUpdateLocation?(location) + lastLocation = location + onCurrentLocationFetch?(.success(location)) + onCurrentLocationFetch = nil } - - func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - print("Location manager error: \(error.localizedDescription)") + + func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { + onError?(error) } } From 736c08e627d16b0afa5c0109ae43ed96a09a306e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 12 Dec 2024 16:27:07 +0000 Subject: [PATCH 08/94] Fix not being able to part live location payload --- .../Models/Attachments/ChatMessageLiveLocationAttachment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift index 6a2bf2fb45e..e0def3809da 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift @@ -19,7 +19,7 @@ public struct LiveLocationAttachmentPayload: AttachmentPayload { /// The longitude of the location. public let longitude: Double /// A boolean value indicating whether the live location sharing was stopped. - public let stoppedSharing: Bool + public let stoppedSharing: Bool? public init(latitude: Double, longitude: Double, stoppedSharing: Bool) { self.latitude = latitude From 154e394424b17cdb0a889e85b1313e9e5160f674 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 13 Dec 2024 01:55:28 +0000 Subject: [PATCH 09/94] Update DemoApp to support live location attachments --- .../DemoAttachmentViewCatalog.swift | 4 +- .../CustomAttachments/DemoComposerVC.swift | 37 ++++++--- ...chmentPayload+AttachmentViewProvider.swift | 6 +- .../LocationAttachmentSnapshotView.swift | 75 ++++++++++++++----- .../LocationAttachmentViewDelegate.swift | 9 +++ .../LocationAttachmentViewInjector.swift | 39 ++++++++-- .../StreamChatWrapper+DemoApp.swift | 21 ++++-- 7 files changed, 145 insertions(+), 46 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoAttachmentViewCatalog.swift index d65f4f2d0f2..926c61b06b2 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(.staticLocation) + 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 a932492e262..ecc479ca39e 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -8,7 +8,7 @@ import StreamChatUI import UIKit class DemoComposerVC: ComposerVC { - private lazy var currentUserLocationProvider = CurrentUserLocationProvider() + private lazy var currentUserLocationProvider = CurrentUserLocationProvider.shared override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions @@ -86,23 +86,36 @@ class DemoComposerVC: ComposerVC { } } - var throttler = Throttler(interval: 5) + var throttler = Throttler(interval: 5, broadcastLatestEvent: false) var messageId: MessageId? func sendInstantLiveLocation() { - currentUserLocationProvider.startMonitoringLocation() - currentUserLocationProvider.didUpdateLocation = { [weak self] location in - self?.throttler.execute { + currentUserLocationProvider.stopMonitoringLocation() + channelController?.stopLiveLocation { [weak self] _ in + self?.currentUserLocationProvider.startMonitoringLocation() + if let lastLocation = self?.currentUserLocationProvider.lastLocation { let liveLocation = LiveLocationAttachmentPayload( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude, + latitude: lastLocation.coordinate.latitude, + longitude: lastLocation.coordinate.longitude, stoppedSharing: false ) - debugPrint("newLiveLocation: \(liveLocation)") - self?.channelController?.updateLiveLocation(liveLocation, messageId: self?.messageId) { + self?.channelController?.shareLiveLocation(liveLocation) { [weak self] in self?.messageId = try? $0.get() } } + self?.currentUserLocationProvider.didUpdateLocation = { [weak self] location in + self?.throttler.execute { + let liveLocation = LiveLocationAttachmentPayload( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + stoppedSharing: false + ) + debugPrint("newLiveLocation: \(liveLocation)") + self?.channelController?.shareLiveLocation(liveLocation) { + self?.messageId = try? $0.get() + } + } + } } } @@ -137,15 +150,16 @@ class CurrentUserLocationProvider: NSObject { var lastLocation: CLLocation? var onError: ((Error) -> Void)? - init(locationManager: CLLocationManager = CLLocationManager()) { + private init(locationManager: CLLocationManager = CLLocationManager()) { self.locationManager = locationManager super.init() self.locationManager.delegate = self } + static let shared = CurrentUserLocationProvider() + func startMonitoringLocation() { locationManager.allowsBackgroundLocationUpdates = true - locationManager.startMonitoringSignificantLocationChanges() requestPermission { [weak self] error in guard let error else { return } self?.onError?(error) @@ -155,7 +169,6 @@ class CurrentUserLocationProvider: NSObject { func stopMonitoringLocation() { locationManager.allowsBackgroundLocationUpdates = false locationManager.stopUpdatingLocation() - locationManager.stopMonitoringSignificantLocationChanges() } func getCurrentLocation(completion: @escaping (Result) -> Void) { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift index 00a6efce321..78c73fb03c2 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift @@ -14,7 +14,11 @@ extension StaticLocationAttachmentPayload: AttachmentPreviewProvider { /// For simplicity, we are using the same view for the Composer preview, /// but a different one could be provided. let preview = LocationAttachmentSnapshotView() - preview.coordinate = .init(latitude: latitude, longitude: longitude) + preview.content = .init( + latitude: latitude, + longitude: longitude, + isLive: false + ) return preview } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 390069bdd2a..4764a147ae6 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -13,16 +13,22 @@ struct LocationCoordinate { class LocationAttachmentSnapshotView: _View { static var snapshotsCache: NSCache = .init() + var snapshotter: MKMapSnapshotter? + + struct Content { + var latitude: CLLocationDegrees + var longitude: CLLocationDegrees + var isLive: Bool = false + } - var coordinate: LocationCoordinate? { + var content: Content? { didSet { updateContent() } } - var snapshotter: MKMapSnapshotter? - var didTapOnLocation: (() -> Void)? + var didTapOnStopSharingLocation: (() -> Void)? lazy var imageView: UIImageView = { let view = UIImageView() @@ -41,6 +47,21 @@ class LocationAttachmentSnapshotView: _View { return view }() + lazy var stopButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "stop.circle"), for: .normal) + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) + button.setTitle("Stop Sharing", for: .normal) + button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) + button.setTitleColor(.red, for: .normal) + button.tintColor = .red + button.backgroundColor = .clear + button.layer.cornerRadius = 16 + button.addTarget(self, action: #selector(handleStopButtonTap), for: .touchUpInside) + return button + }() + let mapOptions: MKMapSnapshotter.Options = .init() override func setUp() { @@ -56,16 +77,21 @@ class LocationAttachmentSnapshotView: _View { override func setUpLayout() { super.setUpLayout() - addSubview(activityIndicatorView) - addSubview(imageView) + stopButton.isHidden = true + activityIndicatorView.hidesWhenStopped = true + + let container = VContainer(alignment: .center) { + imageView + stopButton + .width(120) + .height(30) + }.embed(in: self) + + container.addSubview(activityIndicatorView) 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.centerXAnchor.constraint(equalTo: container.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: container.centerYAnchor) ]) } @@ -76,21 +102,30 @@ class LocationAttachmentSnapshotView: _View { override func updateContent() { super.updateContent() - imageView.image = nil - - guard let coordinate = self.coordinate else { + if content?.isLive == false { + imageView.image = nil + } + + guard let content = self.content else { return } - configureMapPosition(coordinate: coordinate) - - if imageView.image == nil { - activityIndicatorView.startAnimating() + if content.isLive { + stopButton.isHidden = false + } else { + stopButton.isHidden = true } + let coordinate = LocationCoordinate( + latitude: content.latitude, + longitude: content.longitude + ) + configureMapPosition(coordinate: coordinate) + if let snapshotImage = Self.snapshotsCache.object(forKey: coordinate.cachingKey) { imageView.image = snapshotImage } else { + activityIndicatorView.startAnimating() loadMapSnapshotImage(coordinate: coordinate) } } @@ -145,6 +180,10 @@ class LocationAttachmentSnapshotView: _View { } return image } + + @objc private func handleStopButtonTap() { + didTapOnStopSharingLocation?() + } } private extension LocationCoordinate { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index cb09206ba88..dc3dfebc0f9 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -9,6 +9,10 @@ protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate { func didTapOnLocationAttachment( _ attachment: ChatMessageStaticLocationAttachment ) + + func didTapOnStopSharingLocation( + _ attachment: ChatMessageLiveLocationAttachment + ) } extension DemoChatMessageListVC: LocationAttachmentViewDelegate { @@ -16,4 +20,9 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { let mapViewController = LocationDetailViewController(locationAttachment: attachment) navigationController?.pushViewController(mapViewController, animated: true) } + + func didTapOnStopSharingLocation(_ attachment: ChatMessageLiveLocationAttachment) { + CurrentUserLocationProvider.shared.stopMonitoringLocation() + client.channelController(for: attachment.id.cid).stopLiveLocation() + } } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 6b1d3ea9843..329ec225a14 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -9,10 +9,14 @@ import UIKit class LocationAttachmentViewInjector: AttachmentViewInjector { lazy var locationAttachmentView = LocationAttachmentSnapshotView() - var locationAttachment: ChatMessageStaticLocationAttachment? { + var staticLocationAttachment: ChatMessageStaticLocationAttachment? { attachments(payloadType: StaticLocationAttachmentPayload.self).first } + var liveLocationAttachment: ChatMessageLiveLocationAttachment? { + attachments(payloadType: LiveLocationAttachmentPayload.self).first + } + override func contentViewDidLayout(options: ChatMessageLayoutOptions) { super.contentViewDidLayout(options: options) @@ -26,16 +30,27 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { locationAttachmentView.didTapOnLocation = { [weak self] in self?.handleTapOnLocationAttachment() } + locationAttachmentView.didTapOnStopSharingLocation = { [weak self] in + self?.handleTapOnStopSharingLocation() + } } override func contentViewDidUpdateContent() { super.contentViewDidUpdateContent() - guard let location = locationAttachment else { - return + if let staticLocation = staticLocationAttachment { + locationAttachmentView.content = .init( + latitude: staticLocation.latitude, + longitude: staticLocation.longitude, + isLive: false + ) + } else if let liveLocation = liveLocationAttachment { + locationAttachmentView.content = .init( + latitude: liveLocation.latitude, + longitude: liveLocation.longitude, + isLive: liveLocation.stoppedSharing == false || liveLocation.stoppedSharing == nil + ) } - - locationAttachmentView.coordinate = .init(latitude: location.latitude, longitude: location.longitude) } func handleTapOnLocationAttachment() { @@ -43,10 +58,22 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { return } - guard let locationAttachment = self.locationAttachment else { + guard let locationAttachment = staticLocationAttachment else { return } locationAttachmentDelegate.didTapOnLocationAttachment(locationAttachment) } + + func handleTapOnStopSharingLocation() { + guard let locationAttachmentDelegate = contentView.delegate as? LocationAttachmentViewDelegate else { + return + } + + guard let locationAttachment = liveLocationAttachment else { + return + } + + locationAttachmentDelegate.didTapOnStopSharingLocation(locationAttachment) + } } diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index 59e35a0103a..c89d3dddd00 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -9,13 +9,6 @@ import StreamChatUI extension StreamChatWrapper { // Instantiates chat client func setUpChat() { - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { - Components.default.mixedAttachmentInjector.register( - .staticLocation, - with: LocationAttachmentViewInjector.self - ) - } - // Set the log level LogConfig.level = StreamRuntimeCheck.logLevel ?? .warning LogConfig.formatters = [ @@ -29,7 +22,19 @@ extension StreamChatWrapper { if client == nil { client = ChatClient(config: config) } - client?.registerAttachment(StaticLocationAttachmentPayload.self) + + // Custom Attachments + if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { + Components.default.mixedAttachmentInjector.register( + .staticLocation, + with: LocationAttachmentViewInjector.self + ) + Components.default.mixedAttachmentInjector.register( + .liveLocation, + with: LocationAttachmentViewInjector.self + ) + } + client?.registerAttachment(LiveLocationAttachmentPayload.self) // L10N let localizationProvider = Appearance.default.localizationProvider From 3b4d3220ac9b4991d4494cbb54f23c9b90507851 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 13 Dec 2024 01:57:20 +0000 Subject: [PATCH 10/94] Add support for partial message update in MessageUpdater --- .../Endpoints/MessageEndpoints.swift | 26 ++++ .../StreamChat/Workers/MessageUpdater.swift | 135 +++++++++++++----- 2 files changed, 123 insertions(+), 38 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index e0a705226e2..aaec3fa6f74 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( @@ -106,6 +117,21 @@ struct MessagePartialUpdateRequest: Encodable { /// The available message properties that can be updated. struct SetProperties: Encodable { var pinned: Bool? + var extraData: [String: RawJSON]? + var attachments: [MessageAttachmentPayload]? + + enum CodingKeys: String, CodingKey { + case pinned + case extraData + case attachments + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + 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/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 986017a474b..d7c751aee66 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -59,6 +59,11 @@ class MessageUpdater: Worker { let shouldBeHardDeleted = hard || messageDTO.isLocalOnly messageDTO.isHardDeleted = shouldBeHardDeleted + // If the message is a live location message, clear the channel's live location message reference. + if let liveLocationMessageOfChannel = messageDTO.liveLocationMessageOfChannel { + liveLocationMessageOfChannel.liveLocationMessage = nil + } + if messageDTO.isLocalOnly { messageDTO.type = MessageType.deleted.rawValue messageDTO.deletedAt = DBDate() @@ -119,12 +124,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 +186,60 @@ class MessageUpdater: Worker { }) } + func updatePartialMessage( + messageId: MessageId, + text: String? = nil, + attachments: [AnyAttachmentPayload]? = nil, + extraData: [String: RawJSON]? = nil, + completion: ((Result) -> Void)? = nil + ) { + // TODO: This is quite nasty, we should find more optimal way to do this. + 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( + extraData: extraData, + attachments: attachmentPayloads + ) + ) + ) + ) { [weak self] result in + switch result { + case .success(let messagePayloadBoxed): + let messagePayload = messagePayloadBoxed.message + self?.database.write { session in + let messageDTO = try session.saveMessage( + payload: messagePayload, + for: messagePayload.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 +547,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 +576,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 +590,7 @@ class MessageUpdater: Worker { } } } - + private func pinLocalMessage( on messageId: MessageId, pinning: MessagePinning, @@ -553,7 +612,7 @@ class MessageUpdater: Worker { } } } - + private func unpinLocalMessage( on messageId: MessageId, completion: ((Result, MessagePinning) -> Void)? = nil @@ -576,9 +635,9 @@ class MessageUpdater: Worker { } } } - + static let minSignificantDownloadingProgressChange: Double = 0.01 - + func downloadAttachment( _ attachment: ChatMessageAttachment, completion: @escaping (Result, Error>) -> Void @@ -613,7 +672,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 +686,7 @@ class MessageUpdater: Worker { dto?.clearLocalState() }, completion: completion) } - + private func updateDownloadProgress( for attachmentId: AttachmentId, payloadType: Payload.Type, @@ -652,7 +711,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 +728,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 +771,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 +872,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 +966,7 @@ extension MessageUpdater { struct MessageSearchResults { let payload: MessageSearchResultsPayload let models: [ChatMessage] - + var next: String? { payload.next } } } @@ -989,7 +1048,7 @@ extension MessageUpdater { } } } - + func clearSearchResults(for query: MessageSearchQuery) async throws { try await withCheckedThrowingContinuation { continuation in clearSearchResults(for: query) { error in @@ -997,7 +1056,7 @@ extension MessageUpdater { } } } - + func createNewReply( in cid: ChannelId, messageId: MessageId?, @@ -1037,7 +1096,7 @@ extension MessageUpdater { } } } - + func deleteLocalAttachmentDownload(for attachmentId: AttachmentId) async throws { try await withCheckedThrowingContinuation { continuation in deleteLocalAttachmentDownload(for: attachmentId) { error in @@ -1045,7 +1104,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 +1112,7 @@ extension MessageUpdater { } } } - + func deleteReaction(_ type: MessageReactionType, messageId: MessageId) async throws { try await withCheckedThrowingContinuation { continuation in deleteReaction(type, messageId: messageId) { error in @@ -1061,7 +1120,7 @@ extension MessageUpdater { } } } - + func dispatchEphemeralMessageAction( cid: ChannelId, messageId: MessageId, @@ -1077,7 +1136,7 @@ extension MessageUpdater { } } } - + func downloadAttachment( _ attachment: ChatMessageAttachment ) async throws -> ChatMessageAttachment where Payload: DownloadableAttachmentPayload { @@ -1087,7 +1146,7 @@ extension MessageUpdater { } } } - + func editMessage( messageId: MessageId, text: String, @@ -1107,7 +1166,7 @@ extension MessageUpdater { } } } - + func flagMessage( _ flag: Bool, with messageId: MessageId, @@ -1127,7 +1186,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 +1194,7 @@ extension MessageUpdater { } } } - + func loadReactions( cid: ChannelId, messageId: MessageId, @@ -1151,7 +1210,7 @@ extension MessageUpdater { } } } - + @discardableResult func loadReplies( cid: ChannelId, messageId: MessageId, @@ -1169,7 +1228,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 +1236,7 @@ extension MessageUpdater { } } } - + func resendAttachment(with id: AttachmentId) async throws { try await withCheckedThrowingContinuation { continuation in restartFailedAttachmentUploading(with: id) { error in @@ -1185,7 +1244,7 @@ extension MessageUpdater { } } } - + func resendMessage(with messageId: MessageId) async throws { try await withCheckedThrowingContinuation { continuation in resendMessage(with: messageId) { error in @@ -1193,7 +1252,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 +1260,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 +1268,7 @@ extension MessageUpdater { } } } - + func unpinMessage(messageId: MessageId) async throws -> ChatMessage { try await withCheckedThrowingContinuation { continuation in unpinMessage(messageId: messageId) { result in @@ -1217,9 +1276,9 @@ extension MessageUpdater { } } } - + // MARK: - - + func loadReplies( for parentMessageId: MessageId, pagination: MessagesPagination, @@ -1236,7 +1295,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 +1317,7 @@ extension MessageUpdater { paginationStateHandler: paginationStateHandler ) } - + func loadReplies( for parentMessageId: MessageId, after replyId: MessageId?, @@ -1280,7 +1339,7 @@ extension MessageUpdater { paginationStateHandler: paginationStateHandler ) } - + func loadReplies( for parentMessageId: MessageId, around replyId: MessageId, From b9e1f2cf8a16f7a20ece1cc600c08346b9436647 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 13 Dec 2024 01:58:08 +0000 Subject: [PATCH 11/94] Expose the Throttler (Revert this, and use it internally) --- Sources/StreamChatUI/Utils/Throttler.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChatUI/Utils/Throttler.swift b/Sources/StreamChatUI/Utils/Throttler.swift index 0aef17b461d..6149001a966 100644 --- a/Sources/StreamChatUI/Utils/Throttler.swift +++ b/Sources/StreamChatUI/Utils/Throttler.swift @@ -6,7 +6,7 @@ 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 { +public class Throttler { private var workItem: DispatchWorkItem? private let queue: DispatchQueue private var previousRun: Date = Date.distantPast @@ -18,7 +18,7 @@ class Throttler { /// - 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 +31,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 +53,7 @@ class Throttler { } /// Cancel any active action. - func cancel() { + public func cancel() { workItem?.cancel() workItem = nil } From ec2f59a40066cda9c0a198d3364d1a3ef0122f71 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 21:52:04 +0000 Subject: [PATCH 12/94] Add `shareLiveLocation()` and `stopLiveLocation()` to `ChannelController` --- .../ChannelController/ChannelController.swift | 134 +++++++++++++++++- .../StreamChat/Database/DTOs/ChannelDTO.swift | 1 + .../StreamChat/Database/DTOs/MessageDTO.swift | 23 ++- .../StreamChatModel.xcdatamodel/contents | 4 +- 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 04009a520c9..accebc872c8 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 ) @@ -877,6 +884,124 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } + /// Stops sharing the live location message in the channel. + public func stopLiveLocation(completion: ((Result) -> Void)? = nil) { + guard let cid = cid, isChannelAlreadyCreated else { + channelModificationFailed { error in + completion?(.failure(error ?? ClientError.Unknown())) + } + return + } + + client.databaseContainer.write { session in + let channel = session.channel(cid: cid) + guard let message = try channel?.liveLocationMessage?.asModel(), + let liveLocation = message.attachments(payloadType: LiveLocationAttachmentPayload.self).first else { + completion?(.failure(ClientError("No live location message found"))) + return + } + + channel?.liveLocationMessage = nil + + 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)) + } + } + } completion: { error in + if let error { + self.callback { + completion?(.failure(error)) + } + } + } + } + + /// Shares a live location message to the channel. + /// + /// If there is already a live location message in the channel, it will update the existing message. + /// Otherwise, it will create a new message. + /// + /// - Parameters: + /// - location: The static location payload. + /// - text: The text of the message. + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes, not when the message reaches the server. + public func shareLiveLocation( + _ location: LiveLocationAttachmentPayload, + 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.databaseContainer.write { session in + let channel = session.channel(cid: cid) + if let message = try channel?.liveLocationMessage?.asModel() { + self.messageUpdater.updatePartialMessage( + messageId: message.id, + text: text, + attachments: [.init(payload: location)], + extraData: extraData + ) { result in + self.callback { + completion?(result.map(\.id)) + } + } + return + } + + self.updater.createNewMessage( + in: cid, + messageId: nil, + text: text ?? "", + pinning: nil, + isSilent: false, + isSystem: false, + command: nil, + arguments: nil, + attachments: [ + .init(payload: location) + ], + 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)) + } + } + } completion: { error in + if let error { + self.callback { + completion?(.failure(error)) + } + } + } + } + /// Creates a new poll. /// /// - Parameters: @@ -1542,6 +1667,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 diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 9ed3f411b44..52c902fd366 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -66,6 +66,7 @@ class ChannelDTO: NSManagedObject { @NSManaged var watchers: Set @NSManaged var memberListQueries: Set @NSManaged var previewMessage: MessageDTO? + @NSManaged var liveLocationMessage: MessageDTO? /// If the current channel is muted by the current user, `mute` contains details. @NSManaged var mute: ChannelMuteDTO? diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 3415a59ae0d..4110628b005 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -82,6 +82,7 @@ class MessageDTO: NSManagedObject { @NSManaged var quotedBy: Set @NSManaged var searches: Set @NSManaged var previewOfChannel: ChannelDTO? + @NSManaged var liveLocationMessageOfChannel: ChannelDTO? /// If the message is sent by the current user, this field /// contains channel reads of other channel members (excluding the current user), @@ -687,6 +688,16 @@ extension NSManagedObjectContext: MessageDatabaseSession { message.reactionCounts = [:] message.reactionGroups = [] + if let liveLocationAttachment = attachments.first(where: { $0.type == .liveLocation }), + let payload = liveLocationAttachment.payload as? LiveLocationAttachmentPayload { + let stoppedSharing = payload.stoppedSharing ?? false + if stoppedSharing { + channelDTO.liveLocationMessage = nil + } else { + channelDTO.liveLocationMessage = message + } + } + // Message type if parentMessageId != nil { message.type = MessageType.reply.rawValue @@ -873,7 +884,17 @@ extension NSManagedObjectContext: MessageDatabaseSession { } ) dto.attachments = attachments - + + if let liveLocationAttachment = attachments.first(where: { $0.attachmentType == .liveLocation }), + let liveLocationAttachmentPayload = liveLocationAttachment.asAnyModel()?.attachment(payloadType: LiveLocationAttachmentPayload.self) { + let stoppedSharing = liveLocationAttachmentPayload.stoppedSharing ?? false + if stoppedSharing { + channelDTO.liveLocationMessage = nil + } else { + channelDTO.liveLocationMessage = dto + } + } + 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 98617a53291..c38d75f0b25 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -63,6 +63,7 @@ + @@ -240,6 +241,7 @@ + From 0a1e12039ba9bd32c4e8bbce2fe63da248df461c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 16 Dec 2024 17:40:35 +0000 Subject: [PATCH 13/94] Refactor logic to fetch current active locations + Improve API --- .../CustomAttachments/DemoComposerVC.swift | 34 ++--- .../ChannelController/ChannelController.swift | 122 ++++++++---------- .../Database/DTOs/AttachmentDTO.swift | 10 ++ .../StreamChat/Database/DTOs/ChannelDTO.swift | 1 - .../StreamChat/Database/DTOs/MessageDTO.swift | 44 +++---- .../StreamChatModel.xcdatamodel/contents | 3 +- .../Repositories/MessageRepository.swift | 40 +++++- .../StreamChat/Workers/MessageUpdater.swift | 5 - 8 files changed, 143 insertions(+), 116 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index ecc479ca39e..dde9099b916 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -3,6 +3,7 @@ // import CoreLocation +@_spi(Experimental) import StreamChat import StreamChatUI import UIKit @@ -90,31 +91,24 @@ class DemoComposerVC: ComposerVC { var messageId: MessageId? func sendInstantLiveLocation() { - currentUserLocationProvider.stopMonitoringLocation() - channelController?.stopLiveLocation { [weak self] _ in - self?.currentUserLocationProvider.startMonitoringLocation() - if let lastLocation = self?.currentUserLocationProvider.lastLocation { + currentUserLocationProvider.getCurrentLocation { [weak self] result in + switch result { + case .success(let location): let liveLocation = LiveLocationAttachmentPayload( - latitude: lastLocation.coordinate.latitude, - longitude: lastLocation.coordinate.longitude, + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, stoppedSharing: false ) - self?.channelController?.shareLiveLocation(liveLocation) { [weak self] in - self?.messageId = try? $0.get() - } - } - self?.currentUserLocationProvider.didUpdateLocation = { [weak self] location in - self?.throttler.execute { - let liveLocation = LiveLocationAttachmentPayload( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude, - stoppedSharing: false - ) - debugPrint("newLiveLocation: \(liveLocation)") - self?.channelController?.shareLiveLocation(liveLocation) { - self?.messageId = try? $0.get() + self?.channelController?.startLiveLocationSharing(liveLocation) { + switch $0 { + case .success(let messageId): + self?.messageId = messageId + case .failure(let error): + print(error) } } + case .failure(let error): + print(error) } } } diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index accebc872c8..f8ce718ee9d 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -884,59 +884,18 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } } - /// Stops sharing the live location message in the channel. - public func stopLiveLocation(completion: ((Result) -> Void)? = nil) { - guard let cid = cid, isChannelAlreadyCreated else { - channelModificationFailed { error in - completion?(.failure(error ?? ClientError.Unknown())) - } - return - } - - client.databaseContainer.write { session in - let channel = session.channel(cid: cid) - guard let message = try channel?.liveLocationMessage?.asModel(), - let liveLocation = message.attachments(payloadType: LiveLocationAttachmentPayload.self).first else { - completion?(.failure(ClientError("No live location message found"))) - return - } - - channel?.liveLocationMessage = nil - - 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)) - } - } - } completion: { error in - if let error { - self.callback { - completion?(.failure(error)) - } - } - } - } - - /// Shares a live location message to the channel. + /// Starts a live location sharing message in the channel. /// - /// If there is already a live location message in the channel, it will update the existing message. - /// Otherwise, it will create a new message. + /// If there is already an active live location sharing message in the this channel, + /// it will fail with an error. /// /// - Parameters: /// - location: The static location payload. /// - text: The text of the message. - /// - extraData: Additional extra data of the message object. - /// - completion: Called when saving the message to the local DB finishes, not when the message reaches the server. - public func shareLiveLocation( + /// - extraData: Additional extra data of the message object. + /// - completion: Called when saving the message to the local DB finishes, + /// not when the message reaches the server. + public func startLiveLocationSharing( _ location: LiveLocationAttachmentPayload, text: String? = nil, extraData: [String: RawJSON] = [:], @@ -951,23 +910,16 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP return } - client.databaseContainer.write { session in - let channel = session.channel(cid: cid) - if let message = try channel?.liveLocationMessage?.asModel() { - self.messageUpdater.updatePartialMessage( - messageId: message.id, - text: text, - attachments: [.init(payload: location)], - extraData: extraData - ) { result in - self.callback { - completion?(result.map(\.id)) - } + client.messageRepository.getActiveLiveLocationMessages(for: cid) { [weak self] result in + if let message = try? result.get().first { + self?.callback { + // TODO: Create error for this + completion?(.failure(ClientError("Active Location already exists for this channel. \(message.id)"))) } return } - self.updater.createNewMessage( + self?.updater.createNewMessage( in: cid, messageId: nil, text: text ?? "", @@ -987,14 +939,54 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP extraData: extraData ) { result in if let newMessage = try? result.get() { - self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) + self?.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage)) } - self.callback { + self?.callback { completion?(result.map(\.id)) } } - } completion: { error in - if let error { + } + } + + /// Stops sharing the live location message in the channel. + public func stopLiveLocation(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.attachments( + payloadType: LiveLocationAttachmentPayload.self + ).first + else { + self.callback { + // TODO: Create error for this + completion?(.failure(ClientError("No live location message found"))) + } + 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)) } diff --git a/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift b/Sources/StreamChat/Database/DTOs/AttachmentDTO.swift index e1e832e698b..89a4bd4f5ed 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/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 52c902fd366..9ed3f411b44 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -66,7 +66,6 @@ class ChannelDTO: NSManagedObject { @NSManaged var watchers: Set @NSManaged var memberListQueries: Set @NSManaged var previewMessage: MessageDTO? - @NSManaged var liveLocationMessage: MessageDTO? /// If the current channel is muted by the current user, `mute` contains details. @NSManaged var mute: ChannelMuteDTO? diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 4110628b005..9ba26b1109b 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -82,7 +82,6 @@ class MessageDTO: NSManagedObject { @NSManaged var quotedBy: Set @NSManaged var searches: Set @NSManaged var previewOfChannel: ChannelDTO? - @NSManaged var liveLocationMessageOfChannel: ChannelDTO? /// If the message is sent by the current user, this field /// contains channel reads of other channel members (excluding the current user), @@ -585,7 +584,28 @@ class MessageDTO: NSManagedObject { ]) return try load(request, context: context) } - + + static func loadActiveLiveLocationMessages( + channelId: ChannelId?, + context: NSManagedObjectContext + ) throws -> [MessageDTO] { + let request = NSFetchRequest(entityName: MessageDTO.entityName) + MessageDTO.applyPrefetchingState(to: request) + request.fetchLimit = 10 + request.sortDescriptors = [NSSortDescriptor( + keyPath: \MessageDTO.createdAt, + ascending: true + )] + var predicates: [NSPredicate] = [ + .init(format: "ANY attachments.isActiveLocationAttachment == YES") + ] + if let channelId = channelId { + predicates.append(.init(format: "channel.cid == %@", channelId.rawValue)) + } + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + return try load(request, context: context) + } + static func loadReplies( from fromIncludingDate: Date, to toIncludingDate: Date, @@ -688,16 +708,6 @@ extension NSManagedObjectContext: MessageDatabaseSession { message.reactionCounts = [:] message.reactionGroups = [] - if let liveLocationAttachment = attachments.first(where: { $0.type == .liveLocation }), - let payload = liveLocationAttachment.payload as? LiveLocationAttachmentPayload { - let stoppedSharing = payload.stoppedSharing ?? false - if stoppedSharing { - channelDTO.liveLocationMessage = nil - } else { - channelDTO.liveLocationMessage = message - } - } - // Message type if parentMessageId != nil { message.type = MessageType.reply.rawValue @@ -885,16 +895,6 @@ extension NSManagedObjectContext: MessageDatabaseSession { ) dto.attachments = attachments - if let liveLocationAttachment = attachments.first(where: { $0.attachmentType == .liveLocation }), - let liveLocationAttachmentPayload = liveLocationAttachment.asAnyModel()?.attachment(payloadType: LiveLocationAttachmentPayload.self) { - let stoppedSharing = liveLocationAttachmentPayload.stoppedSharing ?? false - if stoppedSharing { - channelDTO.liveLocationMessage = nil - } else { - channelDTO.liveLocationMessage = dto - } - } - 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 c38d75f0b25..5d539135622 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -3,6 +3,7 @@ + @@ -63,7 +64,6 @@ - @@ -241,7 +241,6 @@ - diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 86f222e92ff..25bf88b8796 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -281,7 +281,45 @@ class MessageRepository { } } } - + + func getAllActiveLiveLocationMessages(completion: @escaping (Result<[ChatMessage], Error>) -> Void) { + let context = database.backgroundReadOnlyContext + context.perform { + do { + let messages = try MessageDTO.loadActiveLiveLocationMessages( + channelId: nil, + context: context + ).map { + try $0.asModel() + } + completion(.success(messages)) + } catch { + completion(.failure(error)) + } + } + } + + func getActiveLiveLocationMessages( + for channelId: ChannelId, + completion: @escaping (Result<[ChatMessage], Error>) -> Void + ) { + let context = database.backgroundReadOnlyContext + context.perform { + do { + let messages = try MessageDTO.loadActiveLiveLocationMessages( + 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/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index d7c751aee66..2c8eb6093bd 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -59,11 +59,6 @@ class MessageUpdater: Worker { let shouldBeHardDeleted = hard || messageDTO.isLocalOnly messageDTO.isHardDeleted = shouldBeHardDeleted - // If the message is a live location message, clear the channel's live location message reference. - if let liveLocationMessageOfChannel = messageDTO.liveLocationMessageOfChannel { - liveLocationMessageOfChannel.liveLocationMessage = nil - } - if messageDTO.isLocalOnly { messageDTO.type = MessageType.deleted.rawValue messageDTO.deletedAt = DBDate() From 61a6a70f5aefd3fb8899df2023f6f59285f10dec Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 17 Dec 2024 19:06:19 +0000 Subject: [PATCH 14/94] Add extra data to location attachment payloads --- .../ChatMessageLiveLocationAttachment.swift | 32 ++++++++++++++++++- .../ChatMessageStaticLocationAttachment.swift | 28 +++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift index e0def3809da..2a0b3284cb9 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift @@ -20,11 +20,19 @@ public struct LiveLocationAttachmentPayload: AttachmentPayload { 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) { + public init( + latitude: Double, + longitude: Double, + stoppedSharing: Bool, + extraData: [String: RawJSON]? = nil + ) { self.latitude = latitude self.longitude = longitude self.stoppedSharing = stoppedSharing + self.extraData = extraData } enum CodingKeys: String, CodingKey { @@ -32,4 +40,26 @@ public struct LiveLocationAttachmentPayload: AttachmentPayload { 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/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift index 9fd62a1c01c..416b1f87e2b 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift @@ -18,14 +18,40 @@ public struct StaticLocationAttachmentPayload: AttachmentPayload { 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) { + 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) + ) + } } From 64881a1795a8e419f428ca6b230c47a0d9f0854e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 17 Dec 2024 21:39:26 +0000 Subject: [PATCH 15/94] Add `LocationAttachmentInfo` to be used as argument --- .../ChatMessageLiveLocationAttachment.swift | 0 .../ChatMessageStaticLocationAttachment.swift | 0 .../Location/LocationAttachmentInfo.swift | 22 +++++++++++++++++++ Sources/StreamChat/Models/ChatMessage.swift | 10 +++++++++ Sources/StreamChat/Models/UserInfo.swift | 2 +- StreamChat.xcodeproj/project.pbxproj | 18 +++++++++++++-- 6 files changed, 49 insertions(+), 3 deletions(-) rename Sources/StreamChat/Models/Attachments/{ => Location}/ChatMessageLiveLocationAttachment.swift (100%) rename Sources/StreamChat/Models/Attachments/{ => Location}/ChatMessageStaticLocationAttachment.swift (100%) create mode 100644 Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift similarity index 100% rename from Sources/StreamChat/Models/Attachments/ChatMessageLiveLocationAttachment.swift rename to Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift similarity index 100% rename from Sources/StreamChat/Models/Attachments/ChatMessageStaticLocationAttachment.swift rename to Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift diff --git a/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift b/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift new file mode 100644 index 00000000000..8b7770e56ff --- /dev/null +++ b/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift @@ -0,0 +1,22 @@ +// +// Copyright © 2024 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 d79ee8d96c0..7d38a8aa3d3 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -317,6 +317,16 @@ public extension ChatMessage { attachments(payloadType: VoiceRecordingAttachmentPayload.self) } + /// Returns the attachments of `.staticLocation` type. + var staticLocationAttachments: [ChatMessageStaticLocationAttachment] { + attachments(payloadType: StaticLocationAttachmentPayload.self) + } + + /// Returns the attachments of `.liveLocation` type. + 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 a108ef0327a..e55bca163f8 100644 --- a/Sources/StreamChat/Models/UserInfo.swift +++ b/Sources/StreamChat/Models/UserInfo.swift @@ -4,7 +4,7 @@ import Foundation -/// A model containing user info that's used to connect to chat's backend +/// The user information used to connect the user to chat. public struct UserInfo { /// The id of the user. public let id: UserId diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 2170357518b..346e6d0e4cf 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1819,6 +1819,8 @@ 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 */; }; 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 */; }; @@ -4474,6 +4476,7 @@ 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 = ""; }; 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 = ""; }; @@ -5037,8 +5040,7 @@ 225D7FE125D191400094E555 /* ChatMessageImageAttachment.swift */, 22692C8625D176F4007C41D0 /* ChatMessageLinkAttachment.swift */, 79983C80266633C2000995F6 /* ChatMessageVideoAttachment.swift */, - AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */, - AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.swift */, + ADFCA5B52D121EE9000F515F /* Location */, ); path = Attachments; sourceTree = ""; @@ -9087,6 +9089,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 = ( @@ -11660,6 +11672,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 */, @@ -12473,6 +12486,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 */, From 6f57eb9e2f51e26849e787d20ed14481db2ca5fa Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 17 Dec 2024 21:41:52 +0000 Subject: [PATCH 16/94] Add `text` to partial message update endpoint --- Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift | 3 +++ Sources/StreamChat/Workers/MessageUpdater.swift | 1 + 2 files changed, 4 insertions(+) diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index aaec3fa6f74..3229af7a893 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -117,10 +117,12 @@ 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 @@ -128,6 +130,7 @@ struct MessagePartialUpdateRequest: Encodable { 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) diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 2c8eb6093bd..903ef362dfd 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -207,6 +207,7 @@ class MessageUpdater: Worker { messageId: messageId, request: .init( set: .init( + text: text, extraData: extraData, attachments: attachmentPayloads ) From caf76aba497dff1d1c6862f4e076635b53c9b7c0 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 17 Dec 2024 21:45:04 +0000 Subject: [PATCH 17/94] Add `ChatMessageController.updateMessage()` to support partially updating a message --- .../MessageController/MessageController.swift | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 549d1e597b2..507bf4dda11 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -193,7 +193,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 +249,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 +278,33 @@ 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. + /// - completion: Called when the server updates the message. + public func updateMessage( + text: String? = nil, + attachments: [AnyAttachmentPayload]? = nil, + extraData: [String: RawJSON]? = nil, + completion: ((Result) -> Void)? = nil + ) { + messageUpdater.updatePartialMessage( + messageId: messageId, + text: text, + attachments: attachments, + extraData: extraData + ) { result in + self.callback { + completion?(result) + } + } + } + /// Deletes the message this controller manages. /// /// - Parameters: From 5d09df8cb49363955cd00089b37daf8020a49762 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 17 Dec 2024 21:46:11 +0000 Subject: [PATCH 18/94] Change location live updates APIs --- .../ChannelController/ChannelController.swift | 52 +++++++++++++----- .../CurrentUserController.swift | 26 +++++++++ .../MessageController/MessageController.swift | 53 ++++++++++++++++++- 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index f8ce718ee9d..722de839e75 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -835,14 +835,14 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// Sends a static location message to the channel. /// /// - Parameters: - /// - location: The static location payload. + /// - location: The location information. /// - text: The text of the message. /// - messageId: The id for the sent message. By default, it is automatically generated by Stream. /// - quotedMessageId: The id of the quoted message, in case the location is an inline reply. /// - extraData: Additional extra data of the message object. /// - completion: Called when saving the message to the local DB finishes, not when the message reaches the server. public func sendStaticLocation( - _ location: StaticLocationAttachmentPayload, + _ location: LocationAttachmentInfo, text: String? = nil, messageId: MessageId? = nil, quotedMessageId: MessageId? = nil, @@ -856,6 +856,12 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP return } + let locationPayload = StaticLocationAttachmentPayload( + latitude: location.latitude, + longitude: location.longitude, + extraData: location.extraData + ) + updater.createNewMessage( in: cid, messageId: messageId, @@ -866,7 +872,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP command: nil, arguments: nil, attachments: [ - .init(payload: location) + .init(payload: locationPayload) ], mentionedUserIds: [], quotedMessageId: quotedMessageId, @@ -889,14 +895,16 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// If there is already an active live location sharing message in the this channel, /// it will fail with an error. /// + /// In order to + /// /// - Parameters: - /// - location: The static location payload. + /// - 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. public func startLiveLocationSharing( - _ location: LiveLocationAttachmentPayload, + _ location: LocationAttachmentInfo, text: String? = nil, extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil @@ -913,12 +921,20 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP client.messageRepository.getActiveLiveLocationMessages(for: cid) { [weak self] result in if let message = try? result.get().first { self?.callback { - // TODO: Create error for this - completion?(.failure(ClientError("Active Location already exists for this channel. \(message.id)"))) + 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, @@ -929,7 +945,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP command: nil, arguments: nil, attachments: [ - .init(payload: location) + .init(payload: liveLocationSharingPayload) ], mentionedUserIds: [], quotedMessageId: nil, @@ -961,13 +977,10 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP switch result { case let .success(messages): guard let message = messages.first, - let liveLocation = message.attachments( - payloadType: LiveLocationAttachmentPayload.self - ).first + let liveLocation = message.liveLocationAttachments.first else { self.callback { - // TODO: Create error for this - completion?(.failure(ClientError("No live location message found"))) + completion?(.failure(ClientError.MessageDoesNotHaveLiveLocationAttachment())) } return } @@ -1933,7 +1946,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." @@ -1957,6 +1970,17 @@ extension ClientError { "You can't specify a value outside the range 1-120 for cooldown duration." } } + + final class ActiveLiveLocationAlreadyExists: ClientError { + let messageId: MessageId + + public 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 1c1a1ee3dbf..ccd1518036f 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -230,6 +230,32 @@ public extension CurrentChatUserController { } } + /// Gets the current user messages which have active location sharing. + func getAllActiveLiveLocationMessages(completion: @escaping (Result<[ChatMessage], Error>) -> Void) { + guard let currentUserId = client.currentUserId else { + completion(.failure(ClientError.CurrentUserDoesNotExist())) + return + } + + client.messageRepository.getAllActiveLiveLocationMessages { result in + self.callback { + completion(result) + } + } + } + + /// Updates the current user's active live location sharing messages. + func updateLiveLocation(_ location: LocationAttachmentInfo) { + client.messageRepository.getAllActiveLiveLocationMessages { [weak self] result in + guard let messages = try? result.get() else { return } + for message in messages { + guard let cid = message.cid else { continue } + let messageController = self?.client.messageController(cid: cid, messageId: message.id) + messageController?.updateLiveLocation(latitude: location.latitude, longitude: location.longitude) + } + } + } + /// 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) { diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 507bf4dda11..745eec258c5 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -305,6 +305,45 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP } } + /// Updates the message's live location attachment if it has one. + /// + /// - Parameters: + /// - location: The new location for the live location attachment. + /// - completion: Called when the server updates the message. + public 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, + stoppedSharing: false + ) + + 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: @@ -997,10 +1036,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." + } + } } From 667739fe54490ad07cf0261f2a8b6fdcd5dfca27 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 16:59:13 +0000 Subject: [PATCH 19/94] Improve Demo App Location Provider --- DemoApp/LocationProvider.swift | 94 ++++++++++ DemoApp/Screens/DemoAppTabBarController.swift | 19 +++ .../CustomAttachments/DemoComposerVC.swift | 160 +++--------------- .../LocationAttachmentViewDelegate.swift | 5 +- StreamChat.xcodeproj/project.pbxproj | 4 + 5 files changed, 145 insertions(+), 137 deletions(-) create mode 100644 DemoApp/LocationProvider.swift diff --git a/DemoApp/LocationProvider.swift b/DemoApp/LocationProvider.swift new file mode 100644 index 00000000000..3a11663ddd6 --- /dev/null +++ b/DemoApp/LocationProvider.swift @@ -0,0 +1,94 @@ +// +// Copyright © 2024 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() + + 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) { + 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/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index cbe714c7989..84b2cc51f2a 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -2,11 +2,16 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Combine import StreamChat import StreamChatUI import UIKit class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate { + private var locationProvider = LocationProvider.shared + private var locationUpdatesPublisher = PassthroughSubject() + private var cancellables = Set() + let channelListVC: UIViewController let threadListVC: UIViewController let currentUserController: CurrentChatUserController @@ -61,6 +66,20 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele threadListVC.tabBarItem.badgeColor = .red viewControllers = [channelListVC, threadListVC] + + locationProvider.didUpdateLocation = { [weak self] location in + self?.locationUpdatesPublisher.send(LocationAttachmentInfo( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + )) + } + locationUpdatesPublisher + .throttle(for: 5, scheduler: DispatchQueue.global(), latest: true) + .sink { [weak self] newLocation in + print("Sending new location to the server:", newLocation) + self?.currentUserController.updateLiveLocation(newLocation) + } + .store(in: &cancellables) } func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index dde9099b916..a15a1ef9f6d 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -9,7 +9,7 @@ import StreamChatUI import UIKit class DemoComposerVC: ComposerVC { - private lazy var currentUserLocationProvider = CurrentUserLocationProvider.shared + private var locationProvider = LocationProvider.shared override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions @@ -47,68 +47,45 @@ class DemoComposerVC: ComposerVC { return actions } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + func addStaticLocationToAttachments() { + getCurrentLocationInfo { [weak self] location in + guard let location = location else { return } + let staticLocationPayload = StaticLocationAttachmentPayload( + latitude: location.latitude, + longitude: location.longitude + ) + self?.content.attachments.append(AnyAttachmentPayload(payload: staticLocationPayload)) + } + } - currentUserLocationProvider.stopMonitoringLocation() + func sendInstantStaticLocation() { + getCurrentLocationInfo { [weak self] location in + guard let location = location else { return } + self?.channelController?.sendStaticLocation(location) + } } - func addStaticLocationToAttachments() { - currentUserLocationProvider.getCurrentLocation { [weak self] result in - switch result { - case .success(let location): - let staticLocationPayload = StaticLocationAttachmentPayload( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - self?.content.attachments.append(AnyAttachmentPayload(payload: staticLocationPayload)) - case .failure(let error): - if error is LocationPermissionError { - self?.showLocationPermissionAlert() - } - } + func sendInstantLiveLocation() { + getCurrentLocationInfo { [weak self] location in + guard let location = location else { return } + self?.channelController?.startLiveLocationSharing(location) } } - func sendInstantStaticLocation() { - currentUserLocationProvider.getCurrentLocation { [weak self] result in + private func getCurrentLocationInfo(completion: @escaping (LocationAttachmentInfo?) -> Void) { + locationProvider.getCurrentLocation { [weak self] result in switch result { case .success(let location): - let staticLocationPayload = StaticLocationAttachmentPayload( + let location = LocationAttachmentInfo( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) - self?.channelController?.sendStaticLocation(staticLocationPayload) + completion(location) case .failure(let error): if error is LocationPermissionError { self?.showLocationPermissionAlert() } - } - } - } - - var throttler = Throttler(interval: 5, broadcastLatestEvent: false) - var messageId: MessageId? - - func sendInstantLiveLocation() { - currentUserLocationProvider.getCurrentLocation { [weak self] result in - switch result { - case .success(let location): - let liveLocation = LiveLocationAttachmentPayload( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude, - stoppedSharing: false - ) - self?.channelController?.startLiveLocationSharing(liveLocation) { - switch $0 { - case .success(let messageId): - self?.messageId = messageId - case .failure(let error): - print(error) - } - } - case .failure(let error): - print(error) + completion(nil) } } } @@ -130,90 +107,3 @@ class DemoComposerVC: ComposerVC { present(alert, animated: true) } } - -enum LocationPermissionError: Error { - case permissionDenied - case permissionRestricted -} - -class CurrentUserLocationProvider: 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() - self.locationManager.delegate = self - } - - static let shared = CurrentUserLocationProvider() - - func startMonitoringLocation() { - locationManager.allowsBackgroundLocationUpdates = true - requestPermission { [weak self] error in - guard let error else { return } - self?.onError?(error) - } - } - - func stopMonitoringLocation() { - locationManager.allowsBackgroundLocationUpdates = false - locationManager.stopUpdatingLocation() - } - - 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) { - 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 CurrentUserLocationProvider: 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/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index dc3dfebc0f9..8ce4c0bb6b7 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -22,7 +22,8 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { } func didTapOnStopSharingLocation(_ attachment: ChatMessageLiveLocationAttachment) { - CurrentUserLocationProvider.shared.stopMonitoringLocation() - client.channelController(for: attachment.id.cid).stopLiveLocation() + client + .channelController(for: attachment.id.cid) + .stopLiveLocation() } } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 346e6d0e4cf..2794e21f272 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1821,6 +1821,7 @@ 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 */; }; 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 */; }; @@ -4477,6 +4478,7 @@ 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 = ""; }; 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 = ""; }; @@ -5678,6 +5680,7 @@ A3227E7D284A511200EBE6CC /* DemoAppConfiguration.swift */, AD7110C32B3434F700AFFE28 /* StreamRuntimeCheck+StreamInternal.swift */, 792DDA5B256FB69E001DB91B /* SceneDelegate.swift */, + ADFCA5B62D1232A7000F515F /* LocationProvider.swift */, 8440861528FFE85F0027849C /* Shared */, A3227E56284A47F700EBE6CC /* StreamChat */, A3227ECA284A607D00EBE6CC /* Screens */, @@ -11098,6 +11101,7 @@ A3227E6D284A4B6A00EBE6CC /* UserCredentialsCell.swift in Sources */, 84A33ABA28F86B8500CEC8FD /* StreamChatWrapper+DemoApp.swift in Sources */, AD053BA92B336331003612B6 /* LocationDetailViewController.swift in Sources */, + ADFCA5B72D1232B3000F515F /* LocationProvider.swift in Sources */, A3227E59284A484300EBE6CC /* UIImage+Resized.swift in Sources */, 79B8B64B285CBDC00059FB2D /* DemoChatMessageLayoutOptionsResolver.swift in Sources */, AD053BA32B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift in Sources */, From 713c5e88b67cc6f7c5d6d86cd6cfc08cbb88ec34 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 17:02:16 +0000 Subject: [PATCH 20/94] Improve API by observing active live locations messages --- DemoApp/Screens/DemoAppTabBarController.swift | 11 ++ .../CurrentUserController.swift | 101 +++++++++++++----- .../StreamChat/Database/DTOs/MessageDTO.swift | 22 +++- .../Repositories/MessageRepository.swift | 17 --- .../StreamChat/Workers/MessageUpdater.swift | 1 - Sources/StreamChatUI/Utils/Throttler.swift | 8 +- 6 files changed, 107 insertions(+), 53 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 84b2cc51f2a..a53f8234b6a 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -88,4 +88,15 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele let totalUnreadBadge = unreadCount.channels + unreadCount.threads UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge } + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeActiveLiveLocationMessages messages: [ChatMessage] + ) { + if messages.isEmpty { + locationProvider.stopMonitoringLocation() + } else { + locationProvider.startMonitoringLocation() + } + } } diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index ccd1518036f..4ee8c533d8e 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -58,6 +58,18 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } } + private lazy var activeLiveLocationMessagesObserver: BackgroundListDatabaseObserver = { + let observer = createActiveLiveLocationMessagesObserver() + observer.onDidChange = { [weak self] _ in + self?.delegateCallback { [weak self] in + guard let self = self else { return } + let messages = Array(observer.items) + $0.currentUserController(self, didChangeActiveLiveLocationMessages: messages) + } + } + return observer + }() + /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() @@ -129,6 +141,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt do { try currentUserObserver.startObserving() + try activeLiveLocationMessagesObserver.startObserving() state = .localDataFetched } catch { log.error(""" @@ -230,29 +243,13 @@ public extension CurrentChatUserController { } } - /// Gets the current user messages which have active location sharing. - func getAllActiveLiveLocationMessages(completion: @escaping (Result<[ChatMessage], Error>) -> Void) { - guard let currentUserId = client.currentUserId else { - completion(.failure(ClientError.CurrentUserDoesNotExist())) - return - } - - client.messageRepository.getAllActiveLiveLocationMessages { result in - self.callback { - completion(result) - } - } - } - - /// Updates the current user's active live location sharing messages. + /// Updates the location of all the active live location messages for the current user. func updateLiveLocation(_ location: LocationAttachmentInfo) { - client.messageRepository.getAllActiveLiveLocationMessages { [weak self] result in - guard let messages = try? result.get() else { return } - for message in messages { - guard let cid = message.cid else { continue } - let messageController = self?.client.messageController(cid: cid, messageId: message.id) - messageController?.updateLiveLocation(latitude: location.latitude, longitude: location.longitude) - } + let messages = activeLiveLocationMessagesObserver.items + for message in messages { + guard let cid = message.cid else { continue } + let messageController = client.messageController(cid: cid, messageId: message.id) + messageController.updateLiveLocation(location) } } @@ -376,6 +373,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 } } @@ -404,6 +416,18 @@ private extension CurrentChatUserController { NSFetchedResultsController.self ) } + + func createActiveLiveLocationMessagesObserver() -> BackgroundListDatabaseObserver { + environment.currentUserActiveLiveLocationMessagesObserverBuilder( + client.databaseContainer, + MessageDTO.activeLiveLocationMessagesFetchRequest( + channelId: nil, + currentUserId: client.currentUserId + ), + { try $0.asModel() }, + NSFetchedResultsController.self + ) + } } // MARK: - Delegates @@ -411,16 +435,39 @@ 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. + func currentUserController( + _ controller: CurrentChatUserController, + didChangeActiveLiveLocationMessages messages: [ChatMessage] + ) } 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] + ) {} } public extension CurrentChatUserController { diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 9ba26b1109b..2e1089aec2a 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -585,10 +585,10 @@ class MessageDTO: NSManagedObject { return try load(request, context: context) } - static func loadActiveLiveLocationMessages( + static func activeLiveLocationMessagesFetchRequest( channelId: ChannelId?, - context: NSManagedObjectContext - ) throws -> [MessageDTO] { + currentUserId: UserId? + ) -> NSFetchRequest { let request = NSFetchRequest(entityName: MessageDTO.entityName) MessageDTO.applyPrefetchingState(to: request) request.fetchLimit = 10 @@ -599,10 +599,24 @@ class MessageDTO: NSManagedObject { var predicates: [NSPredicate] = [ .init(format: "ANY attachments.isActiveLocationAttachment == YES") ] - if let channelId = channelId { + if let currentUserId { + predicates.append(.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( + channelId: ChannelId?, + context: NSManagedObjectContext + ) throws -> [MessageDTO] { + let request = activeLiveLocationMessagesFetchRequest( + channelId: channelId, + currentUserId: context.currentUser?.user.id + ) return try load(request, context: context) } diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 25bf88b8796..2a281db6a72 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -282,23 +282,6 @@ class MessageRepository { } } - func getAllActiveLiveLocationMessages(completion: @escaping (Result<[ChatMessage], Error>) -> Void) { - let context = database.backgroundReadOnlyContext - context.perform { - do { - let messages = try MessageDTO.loadActiveLiveLocationMessages( - channelId: nil, - context: context - ).map { - try $0.asModel() - } - completion(.success(messages)) - } catch { - completion(.failure(error)) - } - } - } - func getActiveLiveLocationMessages( for channelId: ChannelId, completion: @escaping (Result<[ChatMessage], Error>) -> Void diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 903ef362dfd..392f1ea1a51 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -188,7 +188,6 @@ class MessageUpdater: Worker { extraData: [String: RawJSON]? = nil, completion: ((Result) -> Void)? = nil ) { - // TODO: This is quite nasty, we should find more optimal way to do this. let attachmentPayloads: [MessageAttachmentPayload]? = attachments?.compactMap { attachment in guard let payloadData = try? JSONEncoder.default.encode(attachment.payload) else { return nil diff --git a/Sources/StreamChatUI/Utils/Throttler.swift b/Sources/StreamChatUI/Utils/Throttler.swift index 6149001a966..0aef17b461d 100644 --- a/Sources/StreamChatUI/Utils/Throttler.swift +++ b/Sources/StreamChatUI/Utils/Throttler.swift @@ -6,7 +6,7 @@ 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:) -public class Throttler { +class Throttler { private var workItem: DispatchWorkItem? private let queue: DispatchQueue private var previousRun: Date = Date.distantPast @@ -18,7 +18,7 @@ public class Throttler { /// - 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. - public init( + init( interval: TimeInterval, broadcastLatestEvent: Bool = true, queue: DispatchQueue = .init(label: "com.stream.throttler", qos: .utility) @@ -31,7 +31,7 @@ public 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. - public func execute(_ action: @escaping () -> Void) { + func execute(_ action: @escaping () -> Void) { workItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -53,7 +53,7 @@ public class Throttler { } /// Cancel any active action. - public func cancel() { + func cancel() { workItem?.cancel() workItem = nil } From 54d623f8bc2c051f82872bbb585def1a14225193 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 19:10:14 +0000 Subject: [PATCH 21/94] Fixed starting monitoring location whenever an active location was updated --- DemoApp/LocationProvider.swift | 5 +++++ DemoApp/Screens/DemoAppTabBarController.swift | 4 +++- Sources/StreamChat/Database/DTOs/MessageDTO.swift | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/DemoApp/LocationProvider.swift b/DemoApp/LocationProvider.swift index 3a11663ddd6..0c81bf3a8fd 100644 --- a/DemoApp/LocationProvider.swift +++ b/DemoApp/LocationProvider.swift @@ -25,6 +25,10 @@ class LocationProvider: NSObject { static let shared = LocationProvider() + var isMonitoringLocation: Bool { + locationManager.delegate != nil + } + func startMonitoringLocation() { locationManager.allowsBackgroundLocationUpdates = true locationManager.delegate = self @@ -55,6 +59,7 @@ class LocationProvider: NSObject { } func requestPermission(completion: @escaping (Error?) -> Void) { + locationManager.delegate = self switch locationManager.authorizationStatus { case .notDetermined: locationManager.requestWhenInUseAuthorization() diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index a53f8234b6a..5be1c2cc1db 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -93,9 +93,11 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele _ controller: CurrentChatUserController, didChangeActiveLiveLocationMessages messages: [ChatMessage] ) { + /// If there are no active live location messages, we stop monitoring the location. if messages.isEmpty { locationProvider.stopMonitoringLocation() - } else { + /// If there are active live location messages, we start monitoring the location. + } else if !locationProvider.isMonitoringLocation { locationProvider.startMonitoringLocation() } } diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 2e1089aec2a..23b1bcbf911 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -591,6 +591,7 @@ class MessageDTO: NSManagedObject { ) -> 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, From 495b3588b6f0b045de40176a26a71ac6fa949209 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 20:49:49 +0000 Subject: [PATCH 22/94] Make the API even easier and add additional delegate methods to know when the user should start and stop location sharing --- DemoApp/Screens/DemoAppTabBarController.swift | 20 +++++----- .../CurrentUserController.swift | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 5be1c2cc1db..f0edfa80f84 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -76,7 +76,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele locationUpdatesPublisher .throttle(for: 5, scheduler: DispatchQueue.global(), latest: true) .sink { [weak self] newLocation in - print("Sending new location to the server:", newLocation) + debugPrint("Location: Sending new location (\(newLocation.latitude), \(newLocation.longitude) to the server.") self?.currentUserController.updateLiveLocation(newLocation) } .store(in: &cancellables) @@ -89,16 +89,16 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge } - func currentUserController( + func currentUserControllerDidStartSharingLiveLocation( _ controller: CurrentChatUserController, - didChangeActiveLiveLocationMessages messages: [ChatMessage] + activeLiveLocationMessages messages: [ChatMessage] ) { - /// If there are no active live location messages, we stop monitoring the location. - if messages.isEmpty { - locationProvider.stopMonitoringLocation() - /// If there are active live location messages, we start monitoring the location. - } else if !locationProvider.isMonitoringLocation { - locationProvider.startMonitoringLocation() - } + debugPrint("Location: Started sharing live location.") + locationProvider.startMonitoringLocation() + } + + func currentUserControllerDidStopSharingLiveLocation(_ controller: CurrentChatUserController) { + debugPrint("Location: Stopped sharing live location.") + locationProvider.stopMonitoringLocation() } } diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 4ee8c533d8e..ebb88bca465 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -58,12 +58,29 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } } + private var isSharingLiveLocation = false { + didSet { + if isSharingLiveLocation == oldValue { + return + } + if isSharingLiveLocation { + delegate?.currentUserControllerDidStartSharingLiveLocation( + self, + activeLiveLocationMessages: Array(activeLiveLocationMessagesObserver.items) + ) + } else { + delegate?.currentUserControllerDidStopSharingLiveLocation(self) + } + } + } + private lazy var activeLiveLocationMessagesObserver: BackgroundListDatabaseObserver = { let observer = createActiveLiveLocationMessagesObserver() 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) } } @@ -451,6 +468,17 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { _ controller: CurrentChatUserController, didChangeActiveLiveLocationMessages messages: [ChatMessage] ) + + /// The current user started sharing his location in message attachments. + func currentUserControllerDidStartSharingLiveLocation( + _ controller: CurrentChatUserController, + activeLiveLocationMessages messages: [ChatMessage] + ) + + /// The current user has no active live location attachments. + func currentUserControllerDidStopSharingLiveLocation( + _ controller: CurrentChatUserController + ) } public extension CurrentChatUserControllerDelegate { @@ -468,6 +496,15 @@ public extension CurrentChatUserControllerDelegate { _ controller: CurrentChatUserController, didChangeActiveLiveLocationMessages messages: [ChatMessage] ) {} + + func currentUserControllerDidStartSharingLiveLocation( + _ controller: CurrentChatUserController, + activeLiveLocationMessages messages: [ChatMessage] + ) {} + + func currentUserControllerDidStopSharingLiveLocation( + _ controller: CurrentChatUserController + ) {} } public extension CurrentChatUserController { From b09bf3894d75f184f1f20569f5792ec17410b1da Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 21:45:21 +0000 Subject: [PATCH 23/94] Move the Throttler to LLC, just like the Debouncer --- .../Utils/Throttler.swift | 18 +++++++++++------- StreamChat.xcodeproj/project.pbxproj | 12 ++++++------ 2 files changed, 17 insertions(+), 13 deletions(-) rename Sources/{StreamChatUI => StreamChat}/Utils/Throttler.swift (83%) diff --git a/Sources/StreamChatUI/Utils/Throttler.swift b/Sources/StreamChat/Utils/Throttler.swift similarity index 83% rename from Sources/StreamChatUI/Utils/Throttler.swift rename to Sources/StreamChat/Utils/Throttler.swift index 0aef17b461d..d9013642e43 100644 --- a/Sources/StreamChatUI/Utils/Throttler.swift +++ b/Sources/StreamChat/Utils/Throttler.swift @@ -5,20 +5,24 @@ import Foundation /// A throttler implementation. The action provided will only be executed if the last action executed has passed an amount of time. -/// Based on the implementation from Apple: https://developer.apple.com/documentation/combine/anypublisher/throttle(for:scheduler:latest:) -class Throttler { +/// +/// The API is based on the implementation from Apple: +/// https://developer.apple.com/documentation/combine/anypublisher/throttle(for:scheduler:latest:) +public class Throttler { private var workItem: DispatchWorkItem? private let queue: DispatchQueue private var previousRun: Date = Date.distantPast - let interval: TimeInterval - let broadcastLatestEvent: Bool + private let broadcastLatestEvent: Bool + + /// The current interval that an action can be executed. + public var interval: TimeInterval /// - Parameters: /// - interval: The interval that an action can be executed. /// - broadcastLatestEvent: A Boolean value that indicates whether we should be using the first or last event of the ones that are being throttled. /// - queue: The queue where the work will be executed. /// This last action will have a delay of the provided interval until it is executed. - init( + public init( interval: TimeInterval, broadcastLatestEvent: Bool = true, queue: DispatchQueue = .init(label: "com.stream.throttler", qos: .utility) @@ -31,7 +35,7 @@ class Throttler { /// Throttle an action. It will cancel the previous action if exists, and it will execute the action immediately /// if the last action executed was past the interval provided. If not, it will only be executed after a delay. /// - Parameter action: The closure to be performed. - func execute(_ action: @escaping () -> Void) { + public func execute(_ action: @escaping () -> Void) { workItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -53,7 +57,7 @@ class Throttler { } /// Cancel any active action. - func cancel() { + public func cancel() { workItem?.cancel() workItem = nil } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 2794e21f272..0ae94101acb 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1822,6 +1822,8 @@ 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 */; }; @@ -2313,8 +2315,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 */; }; @@ -4479,6 +4479,7 @@ 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 = ""; }; @@ -4516,7 +4517,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 = ""; }; @@ -5620,6 +5620,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 */, @@ -6350,7 +6351,6 @@ ACA3C98526CA23F300EB8B07 /* DateUtils.swift */, 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, - C12297D22AC57A3200C5FF04 /* Throttler.swift */, AD169DEC2C9B112B00F58FAC /* KeyboardHandler */, AD95FD0F28F9B72200DBDF41 /* Extensions */, ACCA772826C40C7A007AE2ED /* ImageLoading */, @@ -10794,7 +10794,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 */, @@ -11383,6 +11382,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 */, @@ -12316,6 +12316,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 */, @@ -12742,7 +12743,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 */, From 6468d0d820cf58c9559e3451aea39d8ad54a315b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 21:46:35 +0000 Subject: [PATCH 24/94] Move the Throttling to the current user controller to automatically protect against abusive updates --- DemoApp/Screens/DemoAppTabBarController.swift | 36 ++++++++----- .../CurrentUserController.swift | 52 +++++++++++-------- .../MessageController/MessageController.swift | 7 +++ 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index f0edfa80f84..410635c2824 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -9,8 +9,6 @@ import UIKit class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate { private var locationProvider = LocationProvider.shared - private var locationUpdatesPublisher = PassthroughSubject() - private var cancellables = Set() let channelListVC: UIViewController let threadListVC: UIViewController @@ -68,18 +66,12 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele viewControllers = [channelListVC, threadListVC] locationProvider.didUpdateLocation = { [weak self] location in - self?.locationUpdatesPublisher.send(LocationAttachmentInfo( + let newLocation = LocationAttachmentInfo( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude - )) + ) + self?.currentUserController.updateLiveLocation(newLocation) } - locationUpdatesPublisher - .throttle(for: 5, scheduler: DispatchQueue.global(), latest: true) - .sink { [weak self] newLocation in - debugPrint("Location: Sending new location (\(newLocation.latitude), \(newLocation.longitude) to the server.") - self?.currentUserController.updateLiveLocation(newLocation) - } - .store(in: &cancellables) } func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { @@ -93,12 +85,30 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele _ controller: CurrentChatUserController, activeLiveLocationMessages messages: [ChatMessage] ) { - debugPrint("Location: Started sharing live location.") + debugPrint("[Location] Started sharing live location.") locationProvider.startMonitoringLocation() } func currentUserControllerDidStopSharingLiveLocation(_ controller: CurrentChatUserController) { - debugPrint("Location: Stopped sharing live location.") + 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/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index ebb88bca465..2d968d2cf24 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -58,22 +58,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } } - private var isSharingLiveLocation = false { - didSet { - if isSharingLiveLocation == oldValue { - return - } - if isSharingLiveLocation { - delegate?.currentUserControllerDidStartSharingLiveLocation( - self, - activeLiveLocationMessages: Array(activeLiveLocationMessagesObserver.items) - ) - } else { - delegate?.currentUserControllerDidStopSharingLiveLocation(self) - } - } - } - + /// The observer for the active live location messages. private lazy var activeLiveLocationMessagesObserver: BackgroundListDatabaseObserver = { let observer = createActiveLiveLocationMessagesObserver() observer.onDidChange = { [weak self] _ in @@ -87,6 +72,24 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt return observer }() + /// A flag to indicate whether the current user is sharing his live location. + private var isSharingLiveLocation = false { + didSet { + if isSharingLiveLocation == oldValue { + return + } + if isSharingLiveLocation { + let messages = Array(activeLiveLocationMessagesObserver.items) + delegate?.currentUserControllerDidStartSharingLiveLocation(self, activeLiveLocationMessages: messages) + } else { + delegate?.currentUserControllerDidStopSharingLiveLocation(self) + } + } + } + + /// The throttler for limiting the frequency of live location updates. + private var locationUpdatesThrottler = Throttler(interval: 5, broadcastLatestEvent: true) + /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() @@ -261,12 +264,19 @@ 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. + /// The throttling interval is set to 5 seconds. + /// + /// - Parameter location: The new location to be updated. func updateLiveLocation(_ location: LocationAttachmentInfo) { - let messages = activeLiveLocationMessagesObserver.items - for message in messages { - guard let cid = message.cid else { continue } - let messageController = client.messageController(cid: cid, messageId: message.id) - messageController.updateLiveLocation(location) + locationUpdatesThrottler.execute { [weak self] in + let messages = self?.activeLiveLocationMessagesObserver.items ?? [] + for message in messages { + guard let cid = message.cid else { continue } + let messageController = self?.client.messageController(cid: cid, messageId: message.id) + messageController?.updateLiveLocation(location) + } } } diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 745eec258c5..936ea862fdf 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 hdler. private let replyPaginationHandler: MessagesPaginationStateHandling + + /// The current state of the pagination state. private var replyPaginationState: MessagesPaginationState { replyPaginationHandler.state } /// Creates a new `MessageControllerGeneric`. From effff358c5f9287e2d3c535e348a40a08ed7f63f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 18 Dec 2024 22:01:49 +0000 Subject: [PATCH 25/94] Some minor cleanups --- .../Components/CustomAttachments/DemoComposerVC.swift | 1 - .../CustomAttachments/DemoQuotedChatMessageView.swift | 2 +- .../LocationAttachment/LocationAttachmentViewDelegate.swift | 2 +- DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift | 1 - Sources/StreamChat/ChatClient.swift | 3 ++- .../Controllers/ChannelController/ChannelController.swift | 2 +- 6 files changed, 5 insertions(+), 6 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index a15a1ef9f6d..93b1cbfb576 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -3,7 +3,6 @@ // import CoreLocation -@_spi(Experimental) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift index a47e71991b7..83d9f00ca52 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift @@ -8,7 +8,7 @@ import UIKit class DemoQuotedChatMessageView: QuotedChatMessageView { override func setAttachmentPreview(for message: ChatMessage) { - let locationAttachments = message.attachments(payloadType: StaticLocationAttachmentPayload.self) + let locationAttachments = message.staticLocationAttachments if let locationPayload = locationAttachments.first?.payload { attachmentPreviewView.contentMode = .scaleAspectFit attachmentPreviewView.image = UIImage( diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index 8ce4c0bb6b7..cd308675530 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -24,6 +24,6 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { func didTapOnStopSharingLocation(_ attachment: ChatMessageLiveLocationAttachment) { client .channelController(for: attachment.id.cid) - .stopLiveLocation() + .stopLiveLocationSharing() } } diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index c89d3dddd00..d7fb0ab200f 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -34,7 +34,6 @@ extension StreamChatWrapper { with: LocationAttachmentViewInjector.self ) } - client?.registerAttachment(LiveLocationAttachmentPayload.self) // L10N let localizationProvider = Appearance.default.localizationProvider diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index b57982d8253..08dcbccd65b 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -62,7 +62,8 @@ public class ChatClient { .audio: AudioAttachmentPayload.self, .file: FileAttachmentPayload.self, .voiceRecording: VoiceRecordingAttachmentPayload.self, - .staticLocation: StaticLocationAttachmentPayload.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 722de839e75..1dbcc9dfa64 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -965,7 +965,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } /// Stops sharing the live location message in the channel. - public func stopLiveLocation(completion: ((Result) -> Void)? = nil) { + public func stopLiveLocationSharing(completion: ((Result) -> Void)? = nil) { guard let cid = cid, isChannelAlreadyCreated else { channelModificationFailed { error in completion?(.failure(error ?? ClientError.Unknown())) From 1e684dbb08a0495c1d4308a06054beba3b1ab0a6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 19 Dec 2024 16:32:30 +0000 Subject: [PATCH 26/94] Simplify currentUserControllerDidStartSharingLiveLocation API --- DemoApp/Screens/DemoAppTabBarController.swift | 5 ++--- .../CurrentUserController/CurrentUserController.swift | 9 +++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 410635c2824..5179ec9f6fc 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -82,8 +82,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele } func currentUserControllerDidStartSharingLiveLocation( - _ controller: CurrentChatUserController, - activeLiveLocationMessages messages: [ChatMessage] + _ controller: CurrentChatUserController ) { debugPrint("[Location] Started sharing live location.") locationProvider.startMonitoringLocation() @@ -101,7 +100,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele guard !messages.isEmpty else { return } - + let locations: [String] = messages.compactMap { guard let locationAttachment = $0.liveLocationAttachments.first else { return nil diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 2d968d2cf24..ff2ddb67c14 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -79,8 +79,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt return } if isSharingLiveLocation { - let messages = Array(activeLiveLocationMessagesObserver.items) - delegate?.currentUserControllerDidStartSharingLiveLocation(self, activeLiveLocationMessages: messages) + delegate?.currentUserControllerDidStartSharingLiveLocation(self) } else { delegate?.currentUserControllerDidStopSharingLiveLocation(self) } @@ -481,8 +480,7 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { /// The current user started sharing his location in message attachments. func currentUserControllerDidStartSharingLiveLocation( - _ controller: CurrentChatUserController, - activeLiveLocationMessages messages: [ChatMessage] + _ controller: CurrentChatUserController ) /// The current user has no active live location attachments. @@ -508,8 +506,7 @@ public extension CurrentChatUserControllerDelegate { ) {} func currentUserControllerDidStartSharingLiveLocation( - _ controller: CurrentChatUserController, - activeLiveLocationMessages messages: [ChatMessage] + _ controller: CurrentChatUserController ) {} func currentUserControllerDidStopSharingLiveLocation( From 81ad0f412a8330cd551eb05cf4bf8f276338cf56 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 20 Dec 2024 17:32:13 +0000 Subject: [PATCH 27/94] Improve location attachment view --- DemoApp/Screens/DemoAppTabBarController.swift | 5 + ...chmentPayload+AttachmentViewProvider.swift | 1 + .../LocationAttachmentSnapshotView.swift | 131 ++++++++++++------ .../LocationAttachmentViewInjector.swift | 7 +- 4 files changed, 95 insertions(+), 49 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 5179ec9f6fc..0c746df743c 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -101,10 +101,15 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele return } + messages.forEach { + LocationAttachmentSnapshotView.snapshotsCache.removeObject(forKey: $0.id as NSString) + } + let locations: [String] = messages.compactMap { guard let locationAttachment = $0.liveLocationAttachments.first else { return nil } + return "(\(locationAttachment.latitude), \(locationAttachment.longitude))" } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift index 78c73fb03c2..d1f971c3564 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift @@ -15,6 +15,7 @@ extension StaticLocationAttachmentPayload: AttachmentPreviewProvider { /// but a different one could be provided. let preview = LocationAttachmentSnapshotView() preview.content = .init( + messageId: nil, latitude: latitude, longitude: longitude, isLive: false diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 4764a147ae6..2c4db21abd1 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -3,19 +3,13 @@ // import MapKit +import StreamChat import StreamChatUI import UIKit -struct LocationCoordinate { - let latitude: CLLocationDegrees - let longitude: CLLocationDegrees -} - class LocationAttachmentSnapshotView: _View { - static var snapshotsCache: NSCache = .init() - var snapshotter: MKMapSnapshotter? - struct Content { + var messageId: MessageId? var latitude: CLLocationDegrees var longitude: CLLocationDegrees var isLive: Bool = false @@ -30,6 +24,12 @@ class LocationAttachmentSnapshotView: _View { var didTapOnLocation: (() -> Void)? var didTapOnStopSharingLocation: (() -> Void)? + let mapOptions: MKMapSnapshotter.Options = .init() + let mapHeight: CGFloat = 150 + + static var snapshotsCache: NSCache = .init() + var snapshotter: MKMapSnapshotter? + lazy var imageView: UIImageView = { let view = UIImageView() view.translatesAutoresizingMaskIntoConstraints = false @@ -62,8 +62,6 @@ class LocationAttachmentSnapshotView: _View { return button }() - let mapOptions: MKMapSnapshotter.Options = .init() - override func setUp() { super.setUp() @@ -80,18 +78,20 @@ class LocationAttachmentSnapshotView: _View { stopButton.isHidden = true activityIndicatorView.hidesWhenStopped = true + addSubview(activityIndicatorView) + let container = VContainer(alignment: .center) { imageView + .height(mapHeight) stopButton .width(120) .height(30) }.embed(in: self) - container.addSubview(activityIndicatorView) - NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: container.centerXAnchor), - activityIndicatorView.centerYAnchor.constraint(equalTo: container.centerYAnchor) + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + imageView.widthAnchor.constraint(equalTo: container.widthAnchor) ]) } @@ -102,10 +102,6 @@ class LocationAttachmentSnapshotView: _View { override func updateContent() { super.updateContent() - if content?.isLive == false { - imageView.image = nil - } - guard let content = self.content else { return } @@ -116,51 +112,68 @@ class LocationAttachmentSnapshotView: _View { stopButton.isHidden = true } - let coordinate = LocationCoordinate( - latitude: content.latitude, - longitude: content.longitude - ) - configureMapPosition(coordinate: coordinate) + configureMapPosition() + loadMapSnapshotImage() + } - if let snapshotImage = Self.snapshotsCache.object(forKey: coordinate.cachingKey) { - imageView.image = snapshotImage - } else { - activityIndicatorView.startAnimating() - loadMapSnapshotImage(coordinate: coordinate) + override func layoutSubviews() { + super.layoutSubviews() + + if frame.size.width != mapOptions.size.width { + imageView.image = nil + clearSnapshotCache() } + + 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 + latitude: content.latitude, + longitude: content.longitude ), 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: mapHeight) + + if let cachedSnapshot = getCachedSnapshot() { + imageView.image = cachedSnapshot + 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) + let image = self.generatePinAnnotation(for: snapshot) DispatchQueue.main.async { self.activityIndicatorView.stopAnimating() self.imageView.image = image - Self.snapshotsCache.setObject(image, forKey: coordinate.cachingKey) + self.setCachedSnapshot(image: image) } } } private func generatePinAnnotation( - for snapshot: MKMapSnapshotter.Snapshot, - with coordinate: LocationCoordinate + for snapshot: MKMapSnapshotter.Snapshot ) -> UIImage { let image = UIGraphicsImageRenderer(size: mapOptions.size).image { _ in snapshot.image.draw(at: .zero) @@ -168,9 +181,13 @@ class LocationAttachmentSnapshotView: _View { let pinView = MKPinAnnotationView(annotation: nil, reuseIdentifier: nil) let pinImage = pinView.image + guard let content = self.content else { + return + } + var point = snapshot.point(for: CLLocationCoordinate2D( - latitude: coordinate.latitude, - longitude: coordinate.longitude + latitude: content.latitude, + longitude: content.longitude )) point.x -= pinView.bounds.width / 2 point.y -= pinView.bounds.height / 2 @@ -181,13 +198,39 @@ class LocationAttachmentSnapshotView: _View { return image } - @objc private func handleStopButtonTap() { + @objc func handleStopButtonTap() { didTapOnStopSharingLocation?() } -} -private extension LocationCoordinate { - var cachingKey: NSString { - NSString(string: "\(latitude),\(longitude)") + // 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) + } + + func clearSnapshotCache() { + Self.snapshotsCache.removeAllObjects() + } + + 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/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 329ec225a14..8bfe1011774 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -22,11 +22,6 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { contentView.bubbleContentContainer.insertArrangedSubview(locationAttachmentView, at: 0) - NSLayoutConstraint.activate([ - locationAttachmentView.widthAnchor.constraint(equalToConstant: 250), - locationAttachmentView.heightAnchor.constraint(equalToConstant: 150) - ]) - locationAttachmentView.didTapOnLocation = { [weak self] in self?.handleTapOnLocationAttachment() } @@ -40,12 +35,14 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { if let staticLocation = staticLocationAttachment { locationAttachmentView.content = .init( + messageId: contentView.content?.id, latitude: staticLocation.latitude, longitude: staticLocation.longitude, isLive: false ) } else if let liveLocation = liveLocationAttachment { locationAttachmentView.content = .init( + messageId: contentView.content?.id, latitude: liveLocation.latitude, longitude: liveLocation.longitude, isLive: liveLocation.stoppedSharing == false || liveLocation.stoppedSharing == nil From 9ded30e344657fd41760cb80b04911728ff01b6c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 23 Dec 2024 17:14:45 +0000 Subject: [PATCH 28/94] Add live location map when tapping the live location attachmen --- .../LocationAttachmentViewDelegate.swift | 33 +++++++++++++++++-- .../LocationAttachmentViewInjector.swift | 8 ++--- .../LocationDetailViewController.swift | 16 ++++++--- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index cd308675530..0b7635c0953 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -6,18 +6,45 @@ import StreamChat import StreamChatUI protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate { - func didTapOnLocationAttachment( + func didTapOnStaticLocationAttachment( _ attachment: ChatMessageStaticLocationAttachment ) + func didTapOnLiveLocationAttachment( + _ attachment: ChatMessageLiveLocationAttachment + ) + func didTapOnStopSharingLocation( _ attachment: ChatMessageLiveLocationAttachment ) } extension DemoChatMessageListVC: LocationAttachmentViewDelegate { - func didTapOnLocationAttachment(_ attachment: ChatMessageStaticLocationAttachment) { - let mapViewController = LocationDetailViewController(locationAttachment: attachment) + func didTapOnStaticLocationAttachment(_ attachment: ChatMessageStaticLocationAttachment) { + let mapViewController = LocationDetailViewController( + locationCoordinate: .init( + latitude: attachment.latitude, + longitude: attachment.longitude + ), + isLive: false, + messageController: nil + ) + navigationController?.pushViewController(mapViewController, animated: true) + } + + func didTapOnLiveLocationAttachment(_ attachment: ChatMessageLiveLocationAttachment) { + let messageController = client.messageController( + cid: attachment.id.cid, + messageId: attachment.id.messageId + ) + let mapViewController = LocationDetailViewController( + locationCoordinate: .init( + latitude: attachment.latitude, + longitude: attachment.longitude + ), + isLive: true, + messageController: messageController + ) navigationController?.pushViewController(mapViewController, animated: true) } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 8bfe1011774..65d87f369c0 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -55,11 +55,11 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { return } - guard let locationAttachment = staticLocationAttachment else { - return + if let staticLocationAttachment = self.staticLocationAttachment { + locationAttachmentDelegate.didTapOnStaticLocationAttachment(staticLocationAttachment) + } else if let liveLocationAttachment = self.liveLocationAttachment { + locationAttachmentDelegate.didTapOnLiveLocationAttachment(liveLocationAttachment) } - - locationAttachmentDelegate.didTapOnLocationAttachment(locationAttachment) } func handleTapOnStopSharingLocation() { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 33d47dc91cf..955a004e676 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -7,10 +7,18 @@ import StreamChat import UIKit class LocationDetailViewController: UIViewController { - let locationAttachment: ChatMessageStaticLocationAttachment - - init(locationAttachment: ChatMessageStaticLocationAttachment) { - self.locationAttachment = locationAttachment + let locationCoordinate: CLLocationCoordinate2D + let isLive: Bool + let messageController: ChatMessageController? + + init( + locationCoordinate: CLLocationCoordinate2D, + isLive: Bool, + messageController: ChatMessageController? + ) { + self.locationCoordinate = locationCoordinate + self.isLive = isLive + self.messageController = messageController super.init(nibName: nil, bundle: nil) } From 7df6c3e77c71c6479bd464fa699c5418aac3f782 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 23 Dec 2024 17:15:49 +0000 Subject: [PATCH 29/94] Animate the user location tracking --- .../LocationDetailViewController.swift | 124 ++++++++++++++++-- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 955a004e676..863705286ce 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -27,6 +27,9 @@ 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.isZoomEnabled = true @@ -36,20 +39,125 @@ class LocationDetailViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - let locationCoordinate = CLLocationCoordinate2D( - latitude: locationAttachment.latitude, - longitude: locationAttachment.longitude + mapView.register( + UserAnnotationView.self, + forAnnotationViewWithReuseIdentifier: "UserAnnotation" ) - mapView.region = .init( center: locationCoordinate, - span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5) + span: coordinateSpan ) + mapView.showsUserLocation = false + mapView.delegate = self - let annotation = MKPointAnnotation() - annotation.coordinate = locationCoordinate - mapView.addAnnotation(annotation) + messageController?.synchronize() + messageController?.delegate = self view = mapView } } + +extension LocationDetailViewController: ChatMessageControllerDelegate { + func messageController( + _ controller: ChatMessageController, + didChangeMessage change: EntityChange + ) { + guard let liveLocationAttachment = controller.message?.liveLocationAttachments.first else { + return + } + + let locationCoordinate = CLLocationCoordinate2D( + latitude: liveLocationAttachment.latitude, + longitude: liveLocationAttachment.longitude + ) + + updateUserLocation( + locationCoordinate, + userImage: UIImage(systemName: "location"), + userName: messageController?.message?.author.name ?? "" + ) + } + + func updateUserLocation( + _ coordinate: CLLocationCoordinate2D, + userImage: UIImage?, + userName: String + ) { + if let existingAnnotation = userAnnotation { + UIView.animate(withDuration: 3) { + existingAnnotation.coordinate = coordinate + } + UIView.animate(withDuration: 3, delay: 0.2, options: .curveEaseOut) { + self.mapView.setCenter(coordinate, animated: true) + } + if let annotationView = mapView.view(for: existingAnnotation) as? UserAnnotationView { + annotationView.updateImage(userImage) + } + } else { + // Create new annotation + userAnnotation = UserAnnotation( + coordinate: coordinate, + image: userImage, + title: userName + ) + if let annotation = userAnnotation { + mapView.addAnnotation(annotation) + } + } + } +} + +extension LocationDetailViewController: MKMapViewDelegate { + func mapView( + _ mapView: MKMapView, + viewFor annotation: MKAnnotation + ) -> MKAnnotationView? { + guard let userAnnotation = annotation as? UserAnnotation else { + return nil + } + + let identifier = "UserAnnotation" + let annotationView = mapView.dequeueReusableAnnotationView( + withIdentifier: identifier, + for: userAnnotation + ) as? UserAnnotationView + + annotationView?.updateImage(userAnnotation.image) + return annotationView + } +} + +// Custom annotation class to store user data +class UserAnnotation: NSObject, MKAnnotation { + dynamic var coordinate: CLLocationCoordinate2D + var image: UIImage? + var title: String? + + init(coordinate: CLLocationCoordinate2D, image: UIImage?, title: String?) { + self.coordinate = coordinate + self.image = image + self.title = title + super.init() + } +} + +// Custom annotation view with user avatar +class UserAnnotationView: MKAnnotationView { + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + frame = CGRect(x: 0, y: 0, width: 40, height: 40) + layer.cornerRadius = 20 + layer.masksToBounds = true + layer.borderWidth = 2 + layer.borderColor = UIColor.white.cgColor + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + func updateImage(_ image: UIImage?) { + self.image = image + } +} From b91fa09693f9ea5552b80b4d00cc46826ccd5bf4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 23 Dec 2024 17:33:14 +0000 Subject: [PATCH 30/94] Optimal animation --- .../CurrentUserController.swift | 2 +- .../MessageController/MessageController.swift | 12 ++++++++++++ Sources/StreamChat/Workers/MessageUpdater.swift | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index ff2ddb67c14..d748c473edf 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -87,7 +87,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } /// The throttler for limiting the frequency of live location updates. - private var locationUpdatesThrottler = Throttler(interval: 5, broadcastLatestEvent: true) + private var locationUpdatesThrottler = Throttler(interval: 0.5, broadcastLatestEvent: true) /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 936ea862fdf..00df46adbfc 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -337,6 +337,18 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP stoppedSharing: false ) + // 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, diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 392f1ea1a51..2ce28fbab83 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -1002,7 +1002,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. From e50a63b89db41576de255d91380a8af6e59cfb64 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 23 Dec 2024 17:37:30 +0000 Subject: [PATCH 31/94] Optmizate animation smoothness and server spamm --- .../LocationAttachment/LocationDetailViewController.swift | 4 ++-- .../CurrentUserController/CurrentUserController.swift | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 863705286ce..88d16ba1725 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -84,10 +84,10 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { userName: String ) { if let existingAnnotation = userAnnotation { - UIView.animate(withDuration: 3) { + UIView.animate(withDuration: 5) { existingAnnotation.coordinate = coordinate } - UIView.animate(withDuration: 3, delay: 0.2, options: .curveEaseOut) { + UIView.animate(withDuration: 5, delay: 0.2, options: .curveEaseOut) { self.mapView.setCenter(coordinate, animated: true) } if let annotationView = mapView.view(for: existingAnnotation) as? UserAnnotationView { diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index d748c473edf..201664b3585 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -87,7 +87,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } /// The throttler for limiting the frequency of live location updates. - private var locationUpdatesThrottler = Throttler(interval: 0.5, broadcastLatestEvent: true) + private var locationUpdatesThrottler = Throttler(interval: 3, broadcastLatestEvent: true) /// A type-erased delegate. var multicastDelegate: MulticastDelegate = .init() @@ -265,7 +265,6 @@ 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. - /// The throttling interval is set to 5 seconds. /// /// - Parameter location: The new location to be updated. func updateLiveLocation(_ location: LocationAttachmentInfo) { From 1e9ef447ff8a7df876cc77328efa88409913a91f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 26 Dec 2024 11:19:28 +0000 Subject: [PATCH 32/94] Add CoreData concurrency flag to StreamDevelopers scheme --- .../xcschemes/DemoApp-StreamDevelopers.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme b/StreamChat.xcodeproj/xcshareddata/xcschemes/DemoApp-StreamDevelopers.xcscheme index fe8fca18fa3..abce29fc903 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"> + + + + Date: Thu, 26 Dec 2024 11:19:50 +0000 Subject: [PATCH 33/94] Fix crash when creating the MessageController from a background thread --- .../Controllers/MessageController/MessageController.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 00df46adbfc..d38107f4300 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -978,9 +978,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( From 7a0bd667a62c52b03526a5b6637bd24ab1dee35d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 26 Dec 2024 13:18:34 +0000 Subject: [PATCH 34/94] Fix snapshot chaching --- DemoApp/Screens/DemoAppTabBarController.swift | 4 ---- .../LocationAttachment/LocationAttachmentSnapshotView.swift | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 0c746df743c..2112d21a7bc 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -101,10 +101,6 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele return } - messages.forEach { - LocationAttachmentSnapshotView.snapshotsCache.removeObject(forKey: $0.id as NSString) - } - let locations: [String] = messages.compactMap { guard let locationAttachment = $0.liveLocationAttachments.first else { return nil diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 2c4db21abd1..f2af6967689 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -122,9 +122,8 @@ class LocationAttachmentSnapshotView: _View { if frame.size.width != mapOptions.size.width { imageView.image = nil clearSnapshotCache() + loadMapSnapshotImage() } - - loadMapSnapshotImage() } private func configureMapPosition() { From ecbb7e2e9cff4e1caab624c9dd8fbfcb7f65ae43 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 26 Dec 2024 14:05:14 +0000 Subject: [PATCH 35/94] Fix map detail view controller not showing initial position --- .../LocationDetailViewController.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 88d16ba1725..4dc0365e9d2 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -43,16 +43,22 @@ class LocationDetailViewController: UIViewController { UserAnnotationView.self, forAnnotationViewWithReuseIdentifier: "UserAnnotation" ) - mapView.region = .init( - center: locationCoordinate, - span: coordinateSpan - ) mapView.showsUserLocation = false mapView.delegate = self messageController?.synchronize() messageController?.delegate = self + mapView.region = .init( + center: locationCoordinate, + span: coordinateSpan + ) + updateUserLocation( + locationCoordinate, + userImage: nil, + userName: "" + ) + view = mapView } } From ec15a926901baf0399aeb7e8d5d26937e51fbc5b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 2 Jan 2025 16:31:53 +0000 Subject: [PATCH 36/94] Fix avatar view in map view --- .../LocationAttachmentViewDelegate.swift | 2 +- .../LocationDetailViewController.swift | 97 +++++++++++++------ 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index 0b7635c0953..d2880b10a29 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -42,7 +42,7 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { latitude: attachment.latitude, longitude: attachment.longitude ), - isLive: true, + isLive: attachment.stoppedSharing == false, messageController: messageController ) navigationController?.pushViewController(mapViewController, animated: true) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 4dc0365e9d2..d8fd114084b 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -4,9 +4,10 @@ import MapKit import StreamChat +import StreamChatUI import UIKit -class LocationDetailViewController: UIViewController { +class LocationDetailViewController: UIViewController, ThemeProvider { let locationCoordinate: CLLocationCoordinate2D let isLive: Bool let messageController: ChatMessageController? @@ -54,9 +55,7 @@ class LocationDetailViewController: UIViewController { span: coordinateSpan ) updateUserLocation( - locationCoordinate, - userImage: nil, - userName: "" + locationCoordinate ) view = mapView @@ -78,16 +77,12 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { ) updateUserLocation( - locationCoordinate, - userImage: UIImage(systemName: "location"), - userName: messageController?.message?.author.name ?? "" + locationCoordinate ) } func updateUserLocation( - _ coordinate: CLLocationCoordinate2D, - userImage: UIImage?, - userName: String + _ coordinate: CLLocationCoordinate2D ) { if let existingAnnotation = userAnnotation { UIView.animate(withDuration: 5) { @@ -96,19 +91,14 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { UIView.animate(withDuration: 5, delay: 0.2, options: .curveEaseOut) { self.mapView.setCenter(coordinate, animated: true) } - if let annotationView = mapView.view(for: existingAnnotation) as? UserAnnotationView { - annotationView.updateImage(userImage) - } - } else { + } else if let author = messageController?.message?.author { // Create new annotation - userAnnotation = UserAnnotation( + let userAnnotation = UserAnnotation( coordinate: coordinate, - image: userImage, - title: userName + user: author ) - if let annotation = userAnnotation { - mapView.addAnnotation(annotation) - } + mapView.addAnnotation(userAnnotation) + self.userAnnotation = userAnnotation } } } @@ -128,34 +118,48 @@ extension LocationDetailViewController: MKMapViewDelegate { for: userAnnotation ) as? UserAnnotationView - annotationView?.updateImage(userAnnotation.image) + annotationView?.setUser(userAnnotation.user) + if isLive { + annotationView?.startPulsingAnimation() + } else { + annotationView?.stopPulsingAnimation() + } return annotationView } } -// Custom annotation class to store user data class UserAnnotation: NSObject, MKAnnotation { dynamic var coordinate: CLLocationCoordinate2D - var image: UIImage? - var title: String? + var user: ChatUser - init(coordinate: CLLocationCoordinate2D, image: UIImage?, title: String?) { + init(coordinate: CLLocationCoordinate2D, user: ChatUser) { self.coordinate = coordinate - self.image = image - self.title = title + self.user = user super.init() } } -// Custom annotation view with user avatar class UserAnnotationView: MKAnnotationView { + private lazy var avatarView: ChatUserAvatarView = { + let view = ChatUserAvatarView() + view.shouldShowOnlineIndicator = false + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.masksToBounds = true + return view + }() + + 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: 40, height: 40) layer.cornerRadius = 20 - layer.masksToBounds = true + layer.masksToBounds = false layer.borderWidth = 2 layer.borderColor = UIColor.white.cgColor + addSubview(avatarView) + avatarView.bounds = bounds } @available(*, unavailable) @@ -163,7 +167,38 @@ class UserAnnotationView: MKAnnotationView { fatalError("init(coder:) not implemented") } - func updateImage(_ image: UIImage?) { - self.image = image + 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.4).cgColor + layer.insertSublayer(pulseLayer, below: avatarView.layer) + + let animation = CABasicAnimation(keyPath: "transform.scale") + animation.fromValue = 1.0 + animation.toValue = 1.5 + animation.duration = 1.0 + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + animation.autoreverses = true + animation.repeatCount = .infinity + + pulseLayer.add(animation, forKey: "pulse") + self.pulseLayer = pulseLayer + } + + func stopPulsingAnimation() { + guard pulseLayer != nil else { + return + } + pulseLayer?.removeFromSuperlayer() + pulseLayer = nil } } From 5e3881d73ae5f48cc8649097e6adeb23ca9a6411 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 2 Jan 2025 18:58:27 +0000 Subject: [PATCH 37/94] Add pulse animation when live sharing --- .../LocationDetailViewController.swift | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index d8fd114084b..30ca92d70f8 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -148,18 +148,21 @@ class UserAnnotationView: MKAnnotationView { 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: 40, height: 40) + 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.bounds = bounds + avatarView.width(size.width) + avatarView.height(size.height) } @available(*, unavailable) @@ -179,18 +182,27 @@ class UserAnnotationView: MKAnnotationView { pulseLayer.masksToBounds = false pulseLayer.frame = bounds pulseLayer.cornerRadius = bounds.width / 2 - pulseLayer.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.4).cgColor + pulseLayer.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.5).cgColor layer.insertSublayer(pulseLayer, below: avatarView.layer) - let animation = CABasicAnimation(keyPath: "transform.scale") - animation.fromValue = 1.0 - animation.toValue = 1.5 - animation.duration = 1.0 - animation.timingFunction = CAMediaTimingFunction(name: .easeOut) - animation.autoreverses = true - animation.repeatCount = .infinity - - pulseLayer.add(animation, forKey: "pulse") + 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 } From 64bcf7eb7ad97a9c6257a5a09f3ef8a6390c844f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 2 Jan 2025 19:04:11 +0000 Subject: [PATCH 38/94] Refactor LocationDetailViewController to only use the message controller --- .../LocationAttachmentViewDelegate.swift | 16 ++---- .../LocationDetailViewController.swift | 49 ++++++++++++------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index d2880b10a29..f4143f59f01 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -21,13 +21,12 @@ protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate { extension DemoChatMessageListVC: LocationAttachmentViewDelegate { func didTapOnStaticLocationAttachment(_ attachment: ChatMessageStaticLocationAttachment) { + let messageController = client.messageController( + cid: attachment.id.cid, + messageId: attachment.id.messageId + ) let mapViewController = LocationDetailViewController( - locationCoordinate: .init( - latitude: attachment.latitude, - longitude: attachment.longitude - ), - isLive: false, - messageController: nil + messageController: messageController ) navigationController?.pushViewController(mapViewController, animated: true) } @@ -38,11 +37,6 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { messageId: attachment.id.messageId ) let mapViewController = LocationDetailViewController( - locationCoordinate: .init( - latitude: attachment.latitude, - longitude: attachment.longitude - ), - isLive: attachment.stoppedSharing == false, messageController: messageController ) navigationController?.pushViewController(mapViewController, animated: true) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 30ca92d70f8..b7b4acb7d26 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -8,17 +8,11 @@ import StreamChatUI import UIKit class LocationDetailViewController: UIViewController, ThemeProvider { - let locationCoordinate: CLLocationCoordinate2D - let isLive: Bool - let messageController: ChatMessageController? + let messageController: ChatMessageController init( - locationCoordinate: CLLocationCoordinate2D, - isLive: Bool, - messageController: ChatMessageController? + messageController: ChatMessageController ) { - self.locationCoordinate = locationCoordinate - self.isLive = isLive self.messageController = messageController super.init(nibName: nil, bundle: nil) } @@ -47,16 +41,30 @@ class LocationDetailViewController: UIViewController, ThemeProvider { mapView.showsUserLocation = false mapView.delegate = self - messageController?.synchronize() - messageController?.delegate = self + messageController.synchronize() + messageController.delegate = self - mapView.region = .init( - center: locationCoordinate, - span: coordinateSpan - ) - updateUserLocation( - locationCoordinate - ) + 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 + ) + } view = mapView } @@ -91,7 +99,7 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { UIView.animate(withDuration: 5, delay: 0.2, options: .curveEaseOut) { self.mapView.setCenter(coordinate, animated: true) } - } else if let author = messageController?.message?.author { + } else if let author = messageController.message?.author { // Create new annotation let userAnnotation = UserAnnotation( coordinate: coordinate, @@ -119,7 +127,10 @@ extension LocationDetailViewController: MKMapViewDelegate { ) as? UserAnnotationView annotationView?.setUser(userAnnotation.user) - if isLive { + + let liveLocationAttachment = messageController.message?.liveLocationAttachments.first + let isSharingLiveLocation = liveLocationAttachment?.stoppedSharing == false + if isSharingLiveLocation { annotationView?.startPulsingAnimation() } else { annotationView?.stopPulsingAnimation() From f3460bec131b1f352d4415bd6fb15fc94fda117c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 2 Jan 2025 19:10:22 +0000 Subject: [PATCH 39/94] Refactor code structure of the map detail view controller --- .../LocationDetailViewController.swift | 129 +++--------------- .../LocationAttachment/UserAnnotation.swift | 18 +++ .../UserAnnotationView.swift | 83 +++++++++++ StreamChat.xcodeproj/project.pbxproj | 8 ++ 4 files changed, 131 insertions(+), 107 deletions(-) create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index b7b4acb7d26..2641142a90c 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -68,31 +68,13 @@ class LocationDetailViewController: UIViewController, ThemeProvider { view = mapView } -} - -extension LocationDetailViewController: ChatMessageControllerDelegate { - func messageController( - _ controller: ChatMessageController, - didChangeMessage change: EntityChange - ) { - guard let liveLocationAttachment = controller.message?.liveLocationAttachments.first else { - return - } - - let locationCoordinate = CLLocationCoordinate2D( - latitude: liveLocationAttachment.latitude, - longitude: liveLocationAttachment.longitude - ) - - updateUserLocation( - locationCoordinate - ) - } func updateUserLocation( _ coordinate: CLLocationCoordinate2D ) { if let existingAnnotation = userAnnotation { + // By setting the duration to 5, and since we update the location every 3s + // this will make sure the annotation moves smoothly and has a constant animation. UIView.animate(withDuration: 5) { existingAnnotation.coordinate = coordinate } @@ -111,6 +93,26 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { } } +extension LocationDetailViewController: ChatMessageControllerDelegate { + func messageController( + _ controller: ChatMessageController, + didChangeMessage change: EntityChange + ) { + guard let liveLocationAttachment = controller.message?.liveLocationAttachments.first else { + return + } + + let locationCoordinate = CLLocationCoordinate2D( + latitude: liveLocationAttachment.latitude, + longitude: liveLocationAttachment.longitude + ) + + updateUserLocation( + locationCoordinate + ) + } +} + extension LocationDetailViewController: MKMapViewDelegate { func mapView( _ mapView: MKMapView, @@ -138,90 +140,3 @@ extension LocationDetailViewController: MKMapViewDelegate { return annotationView } } - -class UserAnnotation: NSObject, MKAnnotation { - dynamic var coordinate: CLLocationCoordinate2D - var user: ChatUser - - init(coordinate: CLLocationCoordinate2D, user: ChatUser) { - self.coordinate = coordinate - self.user = user - super.init() - } -} - -class UserAnnotationView: MKAnnotationView { - 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() { - guard pulseLayer != nil else { - return - } - pulseLayer?.removeFromSuperlayer() - pulseLayer = nil - } -} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift new file mode 100644 index 00000000000..a07ffc67793 --- /dev/null +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotation.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import MapKit +import StreamChat + +class UserAnnotation: NSObject, MKAnnotation { + dynamic var coordinate: CLLocationCoordinate2D + var user: ChatUser + + init(coordinate: CLLocationCoordinate2D, user: ChatUser) { + self.coordinate = coordinate + self.user = user + super.init() + } +} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift new file mode 100644 index 00000000000..5561128a03c --- /dev/null +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift @@ -0,0 +1,83 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import MapKit +import StreamChat +import StreamChatUI + +class UserAnnotationView: MKAnnotationView { + 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() { + guard pulseLayer != nil else { + return + } + pulseLayer?.removeFromSuperlayer() + pulseLayer = nil + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 0ae94101acb..dbc6837757a 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1441,6 +1441,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 */; }; @@ -4228,6 +4230,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 = ""; }; @@ -8424,6 +8428,8 @@ AD053BA62B33624C003612B6 /* LocationAttachmentViewDelegate.swift */, AD053BAA2B33638B003612B6 /* LocationAttachmentSnapshotView.swift */, AD053BA82B336331003612B6 /* LocationDetailViewController.swift */, + AD2F2D9A2D271B36006ED24B /* UserAnnotationView.swift */, + AD2F2D982D271B07006ED24B /* UserAnnotation.swift */, ); path = LocationAttachment; sourceTree = ""; @@ -11092,6 +11098,7 @@ A3227EC9284A52EE00EBE6CC /* PushNotifications.swift in Sources */, A3227E65284A4A5C00EBE6CC /* StreamChatWrapper.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 */, @@ -11101,6 +11108,7 @@ 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 */, From 0af646ed3b07c1896ed38ad302a5a875a1ce6261 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 00:00:52 +0000 Subject: [PATCH 40/94] Add bottom sheet to stop location sharing --- .../LocationAttachmentSnapshotView.swift | 9 +- .../LocationDetailViewController.swift | 92 ++++++++++++++++++- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index f2af6967689..80afb9f7af8 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -7,7 +7,7 @@ import StreamChat import StreamChatUI import UIKit -class LocationAttachmentSnapshotView: _View { +class LocationAttachmentSnapshotView: _View, ThemeProvider { struct Content { var messageId: MessageId? var latitude: CLLocationDegrees @@ -50,12 +50,9 @@ class LocationAttachmentSnapshotView: _View { lazy var stopButton: UIButton = { let button = UIButton() button.translatesAutoresizingMaskIntoConstraints = false - button.setImage(UIImage(systemName: "stop.circle"), for: .normal) - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) button.setTitle("Stop Sharing", for: .normal) button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) - button.setTitleColor(.red, for: .normal) - button.tintColor = .red + button.setTitleColor(appearance.colorPalette.alert, for: .normal) button.backgroundColor = .clear button.layer.cornerRadius = 16 button.addTarget(self, action: #selector(handleStopButtonTap), for: .touchUpInside) @@ -85,7 +82,7 @@ class LocationAttachmentSnapshotView: _View { .height(mapHeight) stopButton .width(120) - .height(30) + .height(35) }.embed(in: self) NSLayoutConstraint.activate([ diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 2641142a90c..c449207f7bb 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -34,12 +34,26 @@ class LocationDetailViewController: UIViewController, ThemeProvider { override func viewDidLoad() { super.viewDidLoad() + title = "Location" + navigationController?.navigationBar.backgroundColor = appearance.colorPalette.background + + setupBottomSheet() + mapView.register( UserAnnotationView.self, forAnnotationViewWithReuseIdentifier: "UserAnnotation" ) mapView.showsUserLocation = false mapView.delegate = self + view.backgroundColor = appearance.colorPalette.background + view.addSubview(mapView) + mapView.translatesAutoresizingMaskIntoConstraints = false + 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.safeAreaLayoutGuide.bottomAnchor) + ]) messageController.synchronize() messageController.delegate = self @@ -65,8 +79,6 @@ class LocationDetailViewController: UIViewController, ThemeProvider { locationCoordinate ) } - - view = mapView } func updateUserLocation( @@ -91,6 +103,25 @@ class LocationDetailViewController: UIViewController, ThemeProvider { self.userAnnotation = userAnnotation } } + + func setupBottomSheet() { + if #available(iOS 16.0, *) { + let bottomSheet = LocationBottomSheetViewController( + messageController: messageController + ) + let nav = UINavigationController(rootViewController: bottomSheet) + nav.modalPresentationStyle = .pageSheet + let customDetent = UISheetPresentationController.Detent.custom(resolver: { _ in + 60 + }) + nav.sheetPresentationController?.detents = [customDetent] + nav.sheetPresentationController?.prefersGrabberVisible = false + nav.sheetPresentationController?.preferredCornerRadius = 16 + nav.sheetPresentationController?.largestUndimmedDetentIdentifier = customDetent.identifier + nav.isModalInPresentation = true + present(nav, animated: false) + } + } } extension LocationDetailViewController: ChatMessageControllerDelegate { @@ -129,7 +160,7 @@ extension LocationDetailViewController: MKMapViewDelegate { ) as? UserAnnotationView annotationView?.setUser(userAnnotation.user) - + let liveLocationAttachment = messageController.message?.liveLocationAttachments.first let isSharingLiveLocation = liveLocationAttachment?.stoppedSharing == false if isSharingLiveLocation { @@ -140,3 +171,58 @@ extension LocationDetailViewController: MKMapViewDelegate { return annotationView } } + +class LocationBottomSheetViewController: UIViewController, ThemeProvider { + let messageController: ChatMessageController + + init( + messageController: ChatMessageController + ) { + self.messageController = messageController + super.init(nibName: nil, bundle: nil) + } + + 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(stopSharing), for: .touchUpInside) + return button + }() + + lazy var locationUpdateLabel: UILabel = { + let label = UILabel() + label.text = "Location updated 5 minutes ago" + label.font = appearance.fonts.footnote + label.textColor = appearance.colorPalette.subtitleText + return label + }() + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = appearance.colorPalette.background6 + + let container = VContainer(spacing: 2, alignment: .center) { + sharingButton + locationUpdateLabel + } + + view.addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: view.topAnchor, constant: 8), + container.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + ]) + } + + @objc func stopSharing() { + // Stop sharing the live location + } +} From 544b50666821a971e717d1e6c7885b688077ac06 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 00:44:58 +0000 Subject: [PATCH 41/94] Add stopLiveLocationSharing() to Message Controller --- .../LocationDetailViewController.swift | 14 +++-- .../MessageController/MessageController.swift | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index c449207f7bb..0b2b31d9e49 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -37,8 +37,6 @@ class LocationDetailViewController: UIViewController, ThemeProvider { title = "Location" navigationController?.navigationBar.backgroundColor = appearance.colorPalette.background - setupBottomSheet() - mapView.register( UserAnnotationView.self, forAnnotationViewWithReuseIdentifier: "UserAnnotation" @@ -52,7 +50,7 @@ class LocationDetailViewController: UIViewController, ThemeProvider { mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - mapView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) messageController.synchronize() @@ -81,6 +79,12 @@ class LocationDetailViewController: UIViewController, ThemeProvider { } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + setupBottomSheet() + } + func updateUserLocation( _ coordinate: CLLocationCoordinate2D ) { @@ -119,7 +123,7 @@ class LocationDetailViewController: UIViewController, ThemeProvider { nav.sheetPresentationController?.preferredCornerRadius = 16 nav.sheetPresentationController?.largestUndimmedDetentIdentifier = customDetent.identifier nav.isModalInPresentation = true - present(nav, animated: false) + present(nav, animated: true) } } } @@ -223,6 +227,6 @@ class LocationBottomSheetViewController: UIViewController, ThemeProvider { } @objc func stopSharing() { - // Stop sharing the live location + messageController.stopLiveLocationSharing() } } diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index d38107f4300..96addaa8ab2 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -925,6 +925,57 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP } } } + + /// Stops sharing the live location for this message if it has an active location sharing attachment. + /// + /// - Parameters: + /// - completion: Called when the server updates the message. + public func stopLiveLocationSharing(completion: ((Result) -> Void)? = nil) { + guard let 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 From 0777d9fddd2f251fef9ab1ae18dbf8099f7c6d85 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 01:06:51 +0000 Subject: [PATCH 42/94] Fix stop sharing button not working --- .../LocationDetailViewController.swift | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 0b2b31d9e49..cfc1dfcf3c3 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -113,17 +113,15 @@ class LocationDetailViewController: UIViewController, ThemeProvider { let bottomSheet = LocationBottomSheetViewController( messageController: messageController ) - let nav = UINavigationController(rootViewController: bottomSheet) - nav.modalPresentationStyle = .pageSheet - let customDetent = UISheetPresentationController.Detent.custom(resolver: { _ in - 60 - }) - nav.sheetPresentationController?.detents = [customDetent] - nav.sheetPresentationController?.prefersGrabberVisible = false - nav.sheetPresentationController?.preferredCornerRadius = 16 - nav.sheetPresentationController?.largestUndimmedDetentIdentifier = customDetent.identifier - nav.isModalInPresentation = true - present(nav, animated: true) + bottomSheet.modalPresentationStyle = .pageSheet + let detent = UISheetPresentationController.Detent.custom(resolver: { _ in 60 }) + bottomSheet.sheetPresentationController?.detents = [detent] + bottomSheet.sheetPresentationController?.prefersGrabberVisible = false + bottomSheet.sheetPresentationController?.preferredCornerRadius = 16 + bottomSheet.sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false + bottomSheet.sheetPresentationController?.largestUndimmedDetentIdentifier = detent.identifier + bottomSheet.isModalInPresentation = true + present(bottomSheet, animated: true) } } } From b2dc128a0ea87ed01f0b3674c32f49cb1e43414e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 02:24:31 +0000 Subject: [PATCH 43/94] FIx bottom sheet logic --- .../LocationDetailViewController.swift | 86 ++++++++++++++----- .../UserAnnotationView.swift | 5 +- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index cfc1dfcf3c3..8bb84767f9c 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -31,6 +31,10 @@ class LocationDetailViewController: UIViewController, ThemeProvider { return view }() + var isLiveLocationAttachment: Bool { + messageController.message?.liveLocationAttachments.first != nil + } + override func viewDidLoad() { super.viewDidLoad() @@ -39,7 +43,7 @@ class LocationDetailViewController: UIViewController, ThemeProvider { mapView.register( UserAnnotationView.self, - forAnnotationViewWithReuseIdentifier: "UserAnnotation" + forAnnotationViewWithReuseIdentifier: UserAnnotationView.reuseIdentifier ) mapView.showsUserLocation = false mapView.delegate = self @@ -82,15 +86,15 @@ class LocationDetailViewController: UIViewController, ThemeProvider { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - setupBottomSheet() + presentLocationControlSheet() } func updateUserLocation( _ coordinate: CLLocationCoordinate2D ) { if let existingAnnotation = userAnnotation { - // By setting the duration to 5, and since we update the location every 3s - // this will make sure the annotation moves smoothly and has a constant animation. + // Since we update the location every 3s, by updating the coordinate with 5s animation + // this will make sure the annotation moves smoothly. UIView.animate(withDuration: 5) { existingAnnotation.coordinate = coordinate } @@ -108,20 +112,23 @@ class LocationDetailViewController: UIViewController, ThemeProvider { } } - func setupBottomSheet() { - if #available(iOS 16.0, *) { - let bottomSheet = LocationBottomSheetViewController( - messageController: messageController + func presentLocationControlSheet() { + if #available(iOS 16.0, *), isLiveLocationAttachment, messageController.message?.isSentByCurrentUser == true { + let locationControlSheet = LocationControlSheetViewController( + messageController: messageController.client.messageController( + cid: messageController.cid, + messageId: messageController.messageId + ) ) - bottomSheet.modalPresentationStyle = .pageSheet + locationControlSheet.modalPresentationStyle = .pageSheet let detent = UISheetPresentationController.Detent.custom(resolver: { _ in 60 }) - bottomSheet.sheetPresentationController?.detents = [detent] - bottomSheet.sheetPresentationController?.prefersGrabberVisible = false - bottomSheet.sheetPresentationController?.preferredCornerRadius = 16 - bottomSheet.sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false - bottomSheet.sheetPresentationController?.largestUndimmedDetentIdentifier = detent.identifier - bottomSheet.isModalInPresentation = true - present(bottomSheet, animated: true) + locationControlSheet.sheetPresentationController?.detents = [detent] + locationControlSheet.sheetPresentationController?.prefersGrabberVisible = false + locationControlSheet.sheetPresentationController?.preferredCornerRadius = 16 + locationControlSheet.sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false + locationControlSheet.sheetPresentationController?.largestUndimmedDetentIdentifier = detent.identifier + locationControlSheet.isModalInPresentation = true + present(locationControlSheet, animated: true) } } } @@ -143,6 +150,12 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { updateUserLocation( locationCoordinate ) + + let isLiveLocationSharingStopped = liveLocationAttachment.stoppedSharing == true + if isLiveLocationSharingStopped, let userAnnotation = self.userAnnotation { + let userAnnotationView = mapView.view(for: userAnnotation) as? UserAnnotationView + userAnnotationView?.stopPulsingAnimation() + } } } @@ -155,9 +168,8 @@ extension LocationDetailViewController: MKMapViewDelegate { return nil } - let identifier = "UserAnnotation" let annotationView = mapView.dequeueReusableAnnotationView( - withIdentifier: identifier, + withIdentifier: UserAnnotationView.reuseIdentifier, for: userAnnotation ) as? UserAnnotationView @@ -174,7 +186,7 @@ extension LocationDetailViewController: MKMapViewDelegate { } } -class LocationBottomSheetViewController: UIViewController, ThemeProvider { +class LocationControlSheetViewController: UIViewController, ThemeProvider { let messageController: ChatMessageController init( @@ -195,7 +207,6 @@ class LocationBottomSheetViewController: UIViewController, ThemeProvider { lazy var locationUpdateLabel: UILabel = { let label = UILabel() - label.text = "Location updated 5 minutes ago" label.font = appearance.fonts.footnote label.textColor = appearance.colorPalette.subtitleText return label @@ -209,6 +220,9 @@ class LocationBottomSheetViewController: UIViewController, ThemeProvider { override func viewDidLoad() { super.viewDidLoad() + messageController.synchronize() + messageController.delegate = self + view.backgroundColor = appearance.colorPalette.background6 let container = VContainer(spacing: 2, alignment: .center) { @@ -228,3 +242,35 @@ class LocationBottomSheetViewController: UIViewController, ThemeProvider { messageController.stopLiveLocationSharing() } } + +extension LocationControlSheetViewController: ChatMessageControllerDelegate { + func messageController( + _ controller: ChatMessageController, + didChangeMessage change: EntityChange + ) { + guard let liveLocationAttachment = controller.message?.liveLocationAttachments.first else { + return + } + + let isSharingLiveLocation = liveLocationAttachment.stoppedSharing == false + sharingButton.isEnabled = isSharingLiveLocation + sharingButton.setTitle( + isSharingLiveLocation ? "Stop Sharing" : "Live location ended", + for: .normal + ) + + let buttonColor = appearance.colorPalette.alert + sharingButton.setTitleColor( + isSharingLiveLocation ? buttonColor : buttonColor.withAlphaComponent(0.6), + for: .normal + ) + + if isSharingLiveLocation { + locationUpdateLabel.text = "Location sharing is active" + } else { + let lastUpdated = messageController.message?.updatedAt ?? Date() + let formatter = appearance.formatters.channelListMessageTimestamp + locationUpdateLabel.text = "Location last updated at \(formatter.format(lastUpdated))" + } + } +} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift index 5561128a03c..e6ad750d0ef 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/UserAnnotationView.swift @@ -7,6 +7,8 @@ import StreamChat import StreamChatUI class UserAnnotationView: MKAnnotationView { + static let reuseIdentifier = "UserAnnotationView" + private lazy var avatarView: ChatUserAvatarView = { let view = ChatUserAvatarView() view.shouldShowOnlineIndicator = false @@ -74,9 +76,6 @@ class UserAnnotationView: MKAnnotationView { } func stopPulsingAnimation() { - guard pulseLayer != nil else { - return - } pulseLayer?.removeFromSuperlayer() pulseLayer = nil } From 45ad2f10c5760c99dfc8a4df853a84cb7db87638 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 11:49:16 +0000 Subject: [PATCH 44/94] Add static pin in detail view --- .../LocationAttachment/LocationDetailViewController.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 8bb84767f9c..e43485a870d 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -101,14 +101,17 @@ class LocationDetailViewController: UIViewController, ThemeProvider { UIView.animate(withDuration: 5, delay: 0.2, options: .curveEaseOut) { self.mapView.setCenter(coordinate, animated: true) } - } else if let author = messageController.message?.author { - // Create new annotation + } 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) } } From a3a252e1b69a91d8cae812756ee6348ab29b3fba Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 12:18:59 +0000 Subject: [PATCH 45/94] Fix sharing location for other users active location messages --- .../CurrentUserController.swift | 50 +++++++++++-------- .../StreamChat/Database/DTOs/MessageDTO.swift | 18 +++---- .../Repositories/MessageRepository.swift | 4 ++ 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 201664b3585..f5fd827b23a 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,20 +77,6 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt } } - /// The observer for the active live location messages. - private lazy var activeLiveLocationMessagesObserver: BackgroundListDatabaseObserver = { - let observer = createActiveLiveLocationMessagesObserver() - 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) - } - } - return observer - }() - /// A flag to indicate whether the current user is sharing his live location. private var isSharingLiveLocation = false { didSet { @@ -160,7 +165,6 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt do { try currentUserObserver.startObserving() - try activeLiveLocationMessagesObserver.startObserving() state = .localDataFetched } catch { log.error(""" @@ -268,8 +272,11 @@ public extension CurrentChatUserController { /// /// - Parameter location: The new location to be updated. func updateLiveLocation(_ location: LocationAttachmentInfo) { + guard let messages = activeLiveLocationMessagesObserver?.items, !messages.isEmpty else { + return + } + locationUpdatesThrottler.execute { [weak self] in - let messages = self?.activeLiveLocationMessagesObserver.items ?? [] for message in messages { guard let cid = message.cid else { continue } let messageController = self?.client.messageController(cid: cid, messageId: message.id) @@ -442,12 +449,15 @@ private extension CurrentChatUserController { ) } - func createActiveLiveLocationMessagesObserver() -> BackgroundListDatabaseObserver { - environment.currentUserActiveLiveLocationMessagesObserverBuilder( + func createActiveLiveLocationMessagesObserver() -> BackgroundListDatabaseObserver? { + guard let currentUserId = client.currentUserId else { + return nil + } + return environment.currentUserActiveLiveLocationMessagesObserverBuilder( client.databaseContainer, MessageDTO.activeLiveLocationMessagesFetchRequest( - channelId: nil, - currentUserId: client.currentUserId + currentUserId: currentUserId, + channelId: nil ), { try $0.asModel() }, NSFetchedResultsController.self diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 23b1bcbf911..ce3a273acd1 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -585,9 +585,11 @@ 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( - channelId: ChannelId?, - currentUserId: UserId? + currentUserId: UserId, + channelId: ChannelId? ) -> NSFetchRequest { let request = NSFetchRequest(entityName: MessageDTO.entityName) MessageDTO.applyPrefetchingState(to: request) @@ -598,11 +600,9 @@ class MessageDTO: NSManagedObject { ascending: true )] var predicates: [NSPredicate] = [ - .init(format: "ANY attachments.isActiveLocationAttachment == YES") + .init(format: "ANY attachments.isActiveLocationAttachment == YES"), + .init(format: "user.id == %@", currentUserId) ] - if let currentUserId { - predicates.append(.init(format: "user.id == %@", currentUserId)) - } if let channelId { predicates.append(.init(format: "channel.cid == %@", channelId.rawValue)) } @@ -611,13 +611,11 @@ class MessageDTO: NSManagedObject { } static func loadActiveLiveLocationMessages( + currentUserId: UserId, channelId: ChannelId?, context: NSManagedObjectContext ) throws -> [MessageDTO] { - let request = activeLiveLocationMessagesFetchRequest( - channelId: channelId, - currentUserId: context.currentUser?.user.id - ) + let request = activeLiveLocationMessagesFetchRequest(currentUserId: currentUserId, channelId: channelId) return try load(request, context: context) } diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 2a281db6a72..7fb174baf36 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -289,7 +289,11 @@ class MessageRepository { 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 ) From 6cf17a30452c7a329ba938d8dc073dff920adde7 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 16:34:18 +0000 Subject: [PATCH 46/94] Finish logic for location snapshot view when static vs live --- ...chmentPayload+AttachmentViewProvider.swift | 7 +- .../LocationAttachmentSnapshotView.swift | 97 +++++++++++++------ .../LocationAttachmentViewInjector.swift | 14 +-- 3 files changed, 81 insertions(+), 37 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift index d1f971c3564..7338da2d053 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift @@ -15,10 +15,11 @@ extension StaticLocationAttachmentPayload: AttachmentPreviewProvider { /// but a different one could be provided. let preview = LocationAttachmentSnapshotView() preview.content = .init( + coordinate: .init(latitude: latitude, longitude: longitude), + isLive: false, + isSharingLiveLocation: false, messageId: nil, - latitude: latitude, - longitude: longitude, - isLive: false + author: nil ) return preview } diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 80afb9f7af8..f7586365d89 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -9,10 +9,23 @@ import UIKit class LocationAttachmentSnapshotView: _View, ThemeProvider { struct Content { + var coordinate: CLLocationCoordinate2D + var isLive: Bool + var isSharingLiveLocation: Bool var messageId: MessageId? - var latitude: CLLocationDegrees - var longitude: CLLocationDegrees - var isLive: Bool = false + 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 isFromCurrentUser: Bool { + author?.id == StreamChatWrapper.shared.client?.currentUserId + } } var content: Content? { @@ -59,6 +72,18 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return button }() + lazy var avatarView: ChatUserAvatarView = { + let view = ChatUserAvatarView() + view.translatesAutoresizingMaskIntoConstraints = false + view.shouldShowOnlineIndicator = false + view.layer.masksToBounds = true + view.layer.cornerRadius = 15 + view.layer.borderWidth = 2 + view.layer.borderColor = UIColor.white.cgColor + view.isHidden = true + return view + }() + override func setUp() { super.setUp() @@ -85,10 +110,16 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { .height(35) }.embed(in: self) + addSubview(avatarView) + NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), - activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), - imageView.widthAnchor.constraint(equalTo: container.widthAnchor) + activityIndicatorView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + imageView.widthAnchor.constraint(equalTo: container.widthAnchor), + avatarView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), + avatarView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + avatarView.widthAnchor.constraint(equalToConstant: 30), + avatarView.heightAnchor.constraint(equalToConstant: 30) ]) } @@ -103,7 +134,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return } - if content.isLive { + if content.isSharingLiveLocation && content.isFromCurrentUser { stopButton.isHidden = false } else { stopButton.isHidden = true @@ -129,10 +160,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { } mapOptions.region = .init( - center: CLLocationCoordinate2D( - latitude: content.latitude, - longitude: content.longitude - ), + center: content.coordinate, span: MKCoordinateSpan( latitudeDelta: 0.01, longitudeDelta: 0.01 @@ -149,6 +177,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { if let cachedSnapshot = getCachedSnapshot() { imageView.image = cachedSnapshot + updateAnnotationView() return } else { imageView.image = nil @@ -159,39 +188,51 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { snapshotter = MKMapSnapshotter(options: mapOptions) snapshotter?.start { snapshot, _ in guard let snapshot = snapshot else { return } - let image = self.generatePinAnnotation(for: snapshot) DispatchQueue.main.async { self.activityIndicatorView.stopAnimating() - self.imageView.image = image - self.setCachedSnapshot(image: image) + + 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 - ) -> 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 - - guard let content = self.content else { - return - } - - var point = snapshot.point(for: CLLocationCoordinate2D( - latitude: content.latitude, - longitude: content.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 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() { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 65d87f369c0..1fcab12d47c 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -35,17 +35,19 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { if let staticLocation = staticLocationAttachment { locationAttachmentView.content = .init( + coordinate: .init(latitude: staticLocation.latitude, longitude: staticLocation.longitude), + isLive: false, + isSharingLiveLocation: false, messageId: contentView.content?.id, - latitude: staticLocation.latitude, - longitude: staticLocation.longitude, - isLive: false + 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, - latitude: liveLocation.latitude, - longitude: liveLocation.longitude, - isLive: liveLocation.stoppedSharing == false || liveLocation.stoppedSharing == nil + author: contentView.content?.author ) } } From 26a5e72dfd29d85b803579151017ae814fbab0e4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 17:14:18 +0000 Subject: [PATCH 47/94] Add live location status view in the snapshot view --- .../CustomAttachments/DemoComposerVC.swift | 20 ------- ...chmentPayload+AttachmentViewProvider.swift | 26 --------- .../LocationAttachmentSnapshotView.swift | 29 ++++++++-- .../LocationAttachmentViewInjector.swift | 8 +++ .../LocationSharingStatusView.swift | 54 +++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 8 +-- 6 files changed, 92 insertions(+), 53 deletions(-) delete mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift create mode 100644 DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 93b1cbfb576..bfc5a41ae0a 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -15,15 +15,6 @@ class DemoComposerVC: ComposerVC { let alreadyHasLocation = content.attachments.map(\.type).contains(.staticLocation) if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && !alreadyHasLocation { - let addLocationAction = UIAlertAction( - title: "Add Current Location", - style: .default, - handler: { [weak self] _ in - self?.addStaticLocationToAttachments() - } - ) - actions.append(addLocationAction) - let sendLocationAction = UIAlertAction( title: "Send Current Location", style: .default, @@ -46,17 +37,6 @@ class DemoComposerVC: ComposerVC { return actions } - func addStaticLocationToAttachments() { - getCurrentLocationInfo { [weak self] location in - guard let location = location else { return } - let staticLocationPayload = StaticLocationAttachmentPayload( - latitude: location.latitude, - longitude: location.longitude - ) - self?.content.attachments.append(AnyAttachmentPayload(payload: staticLocationPayload)) - } - } - func sendInstantStaticLocation() { getCurrentLocationInfo { [weak self] location in guard let location = location else { 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 7338da2d053..00000000000 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentPayload+AttachmentViewProvider.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import StreamChat -import StreamChatUI -import UIKit - -/// Location Attachment Composer Preview -extension StaticLocationAttachmentPayload: 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.content = .init( - coordinate: .init(latitude: latitude, longitude: longitude), - isLive: false, - isSharingLiveLocation: false, - messageId: nil, - author: nil - ) - return preview - } -} diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index f7586365d89..ed0b252fe88 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -84,6 +84,13 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return view }() + lazy var sharingStatusView: LocationSharingStatusView = { + let view = LocationSharingStatusView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isHidden = true + return view + }() + override func setUp() { super.setUp() @@ -94,6 +101,12 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { imageView.addGestureRecognizer(tapGestureRecognizer) } + override func setUpAppearance() { + super.setUpAppearance() + + backgroundColor = appearance.colorPalette.background6 + } + override func setUpLayout() { super.setUpLayout() @@ -102,9 +115,11 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { addSubview(activityIndicatorView) - let container = VContainer(alignment: .center) { + let container = VContainer(spacing: 0, alignment: .center) { imageView .height(mapHeight) + sharingStatusView + .height(30) stopButton .width(120) .height(35) @@ -113,9 +128,10 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { addSubview(avatarView) NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), - activityIndicatorView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), imageView.widthAnchor.constraint(equalTo: container.widthAnchor), + avatarView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), avatarView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), avatarView.widthAnchor.constraint(equalToConstant: 30), @@ -136,8 +152,15 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { 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 } configureMapPosition() diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 1fcab12d47c..515982b3801 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -28,6 +28,14 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { 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() { diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift new file mode 100644 index 00000000000..f56cbe2eaed --- /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() + + let container = 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/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index dbc6837757a..8be3472c64f 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1370,7 +1370,6 @@ AD053B9A2B335854003612B6 /* DemoComposerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053B992B335854003612B6 /* DemoComposerVC.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 */; }; @@ -1477,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 */; }; @@ -4183,7 +4183,6 @@ AD053B992B335854003612B6 /* DemoComposerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoComposerVC.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 = ""; }; @@ -4255,6 +4254,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 = ""; }; @@ -8423,11 +8423,11 @@ AD053B9B2B33589C003612B6 /* LocationAttachment */ = { isa = PBXGroup; children = ( - 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 */, ); @@ -11097,6 +11097,7 @@ 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 */, @@ -11111,7 +11112,6 @@ AD2F2D9B2D271B36006ED24B /* UserAnnotationView.swift in Sources */, A3227E59284A484300EBE6CC /* UIImage+Resized.swift in Sources */, 79B8B64B285CBDC00059FB2D /* DemoChatMessageLayoutOptionsResolver.swift in Sources */, - AD053BA32B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift in Sources */, AD053BA12B3359DD003612B6 /* DemoAttachmentViewCatalog.swift in Sources */, AD053B9F2B335929003612B6 /* LocationAttachmentViewInjector.swift in Sources */, ); From e9be9de5404eead47773d4723ed7705fc10ee449 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 21:38:27 +0000 Subject: [PATCH 48/94] Minor cleanup --- .../Components/CustomAttachments/DemoComposerVC.swift | 3 +-- .../LocationAttachment/LocationAttachmentSnapshotView.swift | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index bfc5a41ae0a..c9122a6da59 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -13,8 +13,7 @@ class DemoComposerVC: ComposerVC { override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions - let alreadyHasLocation = content.attachments.map(\.type).contains(.staticLocation) - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && !alreadyHasLocation { + if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { let sendLocationAction = UIAlertAction( title: "Send Current Location", style: .default, diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index ed0b252fe88..1509979a492 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -131,7 +131,6 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), imageView.widthAnchor.constraint(equalTo: container.widthAnchor), - avatarView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), avatarView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), avatarView.widthAnchor.constraint(equalToConstant: 30), From 37713b85608c39125c2f4ae775769d6c39b83852 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 22:04:16 +0000 Subject: [PATCH 49/94] Fix copyright --- DemoApp/LocationProvider.swift | 2 +- .../Location/ChatMessageLiveLocationAttachment.swift | 2 +- .../Location/ChatMessageStaticLocationAttachment.swift | 2 +- .../Models/Attachments/Location/LocationAttachmentInfo.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DemoApp/LocationProvider.swift b/DemoApp/LocationProvider.swift index 0c81bf3a8fd..c05d24d0c5f 100644 --- a/DemoApp/LocationProvider.swift +++ b/DemoApp/LocationProvider.swift @@ -1,5 +1,5 @@ // -// Copyright © 2024 Stream.io Inc. All rights reserved. +// Copyright © 2025 Stream.io Inc. All rights reserved. // import CoreLocation diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift index 2a0b3284cb9..1841b675f8e 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift @@ -1,5 +1,5 @@ // -// Copyright © 2024 Stream.io Inc. All rights reserved. +// Copyright © 2025 Stream.io Inc. All rights reserved. // import Foundation diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift index 416b1f87e2b..458aee39607 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift @@ -1,5 +1,5 @@ // -// Copyright © 2024 Stream.io Inc. All rights reserved. +// Copyright © 2025 Stream.io Inc. All rights reserved. // import Foundation diff --git a/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift b/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift index 8b7770e56ff..74d20ff0b0d 100644 --- a/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift +++ b/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift @@ -1,5 +1,5 @@ // -// Copyright © 2024 Stream.io Inc. All rights reserved. +// Copyright © 2025 Stream.io Inc. All rights reserved. // import Foundation From ef4a2147c5f57657629062f6daee12f09832554d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 23:01:25 +0000 Subject: [PATCH 50/94] Fix loading indicator snapshot view --- .../LocationAttachment/LocationAttachmentSnapshotView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 1509979a492..e89f45dfd10 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -128,8 +128,8 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { addSubview(avatarView) NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), - activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + activityIndicatorView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), imageView.widthAnchor.constraint(equalTo: container.widthAnchor), avatarView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), avatarView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), From 722cbfc7f180a2530b3009ac74c20901bf1755b3 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 3 Jan 2025 23:04:31 +0000 Subject: [PATCH 51/94] Remove support of mixed attachments to locations --- DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index d7fb0ab200f..a8da9109040 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -23,18 +23,6 @@ extension StreamChatWrapper { client = ChatClient(config: config) } - // Custom Attachments - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { - Components.default.mixedAttachmentInjector.register( - .staticLocation, - with: LocationAttachmentViewInjector.self - ) - Components.default.mixedAttachmentInjector.register( - .liveLocation, - with: LocationAttachmentViewInjector.self - ) - } - // L10N let localizationProvider = Appearance.default.localizationProvider Appearance.default.localizationProvider = { key, table in From a347d0013e4e882df10c60a6b3035aa00b7e31fb Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 16:13:16 +0000 Subject: [PATCH 52/94] Add MessageEndpoints test coverage --- .../LocationSharingStatusView.swift | 2 +- .../Endpoints/MessageEndpoints_Tests.swift | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift index f56cbe2eaed..d3b0af9dfcc 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationSharingStatusView.swift @@ -36,7 +36,7 @@ class LocationSharingStatusView: _View, ThemeProvider { override func setUpLayout() { super.setUpLayout() - let container = HContainer(spacing: 4, alignment: .center) { + HContainer(spacing: 4, alignment: .center) { iconImageView .width(16) .height(16) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index 35ea330b58f..3657dac6afd 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -189,4 +189,28 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) XCTAssertEqual("messages/\(messageId)/translate", endpoint.path.value) } + + func test_partialUpdateMessage_buildsCorrectly() { + let messageId: MessageId = .unique + let request = MessagePartialUpdateRequest( + set: .init(pinned: false, text: .unique), + unset: ["custom_text"], + skipEnrichUrl: true + ) + + let expectedEndpoint = Endpoint( + path: .editMessage(messageId), + method: .put, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + + // Build endpoint + let endpoint: Endpoint = .partialUpdateMessage(messageId: messageId, request: request) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)", endpoint.path.value) + } } From c58a3c340714cc05632643265a5da03c19a28cc1 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 17:02:55 +0000 Subject: [PATCH 53/94] Add test coverage to message updater --- .../StreamChat/Workers/MessageUpdater.swift | 18 ++- .../Workers/MessageUpdater_Tests.swift | 118 ++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 2ce28fbab83..a6633106274 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -217,9 +217,25 @@ class MessageUpdater: Worker { 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: messagePayload.cid!, + for: cid, syncOwnReactions: false, cache: nil ) diff --git a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift index a9659877729..5d9271a4614 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 From 776a9917c3ff3a9e3b39f8c6ea9308ca74ba9c32 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 17:19:08 +0000 Subject: [PATCH 54/94] Add test coverage to Message Repository --- .../MessageRepository_Tests.swift | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift index 28abae056a9..6851cd307a0 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 { From d3dfd2ed0f6b6e198ff0434ca434f1b96967d88b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 18:25:59 +0000 Subject: [PATCH 55/94] Add test coverage to message attachments extensions --- .../Models/ChatMessage_Tests.swift | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift index b60cd84054f..d325b7c6baa 100644 --- a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift +++ b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift @@ -392,4 +392,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]) + ) + } } From 047f9625759f47db4d10bed89f103eec1a33391b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 18:44:05 +0000 Subject: [PATCH 56/94] Add test coverage to parsing attachments --- StreamChat.xcodeproj/project.pbxproj | 8 ++ .../LiveLocationAttachmentPayload_Tests.swift | 77 +++++++++++++++++++ ...taticLocationAttachmentPayload_Tests.swift | 72 +++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift create mode 100644 Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 8be3472c64f..19a71caeba5 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1776,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 */; }; @@ -4447,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 = ""; }; @@ -7279,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 */, @@ -11947,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 */, diff --git a/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift new file mode 100644 index 00000000000..e0095d4244b --- /dev/null +++ b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift @@ -0,0 +1,77 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@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 00000000000..84e42b47ea5 --- /dev/null +++ b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift @@ -0,0 +1,72 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@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) + } +} From ea5802e49ff8d0aa97f131a304b86267e80cd876 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 18:58:13 +0000 Subject: [PATCH 57/94] Add test coverage to MessageDTO --- .../DummyData/MessageAttachmentPayload.swift | 40 +++++++++- .../Database/DTOs/MessageDTO_Tests.swift | 80 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift index 486abfe9f85..928ca0f0df6 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift @@ -1,5 +1,5 @@ // -// Copyright © 2025 Stream.io Inc. All rights reserved. +// Copyright 2025 Stream.io Inc. All rights reserved. // import Foundation @@ -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/Database/DTOs/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift index ef48cb32be9..d1d558e7e1d 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? { From 4712649efe9b4d729645bf55c1d2961fca734b37 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 19:28:34 +0000 Subject: [PATCH 58/94] Add message updater mock --- .../Workers/MessageUpdater_Mock.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift index afa56d35bbe..fd128af5d85 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift @@ -114,6 +114,12 @@ final class MessageUpdater_Mock: MessageUpdater { @Atomic var translate_completion: ((Result) -> Void)? @Atomic var translate_completion_result: Result? + @Atomic var updatePartialMessage_messageId: MessageId? + @Atomic var updatePartialMessage_text: String? + @Atomic var updatePartialMessage_attachments: [AnyAttachmentPayload]? + @Atomic var updatePartialMessage_extraData: [String: RawJSON]? + @Atomic var updatePartialMessage_completion: ((Result) -> Void)? + var markThreadRead_threadId: MessageId? var markThreadRead_cid: ChannelId? var markThreadRead_callCount = 0 @@ -247,6 +253,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 +528,20 @@ 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, + completion: ((Result) -> Void)? = nil + ) { + updatePartialMessage_messageId = messageId + updatePartialMessage_text = text + updatePartialMessage_attachments = attachments + updatePartialMessage_extraData = extraData + updatePartialMessage_completion = completion + } } extension MessageUpdater.MessageSearchResults { From 0a46f193139a32fb8e6dae6b93729c9ce1fadd3b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 19:28:49 +0000 Subject: [PATCH 59/94] ActiveLiveLocationAlreadyExists init should not be public --- .../Controllers/ChannelController/ChannelController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 1dbcc9dfa64..b96e4247a50 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -1974,7 +1974,7 @@ public extension ClientError { final class ActiveLiveLocationAlreadyExists: ClientError { let messageId: MessageId - public init(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." From 6be1283b169df7949a667349acf199d43cb85cb9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 20:29:02 +0000 Subject: [PATCH 60/94] Add test coverage to Message Controller --- .../MessageController_Tests.swift | 267 +++++++++++++++++- 1 file changed, 264 insertions(+), 3 deletions(-) diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index c1a298a3b19..b9748ef03f9 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -2483,9 +2483,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 +2491,269 @@ final class MessageController_Tests: XCTestCase { delegate.didChangeRepliesExpectedCount = count wait(for: [expectation], timeout: defaultTimeout) } + + // MARK: - Update Message + + func test_updateMessage_callsMessageUpdater_withCorrectValues() { + // Given + let text: String = .unique + let attachments = [AnyAttachmentPayload.mockFile] + let extraData: [String: RawJSON] = ["key": .string("value")] + + // When + controller.updateMessage(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_updateMessage_propagatesError() { + // Given + let error = TestError() + var completionError: Error? + + // When + let exp = expectation(description: "Completion is called") + controller.updateMessage(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_updateMessage_propagatesSuccess() { + // Given + var completionMessage: ChatMessage? + + // When + let exp = expectation(description: "Completion is called") + controller.updateMessage(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) + XCTAssertEqual(payload?.stoppedSharing, false) + } + + 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 { From 50443622c46a9b1a7d2e27afe8df8ae16f42f259 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 6 Jan 2025 23:52:22 +0000 Subject: [PATCH 61/94] Add test coverage to Channel Controller --- .../Workers/MessageUpdater_Mock.swift | 2 + .../ChannelController_Tests.swift | 203 ++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift index fd128af5d85..882e930d119 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift @@ -119,6 +119,7 @@ final class MessageUpdater_Mock: MessageUpdater { @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? @@ -541,6 +542,7 @@ final class MessageUpdater_Mock: MessageUpdater { updatePartialMessage_attachments = attachments updatePartialMessage_extraData = extraData updatePartialMessage_completion = completion + updatePartialMessage_completion_result?.invoke(with: completion) } } diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 4f5de48e338..0cebb6a8a34 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -43,6 +43,7 @@ final class ChannelController_Tests: XCTestCase { client?.cleanUp() env?.channelUpdater?.cleanUp() env?.memberUpdater?.cleanUp() + env?.messageUpdater?.cleanUp() env?.eventSender?.cleanUp() env = nil @@ -5566,6 +5567,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 +6000,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 +6018,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! From 2957975ac6a5566888caa2fd9b5f6294307ed6b7 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 13:24:51 +0000 Subject: [PATCH 62/94] Fix concurrency issues when stopping and updating the live location at the same time --- .../Controllers/MessageController/MessageController.swift | 3 +-- .../Location/ChatMessageLiveLocationAttachment.swift | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 96addaa8ab2..ca054c4fd80 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -333,8 +333,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP let liveLocationPayload = LiveLocationAttachmentPayload( latitude: location.latitude, - longitude: location.longitude, - stoppedSharing: false + longitude: location.longitude ) // Optimistic update diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift index 1841b675f8e..c1cfbac67e6 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift @@ -26,7 +26,7 @@ public struct LiveLocationAttachmentPayload: AttachmentPayload { public init( latitude: Double, longitude: Double, - stoppedSharing: Bool, + stoppedSharing: Bool? = nil, extraData: [String: RawJSON]? = nil ) { self.latitude = latitude From a73406b80d73b9ebd5e2095b8c5fd108b5c5443c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 13:54:20 +0000 Subject: [PATCH 63/94] Fix Message Controller Tests --- .../MessageController/MessageController_Tests.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index b9748ef03f9..b0c2f76af0a 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -474,8 +474,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 +512,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 +605,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 @@ -2597,7 +2603,6 @@ final class MessageController_Tests: XCTestCase { let payload = env.messageUpdater.updatePartialMessage_attachments?.first?.payload as? LiveLocationAttachmentPayload XCTAssertEqual(payload?.latitude, latitude) XCTAssertEqual(payload?.longitude, longitude) - XCTAssertEqual(payload?.stoppedSharing, false) } func test_updateLiveLocation_whenNoLiveLocationAttachment_completesWithError() { From e203dfa801ac6c30dbee88d408ddd2436459a17c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 14:08:07 +0000 Subject: [PATCH 64/94] Change updateMessage -> partialUpdateMessage --- .../ChannelController/ChannelController.swift | 2 -- .../MessageController/MessageController.swift | 2 +- .../MessageController/MessageController_Tests.swift | 12 ++++++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index b96e4247a50..720f513d948 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -895,8 +895,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// If there is already an active live location sharing message in the this channel, /// it will fail with an error. /// - /// In order to - /// /// - Parameters: /// - location: The location information. /// - text: The text of the message. diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index ca054c4fd80..bdc700b4907 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -294,7 +294,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// - attachments: The attachments to be updated. /// - extraData: The additional data to be updated. /// - completion: Called when the server updates the message. - public func updateMessage( + public func partialUpdateMessage( text: String? = nil, attachments: [AnyAttachmentPayload]? = nil, extraData: [String: RawJSON]? = nil, diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index b0c2f76af0a..232364fcec8 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -2500,14 +2500,14 @@ final class MessageController_Tests: XCTestCase { // MARK: - Update Message - func test_updateMessage_callsMessageUpdater_withCorrectValues() { + func test_partialUpdateMessage_callsMessageUpdater_withCorrectValues() { // Given let text: String = .unique let attachments = [AnyAttachmentPayload.mockFile] let extraData: [String: RawJSON] = ["key": .string("value")] // When - controller.updateMessage(text: text, attachments: attachments, extraData: extraData) + controller.partialUpdateMessage(text: text, attachments: attachments, extraData: extraData) // Then XCTAssertEqual(env.messageUpdater.updatePartialMessage_messageId, messageId) @@ -2516,14 +2516,14 @@ final class MessageController_Tests: XCTestCase { XCTAssertEqual(env.messageUpdater.updatePartialMessage_extraData, extraData) } - func test_updateMessage_propagatesError() { + func test_partialUpdateMessage_propagatesError() { // Given let error = TestError() var completionError: Error? // When let exp = expectation(description: "Completion is called") - controller.updateMessage(text: .unique) { [callbackQueueID] result in + controller.partialUpdateMessage(text: .unique) { [callbackQueueID] result in AssertTestQueue(withId: callbackQueueID) if case let .failure(error) = result { completionError = error @@ -2539,13 +2539,13 @@ final class MessageController_Tests: XCTestCase { XCTAssertEqual(completionError as? TestError, error) } - func test_updateMessage_propagatesSuccess() { + func test_partialUpdateMessage_propagatesSuccess() { // Given var completionMessage: ChatMessage? // When let exp = expectation(description: "Completion is called") - controller.updateMessage(text: .unique) { [callbackQueueID] result in + controller.partialUpdateMessage(text: .unique) { [callbackQueueID] result in AssertTestQueue(withId: callbackQueueID) if case let .success(message) = result { completionMessage = message From 5c30dbee3541044e19eea2de5de4e818d78cf57e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 14:11:04 +0000 Subject: [PATCH 65/94] Add unset support for partial update message --- .../Controllers/MessageController/MessageController.swift | 5 ++++- Sources/StreamChat/Workers/MessageUpdater.swift | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index bdc700b4907..133a1239d7c 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -293,18 +293,21 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// - 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 + extraData: extraData, + unset: unsetProperties ) { result in self.callback { completion?(result) diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index a6633106274..4268b0eb3fd 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -186,6 +186,7 @@ class MessageUpdater: Worker { text: String? = nil, attachments: [AnyAttachmentPayload]? = nil, extraData: [String: RawJSON]? = nil, + unset: [String]? = nil, completion: ((Result) -> Void)? = nil ) { let attachmentPayloads: [MessageAttachmentPayload]? = attachments?.compactMap { attachment in @@ -209,7 +210,8 @@ class MessageUpdater: Worker { text: text, extraData: extraData, attachments: attachmentPayloads - ) + ), + unset: unset ) ) ) { [weak self] result in From 1d7ab643fbcb2c635f3058c304bb56aa65fd7a8b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 14:21:18 +0000 Subject: [PATCH 66/94] Update CHANGELOG.md --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbf1c9110a..f3709c7fdbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ 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) +- Add Static Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessageStaticLocationAttachment` and `StaticLocationAttachmentPayload` + - Add `ChatMessage.staticLocationAttachments` + - Add `ChatChannelController.sendStaticLocation()` +- Add Live Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessageLiveLocationAttachment` and `LiveLocationAttachmentPayload` + - Add `ChatMessage.liveLocationAttachments` + - Add `ChatChannelController.startLiveLocationSharing()` + - Add `ChatChannelController.stopLiveLocationSharing()` + - Add `ChatMessageController.updateLiveLocation()` + - Add `ChatMessageController.stopLiveLocationSharing()` + - Add `CurrentChatUserController.updateLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didStartSharingLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didStopSharingLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didChangeActiveLiveLocationMessages()` ### 🐞 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) From 251fa840a9c47edf64b6836954be2eea9a7a51a2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 14:27:41 +0000 Subject: [PATCH 67/94] Fix tests, not compiling because of unset --- .../Mocks/StreamChat/Workers/MessageUpdater_Mock.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift index 882e930d119..c2d4c42ab04 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift @@ -535,6 +535,7 @@ final class MessageUpdater_Mock: MessageUpdater { text: String? = nil, attachments: [AnyAttachmentPayload]? = nil, extraData: [String: RawJSON]? = nil, + unset: [String]? = nil, completion: ((Result) -> Void)? = nil ) { updatePartialMessage_messageId = messageId From a211f5d00f7d4dae7c1cfe511c20e7d0f580b148 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 14:50:28 +0000 Subject: [PATCH 68/94] Fix test_updatePartialMessage_makesCorrectAPICall --- Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index 3229af7a893..33c50abb97b 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -109,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? From 5b64e9ad4eb385957d5311013b73f51524f5dd52 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 20:09:34 +0000 Subject: [PATCH 69/94] Make `ChatMessageController.updateLiveLocation()` internal --- .../Controllers/MessageController/MessageController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 133a1239d7c..51fb78c2708 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -317,10 +317,16 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// 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. - public func updateLiveLocation( + internal func updateLiveLocation( _ location: LocationAttachmentInfo, completion: ((Result) -> Void)? = nil ) { From 95d359f66a6c7cc335bd8b99b60fab1a73b7206f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 7 Jan 2025 20:10:28 +0000 Subject: [PATCH 70/94] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3709c7fdbe..b963d7f4923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `ChatMessage.liveLocationAttachments` - Add `ChatChannelController.startLiveLocationSharing()` - Add `ChatChannelController.stopLiveLocationSharing()` - - Add `ChatMessageController.updateLiveLocation()` - Add `ChatMessageController.stopLiveLocationSharing()` - Add `CurrentChatUserController.updateLiveLocation()` - Add `CurrentChatUserControllerDelegate.didStartSharingLiveLocation()` From a8bf8dea9ae88021f536980c0922feac5886ef90 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 15:32:34 +0000 Subject: [PATCH 71/94] Fix reloading the snapshot when not necesasry --- .../LocationAttachmentSnapshotView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index e89f45dfd10..966d7c58d8f 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -91,6 +91,8 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return view }() + var previousOrientation = UIDevice.current.orientation + override func setUp() { super.setUp() @@ -169,9 +171,13 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { override func layoutSubviews() { super.layoutSubviews() - if frame.size.width != mapOptions.size.width { + let currentOrientation = UIDevice.current.orientation + if currentOrientation != previousOrientation { imageView.image = nil clearSnapshotCache() + } + + if frame.size.width != mapOptions.size.width { loadMapSnapshotImage() } } From 9c351a870a73b38a5faa351f5455f62bfcb955ff Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 15:32:55 +0000 Subject: [PATCH 72/94] Fix avatar view showing for a split second in the snapshot view for static attachments --- .../LocationAttachment/LocationAttachmentSnapshotView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 966d7c58d8f..bba65aaac80 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -150,6 +150,8 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { guard let content = self.content else { return } + + avatarView.isHidden = true if content.isSharingLiveLocation && content.isFromCurrentUser { stopButton.isHidden = false From b035ed6d028745cb88b125eaae41f5a8b29e7616 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 15:33:27 +0000 Subject: [PATCH 73/94] Change location attachment to have dynamic height depending on message list size --- .../LocationAttachment/LocationAttachmentSnapshotView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index bba65aaac80..6ca711e1940 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -37,8 +37,8 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { var didTapOnLocation: (() -> Void)? var didTapOnStopSharingLocation: (() -> Void)? + let mapHeightRatio: CGFloat = 0.7 let mapOptions: MKMapSnapshotter.Options = .init() - let mapHeight: CGFloat = 150 static var snapshotsCache: NSCache = .init() var snapshotter: MKMapSnapshotter? @@ -119,7 +119,6 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { let container = VContainer(spacing: 0, alignment: .center) { imageView - .height(mapHeight) sharingStatusView .height(30) stopButton @@ -133,6 +132,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { activityIndicatorView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), 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: 30), @@ -203,7 +203,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return } - mapOptions.size = CGSize(width: frame.width, height: mapHeight) + mapOptions.size = CGSize(width: frame.width, height: frame.width * mapHeightRatio) if let cachedSnapshot = getCachedSnapshot() { imageView.image = cachedSnapshot From 2ff83a015c299f8f18252020599e63a74da4c128 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 15:36:23 +0000 Subject: [PATCH 74/94] Extract avatar size in snapshot view --- .../LocationAttachmentSnapshotView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 6ca711e1940..82faf3322c2 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -40,6 +40,8 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { let mapHeightRatio: CGFloat = 0.7 let mapOptions: MKMapSnapshotter.Options = .init() + let avatarSize: CGFloat = 30 + static var snapshotsCache: NSCache = .init() var snapshotter: MKMapSnapshotter? @@ -77,7 +79,7 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { view.translatesAutoresizingMaskIntoConstraints = false view.shouldShowOnlineIndicator = false view.layer.masksToBounds = true - view.layer.cornerRadius = 15 + view.layer.cornerRadius = avatarSize / 2 view.layer.borderWidth = 2 view.layer.borderColor = UIColor.white.cgColor view.isHidden = true @@ -135,8 +137,8 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: mapHeightRatio), avatarView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), avatarView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), - avatarView.widthAnchor.constraint(equalToConstant: 30), - avatarView.heightAnchor.constraint(equalToConstant: 30) + avatarView.widthAnchor.constraint(equalToConstant: avatarSize), + avatarView.heightAnchor.constraint(equalToConstant: avatarSize) ]) } From 3c0cf342cc8d9eceaef86fa0dcffe4e181f333b6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 15:40:40 +0000 Subject: [PATCH 75/94] Fix minor typo --- .../Controllers/MessageController/MessageController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 51fb78c2708..7d8386067f4 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -188,7 +188,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// The polls repository to fetch polls data. private let pollsRepository: PollsRepository - /// The replies pagination hdler. + /// The replies pagination handler. private let replyPaginationHandler: MessagesPaginationStateHandling /// The current state of the pagination state. From 601fdedc7cc03c11b51c539b8a20a123d9d2cd6e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 15:53:28 +0000 Subject: [PATCH 76/94] Add fixed width to map snapshot and simplify caching logic --- .../LocationAttachmentSnapshotView.swift | 12 ------------ .../LocationAttachmentViewInjector.swift | 3 +++ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift index 82faf3322c2..6a043804aa7 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentSnapshotView.swift @@ -93,8 +93,6 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return view }() - var previousOrientation = UIDevice.current.orientation - override func setUp() { super.setUp() @@ -175,12 +173,6 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { override func layoutSubviews() { super.layoutSubviews() - let currentOrientation = UIDevice.current.orientation - if currentOrientation != previousOrientation { - imageView.image = nil - clearSnapshotCache() - } - if frame.size.width != mapOptions.size.width { loadMapSnapshotImage() } @@ -289,10 +281,6 @@ class LocationAttachmentSnapshotView: _View, ThemeProvider { return Self.snapshotsCache.object(forKey: cachingKey) } - func clearSnapshotCache() { - Self.snapshotsCache.removeAllObjects() - } - private func cachingKey() -> NSString? { guard let content = self.content else { return nil diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 515982b3801..2ce022ff142 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -17,10 +17,13 @@ class LocationAttachmentViewInjector: AttachmentViewInjector { attachments(payloadType: LiveLocationAttachmentPayload.self).first } + let mapWidth: CGFloat = 300 + override func contentViewDidLayout(options: ChatMessageLayoutOptions) { super.contentViewDidLayout(options: options) contentView.bubbleContentContainer.insertArrangedSubview(locationAttachmentView, at: 0) + contentView.bubbleThreadFootnoteContainer.width(mapWidth) locationAttachmentView.didTapOnLocation = { [weak self] in self?.handleTapOnLocationAttachment() From e8b6587151d03b8699cd08d50bf1c1a69bb0b856 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 16:32:17 +0000 Subject: [PATCH 77/94] Use a banner view instead of a sheet in the map detail view --- .../LocationDetailViewController.swift | 156 +++++++++--------- 1 file changed, 76 insertions(+), 80 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index e43485a870d..2302574b55a 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -27,6 +27,7 @@ class LocationDetailViewController: UIViewController, ThemeProvider { let mapView: MKMapView = { let view = MKMapView() + view.translatesAutoresizingMaskIntoConstraints = false view.isZoomEnabled = true return view }() @@ -35,9 +36,21 @@ class LocationDetailViewController: UIViewController, ThemeProvider { 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 @@ -49,7 +62,7 @@ class LocationDetailViewController: UIViewController, ThemeProvider { mapView.delegate = self view.backgroundColor = appearance.colorPalette.background view.addSubview(mapView) - mapView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -57,8 +70,17 @@ class LocationDetailViewController: UIViewController, ThemeProvider { mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) - messageController.synchronize() - messageController.delegate = self + 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 { @@ -81,12 +103,8 @@ class LocationDetailViewController: UIViewController, ThemeProvider { locationCoordinate ) } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - presentLocationControlSheet() + updateBannerState() } func updateUserLocation( @@ -115,24 +133,21 @@ class LocationDetailViewController: UIViewController, ThemeProvider { } } - func presentLocationControlSheet() { - if #available(iOS 16.0, *), isLiveLocationAttachment, messageController.message?.isSentByCurrentUser == true { - let locationControlSheet = LocationControlSheetViewController( - messageController: messageController.client.messageController( - cid: messageController.cid, - messageId: messageController.messageId - ) - ) - locationControlSheet.modalPresentationStyle = .pageSheet - let detent = UISheetPresentationController.Detent.custom(resolver: { _ in 60 }) - locationControlSheet.sheetPresentationController?.detents = [detent] - locationControlSheet.sheetPresentationController?.prefersGrabberVisible = false - locationControlSheet.sheetPresentationController?.preferredCornerRadius = 16 - locationControlSheet.sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false - locationControlSheet.sheetPresentationController?.largestUndimmedDetentIdentifier = detent.identifier - locationControlSheet.isModalInPresentation = true - present(locationControlSheet, animated: true) + private func updateBannerState() { + guard let liveLocationAttachment = messageController.message?.liveLocationAttachments.first else { + return } + + let isSharingLiveLocation = liveLocationAttachment.stoppedSharing == false + + let dateFormatter = appearance.formatters.channelListMessageTimestamp + let updatedAtText = dateFormatter.format(messageController.message?.updatedAt ?? Date()) + locationControlBanner.configure( + isSharingEnabled: isSharingLiveLocation, + statusText: isSharingLiveLocation + ? "Location sharing is active" + : "Location last updated at \(updatedAtText)" + ) } } @@ -159,6 +174,8 @@ extension LocationDetailViewController: ChatMessageControllerDelegate { let userAnnotationView = mapView.view(for: userAnnotation) as? UserAnnotationView userAnnotationView?.stopPulsingAnimation() } + + updateBannerState() } } @@ -189,91 +206,70 @@ extension LocationDetailViewController: MKMapViewDelegate { } } -class LocationControlSheetViewController: UIViewController, ThemeProvider { - let messageController: ChatMessageController - - init( - messageController: ChatMessageController - ) { - self.messageController = messageController - super.init(nibName: nil, bundle: nil) +class LocationControlBannerView: UIView, ThemeProvider { + var onStopSharingTapped: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() } - lazy var sharingButton: UIButton = { + @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(stopSharing), for: .touchUpInside) + button.addTarget(self, action: #selector(stopSharingTapped), for: .touchUpInside) return button }() - lazy var locationUpdateLabel: UILabel = { + 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] - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - messageController.synchronize() - messageController.delegate = self - - view.backgroundColor = appearance.colorPalette.background6 - - let container = VContainer(spacing: 2, alignment: .center) { + let container = VContainer(spacing: 0, alignment: .center) { sharingButton locationUpdateLabel } - view.addSubview(container) + addSubview(container) NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: view.topAnchor, constant: 8), - container.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + container.topAnchor.constraint(equalTo: topAnchor, constant: 8), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor) ]) } - - @objc func stopSharing() { - messageController.stopLiveLocationSharing() + + @objc private func stopSharingTapped() { + onStopSharingTapped?() } -} - -extension LocationControlSheetViewController: ChatMessageControllerDelegate { - func messageController( - _ controller: ChatMessageController, - didChangeMessage change: EntityChange - ) { - guard let liveLocationAttachment = controller.message?.liveLocationAttachments.first else { - return - } - - let isSharingLiveLocation = liveLocationAttachment.stoppedSharing == false - sharingButton.isEnabled = isSharingLiveLocation + + func configure(isSharingEnabled: Bool, statusText: String) { + sharingButton.isEnabled = isSharingEnabled sharingButton.setTitle( - isSharingLiveLocation ? "Stop Sharing" : "Live location ended", + isSharingEnabled ? "Stop Sharing" : "Live location ended", for: .normal ) let buttonColor = appearance.colorPalette.alert sharingButton.setTitleColor( - isSharingLiveLocation ? buttonColor : buttonColor.withAlphaComponent(0.6), + isSharingEnabled ? buttonColor : buttonColor.withAlphaComponent(0.6), for: .normal ) - - if isSharingLiveLocation { - locationUpdateLabel.text = "Location sharing is active" - } else { - let lastUpdated = messageController.message?.updatedAt ?? Date() - let formatter = appearance.formatters.channelListMessageTimestamp - locationUpdateLabel.text = "Location last updated at \(formatter.format(lastUpdated))" - } + + locationUpdateLabel.text = statusText } } From 61882ef1e0e73bd34cc80739dbe10b2d493b58e2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 16:35:40 +0000 Subject: [PATCH 78/94] Present the map instead of pushing when on iPad --- .../LocationAttachmentViewDelegate.swift | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index f4143f59f01..a682c7d95b5 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -4,6 +4,7 @@ import StreamChat import StreamChatUI +import UIKit protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate { func didTapOnStaticLocationAttachment( @@ -25,10 +26,7 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { cid: attachment.id.cid, messageId: attachment.id.messageId ) - let mapViewController = LocationDetailViewController( - messageController: messageController - ) - navigationController?.pushViewController(mapViewController, animated: true) + showDetailViewController(messageController: messageController) } func didTapOnLiveLocationAttachment(_ attachment: ChatMessageLiveLocationAttachment) { @@ -36,10 +34,7 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { cid: attachment.id.cid, messageId: attachment.id.messageId ) - let mapViewController = LocationDetailViewController( - messageController: messageController - ) - navigationController?.pushViewController(mapViewController, animated: true) + showDetailViewController(messageController: messageController) } func didTapOnStopSharingLocation(_ attachment: ChatMessageLiveLocationAttachment) { @@ -47,4 +42,16 @@ extension DemoChatMessageListVC: LocationAttachmentViewDelegate { .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) + } } From f7dbf609bb9e72111da6e62fa44f5f92cf10319b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 16:44:10 +0000 Subject: [PATCH 79/94] Add more documentation on how "Tracking" behaviour works --- .../LocationDetailViewController.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 2302574b55a..84f2dd667bd 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -111,13 +111,20 @@ class LocationDetailViewController: UIViewController, ThemeProvider { _ coordinate: CLLocationCoordinate2D ) { if let existingAnnotation = userAnnotation { - // Since we update the location every 3s, by updating the coordinate with 5s animation - // this will make sure the annotation moves smoothly. - UIView.animate(withDuration: 5) { + 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 - } - UIView.animate(withDuration: 5, delay: 0.2, options: .curveEaseOut) { - self.mapView.setCenter(coordinate, animated: true) + mapView.setCenter(coordinate, animated: true) } } else if let author = messageController.message?.author, isLiveLocationAttachment { let userAnnotation = UserAnnotation( From 7b60602b998688da726f5aba73c67946de60a4b3 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 8 Jan 2025 16:54:55 +0000 Subject: [PATCH 80/94] Enable locations by default in the demo app --- .../AppConfigViewController/AppConfigViewController.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index fde449f275b..7ff09950fba 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -47,7 +47,7 @@ class AppConfig { isHardDeleteEnabled: false, isAtlantisEnabled: false, isMessageDebuggerEnabled: false, - isLocationAttachmentsEnabled: false, + isLocationAttachmentsEnabled: true, tokenRefreshDetails: nil, shouldShowConnectionBanner: false, isPremiumMemberFeatureEnabled: false @@ -56,8 +56,6 @@ class AppConfig { if StreamRuntimeCheck.isStreamInternalConfiguration { demoAppConfig.isAtlantisEnabled = true demoAppConfig.isMessageDebuggerEnabled = true - demoAppConfig.isLocationAttachmentsEnabled = true - demoAppConfig.isLocationAttachmentsEnabled = true demoAppConfig.isHardDeleteEnabled = true demoAppConfig.shouldShowConnectionBanner = true demoAppConfig.isPremiumMemberFeatureEnabled = true From cb135d0e95f8daa64eeb4c54bad7bb9e59b8280c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 16:02:50 +0000 Subject: [PATCH 81/94] Fix quote message for live location --- .../DemoQuotedChatMessageView.swift | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift index 83d9f00ca52..f95ee08d579 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift @@ -8,18 +8,19 @@ import UIKit class DemoQuotedChatMessageView: QuotedChatMessageView { override func setAttachmentPreview(for message: ChatMessage) { - let locationAttachments = message.staticLocationAttachments - 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.latitude),\(locationPayload.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 } From f3d7025c427459eedf2dea5de3e0f9610961911c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 16:07:03 +0000 Subject: [PATCH 82/94] Fix location attachments should not be editable --- DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index 6336fa12d6d..9a8e9424d9f 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift @@ -26,6 +26,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 } From 5952dd63dffbe8b0d1cae836ef64219bae1ef94f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 16:10:07 +0000 Subject: [PATCH 83/94] Do not show location attachment picker when inside thread --- .../Components/CustomAttachments/DemoComposerVC.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index c9122a6da59..72c0ca43cc4 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -13,7 +13,7 @@ class DemoComposerVC: ComposerVC { override var attachmentsPickerActions: [UIAlertAction] { var actions = super.attachmentsPickerActions - if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled { + if AppConfig.shared.demoAppConfig.isLocationAttachmentsEnabled && content.isInsideThread == false { let sendLocationAction = UIAlertAction( title: "Send Current Location", style: .default, From cd18446bc6fc5f80e0f81cbb2e1f6ebf7a85bd63 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 16:37:32 +0000 Subject: [PATCH 84/94] Fix preview message for location attachments --- .../DemoChatChannelListItemView.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift index 6cb0826e328..d835b51cb25 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift @@ -6,6 +6,24 @@ 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 { From 77baae2154d2c1636a9d98dcbf3f5563ba7b03b8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 16:38:09 +0000 Subject: [PATCH 85/94] Fix detail map show Stop Sharing button for another user --- .../LocationDetailViewController.swift | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index 84f2dd667bd..f95d9caa1d8 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -145,16 +145,18 @@ class LocationDetailViewController: UIViewController, ThemeProvider { return } - let isSharingLiveLocation = liveLocationAttachment.stoppedSharing == false - + let isFromCurrentUser = messageController.message?.isSentByCurrentUser == true let dateFormatter = appearance.formatters.channelListMessageTimestamp let updatedAtText = dateFormatter.format(messageController.message?.updatedAt ?? Date()) - locationControlBanner.configure( - isSharingEnabled: isSharingLiveLocation, - statusText: isSharingLiveLocation - ? "Location sharing is active" - : "Location last updated at \(updatedAtText)" - ) + if liveLocationAttachment.stoppedSharing == false { + locationControlBanner.configure( + state: isFromCurrentUser + ? .currentUserSharing + : .anotherUserSharing(lastUpdatedAtText: updatedAtText) + ) + } else { + locationControlBanner.configure(state: .ended(lastUpdatedAtText: updatedAtText)) + } } } @@ -263,20 +265,30 @@ class LocationControlBannerView: UIView, ThemeProvider { @objc private func stopSharingTapped() { onStopSharingTapped?() } - - func configure(isSharingEnabled: Bool, statusText: String) { - sharingButton.isEnabled = isSharingEnabled - sharingButton.setTitle( - isSharingEnabled ? "Stop Sharing" : "Live location ended", - for: .normal - ) - let buttonColor = appearance.colorPalette.alert - sharingButton.setTitleColor( - isSharingEnabled ? buttonColor : buttonColor.withAlphaComponent(0.6), - for: .normal - ) - - locationUpdateLabel.text = statusText + enum State { + case currentUserSharing + case anotherUserSharing(lastUpdatedAtText: String) + case ended(lastUpdatedAtText: String) + } + + 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)" + } } } From 07652720c6bcae382fc1d188d7168c14ef1909c9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 22:08:30 +0000 Subject: [PATCH 86/94] Disable locations feature by default --- .../AppConfigViewController/AppConfigViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 7ff09950fba..b6b20cd928e 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -47,7 +47,7 @@ class AppConfig { isHardDeleteEnabled: false, isAtlantisEnabled: false, isMessageDebuggerEnabled: false, - isLocationAttachmentsEnabled: true, + isLocationAttachmentsEnabled: false, tokenRefreshDetails: nil, shouldShowConnectionBanner: false, isPremiumMemberFeatureEnabled: false @@ -57,6 +57,7 @@ class AppConfig { demoAppConfig.isAtlantisEnabled = true demoAppConfig.isMessageDebuggerEnabled = true demoAppConfig.isHardDeleteEnabled = true + demoAppConfig.isLocationAttachmentsEnabled = true demoAppConfig.shouldShowConnectionBanner = true demoAppConfig.isPremiumMemberFeatureEnabled = true StreamRuntimeCheck.assertionsEnabled = true From d06eda08903433c3ed2d3e120ef00f54bc0dbab2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 22:23:05 +0000 Subject: [PATCH 87/94] Add experimental flag --- DemoApp/Screens/DemoAppTabBarController.swift | 1 + .../Components/CustomAttachments/DemoComposerVC.swift | 1 + .../CustomAttachments/DemoQuotedChatMessageView.swift | 1 + .../LocationAttachment/LocationAttachmentViewDelegate.swift | 1 + .../LocationAttachment/LocationAttachmentViewInjector.swift | 1 + .../LocationAttachment/LocationDetailViewController.swift | 1 + .../StreamChat/Components/DemoChatChannelListItemView.swift | 2 ++ DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift | 1 + .../Controllers/ChannelController/ChannelController.swift | 3 +++ .../CurrentUserController/CurrentUserController.swift | 4 ++++ .../Controllers/MessageController/MessageController.swift | 1 + .../Location/ChatMessageLiveLocationAttachment.swift | 2 ++ .../Location/ChatMessageStaticLocationAttachment.swift | 2 ++ Sources/StreamChat/Models/ChatMessage.swift | 2 ++ .../TestData/DummyData/MessageAttachmentPayload.swift | 2 +- .../ChannelController/ChannelController_Tests.swift | 1 + .../MessageController/MessageController_Tests.swift | 1 + .../Attachments/LiveLocationAttachmentPayload_Tests.swift | 1 + .../Attachments/StaticLocationAttachmentPayload_Tests.swift | 1 + Tests/StreamChatTests/Models/ChatMessage_Tests.swift | 1 + 20 files changed, 29 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 2112d21a7bc..e234ee8fef1 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -3,6 +3,7 @@ // import Combine +@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 72c0ca43cc4..10177ec9b8b 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -3,6 +3,7 @@ // import CoreLocation +@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift index f95ee08d579..0acdcf069e8 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index a682c7d95b5..c7e62f10f7e 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index 2ce022ff142..aff97f2e700 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 diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index f95d9caa1d8..c233d132335 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -3,6 +3,7 @@ // import MapKit +@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift index d835b51cb25..efeeca33033 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift @@ -2,6 +2,8 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +@_spi(ExperimentalLocation) +import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index 9a8e9424d9f..e18736f1fe1 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 diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 720f513d948..a5bc318d3ea 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -841,6 +841,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - 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, @@ -901,6 +902,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - 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, @@ -963,6 +965,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } /// 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 diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index f5fd827b23a..a25f2fcb4ba 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -271,6 +271,7 @@ public extension CurrentChatUserController { /// 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 @@ -482,17 +483,20 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { ) /// 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 ) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 7d8386067f4..0673564caa9 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -938,6 +938,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// /// - 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 { diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift index c1cfbac67e6..e5ef2f85bf2 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift @@ -7,9 +7,11 @@ 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 diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift index 458aee39607..6240a10d832 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift @@ -7,9 +7,11 @@ 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 diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 7d38a8aa3d3..cc8dcbd1a8e 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -318,11 +318,13 @@ public extension ChatMessage { } /// 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) } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift index 928ca0f0df6..fd12204b879 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift @@ -3,7 +3,7 @@ // import Foundation -@testable import StreamChat +@testable @_spi(ExperimentalLocation) import StreamChat extension MessageAttachmentPayload { static func dummy( diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 0cebb6a8a34..d1401ba1360 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 diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index 232364fcec8..97e924086c3 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 diff --git a/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift index e0095d4244b..1c2b4626dd5 100644 --- a/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest diff --git a/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift index 84e42b47ea5..8e45688c495 100644 --- a/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift index d325b7c6baa..9435298417d 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 From 37bd1d15282479086487ad2b6f41cb09f398614c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 9 Jan 2025 22:26:17 +0000 Subject: [PATCH 88/94] Update CHANGELOG.md --- CHANGELOG.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b963d7f4923..de6900d6b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,20 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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) -- Add Static Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) - - Add `ChatMessageStaticLocationAttachment` and `StaticLocationAttachmentPayload` - - Add `ChatMessage.staticLocationAttachments` - - Add `ChatChannelController.sendStaticLocation()` -- Add Live Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) - - Add `ChatMessageLiveLocationAttachment` and `LiveLocationAttachmentPayload` - - Add `ChatMessage.liveLocationAttachments` - - Add `ChatChannelController.startLiveLocationSharing()` - - Add `ChatChannelController.stopLiveLocationSharing()` - - Add `ChatMessageController.stopLiveLocationSharing()` - - Add `CurrentChatUserController.updateLiveLocation()` - - Add `CurrentChatUserControllerDelegate.didStartSharingLiveLocation()` - - Add `CurrentChatUserControllerDelegate.didStopSharingLiveLocation()` - - Add `CurrentChatUserControllerDelegate.didChangeActiveLiveLocationMessages()` ### 🐞 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) From 81cc2ee98df1da85f8c99a9fb355235e7a133677 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 10 Jan 2025 11:53:15 +0000 Subject: [PATCH 89/94] Revert "Disable locations feature by default" This reverts commit 07652720c6bcae382fc1d188d7168c14ef1909c9. --- .../AppConfigViewController/AppConfigViewController.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index b6b20cd928e..7ff09950fba 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -47,7 +47,7 @@ class AppConfig { isHardDeleteEnabled: false, isAtlantisEnabled: false, isMessageDebuggerEnabled: false, - isLocationAttachmentsEnabled: false, + isLocationAttachmentsEnabled: true, tokenRefreshDetails: nil, shouldShowConnectionBanner: false, isPremiumMemberFeatureEnabled: false @@ -57,7 +57,6 @@ class AppConfig { demoAppConfig.isAtlantisEnabled = true demoAppConfig.isMessageDebuggerEnabled = true demoAppConfig.isHardDeleteEnabled = true - demoAppConfig.isLocationAttachmentsEnabled = true demoAppConfig.shouldShowConnectionBanner = true demoAppConfig.isPremiumMemberFeatureEnabled = true StreamRuntimeCheck.assertionsEnabled = true From 2c5b331e16f105f14dd32941a4ea9ce16d4fdb86 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 10 Jan 2025 11:53:25 +0000 Subject: [PATCH 90/94] Revert "Add experimental flag" This reverts commit d06eda08903433c3ed2d3e120ef00f54bc0dbab2. --- DemoApp/Screens/DemoAppTabBarController.swift | 1 - .../Components/CustomAttachments/DemoComposerVC.swift | 1 - .../CustomAttachments/DemoQuotedChatMessageView.swift | 1 - .../LocationAttachment/LocationAttachmentViewDelegate.swift | 1 - .../LocationAttachment/LocationAttachmentViewInjector.swift | 1 - .../LocationAttachment/LocationDetailViewController.swift | 1 - .../StreamChat/Components/DemoChatChannelListItemView.swift | 2 -- DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift | 1 - .../Controllers/ChannelController/ChannelController.swift | 3 --- .../CurrentUserController/CurrentUserController.swift | 4 ---- .../Controllers/MessageController/MessageController.swift | 1 - .../Location/ChatMessageLiveLocationAttachment.swift | 2 -- .../Location/ChatMessageStaticLocationAttachment.swift | 2 -- Sources/StreamChat/Models/ChatMessage.swift | 2 -- .../TestData/DummyData/MessageAttachmentPayload.swift | 2 +- .../ChannelController/ChannelController_Tests.swift | 1 - .../MessageController/MessageController_Tests.swift | 1 - .../Attachments/LiveLocationAttachmentPayload_Tests.swift | 1 - .../Attachments/StaticLocationAttachmentPayload_Tests.swift | 1 - Tests/StreamChatTests/Models/ChatMessage_Tests.swift | 1 - 20 files changed, 1 insertion(+), 29 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index e234ee8fef1..2112d21a7bc 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -3,7 +3,6 @@ // import Combine -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 10177ec9b8b..72c0ca43cc4 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -3,7 +3,6 @@ // import CoreLocation -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift index 0acdcf069e8..f95ee08d579 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoQuotedChatMessageView.swift @@ -2,7 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift index c7e62f10f7e..a682c7d95b5 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewDelegate.swift @@ -2,7 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift index aff97f2e700..2ce022ff142 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationAttachmentViewInjector.swift @@ -2,7 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift index c233d132335..f95d9caa1d8 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/LocationAttachment/LocationDetailViewController.swift @@ -3,7 +3,6 @@ // import MapKit -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift index efeeca33033..d835b51cb25 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListItemView.swift @@ -2,8 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) -import StreamChat import StreamChatUI import UIKit diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index e18736f1fe1..9a8e9424d9f 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift @@ -3,7 +3,6 @@ // import Foundation -@_spi(ExperimentalLocation) import StreamChat import StreamChatUI import UIKit diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index a5bc318d3ea..720f513d948 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -841,7 +841,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - 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, @@ -902,7 +901,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - 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, @@ -965,7 +963,6 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } /// 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 diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index a25f2fcb4ba..f5fd827b23a 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -271,7 +271,6 @@ public extension CurrentChatUserController { /// 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 @@ -483,20 +482,17 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { ) /// 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 ) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index 0673564caa9..7d8386067f4 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -938,7 +938,6 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// /// - 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 { diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift index e5ef2f85bf2..c1cfbac67e6 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift @@ -7,11 +7,9 @@ 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 diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift index 6240a10d832..458aee39607 100644 --- a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift @@ -7,11 +7,9 @@ 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 diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index cc8dcbd1a8e..7d38a8aa3d3 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -318,13 +318,11 @@ public extension ChatMessage { } /// 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) } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift index fd12204b879..928ca0f0df6 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessageAttachmentPayload.swift @@ -3,7 +3,7 @@ // import Foundation -@testable @_spi(ExperimentalLocation) import StreamChat +@testable import StreamChat extension MessageAttachmentPayload { static func dummy( diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index d1401ba1360..0cebb6a8a34 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -3,7 +3,6 @@ // import CoreData -@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index 97e924086c3..232364fcec8 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -3,7 +3,6 @@ // import CoreData -@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest diff --git a/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift index 1c2b4626dd5..e0095d4244b 100644 --- a/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/LiveLocationAttachmentPayload_Tests.swift @@ -2,7 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest diff --git a/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift index 8e45688c495..84e42b47ea5 100644 --- a/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/StaticLocationAttachmentPayload_Tests.swift @@ -2,7 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift index 9435298417d..d325b7c6baa 100644 --- a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift +++ b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift @@ -2,7 +2,6 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -@_spi(ExperimentalLocation) @testable import StreamChat @testable import StreamChatTestTools import XCTest From d7728de2e862d63ac94128ba63620d3f1179d5dc Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 10 Jan 2025 11:53:30 +0000 Subject: [PATCH 91/94] Revert "Update CHANGELOG.md" This reverts commit 37bd1d15282479086487ad2b6f41cb09f398614c. --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de6900d6b65..b963d7f4923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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) +- Add Static Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessageStaticLocationAttachment` and `StaticLocationAttachmentPayload` + - Add `ChatMessage.staticLocationAttachments` + - Add `ChatChannelController.sendStaticLocation()` +- Add Live Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessageLiveLocationAttachment` and `LiveLocationAttachmentPayload` + - Add `ChatMessage.liveLocationAttachments` + - Add `ChatChannelController.startLiveLocationSharing()` + - Add `ChatChannelController.stopLiveLocationSharing()` + - Add `ChatMessageController.stopLiveLocationSharing()` + - Add `CurrentChatUserController.updateLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didStartSharingLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didStopSharingLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didChangeActiveLiveLocationMessages()` ### 🐞 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) From 6f277d5e71c4e9712211032558b3c368bc3790b7 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 9 May 2025 16:13:29 +0100 Subject: [PATCH 92/94] Fix missing stuff from merge conflicts --- .../CurrentUserController/CurrentUserController.swift | 2 +- Sources/StreamChat/Workers/MessageUpdater.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index db555b1448c..3e875a27dae 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -656,7 +656,7 @@ public extension CurrentChatUserControllerDelegate { func currentUserControllerDidStopSharingLiveLocation( _ controller: CurrentChatUserController - ) + ) {} func currentUserController( _ controller: CurrentChatUserController, diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 2400e618a16..ede1493d2b4 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -239,6 +239,7 @@ class MessageUpdater: Worker { payload: messagePayload, for: cid, syncOwnReactions: false, + skipDraftUpdate: true, cache: nil ) let message = try messageDTO.asModel() From 6748eed626c0f7decec38dc7f0a30f21917be57c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 9 May 2025 16:44:29 +0100 Subject: [PATCH 93/94] Update CHANGELOG.md --- CHANGELOG.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b83f47ecc51..bd5d15d6d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Add `ChatMessageController.partialUpdateMessage()` [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) +- Add Static Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessageStaticLocationAttachment` and `StaticLocationAttachmentPayload` + - Add `ChatMessage.staticLocationAttachments` + - Add `ChatChannelController.sendStaticLocation()` +- Add Live Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) + - Add `ChatMessageLiveLocationAttachment` and `LiveLocationAttachmentPayload` + - Add `ChatMessage.liveLocationAttachments` + - Add `ChatChannelController.startLiveLocationSharing()` + - Add `ChatChannelController.stopLiveLocationSharing()` + - Add `ChatMessageController.stopLiveLocationSharing()` + - Add `CurrentChatUserController.updateLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didStartSharingLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didStopSharingLiveLocation()` + - Add `CurrentChatUserControllerDelegate.didChangeActiveLiveLocationMessages()` ### 🐞 Fixed - Fix swipe to reply enabled when quoting a message is disabled [#3662](https://github.com/GetStream/stream-chat-swift/pull/3662) - Fix shadowed messages increasing the channel messages unread count [#3665](https://github.com/GetStream/stream-chat-swift/pull/3665) @@ -177,21 +193,6 @@ _January 14, 2025_ - Use `AppSettings.fileUploadConfig` and `AppSettings.imageUploadConfig` for blocking attachment uploads [#3556](https://github.com/GetStream/stream-chat-swift/pull/3556) - 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) -- Add Static Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) - - Add `ChatMessageStaticLocationAttachment` and `StaticLocationAttachmentPayload` - - Add `ChatMessage.staticLocationAttachments` - - Add `ChatChannelController.sendStaticLocation()` -- Add Live Location Attachment Support [#3531](https://github.com/GetStream/stream-chat-swift/pull/3531) - - Add `ChatMessageLiveLocationAttachment` and `LiveLocationAttachmentPayload` - - Add `ChatMessage.liveLocationAttachments` - - Add `ChatChannelController.startLiveLocationSharing()` - - Add `ChatChannelController.stopLiveLocationSharing()` - - Add `ChatMessageController.stopLiveLocationSharing()` - - Add `CurrentChatUserController.updateLiveLocation()` - - Add `CurrentChatUserControllerDelegate.didStartSharingLiveLocation()` - - Add `CurrentChatUserControllerDelegate.didStopSharingLiveLocation()` - - Add `CurrentChatUserControllerDelegate.didChangeActiveLiveLocationMessages()` ### 🐞 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) From 1048c222db91f19679c3841f4044985c94fe4466 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 12 May 2025 15:21:12 +0200 Subject: [PATCH 94/94] Rename LocationAttachmentInfo to LocationInfo --- DemoApp/Screens/DemoAppTabBarController.swift | 2 +- .../CustomAttachments/DemoComposerVC.swift | 4 ++-- .../ChannelController/ChannelController.swift | 4 ++-- .../CurrentUserController.swift | 2 +- .../MessageController/MessageController.swift | 2 +- .../ChatMessageLiveLocationAttachment.swift | 0 .../ChatMessageStaticLocationAttachment.swift | 0 .../LocationInfo.swift} | 4 ++-- StreamChat.xcodeproj/project.pbxproj | 15 +++++++-------- 9 files changed, 16 insertions(+), 17 deletions(-) rename Sources/StreamChat/Models/{Attachments => }/Location/ChatMessageLiveLocationAttachment.swift (100%) rename Sources/StreamChat/Models/{Attachments => }/Location/ChatMessageStaticLocationAttachment.swift (100%) rename Sources/StreamChat/Models/{Attachments/Location/LocationAttachmentInfo.swift => Location/LocationInfo.swift} (84%) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 16ed31ec19d..0537b469fe2 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -67,7 +67,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele threadListVC.tabBarItem.badgeColor = .red locationProvider.didUpdateLocation = { [weak self] location in - let newLocation = LocationAttachmentInfo( + let newLocation = LocationInfo( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) diff --git a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift index 5009a236151..5a06d974ac5 100644 --- a/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift +++ b/DemoApp/StreamChat/Components/CustomAttachments/DemoComposerVC.swift @@ -50,11 +50,11 @@ class DemoComposerVC: ComposerVC { } } - private func getCurrentLocationInfo(completion: @escaping (LocationAttachmentInfo?) -> Void) { + private func getCurrentLocationInfo(completion: @escaping (LocationInfo?) -> Void) { locationProvider.getCurrentLocation { [weak self] result in switch result { case .success(let location): - let location = LocationAttachmentInfo( + let location = LocationInfo( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index b1653195494..2ddc055ee4b 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -860,7 +860,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - extraData: Additional extra data of the message object. /// - completion: Called when saving the message to the local DB finishes, not when the message reaches the server. public func sendStaticLocation( - _ location: LocationAttachmentInfo, + _ location: LocationInfo, text: String? = nil, messageId: MessageId? = nil, quotedMessageId: MessageId? = nil, @@ -920,7 +920,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// - completion: Called when saving the message to the local DB finishes, /// not when the message reaches the server. public func startLiveLocationSharing( - _ location: LocationAttachmentInfo, + _ location: LocationInfo, text: String? = nil, extraData: [String: RawJSON] = [:], completion: ((Result) -> Void)? = nil diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 3e875a27dae..b106d0e6a22 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -300,7 +300,7 @@ public extension CurrentChatUserController { /// The updates are throttled to avoid sending too many requests. /// /// - Parameter location: The new location to be updated. - func updateLiveLocation(_ location: LocationAttachmentInfo) { + func updateLiveLocation(_ location: LocationInfo) { guard let messages = activeLiveLocationMessagesObserver?.items, !messages.isEmpty else { return } diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index c29d423e5c3..03729d237c7 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -340,7 +340,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// - location: The new location for the live location attachment. /// - completion: Called when the server updates the message. internal func updateLiveLocation( - _ location: LocationAttachmentInfo, + _ location: LocationInfo, completion: ((Result) -> Void)? = nil ) { guard let locationAttachment = message?.liveLocationAttachments.first else { diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift b/Sources/StreamChat/Models/Location/ChatMessageLiveLocationAttachment.swift similarity index 100% rename from Sources/StreamChat/Models/Attachments/Location/ChatMessageLiveLocationAttachment.swift rename to Sources/StreamChat/Models/Location/ChatMessageLiveLocationAttachment.swift diff --git a/Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift b/Sources/StreamChat/Models/Location/ChatMessageStaticLocationAttachment.swift similarity index 100% rename from Sources/StreamChat/Models/Attachments/Location/ChatMessageStaticLocationAttachment.swift rename to Sources/StreamChat/Models/Location/ChatMessageStaticLocationAttachment.swift diff --git a/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift b/Sources/StreamChat/Models/Location/LocationInfo.swift similarity index 84% rename from Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift rename to Sources/StreamChat/Models/Location/LocationInfo.swift index 74d20ff0b0d..35fcb5cf1a5 100644 --- a/Sources/StreamChat/Models/Attachments/Location/LocationAttachmentInfo.swift +++ b/Sources/StreamChat/Models/Location/LocationInfo.swift @@ -4,8 +4,8 @@ import Foundation -/// The location attachment information. -public struct LocationAttachmentInfo { +/// The location information. +public struct LocationInfo { public var latitude: Double public var longitude: Double public var extraData: [String: RawJSON]? diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b53afc3531d..5cd6475ca89 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1833,8 +1833,8 @@ 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 */; }; + ADFCA5B32D121EB8000F515F /* LocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B22D121EAF000F515F /* LocationInfo.swift */; }; + ADFCA5B42D121EB8000F515F /* LocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B22D121EAF000F515F /* LocationInfo.swift */; }; ADFCA5B72D1232B3000F515F /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B62D1232A7000F515F /* LocationProvider.swift */; }; ADFCA5B92D1378E2000F515F /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B82D1378E2000F515F /* Throttler.swift */; }; ADFCA5BA2D1378E2000F515F /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFCA5B82D1378E2000F515F /* Throttler.swift */; }; @@ -4505,7 +4505,7 @@ 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 = ""; }; + ADFCA5B22D121EAF000F515F /* LocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationInfo.swift; sourceTree = ""; }; ADFCA5B62D1232A7000F515F /* LocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = ""; }; ADFCA5B82D1378E2000F515F /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; ADFD391C2D47D06E00F8E1B1 /* DraftEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftEndpoints.swift; sourceTree = ""; }; @@ -5068,7 +5068,6 @@ 225D7FE125D191400094E555 /* ChatMessageImageAttachment.swift */, 22692C8625D176F4007C41D0 /* ChatMessageLinkAttachment.swift */, 79983C80266633C2000995F6 /* ChatMessageVideoAttachment.swift */, - ADFCA5B52D121EE9000F515F /* Location */, ); path = Attachments; sourceTree = ""; @@ -5936,6 +5935,7 @@ isa = PBXGroup; children = ( 225D807625D316B10094E555 /* Attachments */, + ADFCA5B52D121EE9000F515F /* Location */, AD8C7C5C2BA3BE1E00260715 /* AppSettings.swift */, 8A62706D24BF45360040BFD6 /* BanEnabling.swift */, 82BE0ACC2C009A17008DA9DC /* BlockedUserDetails.swift */, @@ -6381,7 +6381,6 @@ ACA3C98526CA23F300EB8B07 /* DateUtils.swift */, 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, - C12297D22AC57A3200C5FF04 /* Throttler.swift */, AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */, AD169DEC2C9B112B00F58FAC /* KeyboardHandler */, AD95FD0F28F9B72200DBDF41 /* Extensions */, @@ -9128,7 +9127,7 @@ ADFCA5B52D121EE9000F515F /* Location */ = { isa = PBXGroup; children = ( - ADFCA5B22D121EAF000F515F /* LocationAttachmentInfo.swift */, + ADFCA5B22D121EAF000F515F /* LocationInfo.swift */, AD770B642D09BA0C003AC602 /* ChatMessageStaticLocationAttachment.swift */, AD770B672D09E2CB003AC602 /* ChatMessageLiveLocationAttachment.swift */, ); @@ -11713,7 +11712,7 @@ 79FC85E724ACCBC500A665ED /* Token.swift in Sources */, 4F4562F62C240FD200675C7F /* DatabaseItemConverter.swift in Sources */, 79877A0E2498E4BC00015F8B /* Channel.swift in Sources */, - ADFCA5B32D121EB8000F515F /* LocationAttachmentInfo.swift in Sources */, + ADFCA5B32D121EB8000F515F /* LocationInfo.swift in Sources */, DA4AA3B22502718600FAAF6E /* ChannelController+Combine.swift in Sources */, 40789D1D29F6AC500018C2BB /* AudioPlayingDelegate.swift in Sources */, ADF34F8A25CDC58900AD637C /* ConnectionController.swift in Sources */, @@ -12544,7 +12543,7 @@ C121E895274544B000023E4C /* MessagePinning.swift in Sources */, 4042968429FACA0E0089126D /* AudioSamplesProcessor.swift in Sources */, C121E896274544B000023E4C /* UnreadCount.swift in Sources */, - ADFCA5B42D121EB8000F515F /* LocationAttachmentInfo.swift in Sources */, + ADFCA5B42D121EB8000F515F /* LocationInfo.swift in Sources */, 842F9746277A09B10060A489 /* PinnedMessagesQuery.swift in Sources */, C121E897274544B000023E4C /* User+SwiftUI.swift in Sources */, C121E898274544B000023E4C /* MessageReaction.swift in Sources */,