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))
+  }
 }