diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index c2e284003..aafdf8808 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -15,11 +15,13 @@ extension ContextMenuVC { struct Action { let icon: UIImage? let title: String + let feedback: String? let expirationInfo: ExpirationInfo? let themeColor: ThemeValue let actionType: ActionType + let shouldDismissInfoScreen: Bool let accessibilityLabel: String? - let work: () -> Void + let work: ((() -> Void)?) -> Void enum ActionType { case emoji @@ -33,17 +35,21 @@ extension ContextMenuVC { init( icon: UIImage? = nil, title: String = "", + feedback: String? = nil, expirationInfo: ExpirationInfo? = nil, themeColor: ThemeValue = .textPrimary, actionType: ActionType = .generic, + shouldDismissInfoScreen: Bool = false, accessibilityLabel: String? = nil, - work: @escaping () -> Void + work: @escaping ((() -> Void)?) -> Void ) { self.icon = icon self.title = title + self.feedback = feedback self.expirationInfo = expirationInfo self.themeColor = themeColor self.actionType = actionType + self.shouldDismissInfoScreen = shouldDismissInfoScreen self.accessibilityLabel = accessibilityLabel self.work = work } @@ -55,7 +61,7 @@ extension ContextMenuVC { icon: UIImage(named: "ic_info"), title: "info".localized(), accessibilityLabel: "Message info" - ) { delegate?.info(cellViewModel) } + ) { _ in delegate?.info(cellViewModel) } } static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -66,31 +72,34 @@ extension ContextMenuVC { "resend".localized() ), accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message") - ) { delegate?.retry(cellViewModel) } + ) { completion in delegate?.retry(cellViewModel, completion: completion) } } static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "reply".localized(), + shouldDismissInfoScreen: true, accessibilityLabel: "Reply to message" - ) { delegate?.reply(cellViewModel) } + ) { completion in delegate?.reply(cellViewModel, completion: completion) } } static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized(), + feedback: "copied".localized(), accessibilityLabel: "Copy text" - ) { delegate?.copy(cellViewModel) } + ) { completion in delegate?.copy(cellViewModel, completion: completion) } } static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "accountIDCopy".localized(), + feedback: "copied".localized(), accessibilityLabel: "Copy Session ID" - ) { delegate?.copySessionID(cellViewModel) } + ) { completion in delegate?.copySessionID(cellViewModel, completion: completion) } } static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -102,16 +111,18 @@ extension ContextMenuVC { expiresInSeconds: cellViewModel.expiresInSeconds ), themeColor: .danger, + shouldDismissInfoScreen: true, accessibilityLabel: "Delete message" - ) { delegate?.delete(cellViewModel) } + ) { completion in delegate?.delete(cellViewModel, completion: completion) } } static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "save".localized(), + feedback: "saved".localized(), accessibilityLabel: "Save attachment" - ) { delegate?.save(cellViewModel) } + ) { completion in delegate?.save(cellViewModel, completion: completion) } } static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -120,7 +131,7 @@ extension ContextMenuVC { title: "banUser".localized(), themeColor: .danger, accessibilityLabel: "Ban user" - ) { delegate?.ban(cellViewModel) } + ) { completion in delegate?.ban(cellViewModel, completion: completion) } } static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -128,28 +139,29 @@ extension ContextMenuVC { icon: UIImage(named: "ic_block"), title: "banDeleteAll".localized(), themeColor: .danger, + shouldDismissInfoScreen: true, accessibilityLabel: "Ban user and delete" - ) { delegate?.banAndDeleteAllMessages(cellViewModel) } + ) { completion in delegate?.banAndDeleteAllMessages(cellViewModel, completion: completion) } } static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( title: emoji.rawValue, actionType: .emoji - ) { delegate?.react(cellViewModel, with: emoji) } + ) { _ in delegate?.react(cellViewModel, with: emoji) } } static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( actionType: .emojiPlus, accessibilityLabel: "Add emoji" - ) { delegate?.showFullEmojiKeyboard(cellViewModel) } + ) { _ in delegate?.showFullEmojiKeyboard(cellViewModel) } } static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action { return Action( actionType: .dismiss - ) { delegate?.contextMenuDismissed() } + ) { _ in delegate?.contextMenuDismissed() } } } @@ -295,14 +307,14 @@ extension ContextMenuVC { protocol ContextMenuActionDelegate { func info(_ cellViewModel: MessageViewModel) - func retry(_ cellViewModel: MessageViewModel) - func reply(_ cellViewModel: MessageViewModel) - func copy(_ cellViewModel: MessageViewModel) - func copySessionID(_ cellViewModel: MessageViewModel) - func delete(_ cellViewModel: MessageViewModel) - func save(_ cellViewModel: MessageViewModel) - func ban(_ cellViewModel: MessageViewModel) - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) + func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) func contextMenuDismissed() diff --git a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift index 83678e02e..4bff5f8e6 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift @@ -161,7 +161,7 @@ extension ContextMenuVC { } @objc private func handleTap() { - action.work() + action.work() {} dismissWithTimerInvalidationIfNeeded() } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift b/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift index 5cedd297f..1a855eafc 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift @@ -48,7 +48,7 @@ extension ContextMenuVC { // MARK: - Interaction @objc private func handleTap() { - action.work() + action.work() {} dismiss() } } @@ -106,7 +106,7 @@ extension ContextMenuVC { dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in - self?.action?.work() + self?.action?.work() {} }) } } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 6560785fb..a1251f40e 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -415,7 +415,7 @@ final class ContextMenuVC: UIViewController { }, completion: { [weak self] _ in self?.dismiss() - self?.actions.first(where: { $0.actionType == .dismiss })?.work() + self?.actions.first(where: { $0.actionType == .dismiss })?.work(){} } ) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index fd8c7968b..a2e24f569 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1309,7 +1309,7 @@ extension ConversationVC: } func handleReplyButtonTapped(for cellViewModel: MessageViewModel) { - reply(cellViewModel) + reply(cellViewModel, completion: nil) } func startThread( @@ -1883,7 +1883,7 @@ extension ConversationVC: } } - func retry(_ cellViewModel: MessageViewModel) { + func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { guard let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, @@ -1895,7 +1895,10 @@ extension ConversationVC: title: "theError".localized(), body: .text("shareExtensionDatabaseError".localized()), cancelTitle: "okay".localized(), - cancelStyle: .alert_text + cancelStyle: .alert_text, + afterClosed: { + completion?() + } ) ) @@ -1905,6 +1908,7 @@ extension ConversationVC: // Try to send the optimistic message again sendMessage(optimisticData: optimisticMessageData) + completion?() return } @@ -1953,9 +1957,11 @@ extension ConversationVC: using: dependencies ) } + + completion?() } - func reply(_ cellViewModel: MessageViewModel) { + func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( threadId: self.viewModel.threadData.threadId, authorId: cellViewModel.authorId, @@ -1976,9 +1982,10 @@ extension ConversationVC: isOutgoing: (cellViewModel.variant == .standardOutgoing) ) _ = snInputView.becomeFirstResponder() + completion?() } - func copy(_ cellViewModel: MessageViewModel) { + func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { switch cellViewModel.cellType { case .typingIndicator, .dateHeader, .unreadMarker: break @@ -2006,15 +2013,35 @@ extension ConversationVC: UIPasteboard.general.setData(data, forPasteboardType: type.identifier) } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "copied".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + + completion?() } - func copySessionID(_ cellViewModel: MessageViewModel) { + func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.variant == .standardIncoming else { return } UIPasteboard.general.string = cellViewModel.authorId + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "copied".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + + completion?() } - func delete(_ cellViewModel: MessageViewModel) { + func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { /// Retrieve the deletion actions for the selected message(s) of there are any let messagesToDelete: [MessageViewModel] = [cellViewModel] @@ -2098,6 +2125,7 @@ extension ConversationVC: inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) } + completion?() } } ) @@ -2115,7 +2143,7 @@ extension ConversationVC: } } - func save(_ cellViewModel: MessageViewModel) { + func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.cellType == .mediaMessage else { return } let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) @@ -2155,7 +2183,15 @@ extension ConversationVC: ) } }, - completionHandler: { _, _ in } + completionHandler: { _, _ in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "saved".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + } ) } @@ -2166,9 +2202,11 @@ extension ConversationVC: self?.sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))) } + + completion?() } - func ban(_ cellViewModel: MessageViewModel) { + func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2201,36 +2239,38 @@ extension ConversationVC: .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in - switch result { - case .finished: - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: self?.viewModel.showToast( text: "banUserBanned".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } - case .failure: - DispatchQueue.main.async { [weak self] in + case .failure: self?.viewModel.showToast( text: "banErrorFailed".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } + } + completion?() } } ) self?.becomeFirstResponder() }, - afterClosed: { [weak self] in self?.becomeFirstResponder() } + afterClosed: { [weak self] in + completion?() + self?.becomeFirstResponder() + } ) ) self.present(modal, animated: true) } - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2263,30 +2303,31 @@ extension ConversationVC: .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in - switch result { - case .finished: - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: self?.viewModel.showToast( text: "banUserBanned".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } - case .failure: - DispatchQueue.main.async { [weak self] in + case .failure: self?.viewModel.showToast( text: "banErrorFailed".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } + } + completion?() } } ) self?.becomeFirstResponder() }, - afterClosed: { [weak self] in self?.becomeFirstResponder() } + afterClosed: { [weak self] in + self?.becomeFirstResponder() + } ) ) self.present(modal, animated: true) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index cf8e1b612..23af8758c 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -473,6 +473,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M inputTextView.resignFirstResponder() } + @discardableResult override func becomeFirstResponder() -> Bool { inputTextView.becomeFirstResponder() } diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index ef2b6bd58..4f9a09884 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -10,6 +10,7 @@ struct MessageInfoScreen: View { @EnvironmentObject var host: HostWrapper @State var index = 1 + @State var feedbackMessage: String? = nil static private let cornerRadius: CGFloat = 17 @@ -32,6 +33,9 @@ struct MessageInfoScreen: View { messageViewModel: messageViewModel, dependencies: dependencies ) + .clipShape( + RoundedRectangle(cornerRadius: Self.cornerRadius) + ) .background( RoundedRectangle(cornerRadius: Self.cornerRadius) .fill( @@ -50,7 +54,6 @@ struct MessageInfoScreen: View { .padding(.bottom, Values.verySmallSpacing) .padding(.horizontal, Values.largeSpacing) - if isMessageFailed { let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( variant: messageViewModel.variant, @@ -309,8 +312,17 @@ struct MessageInfoScreen: View { let tintColor: ThemeValue = actions[index].themeColor Button( action: { - actions[index].work() - dismiss() + actions[index].work() { + switch (actions[index].shouldDismissInfoScreen, actions[index].feedback) { + case (false, _): break + case (true, .some): + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { + dismiss() + }) + default: dismiss() + } + } + feedbackMessage = actions[index].feedback }, label: { HStack(spacing: Values.largeSpacing) { @@ -353,6 +365,7 @@ struct MessageInfoScreen: View { } } .backgroundColor(themeColor: .backgroundPrimary) + .toastView(message: $feedbackMessage) } private func showMediaFullScreen(attachment: Attachment) { diff --git a/SessionUIKit/Components/SwiftUI/Toast.swift b/SessionUIKit/Components/SwiftUI/Toast.swift index 715a99b50..a9aea9810 100644 --- a/SessionUIKit/Components/SwiftUI/Toast.swift +++ b/SessionUIKit/Components/SwiftUI/Toast.swift @@ -2,6 +2,7 @@ import SwiftUI import Combine +import NaturalLanguage public struct ToastModifier: ViewModifier { @Binding var message: String? @@ -34,7 +35,17 @@ public struct ToastModifier: ViewModifier { } workItem = task - DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: task) + + let duration: TimeInterval = { + guard let message: String = message else { return 1.5 } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = message + let wordCount = tokenizer.tokens(for: message.startIndex..