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>
This commit is contained in:
Stanislav Dmitrenko
2023-01-10 19:12:48 +00:00
committed by GitHub
parent 810f248c74
commit 16b041c8c6
5 changed files with 118 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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