From 16b041c8c6aeb775e9173ce10ab4303545ffda08 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 10 Jan 2023 19:12:48 +0000 Subject: [PATCH] ios: Live messages without sending an empty text (#1714) * ios: Live messages without sending an empty text * Custom Equatable * Changes * Change * Fix liveMessage not hiding * Refactoring * Refactoring * No animation when removing dummy live message item * Check * Anim * Animation * whitespace * refactor * Fix race * Better fix of race * fix race condition Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 39 ++++++++++++++++++- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Chat/ComposeMessage/ComposeView.swift | 31 +++++++++++---- .../Chat/ComposeMessage/SendMessageView.swift | 33 ++++++++++++++-- apps/ios/SimpleXChat/ChatTypes.swift | 28 +++++++++++++ 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d55c7d5a37..ef3a6f8f32 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -219,7 +219,7 @@ final class ChatModel: ObservableObject { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { let ci = reversedChatItems[i] - withAnimation(.default) { + withAnimation { self.reversedChatItems[i] = cItem self.reversedChatItems[i].viewTimestamp = .now // on some occasions the confirmation of message being accepted by the server (tick) @@ -230,9 +230,18 @@ final class ChatModel: ObservableObject { } return false } else { - withAnimation { reversedChatItems.insert(cItem, at: 0) } + withAnimation(itemAnimation()) { + reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + } return true } + + func itemAnimation() -> Animation? { + switch cItem.chatDir { + case .directSnd, .groupSnd: return cItem.meta.isLive ? nil : .default + default: return .default + } + } } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { @@ -274,6 +283,32 @@ final class ChatModel: ObservableObject { return nil } + func addLiveDummy(_ quotedCItem: ChatItem?, _ chatInfo: ChatInfo) -> ChatItem { + var quoted: CIQuote? = nil + if let quotedItem = quotedCItem, let msgContent = quotedItem.content.msgContent { + quoted = CIQuote.getSampleWithMsgContent(itemId: quotedItem.id, sentAt: quotedItem.meta.updatedAt, msgContent: msgContent, chatDir: quotedItem.chatDir) + } + let cItem = ChatItem.liveChatItemDummy(chatInfo.chatType, quoted) + withAnimation { + reversedChatItems.insert(cItem, at: 0) + } + return cItem + } + + func removeLiveDummy(animated: Bool = true) { + if hasLiveDummy { + if animated { + withAnimation { _ = reversedChatItems.removeFirst() } + } else { + _ = reversedChatItems.removeFirst() + } + } + } + + private var hasLiveDummy: Bool { + reversedChatItems.first?.isLiveDummy == true + } + func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index affd9fe797..9ecf04c011 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -462,7 +462,7 @@ struct ChatView: View { private func menu() -> [UIAction] { var menu: [UIAction] = [] if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed { - if !ci.meta.itemDeleted { + if !ci.meta.itemDeleted && !ci.isLiveDummy && composeState.liveMessage == nil { menu.append(replyUIAction()) } menu.append(shareUIAction()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index fff6f5a178..ab8297cfc8 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -34,7 +34,7 @@ enum VoiceMessageRecordingState { struct LiveMessage { var chatItem: ChatItem var typedMsg: String - var sentMsg: String + var sentMsg: String? } struct ComposeState { @@ -232,9 +232,9 @@ struct ComposeView: View { VStack(spacing: 0) { contextItemView() switch (composeState.editing, composeState.preview) { - case (true, .filePreview): EmptyView() - case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed - default: previewView() + case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed + default: previewView() } HStack (alignment: .bottom) { Button { @@ -255,6 +255,10 @@ struct ComposeView: View { }, sendLiveMessage: sendLiveMessage, updateLiveMessage: updateLiveMessage, + cancelLiveMessage: { + composeState.liveMessage = nil + chatModel.removeLiveDummy() + }, voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { @@ -371,10 +375,11 @@ struct ComposeView: View { if let fileName = composeState.voiceMessageRecordingFileName { cancelVoiceMessageRecording(fileName) } - if composeState.liveMessage != nil { + if composeState.liveMessage != nil && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) { sendMessage() resetLinkPreview() } + chatModel.removeLiveDummy(animated: false) } .onChange(of: chatModel.stopPreviousRecPlay) { _ in if !startingRecording { @@ -396,11 +401,20 @@ struct ComposeView: View { private func sendLiveMessage() async { let typedMsg = composeState.message let sentMsg = truncateToWords(typedMsg) - if composeState.liveMessage == nil, + if !sentMsg.isEmpty && (composeState.liveMessage == nil || composeState.liveMessage?.sentMsg == nil), let ci = await sendMessageAsync(sentMsg, live: true) { await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) } + } else if composeState.liveMessage == nil { + var quoted: ChatItem? = nil + if case let .quotedItem(item) = composeState.contextItem { + quoted = item + } + let cItem = chatModel.addLiveDummy(quoted, chat.chatInfo) + await MainActor.run { + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil)) + } } } @@ -424,7 +438,7 @@ struct ComposeView: View { private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t - return s != lm.sentMsg ? s : nil + return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil } private func truncateToWords(_ s: String) -> String { @@ -512,7 +526,7 @@ struct ComposeView: View { } if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) - } else if let liveMessage = liveMessage { + } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) } else { var quoted: Int64? = nil @@ -609,6 +623,7 @@ struct ComposeView: View { live: live ) { await MainActor.run { + chatModel.removeLiveDummy(animated: false) chatModel.addChatItem(chat.chatInfo, chatItem) } return chatItem diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 7995c8be41..5e85140a05 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -9,11 +9,14 @@ import SwiftUI import SimpleXChat +private let liveMsgInterval: UInt64 = 3000_000000 + struct SendMessageView: View { @Binding var composeState: ComposeState var sendMessage: () -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil + var cancelLiveMessage: (() -> Void)? = nil var showVoiceMessageButton: Bool = true var voiceMessageAllowed: Bool = true var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other @@ -103,6 +106,10 @@ struct SendMessageView: View { } } else if vmrs == .recording && !holdingVMR { finishVoiceMessageRecordingButton() + } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { + cancelLiveMessageButton { + cancelLiveMessage?() + } } else { sendMessageButton() } @@ -129,7 +136,8 @@ struct SendMessageView: View { .disabled( !composeState.sendEnabled || composeState.disabled || - (!voiceMessageAllowed && composeState.voicePreview) + (!voiceMessageAllowed && composeState.voicePreview) || + (composeState.liveMessage != nil && composeState.message.isEmpty) ) .frame(width: 29, height: 29) @@ -220,6 +228,20 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } + private func cancelLiveMessageButton(cancel: @escaping () -> Void) -> some View { + return Button { + cancel() + } label: { + Image(systemName: "multiply") + .resizable() + .scaledToFit() + .foregroundColor(.accentColor) + .frame(width: 15, height: 15) + } + .frame(width: 29, height: 29) + .padding([.bottom, .horizontal], 4) + } + private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View { return Button { switch composeState.preview { @@ -271,9 +293,12 @@ struct SendMessageView: View { sendButtonOpacity = 1 } } - Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { t in - if composeState.liveMessage == nil { t.invalidate() } - Task { await update() } + Task { + _ = try? await Task.sleep(nanoseconds: liveMsgInterval) + while composeState.liveMessage != nil { + await update() + _ = try? await Task.sleep(nanoseconds: liveMsgInterval) + } } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index abf7a15640..9f46ba3912 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1670,6 +1670,7 @@ public struct ChatItem: Identifiable, Decodable { public var file: CIFile? public var viewTimestamp = Date.now + public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { case chatDir, meta, content, formattedText, quotedItem, file @@ -1862,6 +1863,29 @@ public struct ChatItem: Identifiable, Decodable { ) } + public static func liveChatItemDummy(_ chatType: ChatType, _ quoted: CIQuote?) -> ChatItem { + var item = ChatItem( + chatDir: chatType == ChatType.direct ? CIDirection.directSnd : CIDirection.groupSnd, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: false, + itemEdited: false, + itemLive: true, + editable: false + ), + content: .sndMsgContent(msgContent: .text("")), + quotedItem: quoted, + file: nil + ) + item.isLiveDummy = true + return item + } + public static func invalidJSON(_ json: String) -> ChatItem { ChatItem( chatDir: CIDirection.directSnd, @@ -2111,6 +2135,10 @@ public struct CIQuote: Decodable, ItemContent { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } + + public static func getSampleWithMsgContent(itemId: Int64?, sentAt: Date, msgContent: MsgContent, chatDir: CIDirection) -> CIQuote { + return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: msgContent) + } } public struct CIFile: Decodable {