mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
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:
committed by
GitHub
parent
d105e59655
commit
9e847c2e1f
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user