From 9e847c2e1f6906438671464ae94f90a74ab20c5a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 17 Dec 2022 14:02:07 +0000 Subject: [PATCH] ios: live messages (#1569) * ios: live messages * remove comments * remove conflict * live message buttons and alert * only send full words * fix double sending * typing indicator in live items * add live parameter to API * typing indication, pass live parameter to API * refactor to support live messages with attachments * disable attachments --- .../DebugJSON.playground/Contents.swift | 8 +- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +- .../Views/Chat/ChatItem/FramedItemView.swift | 67 ++--- .../Views/Chat/ChatItem/MsgContentView.swift | 70 ++++- apps/ios/Shared/Views/Chat/ChatItemView.swift | 5 +- .../Chat/ComposeMessage/ComposeView.swift | 271 ++++++++++++------ .../Chat/ComposeMessage/SendMessageView.swift | 125 ++++++-- .../Views/UserSettings/SettingsView.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 10 +- apps/ios/SimpleXChat/ChatTypes.swift | 41 +-- 10 files changed, 425 insertions(+), 184 deletions(-) diff --git a/apps/ios/Shared/DebugJSON.playground/Contents.swift b/apps/ios/Shared/DebugJSON.playground/Contents.swift index e62ca1ab53..832afce535 100644 --- a/apps/ios/Shared/DebugJSON.playground/Contents.swift +++ b/apps/ios/Shared/DebugJSON.playground/Contents.swift @@ -1,4 +1,4 @@ -import UIKit +//import UIKit let s = """ { @@ -15,6 +15,6 @@ let s = """ } """ //let s = "\"2022-04-24T11:59:23.703162Z\"" -let json = getJSONDecoder() -let d = s.data(using: .utf8)! -print (try! json.decode(ChatInfo.self, from: d)) +//let json = getJSONDecoder() +//let d = s.data(using: .utf8)! +//print (try! json.decode(ChatInfo.self, from: d)) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f3452e35b9..9076856ba2 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -219,9 +219,9 @@ func loadChat(chat: Chat, search: String = "") { } } -func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async -> ChatItem? { +func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? { let chatModel = ChatModel.shared - let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg) + let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live) let r: ChatResponse if type == .direct { var cItem: ChatItem! @@ -255,8 +255,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) { ) } -func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay) +func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { + let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } throw r } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 3c980fe0de..16bdeb96c1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -31,7 +31,9 @@ struct FramedItemView: View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if chatItem.meta.itemDeleted { - ciDeletedView() + framedItemHeader(icon: "trash", caption: Text("marked deleted").italic()) + } else if chatItem.meta.isLive { + framedItemHeader(caption: Text("LIVE")) } if let qi = chatItem.quotedItem { @@ -73,7 +75,7 @@ struct FramedItemView: View { } @ViewBuilder private func framedMsgContentView() -> some View { - if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) { + if chatItem.formattedText == nil && chatItem.file == nil && !chatItem.meta.isLive && isShortEmoji(chatItem.content.text) { VStack { emojiText(chatItem.content.text) Text("") @@ -88,7 +90,7 @@ struct FramedItemView: View { case let .image(text, image): CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy) .overlay(DetermineWidth()) - if text == "" { + if text == "" && !chatItem.meta.isLive { Color.clear .frame(width: 0, height: 0) .preference( @@ -127,32 +129,33 @@ struct FramedItemView: View { message: err ) } - - @ViewBuilder private func ciDeletedView() -> some View { + + @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> some View { let v = HStack(spacing: 6) { - Image(systemName: "trash") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - Text("marked deleted") + if let icon = icon { + Image(systemName: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + } + caption .font(.caption) - .italic() .lineLimit(1) } - .foregroundColor(.secondary) - .padding(.horizontal, 12) - .padding(.top, 6) - .padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup - .overlay(DetermineWidth()) - .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, colorScheme)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .leading) + .background(chatItemFrameContextColor(chatItem, colorScheme)) if let imgWidth = imgWidth, imgWidth < maxWidth { v.frame(maxWidth: imgWidth, alignment: .leading) } else { v } } - + @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { let v = ZStack(alignment: .topTrailing) { switch (qi.content) { @@ -222,21 +225,21 @@ struct FramedItemView: View { } @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View { - let rtl = isRightToLeft(chatItem.text) + let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text + let rtl = isRightToLeft(text) let v = MsgContentView( - text: ci.text, - formattedText: ci.formattedText, + text: text, + formattedText: text == "" ? [] : ci.formattedText, sender: showMember ? ci.memberDisplayName : nil, - metaText: ci.timestampText, - edited: ci.meta.itemEdited, + meta: ci.meta, rightToLeft: rtl ) - .multilineTextAlignment(rtl ? .trailing : .leading) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .overlay(DetermineWidth()) - .frame(minWidth: 0, alignment: .leading) - .textSelection(.enabled) + .multilineTextAlignment(rtl ? .trailing : .leading) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .overlay(DetermineWidth()) + .frame(minWidth: 0, alignment: .leading) + .textSelection(.enabled) if let imgWidth = imgWidth, imgWidth < maxWidth { v.frame(maxWidth: imgWidth, alignment: .leading) @@ -248,7 +251,7 @@ struct FramedItemView: View { @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) .overlay(DetermineWidth()) - if text != "" { + if text != "" || ci.meta.isLive { ciMsgContentView (chatItem, showMember) } } @@ -270,7 +273,7 @@ private struct MetaColorPreferenceKey: PreferenceKey { func onlyImage(_ ci: ChatItem) -> Bool { if case let .image(text, _) = ci.content.msgContent { - return !ci.meta.itemDeleted && ci.quotedItem == nil && text == "" + return !ci.meta.itemDeleted && !ci.meta.isLive && ci.quotedItem == nil && text == "" } return false } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 000c3da752..4d7e8ec13e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -11,26 +11,76 @@ import SimpleXChat private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) +private let noTyping = Text(" ") + +private let typingIndicators: [Text] = [ + (typing(.black) + typing() + typing()), + (typing(.bold) + typing(.black) + typing()), + (typing() + typing(.bold) + typing(.black)), + (typing() + typing() + typing(.bold)) +] + +private func typing(_ w: Font.Weight = .light) -> Text { + Text(".").fontWeight(w) +} + struct MsgContentView: View { var text: String var formattedText: [FormattedText]? = nil var sender: String? = nil - var metaText: Text? = nil - var edited = false + var meta: CIMeta? = nil var rightToLeft = false + @State private var typingIdx = 0 + @State private var timer: Timer? var body: some View { - let v = messageText(text, formattedText, sender) - if let mt = metaText { - return v + reserveSpaceForMeta(mt, edited) + if meta?.isLive == true { + msgContentView() + .onAppear { switchTyping() } + .onDisappear(perform: stopTyping) + .onChange(of: meta?.isLive, perform: switchTyping) + .onChange(of: meta?.recent, perform: switchTyping) } else { - return v + msgContentView() } } - - private func reserveSpaceForMeta(_ meta: Text, _ edited: Bool) -> Text { + + private func switchTyping(_: Bool? = nil) { + if let meta = meta, meta.isLive && meta.recent { + timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in + typingIdx = (typingIdx + 1) % typingIndicators.count + } + } else { + stopTyping() + } + } + + private func stopTyping() { + timer?.invalidate() + timer = nil + } + + private func msgContentView() -> Text { + var v = messageText(text, formattedText, sender) + if let mt = meta { + if mt.isLive { + v = v + typingIndicator(mt.recent) + } + v = v + reserveSpaceForMeta(mt.timestampText, mt.itemEdited) + } + return v + } + + private func typingIndicator(_ recent: Bool) -> Text { + return (recent ? typingIndicators[typingIdx] : noTyping) + .font(.body.monospaced()) + .kerning(-2) + .foregroundColor(.secondary) + } + + private func reserveSpaceForMeta(_ mt: Text, _ edited: Bool) -> Text { let reserve = rightToLeft ? "\n" : edited ? " " : " " - return (Text(reserve) + meta) + return (Text(reserve) + mt) .font(.caption) .foregroundColor(.clear) } @@ -105,7 +155,7 @@ struct MsgContentView_Previews: PreviewProvider { text: chatItem.text, formattedText: chatItem.formattedText, sender: chatItem.memberDisplayName, - metaText: chatItem.timestampText + meta: chatItem.meta ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5ee88536b5..a528d6b864 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -21,7 +21,7 @@ struct ChatItemView: View { let ci = chatItem if chatItem.meta.itemDeleted && !revealed { MarkedDeletedItemView(chatItem: chatItem, showMember: showMember) - } else if ci.quotedItem == nil && !ci.meta.itemDeleted { + } else if ci.quotedItem == nil && !ci.meta.itemDeleted && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { EmojiItemView(chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { @@ -102,7 +102,8 @@ struct ChatItemView_Previews: PreviewProvider { ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(true)) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, false, false, true), revealed: Binding.constant(true)) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, false, true), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 768fdb6bdc..748eb3b4f6 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -29,8 +29,17 @@ enum VoiceMessageRecordingState { case finished } +struct LiveMessage { + var chatItem: ChatItem + var typedMsg: String + var sentMsg: String + + var changed: Bool { typedMsg != sentMsg } +} + struct ComposeState { var message: String + var liveMessage: LiveMessage? = nil var preview: ComposePreview var contextItem: ComposeContextItem var voiceMessageRecordingState: VoiceMessageRecordingState @@ -40,11 +49,13 @@ struct ComposeState { init( message: String = "", + liveMessage: LiveMessage? = nil, preview: ComposePreview = .noPreview, contextItem: ComposeContextItem = .noContextItem, voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording ) { self.message = message + self.liveMessage = liveMessage self.preview = preview self.contextItem = contextItem self.voiceMessageRecordingState = voiceMessageRecordingState @@ -64,12 +75,14 @@ struct ComposeState { func copy( message: String? = nil, + liveMessage: LiveMessage? = nil, preview: ComposePreview? = nil, contextItem: ComposeContextItem? = nil, voiceMessageRecordingState: VoiceMessageRecordingState? = nil ) -> ComposeState { ComposeState( message: message ?? self.message, + liveMessage: liveMessage ?? self.liveMessage, preview: preview ?? self.preview, contextItem: contextItem ?? self.contextItem, voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState @@ -88,7 +101,7 @@ struct ComposeState { case .imagePreviews: return true case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true - default: return !message.isEmpty + default: return !message.isEmpty || liveMessage != nil } } @@ -128,6 +141,15 @@ struct ComposeState { default: return false } } + + var attachmentDisabled: Bool { + if editing || liveMessage != nil { return true } + switch preview { + case .noPreview: return false + case .linkPreview: return false + default: return true + } + } } func chatItemPreview(chatItem: ChatItem) -> ComposePreview { @@ -174,7 +196,7 @@ struct ComposeView: View { // fails to stop on ComposeVoiceView.playbackMode().onDisappear, // this is a workaround to fire an explicit event in certain cases @State private var stopPlayback: Bool = false - + var body: some View { VStack(spacing: 0) { contextItemView() @@ -190,7 +212,7 @@ struct ComposeView: View { Image(systemName: "paperclip") .resizable() } - .disabled(composeState.editing || !(composeState.noPreview || composeState.linkPreview != nil)) + .disabled(composeState.attachmentDisabled) .frame(width: 25, height: 25) .padding(.bottom, 12) .padding(.leading, 12) @@ -200,6 +222,8 @@ struct ComposeView: View { sendMessage() resetLinkPreview() }, + sendLiveMessage: sendLiveMessage, + updateLiveMessage: updateLiveMessage, voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { @@ -308,6 +332,10 @@ struct ComposeView: View { if let fileName = composeState.voiceMessageRecordingFileName { cancelVoiceMessageRecording(fileName) } + if composeState.liveMessage != nil { + sendMessage() + resetLinkPreview() + } } .onChange(of: chatModel.stopPreviousRecPlay) { _ in if !startingRecording { @@ -326,6 +354,60 @@ struct ComposeView: View { } } + private func sendLiveMessage() async { + let typedMsg = composeState.message + let sentMsg = truncateToWords(typedMsg) + if composeState.liveMessage == nil, + let ci = await sendMessageAsync(sentMsg, live: true) { + await MainActor.run { + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) + } + } + } + + private func updateLiveMessage() async { + let typedMsg = composeState.message + if let liveMessage = composeState.liveMessage { + if let sentMsg = liveMessageToSend(liveMessage, typedMsg), + let ci = await sendMessageAsync(sentMsg, live: true) { + await MainActor.run { + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) + } + } else if liveMessage.typedMsg != typedMsg { + await MainActor.run { + var lm = liveMessage + lm.typedMsg = typedMsg + composeState = composeState.copy(liveMessage: lm) + } + } + } + } + + private func liveMessageToSend(_ liveMessage: LiveMessage, _ typedMsg: String) -> String? { + if liveMessage.typedMsg != typedMsg { + let s = truncateToWords(typedMsg) + return s == liveMessage.sentMsg ? nil : s + } + return liveMessage.changed + ? liveMessage.typedMsg + : nil + } + + private func truncateToWords(_ s: String) -> String { + if let i = s.lastIndex(where: { !alphaNumeric($0) }) { + let s1 = s[...i] + if let j = s1.lastIndex(where: alphaNumeric), i < s1.endIndex { + return String(s1[...j]) + } + return String(s1) + } + return "" + + func alphaNumeric(_ c: Character) -> Bool { + c.isLetter || c.isNumber + } + } + @ViewBuilder func previewView() -> some View { switch composeState.preview { case .noPreview: @@ -383,72 +465,55 @@ struct ComposeView: View { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") - switch composeState.contextItem { - case let .editingItem(chatItem: ei): - if let oldMsgContent = ei.content.msgContent { - do { - await sending() - let mc = updateMsgContent(oldMsgContent) - let chatItem = try await apiUpdateChatItem( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - itemId: ei.id, - msg: mc - ) - await MainActor.run { - clearState() - let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem) - } - } catch { - logger.error("ChatView.sendMessage error: \(error.localizedDescription)") - await MainActor.run { - composeState.disabled = false - composeState.inProgress = false - } - AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))") - } - } else { - await MainActor.run { clearState() } - } - default: - await sending() - var quoted: Int64? = nil - if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { - quoted = quotedItem.id - } + _ = await sendMessageAsync(nil, live: false) + } + } - switch (composeState.preview) { - case .noPreview: - await send(.text(composeState.message), quoted: quoted) - case .linkPreview: - await send(checkLinkPreview(), quoted: quoted) - case let .imagePreviews(imagePreviews: images): - var text = composeState.message - var sent = false - for i in 0.. 0 { _ = try? await Task.sleep(nanoseconds: 100_000000) } - if let savedFile = saveImage(chosenImages[i]) { - await send(.image(text: text, image: images[i]), quoted: quoted, file: savedFile) - text = "" - quoted = nil - sent = true - } - } - if !sent { - await send(.text(composeState.message), quoted: quoted) - } - case let .voicePreview(recordingFileName, duration): - stopPlayback.toggle() - await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName) - case .filePreview: - if let fileURL = chosenFile, - let savedFile = saveFileFromURL(fileURL) { - await send(.file(composeState.message), quoted: quoted, file: savedFile) + private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? { + var sent: ChatItem? + let msgText = text ?? composeState.message + if !live { await sending() } + if case let .editingItem(ci) = composeState.contextItem { + sent = await updateMessage(ci, live: live) + } else if let liveMessage = composeState.liveMessage { + sent = await updateMessage(liveMessage.chatItem, live: live) + } else { + var quoted: Int64? = nil + if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { + quoted = quotedItem.id + } + + switch (composeState.preview) { + case .noPreview: + sent = await send(.text(msgText), quoted: quoted, live: live) + case .linkPreview: + sent = await send(checkLinkPreview(), quoted: quoted, live: live) + case let .imagePreviews(imagePreviews: images): + let last = min(chosenImages.count, images.count) - 1 + for i in 0.. ChatItem? { + if let oldMsgContent = ei.content.msgContent { + do { + let mc = updateMsgContent(oldMsgContent) + let chatItem = try await apiUpdateChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: ei.id, + msg: mc, + live: live + ) + await MainActor.run { + _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem) + } + return chatItem + } catch { + logger.error("ChatView.sendMessage error: \(error.localizedDescription)") + AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))") + } + } + return nil + } + + func updateMsgContent(_ msgContent: MsgContent) -> MsgContent { + switch msgContent { + case .text: + return checkLinkPreview() + case .link: + return checkLinkPreview() + case .image(_, let image): + return .image(text: msgText, image: image) + case .voice(_, let duration): + return .voice(text: msgText, duration: duration) + case .file: + return .file(msgText) + case .unknown(let type, _): + return .unknown(type: type, text: msgText) + } + } + + func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? { if let chatItem = await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, file: file, quotedItemId: quoted, - msg: mc + msg: mc, + live: live ) { await MainActor.run { chatModel.addChatItem(chat.chatInfo, chatItem) } + return chatItem } + return nil } } @@ -560,12 +668,14 @@ struct ComposeView: View { clearState() } - private func clearState() { - composeState = ComposeState() - linkUrl = nil - prevLinkUrl = nil - pendingLinkUrl = nil - cancelledLinks = [] + private func clearState(live: Bool = false) { + if live { + composeState.disabled = false + composeState.inProgress = false + } else { + composeState = ComposeState() + resetLinkPreview() + } chosenImages = [] chosenFile = nil audioRecorder = nil @@ -573,23 +683,6 @@ struct ComposeView: View { startingRecording = false } - private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent { - switch msgContent { - case .text: - return checkLinkPreview() - case .link: - return checkLinkPreview() - case .image(_, let image): - return .image(text: composeState.message, image: image) - case .voice(_, let duration): - return .voice(text: composeState.message, duration: duration) - case .file: - return .file(composeState.message) - case .unknown(let type, _): - return .unknown(type: type, text: composeState.message) - } - } - private func showLinkPreview(_ s: String) { prevLinkUrl = linkUrl linkUrl = parseMessage(s) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index eedae62e9e..caf3c7c2c8 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -12,6 +12,8 @@ import SimpleXChat struct SendMessageView: View { @Binding var composeState: ComposeState var sendMessage: () -> Void + var sendLiveMessage: (() async -> Void)? = nil + var updateLiveMessage: (() async -> Void)? = nil var showVoiceMessageButton: Bool = true var voiceMessageAllowed: Bool = true var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other @@ -25,8 +27,11 @@ struct SendMessageView: View { @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) + @State private var sendButtonSize: CGFloat = 29 + @State private var sendButtonOpacity: CGFloat = 1 var maxHeight: CGFloat = 360 var minHeight: CGFloat = 37 + @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { ZStack { @@ -75,25 +80,45 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 3) } else { let vmrs = composeState.voiceMessageRecordingState - if showVoiceMessageButton, - composeState.message.isEmpty, - !composeState.editing, - (composeState.noPreview && vmrs == .noRecording) - || (vmrs == .recording && holdingVMR) { - if voiceMessageAllowed { - RecordVoiceMessageButton( - startVoiceMessageRecording: startVoiceMessageRecording, - finishVoiceMessageRecording: finishVoiceMessageRecording, - holdingVMR: $holdingVMR, - disabled: composeState.disabled - ) - } else { - voiceMessageNotAllowedButton() + if showVoiceMessageButton + && composeState.message.isEmpty + && !composeState.editing + && composeState.liveMessage == nil + && ((composeState.noPreview && vmrs == .noRecording) + || (vmrs == .recording && holdingVMR)) { + HStack { + if voiceMessageAllowed { + RecordVoiceMessageButton( + startVoiceMessageRecording: startVoiceMessageRecording, + finishVoiceMessageRecording: finishVoiceMessageRecording, + holdingVMR: $holdingVMR, + disabled: composeState.disabled + ) + } else { + voiceMessageNotAllowedButton() + } + if let send = sendLiveMessage, let update = updateLiveMessage { + startLiveMessageButton(send: send, update: update) + } } } else if vmrs == .recording && !holdingVMR { finishVoiceMessageRecordingButton() } else { - sendMessageButton() + let v = sendMessageButton() + if composeState.liveMessage == nil, + !composeState.voicePreview && !composeState.editing, + let send = sendLiveMessage, + let update = updateLiveMessage { + v.contextMenu{ + Button { + startLiveMessage(send: send, update: update) + } label: { + Label("Send live message", systemImage: "ellipsis.circle") + } + } + } else { + v + } } } } @@ -106,10 +131,14 @@ struct SendMessageView: View { } private func sendMessageButton() -> some View { - Button(action: { sendMessage() }) { - Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill") + Button(action: sendMessage) { + Image(systemName: composeState.editing || composeState.liveMessage != nil + ? "checkmark.circle.fill" + : "arrow.up.circle.fill") .resizable() .foregroundColor(.accentColor) + .frame(width: sendButtonSize, height: sendButtonSize) + .opacity(sendButtonOpacity) } .disabled( !composeState.sendEnabled || @@ -154,7 +183,7 @@ struct SendMessageView: View { } private func voiceMessageNotAllowedButton() -> some View { - Button(action: { + Button { switch showEnableVoiceMessagesAlert { case .userEnable: AlertManager.shared.showAlert(Alert( @@ -181,7 +210,7 @@ struct SendMessageView: View { message: "Please check yours and your contact preferences." ) } - }) { + } label: { Image(systemName: "mic") .foregroundColor(.secondary) } @@ -190,6 +219,64 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } + private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View { + return Button { + switch composeState.preview { + case .noPreview: startLiveMessage(send: send, update: update) + default: () + } + } label: { + ZStack { + Image(systemName: "ellipsis.circle.fill") + .resizable() + .foregroundColor(.accentColor) + } + } + .frame(width: 29, height: 29) + .padding([.bottom, .horizontal], 4) + } + + private func startLiveMessage(send: @escaping () async -> Void, update: @escaping () async -> Void) { + if liveMessageAlertShown { + start() + } else { + AlertManager.shared.showAlert(Alert( + title: Text("Live message!"), + message: Text("Send a live message - it will update for the recipient(s) as you type it"), + primaryButton: .default(Text("Send")) { + liveMessageAlertShown = true + start() + }, + secondaryButton: .cancel() + )) + } + + func start() { + Task { + await send() + await MainActor.run { run() } + } + } + + @Sendable func run() { + Timer.scheduledTimer(withTimeInterval: 0.75, repeats: true) { t in + withAnimation(.easeInOut(duration: 0.7)) { + sendButtonSize = sendButtonSize == 29 ? 26 : 29 + sendButtonOpacity = sendButtonOpacity == 1 ? 0.75 : 1 + } + if composeState.liveMessage == nil { + t.invalidate() + sendButtonSize = 29 + sendButtonOpacity = 1 + } + } + Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { t in + if composeState.liveMessage == nil { t.invalidate() } + Task { await update() } + } + } + } + private func finishVoiceMessageRecordingButton() -> some View { Button(action: { finishVoiceMessageRecording?() }) { Image(systemName: "stop.fill") diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 83a3428a31..b24d2b6549 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -38,6 +38,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" +let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -57,7 +58,8 @@ let appDefaults: [String: Any] = [ DEFAULT_ACCENT_COLOR_GREEN: 0.533, DEFAULT_ACCENT_COLOR_BLUE: 1.000, DEFAULT_USER_INTERFACE_STYLE: 0, - DEFAULT_CONNECT_VIA_LINK_TAB: "scan" + DEFAULT_CONNECT_VIA_LINK_TAB: "scan", + DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false ] enum SimpleXLinkMode: String, Identifiable { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 7ca8a8de37..3d6e0c2b20 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -27,8 +27,8 @@ public enum ChatCommand { case apiStorageEncryption(config: DBEncryptionConfig) case apiGetChats case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) - case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool) + case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) @@ -109,10 +109,10 @@ public enum ChatCommand { case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") - case let .apiSendMessage(type, id, file, quotedItemId, mc): + case let .apiSendMessage(type, id, file, quotedItemId, mc, live): let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) - return "/_send \(ref(type, id)) json \(msg)" - case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" + return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)" + case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 26439c8e8c..57932e320f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -312,11 +312,11 @@ public enum ChatFeature: String, Decodable, Feature { public func allowDescription(_ allowed: FeatureAllowed) -> LocalizedStringKey { switch self { case .timedMessages: - switch allowed { - case .always: return "Allow your contacts to send disappearing messages." - case .yes: return "Allow disappearing messages only if your contact allows it to you." - case .no: return "Prohibit sending disappearing messages." - } + switch allowed { + case .always: return "Allow your contacts to send disappearing messages." + case .yes: return "Allow disappearing messages only if your contact allows it to you." + case .no: return "Prohibit sending disappearing messages." + } case .fullDelete: switch allowed { case .always: return "Allow your contacts to irreversibly delete sent messages." @@ -335,13 +335,13 @@ public enum ChatFeature: String, Decodable, Feature { public func enabledDescription(_ enabled: FeatureEnabled) -> LocalizedStringKey { switch self { case .timedMessages: - return enabled.forUser && enabled.forContact - ? "Both you and your contact can send disappearing messages." - : enabled.forUser - ? "Only you can send disappearing messages." - : enabled.forContact - ? "Only your contact can send disappearing messages." - : "Disappearing messages are prohibited in this chat." + return enabled.forUser && enabled.forContact + ? "Both you and your contact can send disappearing messages." + : enabled.forUser + ? "Only you can send disappearing messages." + : enabled.forContact + ? "Only your contact can send disappearing messages." + : "Disappearing messages are prohibited in this chat." case .fullDelete: return enabled.forUser && enabled.forContact ? "Both you and your contact can irreversibly delete sent messages." @@ -1547,10 +1547,10 @@ public struct ChatItem: Identifiable, Decodable { } } - public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { + public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ itemLive: Bool = false, _ editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), + meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemLive, editable), content: .sndMsgContent(msgContent: .text(text)), quotedItem: quotedItem, file: file @@ -1640,6 +1640,7 @@ public struct ChatItem: Identifiable, Decodable { updatedAt: .now, itemDeleted: false, itemEdited: false, + itemLive: false, editable: false ), content: .rcvDeleted(deleteMode: .cidmBroadcast), @@ -1668,19 +1669,22 @@ public enum CIDirection: Decodable { } public struct CIMeta: Decodable { - var itemId: Int64 + public var itemId: Int64 var itemTs: Date var itemText: String public var itemStatus: CIStatus var createdAt: Date - var updatedAt: Date + public var updatedAt: Date public var itemDeleted: Bool public var itemEdited: Bool + public var itemLive: Bool? public var editable: Bool - var timestampText: Text { get { formatTimestampText(itemTs) } } + public var timestampText: Text { get { formatTimestampText(itemTs) } } + public var recent: Bool { updatedAt + 10 > .now } + public var isLive: Bool { itemLive == true } - public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { + public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ itemLive: Bool = false, _ editable: Bool = true) -> CIMeta { CIMeta( itemId: id, itemTs: ts, @@ -1690,6 +1694,7 @@ public struct CIMeta: Decodable { updatedAt: ts, itemDeleted: itemDeleted, itemEdited: itemEdited, + itemLive: itemLive, editable: editable ) }