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
This commit is contained in:
Evgeny Poberezkin
2022-12-17 14:02:07 +00:00
committed by GitHub
parent d105e59655
commit 9e847c2e1f
10 changed files with 425 additions and 184 deletions
@@ -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))
+4 -4
View File
@@ -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
}
@@ -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
}
@@ -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
)
}
}
@@ -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))
}
@@ -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..<min(chosenImages.count, images.count) {
if i > 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..<last {
if let savedFile = saveImage(chosenImages[i]) {
_ = await send(.image(text: "", image: images[i]), quoted: nil, file: savedFile)
}
_ = try? await Task.sleep(nanoseconds: 100_000000)
}
if let savedFile = saveImage(chosenImages[last]) {
sent = await send(.image(text: msgText, image: images[last]), quoted: quoted, file: savedFile, live: live)
}
if sent == nil {
sent = await send(.text(msgText), quoted: quoted, live: live)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
case .filePreview:
if let fileURL = chosenFile,
let savedFile = saveFileFromURL(fileURL) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
}
}
await MainActor.run { clearState() }
}
await MainActor.run { clearState(live: live) }
return sent
func sending() async {
await MainActor.run { composeState.disabled = true }
@@ -457,18 +522,61 @@ struct ComposeView: View {
}
}
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil) async {
func updateMessage(_ ei: ChatItem, live: Bool) async -> 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)
@@ -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")
@@ -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 {
+5 -5
View File
@@ -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)"
+23 -18
View File
@@ -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
)
}