diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ChatFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ChatFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..36b050b6723be25e47fd61ca191fa2b0859600da --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ChatFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1400" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeature" + BuildableName = "ChatFeature" + BlueprintName = "ChatFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeatureTests" + BuildableName = "ChatFeatureTests" + BlueprintName = "ChatFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeature" + BuildableName = "ChatFeature" + BlueprintName = "ChatFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 8b52ce874bc2b789a2f9e74a33311738e4bdf7d4..c5bcba6a09c4d828a7506a531d429f9d9ab8a92d 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -5,8 +5,8 @@ let swiftSettings: [SwiftSetting] = [ .unsafeFlags( [ // "-Xfrontend", "-warn-concurrency", - "-Xfrontend", "-debug-time-function-bodies", - "-Xfrontend", "-debug-time-expression-type-checking", + // "-Xfrontend", "-debug-time-function-bodies", + // "-Xfrontend", "-debug-time-expression-type-checking", ], .when(configuration: .debug) ), @@ -20,6 +20,7 @@ let package = Package( products: [ .library(name: "AppCore", targets: ["AppCore"]), .library(name: "AppFeature", targets: ["AppFeature"]), + .library(name: "ChatFeature", targets: ["ChatFeature"]), .library(name: "CheckContactAuthFeature", targets: ["CheckContactAuthFeature"]), .library(name: "ConfirmRequestFeature", targets: ["ConfirmRequestFeature"]), .library(name: "ContactFeature", targets: ["ContactFeature"]), @@ -76,6 +77,7 @@ let package = Package( name: "AppFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "ChatFeature"), .target(name: "CheckContactAuthFeature"), .target(name: "ConfirmRequestFeature"), .target(name: "ContactFeature"), @@ -101,6 +103,24 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "ChatFeature", + dependencies: [ + .target(name: "AppCore"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ChatFeatureTests", + dependencies: [ + .target(name: "ChatFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "CheckContactAuthFeature", dependencies: [ @@ -137,6 +157,7 @@ let package = Package( name: "ContactFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "ChatFeature"), .target(name: "CheckContactAuthFeature"), .target(name: "ConfirmRequestFeature"), .target(name: "SendRequestFeature"), diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index 0e5e54ad091bf310eb399f185b4c8680fed98e07..65400dff681f98714be5c8db74bc82b6558dfe17 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -49,6 +49,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeatureTests" + BuildableName = "ChatFeatureTests" + BlueprintName = "ChatFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppCore/MessageListenerHandler/MessageListenerHandler.swift b/Examples/xx-messenger/Sources/AppCore/MessageListenerHandler/MessageListenerHandler.swift new file mode 100644 index 0000000000000000000000000000000000000000..70fbace772986d506de4ee2338445a7c656bae8e --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/MessageListenerHandler/MessageListenerHandler.swift @@ -0,0 +1,50 @@ +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct MessageListenerHandler { + public typealias OnError = (Error) -> Void + + public var run: (@escaping OnError) -> Cancellable + + public func callAsFunction(onError: @escaping OnError) -> Cancellable { + run(onError) + } +} + +extension MessageListenerHandler { + public static func live( + messenger: Messenger, + db: DBManagerGetDB + ) -> MessageListenerHandler { + MessageListenerHandler { onError in + let listener = Listener { message in + do { + let payload = try MessagePayload.decode(message.payload) + try db().saveMessage(.init( + networkId: message.id, + senderId: message.sender, + recipientId: message.recipientId, + groupId: nil, + date: Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1_000_000_000), + status: .received, + isUnread: true, + text: payload.text, + roundURL: message.roundURL + )) + } catch { + onError(error) + } + } + return messenger.registerMessageListener(listener) + } + } +} + +extension MessageListenerHandler { + public static let unimplemented = MessageListenerHandler( + run: XCTUnimplemented("\(Self.self)", placeholder: Cancellable {}) + ) +} diff --git a/Examples/xx-messenger/Sources/AppCore/Models/MessagePayload.swift b/Examples/xx-messenger/Sources/AppCore/Models/MessagePayload.swift new file mode 100644 index 0000000000000000000000000000000000000000..67fb94d905b06ad25816ca8c4d11081c72053f19 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/Models/MessagePayload.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct MessagePayload: Equatable { + public init(text: String) { + self.text = text + } + + public var text: String +} + +extension MessagePayload: Codable { + enum CodingKeys: String, CodingKey { + case text + } + + public static func decode(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } + + public func encode() throws -> Data { + try JSONEncoder().encode(self) + } +} diff --git a/Examples/xx-messenger/Sources/AppCore/SendMessage/SendMessage.swift b/Examples/xx-messenger/Sources/AppCore/SendMessage/SendMessage.swift new file mode 100644 index 0000000000000000000000000000000000000000..066e7570805a7e4e69c64139a426e782c8c34913 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SendMessage/SendMessage.swift @@ -0,0 +1,84 @@ +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct SendMessage { + public typealias OnError = (Error) -> Void + public typealias Completion = () -> Void + + public var run: (String, Data, @escaping OnError, @escaping Completion) -> Void + + public func callAsFunction( + text: String, + to recipientId: Data, + onError: @escaping OnError, + completion: @escaping Completion + ) { + run(text, recipientId, onError, completion) + } +} + +extension SendMessage { + public static func live( + messenger: Messenger, + db: DBManagerGetDB, + now: @escaping () -> Date + ) -> SendMessage { + SendMessage { text, recipientId, onError, completion in + do { + let myContactId = try messenger.e2e.tryGet().getContact().getId() + let message = try db().saveMessage(.init( + senderId: myContactId, + recipientId: recipientId, + groupId: nil, + date: now(), + status: .sending, + isUnread: false, + text: text + )) + let payload = MessagePayload(text: message.text) + let report = try messenger.sendMessage( + recipientId: recipientId, + payload: try payload.encode(), + deliveryCallback: { deliveryReport in + let status: XXModels.Message.Status + switch deliveryReport.result { + case .delivered: + status = .sent + case .notDelivered(let timedOut): + status = timedOut ? .sendingTimedOut : .sendingFailed + case .failure(let error): + status = .sendingFailed + onError(error) + } + do { + try db().bulkUpdateMessages( + .init(id: [message.id]), + .init(status: status) + ) + } catch { + onError(error) + } + completion() + } + ) + if var message = try db().fetchMessages(.init(id: [message.id])).first { + message.networkId = report.messageId + message.roundURL = report.roundURL + _ = try db().saveMessage(message) + } + } catch { + onError(error) + completion() + } + } + } +} + +extension SendMessage { + public static let unimplemented = SendMessage( + run: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift new file mode 100644 index 0000000000000000000000000000000000000000..976b1ffe10ee8329a62ed230e2030e6a19deb918 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift @@ -0,0 +1,103 @@ +// MIT License +// +// Copyright (c) 2022 Dariusz Rybicki Darrarski +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Source: https://github.com/darrarski/swiftui-tabs-view/blob/be6865324ed9651c22df36540f932c10ab9c7c34/Sources/SwiftUITabsView/GeometryReaderViewModifier.swift + +import SwiftUI + +extension View { + func geometryReader<Geometry: Codable>( + geometry: @escaping (GeometryProxy) -> Geometry, + onChange: @escaping (Geometry) -> Void + ) -> some View { + modifier(GeometryReaderViewModifier( + geometry: geometry, + onChange: onChange + )) + } +} + +struct GeometryReaderViewModifier<Geometry: Codable>: ViewModifier { + var geometry: (GeometryProxy) -> Geometry + var onChange: (Geometry) -> Void + + func body(content: Content) -> some View { + content + .background { + GeometryReader { geometryProxy in + Color.clear + .preference(key: GeometryPreferenceKey.self, value: { + let geometry = self.geometry(geometryProxy) + let data = try? JSONEncoder().encode(geometry) + return data + }()) + .onPreferenceChange(GeometryPreferenceKey.self) { data in + if let data = data, + let geomerty = try? JSONDecoder().decode(Geometry.self, from: data) + { + onChange(geomerty) + } + } + } + } + } +} + +struct GeometryPreferenceKey: PreferenceKey { + static var defaultValue: Data? = nil + + static func reduce(value: inout Data?, nextValue: () -> Data?) { + value = nextValue() + } +} + +#if DEBUG +struct GeometryReaderModifier_Previews: PreviewProvider { + struct Preview: View { + @State var size: CGSize = .zero + + var body: some View { + VStack { + Text("Hello, World!") + .font(.largeTitle) + .background(Color.accentColor.opacity(0.15)) + .geometryReader( + geometry: \.size, + onChange: { size = $0 } + ) + + Text("\(Int(size.width.rounded())) x \(Int(size.height.rounded()))") + .font(.caption) + .frame(width: size.width, height: size.height) + .background(Color.accentColor.opacity(0.15)) + } + } + } + + static var previews: some View { + Preview() +#if os(macOS) + .frame(width: 640, height: 480) +#endif + } +} +#endif diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift new file mode 100644 index 0000000000000000000000000000000000000000..23d709d80c96c3c1764bb0a45408e7a5e243c137 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift @@ -0,0 +1,213 @@ +// MIT License +// +// Copyright (c) 2022 Dariusz Rybicki Darrarski +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Source: https://github.com/darrarski/swiftui-tabs-view/blob/be6865324ed9651c22df36540f932c10ab9c7c34/Sources/SwiftUITabsView/ToolbarViewModifier.swift + +import SwiftUI + +/// Describes position of the toolbar. +public enum ToolbarPosition: Equatable { + /// Bar positioned above the content. + case top + + /// Tabs bar positioned below the content. + case bottom + + var verticalEdge: VerticalEdge { + switch self { + case .top: return .top + case .bottom: return .bottom + } + } + + var frameAlignment: Alignment { + switch self { + case .top: return .top + case .bottom: return .bottom + } + } +} + +struct ToolbarPositionKey: EnvironmentKey { + static var defaultValue: ToolbarPosition = .bottom +} + +extension EnvironmentValues { + var toolbarPosition: ToolbarPosition { + get { self[ToolbarPositionKey.self] } + set { self[ToolbarPositionKey.self] = newValue } + } +} + +extension View { + public func toolbar<Bar: View>( + position: ToolbarPosition = .bottom, + ignoresKeyboard: Bool = true, + frameChangeAnimation: Animation? = .default, + @ViewBuilder bar: @escaping () -> Bar + ) -> some View { + modifier(ToolbarViewModifier( + ignoresKeyboard: ignoresKeyboard, + frameChangeAnimation: frameChangeAnimation, + bar: bar + )) + .environment(\.toolbarPosition, position) + } +} + +struct ToolbarViewModifier<Bar: View>: ViewModifier { + init( + ignoresKeyboard: Bool = true, + frameChangeAnimation: Animation? = .default, + @ViewBuilder bar: @escaping () -> Bar + ) { + self.ignoresKeyboard = ignoresKeyboard + self.frameChangeAnimation = frameChangeAnimation + self.bar = bar + } + + var ignoresKeyboard: Bool + var frameChangeAnimation: Animation? + var bar: () -> Bar + + @Environment(\.toolbarPosition) var position + @State var contentFrame: CGRect? + @State var toolbarFrame: CGRect? + @State var toolbarSafeAreaInset: CGSize = .zero + + var keyboardSafeAreaEdges: Edge.Set { + guard ignoresKeyboard else { return [] } + switch position { + case .top: return .top + case .bottom: return .bottom + } + } + + func body(content: Content) -> some View { + ZStack { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbarSafeAreaInset() + .geometryReader( + geometry: { $0.frame(in: .global) }, + onChange: { frame in + withAnimation(contentFrame == nil ? .none : frameChangeAnimation) { + contentFrame = frame + toolbarSafeAreaInset = makeToolbarSafeAreaInset() + } + } + ) + + bar() + .geometryReader( + geometry: { $0.frame(in: .global) }, + onChange: { frame in + withAnimation(toolbarFrame == nil ? .none : frameChangeAnimation) { + toolbarFrame = frame + toolbarSafeAreaInset = makeToolbarSafeAreaInset() + } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.frameAlignment) + .ignoresSafeArea(.keyboard, edges: keyboardSafeAreaEdges) + } + .environment(\.toolbarSafeAreaInset, toolbarSafeAreaInset) + } + + func makeToolbarSafeAreaInset() -> CGSize { + guard let contentFrame = contentFrame, + let toolbarFrame = toolbarFrame + else { return .zero } + + var size = contentFrame.intersection(toolbarFrame).size + size.width = max(0, size.width) + size.height = max(0, size.height) + + return size + } +} + +struct ToolbarSafeAreaInsetKey: EnvironmentKey { + static var defaultValue: CGSize = .zero +} + +extension EnvironmentValues { + var toolbarSafeAreaInset: CGSize { + get { self[ToolbarSafeAreaInsetKey.self] } + set { self[ToolbarSafeAreaInsetKey.self] = newValue } + } +} + +struct ToolbarSafeAreaInsetViewModifier: ViewModifier { + @Environment(\.toolbarPosition) var position + @Environment(\.toolbarSafeAreaInset) var toolbarSafeAreaInset + + func body(content: Content) -> some View { + content + .safeAreaInset(edge: position.verticalEdge) { + Color.clear.frame( + width: toolbarSafeAreaInset.width, + height: toolbarSafeAreaInset.height + ) + } + } +} + +extension View { + /// Add safe area inset for toolbar. + /// + /// Use this modifier if your content is embedded in `NavigationView`. + /// Apply it on the content inside the `NavigationView`. + /// + /// - Returns: View with additional safe area insets matching the toolbar. + public func toolbarSafeAreaInset() -> some View { + modifier(ToolbarSafeAreaInsetViewModifier()) + } +} + +#if DEBUG +struct ToolbarViewModifier_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(1..<21) { row in + VStack(alignment: .leading, spacing: 0) { + Text("Row #\(row)") + TextField("Text", text: .constant("")) + } + .padding() + .background(Color.accentColor.opacity(row % 2 == 0 ? 0.1 : 0.15)) + } + } + } + .toolbar(ignoresKeyboard: true) { + Text("Bottom Bar") + .padding() + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + } +#if os(macOS) + .frame(width: 640, height: 480) +#endif + } +} +#endif diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 7adbcae89b9a90d56d2297c29f63ba4be3700367..611123e0334c6078250bf0e782153975d6891799 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -1,4 +1,5 @@ import AppCore +import ChatFeature import CheckContactAuthFeature import ConfirmRequestFeature import ContactFeature @@ -64,6 +65,19 @@ extension AppEnvironment { mainQueue: mainQueue, bgQueue: bgQueue ) + }, + chat: { + ChatEnvironment( + messenger: messenger, + db: dbManager.getDB, + sendMessage: .live( + messenger: messenger, + db: dbManager.getDB, + now: Date.init + ), + mainQueue: mainQueue, + bgQueue: bgQueue + ) } ) @@ -87,6 +101,10 @@ extension AppEnvironment { messenger: messenger, dbManager: dbManager, authHandler: authHandler, + messageListener: .live( + messenger: messenger, + db: dbManager.getDB + ), mainQueue: mainQueue, bgQueue: bgQueue, register: { diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..46cfdbfa7103029615bd525abd78d8f54dfa3a10 --- /dev/null +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift @@ -0,0 +1,185 @@ +import AppCore +import Combine +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct ChatState: Equatable, Identifiable { + public enum ID: Equatable, Hashable { + case contact(Data) + } + + public struct Message: Equatable, Identifiable { + public init( + id: Int64, + date: Date, + senderId: Data, + text: String, + status: XXModels.Message.Status + ) { + self.id = id + self.date = date + self.senderId = senderId + self.text = text + self.status = status + } + + public var id: Int64 + public var date: Date + public var senderId: Data + public var text: String + public var status: XXModels.Message.Status + } + + public init( + id: ID, + myContactId: Data? = nil, + messages: IdentifiedArrayOf<Message> = [], + failure: String? = nil, + sendFailure: String? = nil, + text: String = "" + ) { + self.id = id + self.myContactId = myContactId + self.messages = messages + self.failure = failure + self.sendFailure = sendFailure + self.text = text + } + + public var id: ID + public var myContactId: Data? + public var messages: IdentifiedArrayOf<Message> + public var failure: String? + public var sendFailure: String? + @BindableState public var text: String +} + +public enum ChatAction: Equatable, BindableAction { + case start + case didFetchMessages(IdentifiedArrayOf<ChatState.Message>) + case sendTapped + case sendFailed(String) + case dismissSendFailureTapped + case binding(BindingAction<ChatState>) +} + +public struct ChatEnvironment { + public init( + messenger: Messenger, + db: DBManagerGetDB, + sendMessage: SendMessage, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.db = db + self.sendMessage = sendMessage + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var sendMessage: SendMessage + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> +} + +#if DEBUG +extension ChatEnvironment { + public static let unimplemented = ChatEnvironment( + messenger: .unimplemented, + db: .unimplemented, + sendMessage: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) +} +#endif + +public let chatReducer = Reducer<ChatState, ChatAction, ChatEnvironment> +{ state, action, env in + enum FetchEffectId {} + + switch action { + case .start: + state.failure = nil + do { + let myContactId = try env.messenger.e2e.tryGet().getContact().getId() + state.myContactId = myContactId + let queryChat: XXModels.Message.Query.Chat + switch state.id { + case .contact(let contactId): + queryChat = .direct(myContactId, contactId) + } + let query = XXModels.Message.Query(chat: queryChat) + return try env.db().fetchMessagesPublisher(query) + .assertNoFailure() + .map { messages in + messages.compactMap { message in + guard let id = message.id else { return nil } + return ChatState.Message( + id: id, + date: message.date, + senderId: message.senderId, + text: message.text, + status: message.status + ) + } + } + .map { IdentifiedArrayOf<ChatState.Message>(uniqueElements: $0) } + .map(ChatAction.didFetchMessages) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: FetchEffectId.self, cancelInFlight: true) + } catch { + state.failure = error.localizedDescription + return .none + } + + case .didFetchMessages(let messages): + state.messages = messages + return .none + + case .sendTapped: + let text = state.text + let chatId = state.id + state.text = "" + return Effect.run { subscriber in + switch chatId { + case .contact(let recipientId): + env.sendMessage( + text: text, + to: recipientId, + onError: { error in + subscriber.send(.sendFailed(error.localizedDescription)) + }, + completion: { + subscriber.send(completion: .finished) + } + ) + } + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .sendFailed(let failure): + state.sendFailure = failure + return .none + + case .dismissSendFailureTapped: + state.sendFailure = nil + return .none + + case .binding(_): + return .none + } +} +.binding() diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift new file mode 100644 index 0000000000000000000000000000000000000000..23596dc1ceecc06d65cc6342318278f84313f03a --- /dev/null +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift @@ -0,0 +1,191 @@ +import AppCore +import ComposableArchitecture +import SwiftUI + +public struct ChatView: View { + public init(store: Store<ChatState, ChatAction>) { + self.store = store + } + + let store: Store<ChatState, ChatAction> + + struct ViewState: Equatable { + var myContactId: Data? + var messages: IdentifiedArrayOf<ChatState.Message> + var failure: String? + var sendFailure: String? + var text: String + + init(state: ChatState) { + myContactId = state.myContactId + messages = state.messages + failure = state.failure + sendFailure = state.sendFailure + text = state.text + } + } + + public var body: some View { + WithViewStore(store, observe: ViewState.init) { viewStore in + ScrollView { + LazyVStack { + if let failure = viewStore.failure { + VStack { + Text(failure) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + viewStore.send(.start) + } label: { + Text("Retry").padding() + } + } + .padding() + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Material.ultraThick) + } + .padding() + } + + ForEach(viewStore.messages) { message in + MessageView( + message: message, + myContactId: viewStore.myContactId + ) + } + + if let sendFailure = viewStore.sendFailure { + VStack { + Text(sendFailure) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + viewStore.send(.dismissSendFailureTapped) + } label: { + Text("Dismiss").padding() + } + } + .padding() + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Material.ultraThick) + } + .padding() + } + } + } + .toolbar( + position: .bottom, + ignoresKeyboard: true, + frameChangeAnimation: .default + ) { + VStack(spacing: 0) { + Divider() + HStack { + TextField("Text", text: viewStore.binding( + get: \.text, + send: { ChatAction.set(\.$text, $0) } + )) + .textFieldStyle(.roundedBorder) + + Button { + viewStore.send(.sendTapped) + } label: { + Image(systemName: "paperplane.fill") + } + .buttonStyle(.borderedProminent) + } + .padding() + } + .background(Material.bar) + } + .navigationTitle("Chat") + .task { viewStore.send(.start) } + .toolbarSafeAreaInset() + } + } + + struct MessageView: View { + var message: ChatState.Message + var myContactId: Data? + + var alignment: Alignment { + message.senderId == myContactId ? .trailing : .leading + } + + var textColor: Color? { + message.senderId == myContactId ? Color.white : nil + } + + var body: some View { + VStack { + Text("\(message.date.formatted()), \(statusText)") + .foregroundColor(.secondary) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: alignment) + + Text(message.text) + .foregroundColor(textColor) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background { + if message.senderId == myContactId { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.blue) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Material.ultraThick) + } + } + .frame(maxWidth: .infinity, alignment: alignment) + } + .padding(.horizontal) + } + + var statusText: String { + switch message.status { + case .sending: return "Sending" + case .sendingTimedOut: return "Sending timed out" + case .sendingFailed: return "Failed" + case .sent: return "Sent" + case .receiving: return "Receiving" + case .receivingFailed: return "Receiving failed" + case .received: return "Received" + } + } + } +} + +#if DEBUG +public struct ChatView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + ChatView(store: Store( + initialState: ChatState( + id: .contact("contact-id".data(using: .utf8)!), + myContactId: "my-contact-id".data(using: .utf8)!, + messages: [ + .init( + id: 1, + date: Date(), + senderId: "contact-id".data(using: .utf8)!, + text: "Hello!", + status: .received + ), + .init( + id: 2, + date: Date(), + senderId: "my-contact-id".data(using: .utf8)!, + text: "Hi!", + status: .sent + ), + ], + failure: "Something went wrong when fetching messages from database.", + sendFailure: "Something went wrong when sending message." + ), + reducer: .empty, + environment: () + )) + } + } +} +#endif diff --git a/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift b/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift index 545cb8fb520e9f1a99e691855c78dd1b166e98a0..993796e4bf4f286a29fdb7319fb39332074c68da 100644 --- a/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift +++ b/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift @@ -1,4 +1,5 @@ import AppCore +import ChatFeature import CheckContactAuthFeature import ComposableArchitecture import ComposablePresentation @@ -22,7 +23,8 @@ public struct ContactState: Equatable { sendRequest: SendRequestState? = nil, verifyContact: VerifyContactState? = nil, confirmRequest: ConfirmRequestState? = nil, - checkAuth: CheckContactAuthState? = nil + checkAuth: CheckContactAuthState? = nil, + chat: ChatState? = nil ) { self.id = id self.dbContact = dbContact @@ -34,6 +36,7 @@ public struct ContactState: Equatable { self.verifyContact = verifyContact self.confirmRequest = confirmRequest self.checkAuth = checkAuth + self.chat = chat } public var id: Data @@ -46,6 +49,7 @@ public struct ContactState: Equatable { public var verifyContact: VerifyContactState? public var confirmRequest: ConfirmRequestState? public var checkAuth: CheckContactAuthState? + public var chat: ChatState? } public enum ContactAction: Equatable, BindableAction { @@ -64,6 +68,9 @@ public enum ContactAction: Equatable, BindableAction { case confirmRequestTapped case confirmRequestDismissed case confirmRequest(ConfirmRequestAction) + case chatTapped + case chatDismissed + case chat(ChatAction) case binding(BindingAction<ContactState>) } @@ -76,7 +83,8 @@ public struct ContactEnvironment { sendRequest: @escaping () -> SendRequestEnvironment, verifyContact: @escaping () -> VerifyContactEnvironment, confirmRequest: @escaping () -> ConfirmRequestEnvironment, - checkAuth: @escaping () -> CheckContactAuthEnvironment + checkAuth: @escaping () -> CheckContactAuthEnvironment, + chat: @escaping () -> ChatEnvironment ) { self.messenger = messenger self.db = db @@ -86,6 +94,7 @@ public struct ContactEnvironment { self.verifyContact = verifyContact self.confirmRequest = confirmRequest self.checkAuth = checkAuth + self.chat = chat } public var messenger: Messenger @@ -96,6 +105,7 @@ public struct ContactEnvironment { public var verifyContact: () -> VerifyContactEnvironment public var confirmRequest: () -> ConfirmRequestEnvironment public var checkAuth: () -> CheckContactAuthEnvironment + public var chat: () -> ChatEnvironment } #if DEBUG @@ -108,7 +118,8 @@ extension ContactEnvironment { sendRequest: { .unimplemented }, verifyContact: { .unimplemented }, confirmRequest: { .unimplemented }, - checkAuth: { .unimplemented } + checkAuth: { .unimplemented }, + chat: { .unimplemented } ) } #endif @@ -204,7 +215,15 @@ public let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironm state.confirmRequest = nil return .none - case .binding(_), .sendRequest(_), .verifyContact(_), .confirmRequest(_), .checkAuth(_): + case .chatTapped: + state.chat = ChatState(id: .contact(state.id)) + return .none + + case .chatDismissed: + state.chat = nil + return .none + + case .binding(_), .sendRequest(_), .verifyContact(_), .confirmRequest(_), .checkAuth(_), .chat(_): return .none } } @@ -237,3 +256,10 @@ public let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironm action: /ContactAction.checkAuth, environment: { $0.checkAuth() } ) +.presenting( + chatReducer, + state: .keyPath(\.chat), + id: .keyPath(\.?.id), + action: /ContactAction.chat, + environment: { $0.chat() } +) diff --git a/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift b/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift index 08ae7eb8863b95bc9e5b3d0ef8e7f529b2f8b39e..d775df01eeed7ee6175b00d231149a351be84882 100644 --- a/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift +++ b/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift @@ -1,4 +1,5 @@ import AppCore +import ChatFeature import CheckContactAuthFeature import ComposableArchitecture import ComposablePresentation @@ -148,6 +149,20 @@ public struct ContactView: View { Text("Auth") } .animation(.default, value: viewStore.dbContact?.authStatus) + + Section { + Button { + viewStore.send(.chatTapped) + } label: { + HStack { + Text("Chat") + Spacer() + Image(systemName: "chevron.forward") + } + } + } header: { + Text("Chat") + } } } .navigationTitle("Contact") @@ -185,6 +200,14 @@ public struct ContactView: View { onDeactivate: { viewStore.send(.checkAuthDismissed) }, destination: CheckContactAuthView.init(store:) )) + .background(NavigationLinkWithStore( + store.scope( + state: \.chat, + action: ContactAction.chat + ), + onDeactivate: { viewStore.send(.chatDismissed) }, + destination: ChatView.init(store:) + )) } } } diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index e28008f39e924af1c5816bb33c9e4dce11798476..894c5aca32ca1170d0acd47ae89a8242f2e73cb8 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -15,6 +15,7 @@ public struct HomeState: Equatable { public init( failure: String? = nil, authFailure: String? = nil, + messageListenerFailure: String? = nil, isNetworkHealthy: Bool? = nil, networkNodesReport: NodeRegistrationReport? = nil, isDeletingAccount: Bool = false, @@ -25,6 +26,7 @@ public struct HomeState: Equatable { ) { self.failure = failure self.authFailure = authFailure + self.messageListenerFailure = messageListenerFailure self.isNetworkHealthy = isNetworkHealthy self.isDeletingAccount = isDeletingAccount self.alert = alert @@ -35,6 +37,7 @@ public struct HomeState: Equatable { public var failure: String? public var authFailure: String? + public var messageListenerFailure: String? public var isNetworkHealthy: Bool? public var networkNodesReport: NodeRegistrationReport? public var isDeletingAccount: Bool @@ -59,6 +62,13 @@ public enum HomeAction: Equatable { case failureDismissed } + public enum MessageListener: Equatable { + case start + case stop + case failure(NSError) + case failureDismissed + } + public enum NetworkMonitor: Equatable { case start case stop @@ -75,6 +85,7 @@ public enum HomeAction: Equatable { case messenger(Messenger) case authHandler(AuthHandler) + case messageListener(MessageListener) case networkMonitor(NetworkMonitor) case deleteAccount(DeleteAccount) case didDismissAlert @@ -93,6 +104,7 @@ public struct HomeEnvironment { messenger: Messenger, dbManager: DBManager, authHandler: AuthCallbackHandler, + messageListener: MessageListenerHandler, mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue>, register: @escaping () -> RegisterEnvironment, @@ -102,6 +114,7 @@ public struct HomeEnvironment { self.messenger = messenger self.dbManager = dbManager self.authHandler = authHandler + self.messageListener = messageListener self.mainQueue = mainQueue self.bgQueue = bgQueue self.register = register @@ -112,6 +125,7 @@ public struct HomeEnvironment { public var messenger: Messenger public var dbManager: DBManager public var authHandler: AuthCallbackHandler + public var messageListener: MessageListenerHandler public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> public var register: () -> RegisterEnvironment @@ -124,6 +138,7 @@ extension HomeEnvironment { messenger: .unimplemented, dbManager: .unimplemented, authHandler: .unimplemented, + messageListener: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented, register: { .unimplemented }, @@ -137,11 +152,13 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> enum NetworkHealthEffectId {} enum NetworkNodesEffectId {} enum AuthCallbacksEffectId {} + enum MessageListenerEffectId {} switch action { case .messenger(.start): return .merge( Effect(value: .authHandler(.start)), + Effect(value: .messageListener(.start)), Effect(value: .networkMonitor(.stop)), Effect.result { do { @@ -149,6 +166,7 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> if env.messenger.isConnected() == false { try env.messenger.connect() + try env.messenger.listenForMessages() } if env.messenger.isLoggedIn() == false { @@ -202,6 +220,29 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> state.authFailure = nil return .none + case .messageListener(.start): + return Effect.run { subscriber in + let cancellable = env.messageListener(onError: { error in + subscriber.send(.messageListener(.failure(error as NSError))) + }) + return AnyCancellable { cancellable.cancel() } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: MessageListenerEffectId.self, cancelInFlight: true) + + case .messageListener(.stop): + return .cancel(id: MessageListenerEffectId.self) + + case .messageListener(.failure(let error)): + state.messageListenerFailure = error.localizedDescription + return .none + + case .messageListener(.failureDismissed): + state.messageListenerFailure = nil + return .none + case .networkMonitor(.start): return .merge( Effect.run { subscriber in diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index f6a964896e6e67544e693d83cb26378756414b86..8cd7259b6c57a7054b1bae658df8772b0a7efa3d 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -16,6 +16,7 @@ public struct HomeView: View { struct ViewState: Equatable { var failure: String? var authFailure: String? + var messageListenerFailure: String? var isNetworkHealthy: Bool? var networkNodesReport: NodeRegistrationReport? var isDeletingAccount: Bool @@ -23,6 +24,7 @@ public struct HomeView: View { init(state: HomeState) { failure = state.failure authFailure = state.authFailure + messageListenerFailure = state.messageListenerFailure isNetworkHealthy = state.isNetworkHealthy isDeletingAccount = state.isDeletingAccount networkNodesReport = state.networkNodesReport @@ -59,6 +61,19 @@ public struct HomeView: View { } } + if let messageListenerFailure = viewStore.messageListenerFailure { + Section { + Text(messageListenerFailure) + Button { + viewStore.send(.messageListener(.failureDismissed)) + } label: { + Text("Dismiss") + } + } header: { + Text("Message Listener Error") + } + } + Section { HStack { Text("Health") diff --git a/Examples/xx-messenger/Tests/AppCoreTests/MessageListenerHandler/MessageListenerHandlerTests.swift b/Examples/xx-messenger/Tests/AppCoreTests/MessageListenerHandler/MessageListenerHandlerTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..4038cb0d36019822347e358c1139809d4877f059 --- /dev/null +++ b/Examples/xx-messenger/Tests/AppCoreTests/MessageListenerHandler/MessageListenerHandlerTests.swift @@ -0,0 +1,110 @@ +import CustomDump +import XCTest +import XXClient +import XXMessengerClient +import XXModels +@testable import AppCore + +final class MessageListenerHandlerTests: XCTestCase { + func testHandleIncomingMessage() throws { + var didRegisterListener: [Listener] = [] + var didCancelListener = 0 + var didSaveMessage: [XXModels.Message] = [] + + var messenger: Messenger = .unimplemented + messenger.registerMessageListener.run = { listener in + didRegisterListener.append(listener) + return Cancellable { didCancelListener += 1 } + } + var db: DBManagerGetDB = .unimplemented + db.run = { + var db: Database = .failing + db.saveMessage.run = { message in + didSaveMessage.append(message) + return message + } + return db + } + let handler: MessageListenerHandler = .live( + messenger: messenger, + db: db + ) + + var cancellable: Cancellable? = handler(onError: { _ in XCTFail() }) + + XCTAssertNoDifference(didRegisterListener.count, 1) + + let payload = MessagePayload(text: "Hello") + let xxMessage = XXClient.Message( + messageType: 111, + id: "message-id".data(using: .utf8)!, + payload: try! payload.encode(), + sender: "sender-id".data(using: .utf8)!, + recipientId: "recipient-id".data(using: .utf8)!, + ephemeralId: 222, + timestamp: 1_653_580_439_357_351_000, + encrypted: true, + roundId: 333, + roundURL: "round-url" + ) + didRegisterListener.first?.handle(xxMessage) + + XCTAssertNoDifference(didSaveMessage, [ + .init( + networkId: xxMessage.id, + senderId: xxMessage.sender, + recipientId: xxMessage.recipientId, + groupId: nil, + date: Date(timeIntervalSince1970: TimeInterval(xxMessage.timestamp) / 1_000_000_000), + status: .received, + isUnread: true, + text: payload.text, + roundURL: xxMessage.roundURL + ) + ]) + + cancellable = nil + _ = cancellable + + XCTAssertNoDifference(didCancelListener, 1) + } + + func testDatabaseFailure() { + struct Failure: Error, Equatable {} + let error = Failure() + var registeredListeners: [Listener] = [] + var didReceiveError: [Error] = [] + + var messenger: Messenger = .unimplemented + messenger.registerMessageListener.run = { listener in + registeredListeners.append(listener) + return Cancellable {} + } + var db: DBManagerGetDB = .unimplemented + db.run = { throw error } + let handler: MessageListenerHandler = .live( + messenger: messenger, + db: db + ) + + _ = handler(onError: { error in didReceiveError.append(error) }) + + let payload = MessagePayload(text: "Hello") + let xxMessage = XXClient.Message( + messageType: 111, + id: "message-id".data(using: .utf8)!, + payload: try! payload.encode(), + sender: "sender-id".data(using: .utf8)!, + recipientId: "recipient-id".data(using: .utf8)!, + ephemeralId: 222, + timestamp: 1_653_580_439_357_351_000, + encrypted: true, + roundId: 333, + roundURL: "round-url" + ) + registeredListeners.first?.handle(xxMessage) + + XCTAssertNoDifference(didReceiveError.count, 1) + XCTAssertNoDifference(didReceiveError.first as? Failure, error) + } +} diff --git a/Examples/xx-messenger/Tests/AppCoreTests/SendMessage/SendMessageTests.swift b/Examples/xx-messenger/Tests/AppCoreTests/SendMessage/SendMessageTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..0ff8536f453530bba378b8279686d344f8cace74 --- /dev/null +++ b/Examples/xx-messenger/Tests/AppCoreTests/SendMessage/SendMessageTests.swift @@ -0,0 +1,270 @@ +import CustomDump +import XCTest +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels +@testable import AppCore + +final class SendMessageTests: XCTestCase { + func testSend() { + struct MessengerSendMessageParams: Equatable { + var recipientId: Data + var payload: Data + } + struct MessageBulkUpdate: Equatable { + var query: XXModels.Message.Query + var assignments: XXModels.Message.Assignments + } + + var messengerDidSendMessageWithParams: [MessengerSendMessageParams] = [] + var messengerDidSendMessageWithDeliveryCallback: [MessengerSendMessage.DeliveryCallback?] = [] + var dbDidSaveMessage: [XXModels.Message] = [] + var dbDidFetchMessagesWithQuery: [XXModels.Message.Query] = [] + var dbDidBulkUpdateMessages: [MessageBulkUpdate] = [] + var didReceiveError: [Error] = [] + var didComplete = 0 + + let myContactId = "my-contact-id".data(using: .utf8)! + let text = "Hello" + let recipientId = "recipient-id".data(using: .utf8)! + let messageId: Int64 = 123 + let sendReport = E2ESendReport( + rounds: [], + roundURL: "round-url", + messageId: "message-id".data(using: .utf8)!, + timestamp: 0, + keyResidue: Data() + ) + var dbFetchMessagesResult: [XXModels.Message] = [] + + var messenger: Messenger = .unimplemented + messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in myContactId } + return contact + } + return e2e + } + messenger.sendMessage.run = { recipientId, payload, deliveryCallback in + messengerDidSendMessageWithParams.append(.init(recipientId: recipientId, payload: payload)) + messengerDidSendMessageWithDeliveryCallback.append(deliveryCallback) + return sendReport + } + var db: DBManagerGetDB = .unimplemented + db.run = { + var db: Database = .failing + db.saveMessage.run = { message in + dbDidSaveMessage.append(message) + var message = message + message.id = messageId + dbFetchMessagesResult = [message] + return message + } + db.fetchMessages.run = { query in + dbDidFetchMessagesWithQuery.append(query) + return dbFetchMessagesResult + } + db.bulkUpdateMessages.run = { query, assignments in + dbDidBulkUpdateMessages.append(.init(query: query, assignments: assignments)) + return 0 + } + return db + } + let now = Date() + let send: SendMessage = .live( + messenger: messenger, + db: db, + now: { now } + ) + + send( + text: text, + to: recipientId, + onError: { error in didReceiveError.append(error) }, + completion: { didComplete += 1 } + ) + + XCTAssertNoDifference(dbDidSaveMessage, [ + .init( + senderId: myContactId, + recipientId: recipientId, + groupId: nil, + date: now, + status: .sending, + isUnread: false, + text: text + ), + .init( + id: messageId, + networkId: sendReport.messageId!, + senderId: myContactId, + recipientId: recipientId, + groupId: nil, + date: now, + status: .sending, + isUnread: false, + text: text, + roundURL: sendReport.roundURL! + ), + ]) + XCTAssertNoDifference(messengerDidSendMessageWithParams, [ + .init(recipientId: recipientId, payload: try! MessagePayload(text: text).encode()) + ]) + XCTAssertNoDifference(dbDidFetchMessagesWithQuery, [ + .init(id: [messageId]) + ]) + + dbDidBulkUpdateMessages = [] + didComplete = 0 + messengerDidSendMessageWithDeliveryCallback.first??(.init( + report: sendReport, + result: .delivered + )) + + XCTAssertNoDifference(dbDidBulkUpdateMessages, [ + .init(query: .init(id: [messageId]), assignments: .init(status: .sent)) + ]) + XCTAssertNoDifference(didComplete, 1) + + dbDidBulkUpdateMessages = [] + didComplete = 0 + messengerDidSendMessageWithDeliveryCallback.first??(.init( + report: sendReport, + result: .notDelivered(timedOut: true) + )) + + XCTAssertNoDifference(dbDidBulkUpdateMessages, [ + .init(query: .init(id: [messageId]), assignments: .init(status: .sendingTimedOut)) + ]) + XCTAssertNoDifference(didComplete, 1) + + dbDidBulkUpdateMessages = [] + didComplete = 0 + messengerDidSendMessageWithDeliveryCallback.first??(.init( + report: sendReport, + result: .notDelivered(timedOut: false) + )) + + XCTAssertNoDifference(dbDidBulkUpdateMessages, [ + .init(query: .init(id: [messageId]), assignments: .init(status: .sendingFailed)) + ]) + XCTAssertNoDifference(didComplete, 1) + + dbDidBulkUpdateMessages = [] + didComplete = 0 + let deliveryFailure = NSError(domain: "test", code: 123) + messengerDidSendMessageWithDeliveryCallback.first??(.init( + report: sendReport, + result: .failure(deliveryFailure) + )) + XCTAssertNoDifference(didComplete, 1) + + XCTAssertNoDifference(dbDidBulkUpdateMessages, [ + .init(query: .init(id: [messageId]), assignments: .init(status: .sendingFailed)) + ]) + XCTAssertNoDifference(didReceiveError.count, 1) + XCTAssertNoDifference(didReceiveError.first as NSError?, deliveryFailure) + XCTAssertNoDifference(didComplete, 1) + } + + func testSendDatabaseFailure() { + struct Failure: Error, Equatable {} + let error = Failure() + + var didReceiveError: [Error] = [] + var didComplete = 0 + + var messenger: Messenger = .unimplemented + messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in Data() } + return contact + } + return e2e + } + var db: DBManagerGetDB = .unimplemented + db.run = { throw error } + let send: SendMessage = .live( + messenger: messenger, + db: db, + now: XCTUnimplemented("now", placeholder: Date()) + ) + + send( + text: "Hello", + to: "recipient-id".data(using: .utf8)!, + onError: { error in didReceiveError.append(error) }, + completion: { didComplete += 1 } + ) + + XCTAssertNoDifference(didReceiveError.count, 1) + XCTAssertNoDifference(didReceiveError.first as? Failure, error) + XCTAssertNoDifference(didComplete, 1) + } + + func testBulkUpdateOnDeliveryFailure() { + struct Failure: Error, Equatable {} + let error = Failure() + let sendReport = E2ESendReport( + rounds: [], + roundURL: "", + messageId: Data(), + timestamp: 0, + keyResidue: Data() + ) + + var messengerDidSendMessageWithDeliveryCallback: [MessengerSendMessage.DeliveryCallback?] = [] + var didReceiveError: [Error] = [] + var didComplete = 0 + + var messenger: Messenger = .unimplemented + messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in Data() } + return contact + } + return e2e + } + messenger.sendMessage.run = { _, _, deliveryCallback in + messengerDidSendMessageWithDeliveryCallback.append(deliveryCallback) + return sendReport + } + var db: DBManagerGetDB = .unimplemented + db.run = { + var db: Database = .failing + db.saveMessage.run = { $0 } + db.fetchMessages.run = { _ in [] } + db.bulkUpdateMessages.run = { _, _ in throw error } + return db + } + let send: SendMessage = .live( + messenger: messenger, + db: db, + now: Date.init + ) + + send( + text: "Hello", + to: "recipient-id".data(using: .utf8)!, + onError: { error in didReceiveError.append(error) }, + completion: { didComplete += 1 } + ) + + messengerDidSendMessageWithDeliveryCallback.first??(.init( + report: sendReport, + result: .delivered + )) + + XCTAssertNoDifference(didReceiveError.count, 1) + XCTAssertNoDifference(didReceiveError.first as? Failure, error) + XCTAssertNoDifference(didComplete, 1) + } +} + diff --git a/Examples/xx-messenger/Tests/ChatFeatureTests/ChatFeatureTests.swift b/Examples/xx-messenger/Tests/ChatFeatureTests/ChatFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..1a513af07195076dabb987bee120111411a6c633 --- /dev/null +++ b/Examples/xx-messenger/Tests/ChatFeatureTests/ChatFeatureTests.swift @@ -0,0 +1,210 @@ +import AppCore +import Combine +import ComposableArchitecture +import CustomDump +import XCTest +import XXClient +import XXMessengerClient +import XXModels +@testable import ChatFeature + +final class ChatFeatureTests: XCTestCase { + func testStart() { + let contactId = "contact-id".data(using: .utf8)! + let myContactId = "my-contact-id".data(using: .utf8)! + + let store = TestStore( + initialState: ChatState(id: .contact(contactId)), + reducer: chatReducer, + environment: .unimplemented + ) + + var didFetchMessagesWithQuery: [XXModels.Message.Query] = [] + let messagesPublisher = PassthroughSubject<[XXModels.Message], Error>() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in myContactId } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchMessagesPublisher.run = { query in + didFetchMessagesWithQuery.append(query) + return messagesPublisher.eraseToAnyPublisher() + } + return db + } + + store.send(.start) { + $0.myContactId = myContactId + } + + XCTAssertNoDifference(didFetchMessagesWithQuery, [ + .init(chat: .direct(myContactId, contactId)) + ]) + + messagesPublisher.send([ + .init( + id: nil, + senderId: contactId, + recipientId: myContactId, + groupId: nil, + date: Date(timeIntervalSince1970: 0), + status: .received, + isUnread: false, + text: "Message 0" + ), + .init( + id: 1, + senderId: contactId, + recipientId: myContactId, + groupId: nil, + date: Date(timeIntervalSince1970: 1), + status: .received, + isUnread: false, + text: "Message 1" + ), + .init( + id: 2, + senderId: myContactId, + recipientId: contactId, + groupId: nil, + date: Date(timeIntervalSince1970: 2), + status: .sent, + isUnread: false, + text: "Message 2" + ), + ]) + + let expectedMessages = IdentifiedArrayOf<ChatState.Message>(uniqueElements: [ + .init( + id: 1, + date: Date(timeIntervalSince1970: 1), + senderId: contactId, + text: "Message 1", + status: .received + ), + .init( + id: 2, + date: Date(timeIntervalSince1970: 2), + senderId: myContactId, + text: "Message 2", + status: .sent + ), + ]) + + store.receive(.didFetchMessages(expectedMessages)) { + $0.messages = expectedMessages + } + + messagesPublisher.send(completion: .finished) + } + + func testStartFailure() { + let store = TestStore( + initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), + reducer: chatReducer, + environment: .unimplemented + ) + + struct Failure: Error {} + let error = Failure() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in throw error } + return contact + } + return e2e + } + + store.send(.start) { + $0.failure = error.localizedDescription + } + } + + func testSend() { + struct SendMessageParams: Equatable { + var text: String + var recipientId: Data + } + var didSendMessageWithParams: [SendMessageParams] = [] + var sendMessageCompletion: SendMessage.Completion? + + let store = TestStore( + initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), + reducer: chatReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.sendMessage.run = { text, recipientId, _, completion in + didSendMessageWithParams.append(.init(text: text, recipientId: recipientId)) + sendMessageCompletion = completion + } + + store.send(.set(\.$text, "Hello")) { + $0.text = "Hello" + } + + store.send(.sendTapped) { + $0.text = "" + } + + XCTAssertNoDifference(didSendMessageWithParams, [ + .init(text: "Hello", recipientId: "contact-id".data(using: .utf8)!) + ]) + + sendMessageCompletion?() + } + + func testSendFailure() { + var sendMessageOnError: SendMessage.OnError? + var sendMessageCompletion: SendMessage.Completion? + + let store = TestStore( + initialState: ChatState( + id: .contact("contact-id".data(using: .utf8)!), + text: "Hello" + ), + reducer: chatReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.sendMessage.run = { _, _, onError, completion in + sendMessageOnError = onError + sendMessageCompletion = completion + } + + store.send(.sendTapped) { + $0.text = "" + } + + let error = NSError(domain: "test", code: 123) + sendMessageOnError?(error) + + store.receive(.sendFailed(error.localizedDescription)) { + $0.sendFailure = error.localizedDescription + } + + sendMessageCompletion?() + + store.send(.dismissSendFailureTapped) { + $0.sendFailure = nil + } + } +} diff --git a/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift b/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift index afc146b1981cf07e5c0105da1c6022f51c56aef4..11bfe8a7743ee597706a25f62a0c8f819aea17a8 100644 --- a/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift +++ b/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift @@ -1,3 +1,4 @@ +import ChatFeature import CheckContactAuthFeature import Combine import ComposableArchitecture @@ -280,4 +281,34 @@ final class ContactFeatureTests: XCTestCase { $0.confirmRequest = nil } } + + func testChatTapped() { + let contactId = "contact-id".data(using: .utf8)! + let store = TestStore( + initialState: ContactState( + id: contactId + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.chatTapped) { + $0.chat = ChatState(id: .contact(contactId)) + } + } + + func testChatDismissed() { + let store = TestStore( + initialState: ContactState( + id: "contact-id".data(using: .utf8)!, + chat: ChatState(id: .contact("contact-id".data(using: .utf8)!)) + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.chatDismissed) { + $0.chat = nil + } + } } diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index f3e5bcf8f787e963820b4a33cd09cb31f4494fd5..fc671b1ec7406823bd903d07307c8146e948ed39 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -19,13 +19,16 @@ final class HomeFeatureTests: XCTestCase { var messengerDidStartWithTimeout: [Int] = [] var messengerDidConnect = 0 + var messengerDidListenForMessages = 0 store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { messengerDidStartWithTimeout.append($0) } store.environment.messenger.isConnected.run = { false } store.environment.messenger.connect.run = { messengerDidConnect += 1 } + store.environment.messenger.listenForMessages.run = { messengerDidListenForMessages += 1 } store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { false } @@ -33,14 +36,17 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidStartWithTimeout, [30_000]) XCTAssertNoDifference(messengerDidConnect, 1) + XCTAssertNoDifference(messengerDidListenForMessages, 1) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.didStartUnregistered)) { $0.register = RegisterState() } store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testMessengerStartRegistered() { @@ -52,14 +58,17 @@ final class HomeFeatureTests: XCTestCase { var messengerDidStartWithTimeout: [Int] = [] var messengerDidConnect = 0 + var messengerDidListenForMessages = 0 var messengerDidLogIn = 0 store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { messengerDidStartWithTimeout.append($0) } store.environment.messenger.isConnected.run = { false } store.environment.messenger.connect.run = { messengerDidConnect += 1 } + store.environment.messenger.listenForMessages.run = { messengerDidListenForMessages += 1 } store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { messengerDidLogIn += 1 } @@ -77,15 +86,18 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidStartWithTimeout, [30_000]) XCTAssertNoDifference(messengerDidConnect, 1) + XCTAssertNoDifference(messengerDidListenForMessages, 1) XCTAssertNoDifference(messengerDidLogIn, 1) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.didStartRegistered)) store.receive(.networkMonitor(.start)) store.send(.networkMonitor(.stop)) store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testRegisterFinished() { @@ -103,6 +115,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { messengerDidStartWithTimeout.append($0) } store.environment.messenger.isConnected.run = { true } store.environment.messenger.isLoggedIn.run = { false } @@ -128,12 +141,14 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidLogIn, 1) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.didStartRegistered)) store.receive(.networkMonitor(.start)) store.send(.networkMonitor(.stop)) store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testMessengerStartFailure() { @@ -149,17 +164,20 @@ final class HomeFeatureTests: XCTestCase { store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { _ in throw error } store.send(.messenger(.start)) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testMessengerStartConnectFailure() { @@ -175,6 +193,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { _ in } store.environment.messenger.isConnected.run = { false } store.environment.messenger.connect.run = { throw error } @@ -182,12 +201,14 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testMessengerStartIsRegisteredFailure() { @@ -203,6 +224,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { _ in } store.environment.messenger.isConnected.run = { true } store.environment.messenger.isLoggedIn.run = { false } @@ -211,12 +233,14 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testMessengerStartLogInFailure() { @@ -232,6 +256,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate store.environment.authHandler.run = { _ in Cancellable {} } + store.environment.messageListener.run = { _ in Cancellable {} } store.environment.messenger.start.run = { _ in } store.environment.messenger.isConnected.run = { true } store.environment.messenger.isLoggedIn.run = { false } @@ -241,12 +266,14 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) store.receive(.authHandler(.start)) + store.receive(.messageListener(.start)) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } store.send(.authHandler(.stop)) + store.send(.messageListener(.stop)) } func testNetworkMonitorStart() { @@ -559,4 +586,45 @@ final class HomeFeatureTests: XCTestCase { authHandlerOnError.first?(AuthHandlerError(id: 2)) } + + func testMessageListener() { + let store = TestStore( + initialState: HomeState(), + reducer: homeReducer, + environment: .unimplemented + ) + + var didRunMessageListener = 0 + var didCancelMessageListener = 0 + var messageListenerOnError: [MessageListenerHandler.OnError] = [] + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messageListener.run = { onError in + didRunMessageListener += 1 + messageListenerOnError.append(onError) + return Cancellable { didCancelMessageListener += 1 } + } + + store.send(.messageListener(.start)) + + XCTAssertNoDifference(didRunMessageListener, 1) + + struct MessageListenerError: Error { var id: Int } + messageListenerOnError.first?(MessageListenerError(id: 1)) + + store.receive(.messageListener(.failure(MessageListenerError(id: 1) as NSError))) { + $0.messageListenerFailure = MessageListenerError(id: 1).localizedDescription + } + + store.send(.messageListener(.failureDismissed)) { + $0.messageListenerFailure = nil + } + + store.send(.messageListener(.stop)) + + XCTAssertNoDifference(didCancelMessageListener, 1) + + messageListenerOnError.first?(MessageListenerError(id: 2)) + } }