diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift new file mode 100644 index 0000000000000000000000000000000000000000..6c71789dbbb201122cfda709999ac36984363b55 --- /dev/null +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift @@ -0,0 +1,208 @@ +import AppCore +import Combine +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct ChatComponent: ReducerProtocol { + public struct State: 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, + fileTransfer: XXModels.FileTransfer? = nil + ) { + self.id = id + self.date = date + self.senderId = senderId + self.text = text + self.status = status + self.fileTransfer = fileTransfer + } + + public var id: Int64 + public var date: Date + public var senderId: Data + public var text: String + public var status: XXModels.Message.Status + public var fileTransfer: XXModels.FileTransfer? + } + + 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 Action: Equatable, BindableAction { + case start + case didFetchMessages(IdentifiedArrayOf<State.Message>) + case sendTapped + case sendFailed(String) + case imagePicked(Data) + case dismissSendFailureTapped + case binding(BindingAction<State>) + } + + public init() {} + + @Dependency(\.app.messenger) var messenger: Messenger + @Dependency(\.app.dbManager.getDB) var db: DBManagerGetDB + @Dependency(\.app.sendMessage) var sendMessage: SendMessage + @Dependency(\.app.sendImage) var sendImage: SendImage + @Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue> + @Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue> + + public var body: some ReducerProtocol<State, Action> { + BindingReducer() + Reduce { state, action in + enum FetchEffectId {} + + switch action { + case .start: + state.failure = nil + do { + let myContactId = try messenger.e2e.tryGet().getContact().getId() + state.myContactId = myContactId + let queryChat: XXModels.Message.Query.Chat + let receivedFileTransfersQuery: XXModels.FileTransfer.Query + let sentFileTransfersQuery: XXModels.FileTransfer.Query + switch state.id { + case .contact(let contactId): + queryChat = .direct(myContactId, contactId) + receivedFileTransfersQuery = .init( + contactId: contactId, + isIncoming: true + ) + sentFileTransfersQuery = .init( + contactId: myContactId, + isIncoming: false + ) + } + let messagesQuery = XXModels.Message.Query(chat: queryChat) + return Publishers.CombineLatest3( + try db().fetchMessagesPublisher(messagesQuery), + try db().fetchFileTransfersPublisher(receivedFileTransfersQuery), + try db().fetchFileTransfersPublisher(sentFileTransfersQuery) + ) + .map { messages, receivedFileTransfers, sentFileTransfers in + (messages, receivedFileTransfers + sentFileTransfers) + } + .assertNoFailure() + .map { messages, fileTransfers in + messages.compactMap { message in + guard let id = message.id else { return nil } + return State.Message( + id: id, + date: message.date, + senderId: message.senderId, + text: message.text, + status: message.status, + fileTransfer: fileTransfers.first { $0.id == message.fileTransferId } + ) + } + } + .removeDuplicates() + .map { IdentifiedArrayOf<State.Message>(uniqueElements: $0) } + .map(Action.didFetchMessages) + .subscribe(on: bgQueue) + .receive(on: 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): + sendMessage( + text: text, + to: recipientId, + onError: { error in + subscriber.send(.sendFailed(error.localizedDescription)) + }, + completion: { + subscriber.send(completion: .finished) + } + ) + } + return AnyCancellable {} + } + .subscribe(on: bgQueue) + .receive(on: mainQueue) + .eraseToEffect() + + case .sendFailed(let failure): + state.sendFailure = failure + return .none + + case .imagePicked(let data): + let chatId = state.id + return Effect.run { subscriber in + switch chatId { + case .contact(let recipientId): + sendImage( + data, + to: recipientId, + onError: { error in + subscriber.send(.sendFailed(error.localizedDescription)) + }, + completion: { + subscriber.send(completion: .finished) + } + ) + } + return AnyCancellable {} + } + .subscribe(on: bgQueue) + .receive(on: mainQueue) + .eraseToEffect() + + case .dismissSendFailureTapped: + state.sendFailure = nil + return .none + + case .binding(_): + return .none + } + } + } +} diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift deleted file mode 100644 index 3538bc2536f23e493a298f2761fd3614711f77da..0000000000000000000000000000000000000000 --- a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift +++ /dev/null @@ -1,234 +0,0 @@ -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, - fileTransfer: XXModels.FileTransfer? = nil - ) { - self.id = id - self.date = date - self.senderId = senderId - self.text = text - self.status = status - self.fileTransfer = fileTransfer - } - - public var id: Int64 - public var date: Date - public var senderId: Data - public var text: String - public var status: XXModels.Message.Status - public var fileTransfer: XXModels.FileTransfer? - } - - 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 imagePicked(Data) - case dismissSendFailureTapped - case binding(BindingAction<ChatState>) -} - -public struct ChatEnvironment { - public init( - messenger: Messenger, - db: DBManagerGetDB, - sendMessage: SendMessage, - sendImage: SendImage, - mainQueue: AnySchedulerOf<DispatchQueue>, - bgQueue: AnySchedulerOf<DispatchQueue> - ) { - self.messenger = messenger - self.db = db - self.sendMessage = sendMessage - self.sendImage = sendImage - self.mainQueue = mainQueue - self.bgQueue = bgQueue - } - - public var messenger: Messenger - public var db: DBManagerGetDB - public var sendMessage: SendMessage - public var sendImage: SendImage - 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, - sendImage: .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 - let receivedFileTransfersQuery: XXModels.FileTransfer.Query - let sentFileTransfersQuery: XXModels.FileTransfer.Query - switch state.id { - case .contact(let contactId): - queryChat = .direct(myContactId, contactId) - receivedFileTransfersQuery = .init( - contactId: contactId, - isIncoming: true - ) - sentFileTransfersQuery = .init( - contactId: myContactId, - isIncoming: false - ) - } - let messagesQuery = XXModels.Message.Query(chat: queryChat) - return Publishers.CombineLatest3( - try env.db().fetchMessagesPublisher(messagesQuery), - try env.db().fetchFileTransfersPublisher(receivedFileTransfersQuery), - try env.db().fetchFileTransfersPublisher(sentFileTransfersQuery) - ) - .map { messages, receivedFileTransfers, sentFileTransfers in - (messages, receivedFileTransfers + sentFileTransfers) - } - .assertNoFailure() - .map { messages, fileTransfers 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, - fileTransfer: fileTransfers.first { $0.id == message.fileTransferId } - ) - } - } - .removeDuplicates() - .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 .imagePicked(let data): - let chatId = state.id - return Effect.run { subscriber in - switch chatId { - case .contact(let recipientId): - env.sendImage( - data, - 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 .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 index 6058b61bc86fe9c54f25483e7de2a05c5a35dc9d..7ac107f479b1bf8dee80c566043997caa7644cb0 100644 --- a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift @@ -3,21 +3,21 @@ import ComposableArchitecture import SwiftUI public struct ChatView: View { - public init(store: Store<ChatState, ChatAction>) { + public init(store: StoreOf<ChatComponent>) { self.store = store } - let store: Store<ChatState, ChatAction> + let store: StoreOf<ChatComponent> @State var isPresentingImagePicker = false struct ViewState: Equatable { var myContactId: Data? - var messages: IdentifiedArrayOf<ChatState.Message> + var messages: IdentifiedArrayOf<ChatComponent.State.Message> var failure: String? var sendFailure: String? var text: String - init(state: ChatState) { + init(state: ChatComponent.State) { myContactId = state.myContactId messages = state.messages failure = state.failure @@ -84,7 +84,7 @@ public struct ChatView: View { HStack { TextField("Text", text: viewStore.binding( get: \.text, - send: { ChatAction.set(\.$text, $0) } + send: { ChatComponent.Action.set(\.$text, $0) } )) .textFieldStyle(.roundedBorder) @@ -122,7 +122,7 @@ public struct ChatView: View { } struct MessageView: View { - var message: ChatState.Message + var message: ChatComponent.State.Message var myContactId: Data? var alignment: Alignment { @@ -199,7 +199,7 @@ public struct ChatView_Previews: PreviewProvider { public static var previews: some View { NavigationView { ChatView(store: Store( - initialState: ChatState( + initialState: ChatComponent.State( id: .contact("contact-id".data(using: .utf8)!), myContactId: "my-contact-id".data(using: .utf8)!, messages: [ @@ -262,8 +262,7 @@ public struct ChatView_Previews: PreviewProvider { failure: "Something went wrong when fetching messages from database.", sendFailure: "Something went wrong when sending message." ), - reducer: .empty, - environment: () + reducer: EmptyReducer() )) } } diff --git a/Examples/xx-messenger/Tests/ChatFeatureTests/ChatFeatureTests.swift b/Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift similarity index 79% rename from Examples/xx-messenger/Tests/ChatFeatureTests/ChatFeatureTests.swift rename to Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift index 01d0ed8faa3c37b0a2005ace0b7c98d13b2e6590..c8f5952864c6f77f04ec42b53b5875a317ee37a4 100644 --- a/Examples/xx-messenger/Tests/ChatFeatureTests/ChatFeatureTests.swift +++ b/Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift @@ -8,15 +8,14 @@ import XXMessengerClient import XXModels @testable import ChatFeature -final class ChatFeatureTests: XCTestCase { +final class ChatComponentTests: 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 + initialState: ChatComponent.State(id: .contact(contactId)), + reducer: ChatComponent() ) var didFetchMessagesWithQuery: [XXModels.Message.Query] = [] @@ -24,9 +23,9 @@ final class ChatFeatureTests: XCTestCase { var didFetchFileTransfersWithQuery: [XXModels.FileTransfer.Query] = [] let fileTransfersPublisher = PassthroughSubject<[XXModels.FileTransfer], Error>() - store.environment.mainQueue = .immediate - store.environment.bgQueue = .immediate - store.environment.messenger.e2e.get = { + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.messenger.e2e.get = { var e2e: E2E = .unimplemented e2e.getContact.run = { var contact: XXClient.Contact = .unimplemented(Data()) @@ -35,7 +34,7 @@ final class ChatFeatureTests: XCTestCase { } return e2e } - store.environment.db.run = { + store.dependencies.app.dbManager.getDB.run = { var db: Database = .unimplemented db.fetchMessagesPublisher.run = { query in didFetchMessagesWithQuery.append(query) @@ -113,7 +112,7 @@ final class ChatFeatureTests: XCTestCase { sentFileTransfer, ]) - let expectedMessages = IdentifiedArrayOf<ChatState.Message>(uniqueElements: [ + let expectedMessages = IdentifiedArrayOf<ChatComponent.State.Message>(uniqueElements: [ .init( id: 1, date: Date(timeIntervalSince1970: 1), @@ -142,17 +141,16 @@ final class ChatFeatureTests: XCTestCase { func testStartFailure() { let store = TestStore( - initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), - reducer: chatReducer, - environment: .unimplemented + initialState: ChatComponent.State(id: .contact("contact-id".data(using: .utf8)!)), + reducer: ChatComponent() ) struct Failure: Error {} let error = Failure() - store.environment.mainQueue = .immediate - store.environment.bgQueue = .immediate - store.environment.messenger.e2e.get = { + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.messenger.e2e.get = { var e2e: E2E = .unimplemented e2e.getContact.run = { var contact: XXClient.Contact = .unimplemented(Data()) @@ -176,14 +174,13 @@ final class ChatFeatureTests: XCTestCase { var sendMessageCompletion: SendMessage.Completion? let store = TestStore( - initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), - reducer: chatReducer, - environment: .unimplemented + initialState: ChatComponent.State(id: .contact("contact-id".data(using: .utf8)!)), + reducer: ChatComponent() ) - store.environment.mainQueue = .immediate - store.environment.bgQueue = .immediate - store.environment.sendMessage.run = { text, recipientId, _, completion in + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.sendMessage.run = { text, recipientId, _, completion in didSendMessageWithParams.append(.init(text: text, recipientId: recipientId)) sendMessageCompletion = completion } @@ -208,17 +205,16 @@ final class ChatFeatureTests: XCTestCase { var sendMessageCompletion: SendMessage.Completion? let store = TestStore( - initialState: ChatState( + initialState: ChatComponent.State( id: .contact("contact-id".data(using: .utf8)!), text: "Hello" ), - reducer: chatReducer, - environment: .unimplemented + reducer: ChatComponent() ) - store.environment.mainQueue = .immediate - store.environment.bgQueue = .immediate - store.environment.sendMessage.run = { _, _, onError, completion in + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.sendMessage.run = { _, _, onError, completion in sendMessageOnError = onError sendMessageCompletion = completion } @@ -250,14 +246,13 @@ final class ChatFeatureTests: XCTestCase { var sendImageCompletion: SendImage.Completion? let store = TestStore( - initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), - reducer: chatReducer, - environment: .unimplemented + initialState: ChatComponent.State(id: .contact("contact-id".data(using: .utf8)!)), + reducer: ChatComponent() ) - store.environment.mainQueue = .immediate - store.environment.bgQueue = .immediate - store.environment.sendImage.run = { image, recipientId, _, completion in + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.sendImage.run = { image, recipientId, _, completion in didSendImageWithParams.append(.init(image: image, recipientId: recipientId)) sendImageCompletion = completion } @@ -277,16 +272,15 @@ final class ChatFeatureTests: XCTestCase { var sendImageCompletion: SendImage.Completion? let store = TestStore( - initialState: ChatState( + initialState: ChatComponent.State( id: .contact("contact-id".data(using: .utf8)!) ), - reducer: chatReducer, - environment: .unimplemented + reducer: ChatComponent() ) - store.environment.mainQueue = .immediate - store.environment.bgQueue = .immediate - store.environment.sendImage.run = { _, _, onError, completion in + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.sendImage.run = { _, _, onError, completion in sendImageOnError = onError sendImageCompletion = completion }