Merge pull request #370 from RyanRory/feedback-for-actions

Add feedbacks for actions in message info screen
pull/1061/head
Morgan Pretty 3 days ago committed by GitHub
commit 507bcc6fa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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()

@ -161,7 +161,7 @@ extension ContextMenuVC {
}
@objc private func handleTap() {
action.work()
action.work() {}
dismissWithTimerInvalidationIfNeeded()
}

@ -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() {}
})
}
}

@ -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(){}
}
)
}

@ -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)

@ -473,6 +473,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
inputTextView.resignFirstResponder()
}
@discardableResult
override func becomeFirstResponder() -> Bool {
inputTextView.becomeFirstResponder()
}

@ -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) {

@ -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..<message.endIndex).count
return min(1.5 + Double(wordCount - 1) * 0.1 , 5)
}()
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: task)
}
private func dismissToast() {
@ -61,24 +72,18 @@ public struct ToastView_SwiftUI: View {
VStack(
spacing: 0
) {
ZStack {
Capsule()
.foregroundColor(themeColor: .toast_background)
Text(message)
.font(.system(size: Values.verySmallFontSize))
.foregroundColor(themeColor: .textPrimary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, Values.mediumSpacing)
}
.frame(
width: Self.width,
height: Self.height
)
Text(message)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, Values.largeSpacing)
.frame(height: Self.height)
.background(
Capsule()
.foregroundColor(themeColor: .toast_background)
)
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .bottom
)

Loading…
Cancel
Save