UI: hide keyboard when presenting context menu for message

Gitlab: #239
Change-Id: I73bbce31461a6f3e595a93bdb1e2cce7e536d8d9
diff --git a/Ring/Ring/Extensions/View+Helpers.swift b/Ring/Ring/Extensions/View+Helpers.swift
index 641672c..35d5045 100644
--- a/Ring/Ring/Extensions/View+Helpers.swift
+++ b/Ring/Ring/Extensions/View+Helpers.swift
@@ -19,6 +19,7 @@
  */
 
 import SwiftUI
+import Combine
 
 extension View {
     func placeholder<Content: View>(
@@ -97,3 +98,22 @@
         }
     }
 }
+
+extension Publishers {
+    static var keyboardHeight: AnyPublisher<CGFloat, Never> {
+        let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
+            .map { $0.keyboardHeight }
+
+        let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
+            .map { _ in CGFloat(0) }
+
+        return MergeMany(willShow, willHide)
+            .eraseToAnyPublisher()
+    }
+}
+
+extension Notification {
+    var keyboardHeight: CGFloat {
+        (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height ?? 0
+    }
+}
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/ContextMenuView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/ContextMenuView.swift
index c470c9e..e59a6b5 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/ContextMenuView.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/ContextMenuView.swift
@@ -20,6 +20,15 @@
 
 import SwiftUI
 
+enum ContextMenuPresentingState {
+    case none
+    case shouldPresent
+    case willDismissWithoutAction
+    case willDismissWithTextEditingAction
+    case willDismissWithAction
+    case dismissed
+}
+
 struct VisualEffect: UIViewRepresentable {
     @SwiftUI.State var style: UIBlurEffect.Style
     var withVibrancy: Bool
@@ -35,7 +44,7 @@
 
 struct ContextMenuView: View {
     var model: ContextMenuVM
-    @Binding var showContextMenu: Bool
+    @Binding var presentingState: ContextMenuPresentingState
     // animations
     @SwiftUI.State private var blurAmount = 0.0
     @SwiftUI.State private var backgroundScale: CGFloat = 1.00
@@ -87,7 +96,8 @@
             .background(makeBackground())
         }
         .onTapGesture {
-            withAnimation(Animation.easeOut(duration: 0.2)) {
+            presentingState = .willDismissWithoutAction
+            withAnimation(Animation.easeOut(duration: 0.3)) {
                 scrollViewHeight = model.messageFrame.height
                 blurAmount = 0
                 backgroundScale = 1.00
@@ -99,8 +109,8 @@
                 messageOffsetDiff = 0
                 cornerRadius = 0
             }
-            DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
-                showContextMenu = false
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+                presentingState = .dismissed
             }
         }
         .onAppear(perform: {
@@ -146,8 +156,13 @@
             ForEach(model.menuItems) { item in
                 VStack(spacing: 0) {
                     Button {
-                        showContextMenu = false
+                        let shouldShowKeyboard = item == .copy || item == .deleteMessage
+                        let state: ContextMenuPresentingState = shouldShowKeyboard ? .willDismissWithAction : .willDismissWithTextEditingAction
+                        presentingState = state
                         model.presentingMessage.model.contextMenuSelect(item: item)
+                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+                            presentingState = .dismissed
+                        }
                     } label: {
                         HStack {
                             Spacer()
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift
index 5191374..625b9e4 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift
@@ -123,8 +123,8 @@
 
 struct MessagePanelView: View {
     @StateObject var model: MessagePanelVM
+    @Binding var isFocused: Bool
     @SwiftUI.State private var text: String = ""
-    @SwiftUI.State private var isFocused: Bool = false
     @SwiftUI.State private var textHeight: CGFloat = 0
     let padding: CGFloat = 10
 
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift
index 5d4b104..bb2f3ba 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift
@@ -22,6 +22,7 @@
 import SwiftUI
 import UIKit
 import RxSwift
+import Combine
 
 struct Flipped: ViewModifier {
     func body(content: Content) -> some View {
@@ -52,15 +53,20 @@
     let scrollReserved = UIScreen.main.bounds.height * 1.5
 
     // context menu
-    @SwiftUI.State private var showContextMenu = false
+    @SwiftUI.State var contextMenuPresentingState: ContextMenuPresentingState = .none
     @SwiftUI.State private var currentSnapshot: UIImage?
     @SwiftUI.State private var presentingMessage: MessageBubbleView?
     @SwiftUI.State private var messageFrame: CGRect?
     var contextMenuModel = ContextMenuVM()
     @SwiftUI.State private var screenHeight: CGFloat = 0
-    @SwiftUI.State private var showReactionsView = false
-    @SwiftUI.State private var reactionsForMessage: ReactionsContainerModel?
     @SwiftUI.State private var messageContainerHeight: CGFloat = 0
+    @SwiftUI.State private var shouldHideActiveKeyboard = false
+    @SwiftUI.State var isMessageBarFocused: Bool = false
+    @SwiftUI.State var keyboardHeight: CGFloat = 0
+
+    // reactions
+    @SwiftUI.State private var reactionsForMessage: ReactionsContainerModel?
+    @SwiftUI.State private var showReactionsView = false
 
     var body: some View {
         ZStack {
@@ -74,8 +80,8 @@
                         }
                     }
                     .layoutPriority(1)
-                    .padding(.bottom, messageContainerHeight - 30)
-                    MessagePanelView(model: model.messagePanel)
+                    .padding(.bottom, shouldHideActiveKeyboard ? keyboardHeight : messageContainerHeight - 30)
+                    MessagePanelView(model: model.messagePanel, isFocused: $isMessageBarFocused)
                         .alignmentGuide(VerticalAlignment.center) { dimensions in
                             DispatchQueue.main.async {
                                 self.messageContainerHeight = dimensions.height
@@ -83,21 +89,30 @@
                             return dimensions[VerticalAlignment.center]
                         }
                 }
-                .overlay(showContextMenu && contextMenuModel.presentingMessage != nil ? makeOverlay() : nil)
+                .overlay(contextMenuPresentingState == .shouldPresent && contextMenuModel.presentingMessage != nil ? makeOverlay() : nil)
                 // hide navigation bar when presenting context menu
-                .onChange(of: showContextMenu) { newValue in
-                    model.hideNavigationBar.accept(newValue)
+                .onChange(of: contextMenuPresentingState) { newValue in
+                    let shouldHide = newValue == .shouldPresent
+                    model.hideNavigationBar.accept(shouldHide)
                 }
                 // hide context menu overly when device is rotated
                 .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
                     if screenHeight != UIScreen.main.bounds.size.height && screenHeight != 0 {
                         screenHeight = UIScreen.main.bounds.size.height
-                        showContextMenu = false
+                        contextMenuPresentingState = .dismissed
+                        self.shouldHideActiveKeyboard = false
                     }
                 }
                 .onAppear(perform: {
                     screenHeight = UIScreen.main.bounds.size.height
                 })
+
+                .onChange(of: contextMenuPresentingState, perform: { state in
+                    contextMenuPresentingStateChanged(state)
+                })
+                .onReceive(Publishers.keyboardHeight) { height in
+                    handleKeyboardHeightChange(height)
+                }
                 if model.shouldShowMap {
                     LocationSharingView(model: model)
                 }
@@ -132,7 +147,7 @@
     }
 
     func makeOverlay() -> some View {
-        return ContextMenuView(model: contextMenuModel, showContextMenu: $showContextMenu)
+        return ContextMenuView(model: contextMenuModel, presentingState: $contextMenuPresentingState)
     }
 
     private func createMessagesStackView() -> some View {
@@ -194,13 +209,21 @@
 
     private func createMessageRowView(for message: MessageContainerModel) -> some View {
         MessageRowView(messageModel: message, onLongPress: {(frame, message) in
-            if showContextMenu == true {
+            if contextMenuPresentingState != .dismissed && contextMenuPresentingState != .none {
                 return
             }
             model.hideNavigationBar.accept(true)
             contextMenuModel.presentingMessage = message
             contextMenuModel.messageFrame = frame
-            showContextMenu = true
+            /*
+             If the keyboard is open, it should be closed.
+             Once the context menu is removed, the keyboard
+             should be shown again.
+             */
+            if keyboardHeight > 0 {
+                hideKeyboardIfNeed()
+            }
+            contextMenuPresentingState = .shouldPresent
         }, showReactionsView: {message in
             reactionsForMessage = message
             showReactionsView.toggle()
@@ -248,6 +271,58 @@
         .ignoresSafeArea(.container, edges: [])
         .shadowForConversation()
     }
+
+    private func hideKeyboardIfNeed() {
+        if keyboardHeight > 0 {
+            withAnimation {
+                self.shouldHideActiveKeyboard = true
+            }
+            self.hideKeyboard()
+            self.isMessageBarFocused = false
+        }
+    }
+
+    private func handleKeyboardHeightChange(_ height: CGFloat) {
+        /*
+         If shouldHideActiveKeyboard is true, we don't track the
+         keyboard height, as the keyboard is temporarily hidden
+         and expected to reappear once the context menu is removed.
+         */
+        if !shouldHideActiveKeyboard {
+            DispatchQueue.main.async {
+                withAnimation {
+                    keyboardHeight = height
+                }
+            }
+        }
+    }
+
+    private func contextMenuPresentingStateChanged(_ state: ContextMenuPresentingState) {
+        /*
+         When the context menu is removed, we need to reopen
+         the keyboard and reactivate the message bar if it was
+         active before, except in cases where a selected action
+         will trigger the keyboard itself. For example, actions
+         like editing or replying to a message.
+         */
+        switch state {
+        case .willDismissWithoutAction, .willDismissWithAction:
+            if shouldHideActiveKeyboard {
+                isMessageBarFocused = true
+                withAnimation {
+                    shouldHideActiveKeyboard = false
+                }
+            }
+        case .none, .shouldPresent, .dismissed:
+            break
+        case .willDismissWithTextEditingAction:
+            if shouldHideActiveKeyboard {
+                withAnimation {
+                    shouldHideActiveKeyboard = false
+                }
+            }
+        }
+    }
 }
 
 func topVC() -> UIViewController? {
diff --git a/Ring/Ring/Features/Walkthrough/CreateAccount/CreateAccountViewModel.swift b/Ring/Ring/Features/Walkthrough/CreateAccount/CreateAccountViewModel.swift
index 5d09128..0bd7eb2 100644
--- a/Ring/Ring/Features/Walkthrough/CreateAccount/CreateAccountViewModel.swift
+++ b/Ring/Ring/Features/Walkthrough/CreateAccount/CreateAccountViewModel.swift
@@ -212,7 +212,7 @@
     let password = BehaviorRelay<String>(value: "")
     let confirmPassword = BehaviorRelay<String>(value: "")
     let notificationSwitch = BehaviorRelay<Bool>(value: true)
-    let nameRegistrationTimeout:CGFloat = 30
+    let nameRegistrationTimeout: CGFloat = 30
     lazy var usernameValidationState = BehaviorRelay<UsernameValidationState>(value: .unknown)
     lazy var canAskForAccountCreation: Observable<Bool> = {
         return Observable.combineLatest(self.usernameValidationState.asObservable(),