2022-03-17 09:42:59 +00:00
|
|
|
//
|
|
|
|
// ComposeView.swift
|
|
|
|
// SimpleX
|
|
|
|
//
|
|
|
|
// Created by Evgeny on 13/03/2022.
|
|
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
|
|
//
|
|
|
|
|
|
|
|
import SwiftUI
|
2022-05-31 07:55:13 +01:00
|
|
|
import SimpleXChat
|
2022-03-17 09:42:59 +00:00
|
|
|
|
2022-04-25 12:44:24 +04:00
|
|
|
enum ComposePreview {
|
|
|
|
case noPreview
|
2022-05-30 08:59:04 +01:00
|
|
|
case linkPreview(linkPreview: LinkPreview?)
|
2022-10-10 10:40:30 +01:00
|
|
|
case imagePreviews(imagePreviews: [String])
|
2022-11-24 21:18:28 +04:00
|
|
|
case voicePreview(recordingFileName: String, duration: Int)
|
2022-05-06 21:10:32 +04:00
|
|
|
case filePreview(fileName: String)
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
2022-04-19 12:29:03 +04:00
|
|
|
|
2022-04-25 12:44:24 +04:00
|
|
|
enum ComposeContextItem {
|
|
|
|
case noContextItem
|
|
|
|
case quotedItem(chatItem: ChatItem)
|
|
|
|
case editingItem(chatItem: ChatItem)
|
|
|
|
}
|
2022-04-08 18:17:10 +01:00
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
enum VoiceMessageRecordingState {
|
|
|
|
case noRecording
|
|
|
|
case recording
|
|
|
|
case finished
|
|
|
|
}
|
|
|
|
|
2022-12-17 14:02:07 +00:00
|
|
|
struct LiveMessage {
|
|
|
|
var chatItem: ChatItem
|
|
|
|
var typedMsg: String
|
|
|
|
var sentMsg: String
|
|
|
|
|
|
|
|
var changed: Bool { typedMsg != sentMsg }
|
|
|
|
}
|
|
|
|
|
2022-04-25 12:44:24 +04:00
|
|
|
struct ComposeState {
|
|
|
|
var message: String
|
2022-12-17 14:02:07 +00:00
|
|
|
var liveMessage: LiveMessage? = nil
|
2022-04-25 12:44:24 +04:00
|
|
|
var preview: ComposePreview
|
|
|
|
var contextItem: ComposeContextItem
|
2022-11-24 21:18:28 +04:00
|
|
|
var voiceMessageRecordingState: VoiceMessageRecordingState
|
2022-10-10 10:40:30 +01:00
|
|
|
var inProgress = false
|
|
|
|
var disabled = false
|
2022-05-30 08:59:04 +01:00
|
|
|
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
2022-04-25 12:44:24 +04:00
|
|
|
|
|
|
|
init(
|
|
|
|
message: String = "",
|
2022-12-17 14:02:07 +00:00
|
|
|
liveMessage: LiveMessage? = nil,
|
2022-04-25 12:44:24 +04:00
|
|
|
preview: ComposePreview = .noPreview,
|
2022-11-24 21:18:28 +04:00
|
|
|
contextItem: ComposeContextItem = .noContextItem,
|
2022-11-25 15:16:37 +04:00
|
|
|
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
|
2022-04-25 12:44:24 +04:00
|
|
|
) {
|
|
|
|
self.message = message
|
2022-12-17 14:02:07 +00:00
|
|
|
self.liveMessage = liveMessage
|
2022-04-25 12:44:24 +04:00
|
|
|
self.preview = preview
|
|
|
|
self.contextItem = contextItem
|
2022-11-24 21:18:28 +04:00
|
|
|
self.voiceMessageRecordingState = voiceMessageRecordingState
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
init(editingItem: ChatItem) {
|
|
|
|
self.message = editingItem.content.text
|
|
|
|
self.preview = chatItemPreview(chatItem: editingItem)
|
|
|
|
self.contextItem = .editingItem(chatItem: editingItem)
|
2022-11-24 21:18:28 +04:00
|
|
|
if let emc = editingItem.content.msgContent,
|
|
|
|
case .voice = emc {
|
|
|
|
self.voiceMessageRecordingState = .finished
|
|
|
|
} else {
|
|
|
|
self.voiceMessageRecordingState = .noRecording
|
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
func copy(
|
|
|
|
message: String? = nil,
|
2022-12-17 14:02:07 +00:00
|
|
|
liveMessage: LiveMessage? = nil,
|
2022-04-25 12:44:24 +04:00
|
|
|
preview: ComposePreview? = nil,
|
2022-11-24 21:18:28 +04:00
|
|
|
contextItem: ComposeContextItem? = nil,
|
|
|
|
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
|
2022-04-25 12:44:24 +04:00
|
|
|
) -> ComposeState {
|
|
|
|
ComposeState(
|
|
|
|
message: message ?? self.message,
|
2022-12-17 14:02:07 +00:00
|
|
|
liveMessage: liveMessage ?? self.liveMessage,
|
2022-04-25 12:44:24 +04:00
|
|
|
preview: preview ?? self.preview,
|
2022-11-24 21:18:28 +04:00
|
|
|
contextItem: contextItem ?? self.contextItem,
|
|
|
|
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
|
2022-04-25 12:44:24 +04:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
var editing: Bool {
|
2022-04-25 12:44:24 +04:00
|
|
|
switch contextItem {
|
|
|
|
case .editingItem: return true
|
|
|
|
default: return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
var sendEnabled: Bool {
|
2022-04-25 12:44:24 +04:00
|
|
|
switch preview {
|
2022-11-24 21:18:28 +04:00
|
|
|
case .imagePreviews: return true
|
|
|
|
case .voicePreview: return voiceMessageRecordingState == .finished
|
|
|
|
case .filePreview: return true
|
2022-12-17 14:02:07 +00:00
|
|
|
default: return !message.isEmpty || liveMessage != nil
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
var linkPreviewAllowed: Bool {
|
2022-04-25 12:44:24 +04:00
|
|
|
switch preview {
|
2022-11-24 21:18:28 +04:00
|
|
|
case .imagePreviews: return false
|
|
|
|
case .voicePreview: return false
|
|
|
|
case .filePreview: return false
|
|
|
|
default: return useLinkPreviews
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
var linkPreview: LinkPreview? {
|
2022-04-25 12:44:24 +04:00
|
|
|
switch preview {
|
2022-11-24 21:18:28 +04:00
|
|
|
case let .linkPreview(linkPreview): return linkPreview
|
|
|
|
default: return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var voiceMessageRecordingFileName: String? {
|
|
|
|
switch preview {
|
|
|
|
case let .voicePreview(recordingFileName: recordingFileName, _): return recordingFileName
|
|
|
|
default: return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var noPreview: Bool {
|
|
|
|
switch preview {
|
|
|
|
case .noPreview: return true
|
|
|
|
default: return false
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
}
|
2022-11-25 15:16:37 +04:00
|
|
|
|
|
|
|
var voicePreview: Bool {
|
|
|
|
switch preview {
|
|
|
|
case .voicePreview: return true
|
|
|
|
default: return false
|
|
|
|
}
|
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
|
|
|
|
var attachmentDisabled: Bool {
|
|
|
|
if editing || liveMessage != nil { return true }
|
|
|
|
switch preview {
|
|
|
|
case .noPreview: return false
|
|
|
|
case .linkPreview: return false
|
|
|
|
default: return true
|
|
|
|
}
|
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
|
|
|
let chatItemPreview: ComposePreview
|
|
|
|
switch chatItem.content.msgContent {
|
2022-05-06 21:10:32 +04:00
|
|
|
case .text:
|
|
|
|
chatItemPreview = .noPreview
|
2022-04-25 12:44:24 +04:00
|
|
|
case let .link(_, preview: preview):
|
|
|
|
chatItemPreview = .linkPreview(linkPreview: preview)
|
2022-11-24 21:18:28 +04:00
|
|
|
case let .image(_, image):
|
2022-10-10 10:40:30 +01:00
|
|
|
chatItemPreview = .imagePreviews(imagePreviews: [image])
|
2022-11-24 21:18:28 +04:00
|
|
|
case let .voice(_, duration):
|
|
|
|
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
2022-05-06 21:10:32 +04:00
|
|
|
case .file:
|
|
|
|
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
|
2022-04-25 12:44:24 +04:00
|
|
|
default:
|
|
|
|
chatItemPreview = .noPreview
|
|
|
|
}
|
|
|
|
return chatItemPreview
|
|
|
|
}
|
|
|
|
|
|
|
|
struct ComposeView: View {
|
|
|
|
@EnvironmentObject var chatModel: ChatModel
|
2022-11-25 15:16:37 +04:00
|
|
|
@ObservedObject var chat: Chat
|
2022-04-25 12:44:24 +04:00
|
|
|
@Binding var composeState: ComposeState
|
2022-03-17 09:42:59 +00:00
|
|
|
@FocusState.Binding var keyboardVisible: Bool
|
2022-04-25 12:44:24 +04:00
|
|
|
|
2022-04-08 18:17:10 +01:00
|
|
|
@State var linkUrl: URL? = nil
|
|
|
|
@State var prevLinkUrl: URL? = nil
|
|
|
|
@State var pendingLinkUrl: URL? = nil
|
|
|
|
@State var cancelledLinks: Set<String> = []
|
|
|
|
|
2022-04-19 12:29:03 +04:00
|
|
|
@State private var showChooseSource = false
|
|
|
|
@State private var showImagePicker = false
|
2022-05-18 21:32:30 +04:00
|
|
|
@State private var showTakePhoto = false
|
2022-10-10 10:40:30 +01:00
|
|
|
@State var chosenImages: [UIImage] = []
|
2022-05-06 21:10:32 +04:00
|
|
|
@State private var showFileImporter = false
|
|
|
|
@State var chosenFile: URL? = nil
|
2022-11-24 21:18:28 +04:00
|
|
|
|
|
|
|
@State private var audioRecorder: AudioRecorder?
|
|
|
|
@State private var voiceMessageRecordingTime: TimeInterval?
|
|
|
|
@State private var startingRecording: Bool = false
|
2022-11-29 15:23:54 +04:00
|
|
|
// for some reason voice message preview playback occasionally
|
|
|
|
// 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
|
2022-12-17 14:02:07 +00:00
|
|
|
|
2022-03-17 09:42:59 +00:00
|
|
|
var body: some View {
|
|
|
|
VStack(spacing: 0) {
|
2022-04-25 12:44:24 +04:00
|
|
|
contextItemView()
|
2022-11-24 21:18:28 +04:00
|
|
|
switch (composeState.editing, composeState.preview) {
|
2022-05-06 21:10:32 +04:00
|
|
|
case (true, .filePreview): EmptyView()
|
2022-11-24 21:18:28 +04:00
|
|
|
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
2022-05-06 21:10:32 +04:00
|
|
|
default: previewView()
|
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
HStack (alignment: .bottom) {
|
|
|
|
Button {
|
|
|
|
showChooseSource = true
|
|
|
|
} label: {
|
|
|
|
Image(systemName: "paperclip")
|
|
|
|
.resizable()
|
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
.disabled(composeState.attachmentDisabled)
|
2022-04-25 12:44:24 +04:00
|
|
|
.frame(width: 25, height: 25)
|
|
|
|
.padding(.bottom, 12)
|
|
|
|
.padding(.leading, 12)
|
2022-04-19 12:29:03 +04:00
|
|
|
SendMessageView(
|
2022-04-25 12:44:24 +04:00
|
|
|
composeState: $composeState,
|
2022-04-27 20:54:21 +04:00
|
|
|
sendMessage: {
|
|
|
|
sendMessage()
|
2022-04-19 12:29:03 +04:00
|
|
|
resetLinkPreview()
|
|
|
|
},
|
2022-12-17 14:02:07 +00:00
|
|
|
sendLiveMessage: sendLiveMessage,
|
|
|
|
updateLiveMessage: updateLiveMessage,
|
2022-11-25 15:16:37 +04:00
|
|
|
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
|
2022-11-25 21:05:14 +04:00
|
|
|
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
2022-11-24 21:18:28 +04:00
|
|
|
startVoiceMessageRecording: {
|
|
|
|
Task {
|
|
|
|
await startVoiceMessageRecording()
|
|
|
|
}
|
|
|
|
},
|
|
|
|
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
|
2022-11-25 21:05:14 +04:00
|
|
|
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
|
2022-12-17 12:01:49 +03:00
|
|
|
onImageAdded: { image in chosenImages = [image] },
|
2022-04-25 12:44:24 +04:00
|
|
|
keyboardVisible: $keyboardVisible
|
2022-04-19 12:29:03 +04:00
|
|
|
)
|
2022-04-25 12:44:24 +04:00
|
|
|
.padding(.trailing, 12)
|
2022-04-19 12:29:03 +04:00
|
|
|
.background(.background)
|
|
|
|
}
|
2022-03-17 09:42:59 +00:00
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
.onChange(of: composeState.message) { _ in
|
2022-11-24 21:18:28 +04:00
|
|
|
if composeState.linkPreviewAllowed {
|
2022-04-25 12:44:24 +04:00
|
|
|
if composeState.message.count > 0 {
|
|
|
|
showLinkPreview(composeState.message)
|
|
|
|
} else {
|
|
|
|
resetLinkPreview()
|
|
|
|
}
|
2022-04-08 18:17:10 +01:00
|
|
|
}
|
2022-03-25 22:26:05 +04:00
|
|
|
}
|
2022-04-19 12:29:03 +04:00
|
|
|
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
|
|
|
|
Button("Take picture") {
|
2022-05-18 21:32:30 +04:00
|
|
|
showTakePhoto = true
|
2022-04-19 12:29:03 +04:00
|
|
|
}
|
|
|
|
Button("Choose from library") {
|
|
|
|
showImagePicker = true
|
|
|
|
}
|
2022-05-30 09:03:56 +01:00
|
|
|
if UIPasteboard.general.hasImages {
|
|
|
|
Button("Paste image") {
|
2022-10-10 10:40:30 +01:00
|
|
|
chosenImages = imageList(UIPasteboard.general.image)
|
2022-05-30 09:03:56 +01:00
|
|
|
}
|
|
|
|
}
|
2022-05-06 21:10:32 +04:00
|
|
|
Button("Choose file") {
|
|
|
|
showFileImporter = true
|
|
|
|
}
|
2022-04-19 12:29:03 +04:00
|
|
|
}
|
2022-05-18 21:32:30 +04:00
|
|
|
.fullScreenCover(isPresented: $showTakePhoto) {
|
|
|
|
ZStack {
|
|
|
|
Color.black.edgesIgnoringSafeArea(.all)
|
2022-10-10 10:40:30 +01:00
|
|
|
CameraImageListPicker(images: $chosenImages)
|
2022-04-19 12:29:03 +04:00
|
|
|
}
|
|
|
|
}
|
2022-12-03 21:42:12 +00:00
|
|
|
.sheet(isPresented: $showImagePicker) {
|
2022-10-10 10:40:30 +01:00
|
|
|
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
|
|
|
|
showImagePicker = false
|
|
|
|
if itemsSelected {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: []))
|
|
|
|
}
|
|
|
|
}
|
2022-05-18 21:32:30 +04:00
|
|
|
}
|
|
|
|
}
|
2022-10-10 10:40:30 +01:00
|
|
|
.onChange(of: chosenImages) { images in
|
|
|
|
Task {
|
|
|
|
var imgs: [String] = []
|
|
|
|
for image in images {
|
|
|
|
if let img = resizeImageToStrSize(image, maxDataSize: 14000) {
|
|
|
|
imgs.append(img)
|
|
|
|
await MainActor.run {
|
|
|
|
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if imgs.count == 0 {
|
|
|
|
await MainActor.run {
|
|
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
|
|
}
|
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
}
|
2022-05-06 21:10:32 +04:00
|
|
|
.fileImporter(
|
|
|
|
isPresented: $showFileImporter,
|
|
|
|
allowedContentTypes: [.data],
|
|
|
|
allowsMultipleSelection: false
|
|
|
|
) { result in
|
2022-06-24 13:52:20 +01:00
|
|
|
if case let .success(files) = result, let fileURL = files.first {
|
2022-05-06 21:10:32 +04:00
|
|
|
do {
|
|
|
|
var fileSize: Int? = nil
|
|
|
|
if fileURL.startAccessingSecurityScopedResource() {
|
|
|
|
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
|
|
|
fileSize = resourceValues.fileSize
|
|
|
|
}
|
|
|
|
fileURL.stopAccessingSecurityScopedResource()
|
|
|
|
if let fileSize = fileSize,
|
2022-11-26 12:43:26 +04:00
|
|
|
fileSize <= MAX_FILE_SIZE {
|
2022-05-06 21:10:32 +04:00
|
|
|
chosenFile = fileURL
|
|
|
|
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
|
|
|
|
} else {
|
2022-11-26 12:43:26 +04:00
|
|
|
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
2022-05-06 21:10:32 +04:00
|
|
|
AlertManager.shared.showAlertMsg(
|
|
|
|
title: "Large file!",
|
|
|
|
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
|
|
|
)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
logger.error("ComposeView fileImporter error \(error.localizedDescription)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-11-24 21:18:28 +04:00
|
|
|
.onDisappear {
|
|
|
|
if let fileName = composeState.voiceMessageRecordingFileName {
|
|
|
|
cancelVoiceMessageRecording(fileName)
|
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
if composeState.liveMessage != nil {
|
|
|
|
sendMessage()
|
|
|
|
resetLinkPreview()
|
|
|
|
}
|
2022-11-24 21:18:28 +04:00
|
|
|
}
|
|
|
|
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
|
|
|
if !startingRecording {
|
|
|
|
if composeState.voiceMessageRecordingState == .recording {
|
|
|
|
finishVoiceMessageRecording()
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
startingRecording = false
|
|
|
|
}
|
|
|
|
}
|
2022-11-29 15:23:54 +04:00
|
|
|
.onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in
|
|
|
|
if !vmAllowed && composeState.voicePreview,
|
|
|
|
let fileName = composeState.voiceMessageRecordingFileName {
|
|
|
|
cancelVoiceMessageRecording(fileName)
|
|
|
|
}
|
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
|
2022-12-17 14:02:07 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-25 12:44:24 +04:00
|
|
|
@ViewBuilder func previewView() -> some View {
|
|
|
|
switch composeState.preview {
|
|
|
|
case .noPreview:
|
|
|
|
EmptyView()
|
|
|
|
case let .linkPreview(linkPreview: preview):
|
|
|
|
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
|
2022-10-10 10:40:30 +01:00
|
|
|
case let .imagePreviews(imagePreviews: images):
|
2022-04-25 12:44:24 +04:00
|
|
|
ComposeImageView(
|
2022-10-10 10:40:30 +01:00
|
|
|
images: images,
|
2022-05-06 21:10:32 +04:00
|
|
|
cancelImage: {
|
|
|
|
composeState = composeState.copy(preview: .noPreview)
|
2022-10-10 10:40:30 +01:00
|
|
|
chosenImages = []
|
2022-05-06 21:10:32 +04:00
|
|
|
},
|
2022-11-24 21:18:28 +04:00
|
|
|
cancelEnabled: !composeState.editing)
|
|
|
|
case let .voicePreview(recordingFileName, _):
|
|
|
|
ComposeVoiceView(
|
|
|
|
recordingFileName: recordingFileName,
|
|
|
|
recordingTime: $voiceMessageRecordingTime,
|
|
|
|
recordingState: $composeState.voiceMessageRecordingState,
|
|
|
|
cancelVoiceMessage: { cancelVoiceMessageRecording($0) },
|
2022-11-29 15:23:54 +04:00
|
|
|
cancelEnabled: !composeState.editing,
|
|
|
|
stopPlayback: $stopPlayback
|
2022-11-24 21:18:28 +04:00
|
|
|
)
|
2022-05-06 21:10:32 +04:00
|
|
|
case let .filePreview(fileName: fileName):
|
|
|
|
ComposeFileView(
|
|
|
|
fileName: fileName,
|
|
|
|
cancelFile: {
|
|
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
|
|
chosenFile = nil
|
|
|
|
},
|
2022-11-24 21:18:28 +04:00
|
|
|
cancelEnabled: !composeState.editing)
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@ViewBuilder private func contextItemView() -> some View {
|
|
|
|
switch composeState.contextItem {
|
|
|
|
case .noContextItem:
|
|
|
|
EmptyView()
|
|
|
|
case let .quotedItem(chatItem: quotedItem):
|
2022-05-06 21:10:32 +04:00
|
|
|
ContextItemView(
|
|
|
|
contextItem: quotedItem,
|
2022-05-09 09:12:32 +01:00
|
|
|
contextIcon: "arrowshape.turn.up.left",
|
2022-05-06 21:10:32 +04:00
|
|
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
|
|
|
)
|
2022-04-25 12:44:24 +04:00
|
|
|
case let .editingItem(chatItem: editingItem):
|
2022-05-06 21:10:32 +04:00
|
|
|
ContextItemView(
|
|
|
|
contextItem: editingItem,
|
2022-05-09 09:12:32 +01:00
|
|
|
contextIcon: "pencil",
|
2022-05-06 21:10:32 +04:00
|
|
|
cancelContextItem: { clearState() }
|
|
|
|
)
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-27 20:54:21 +04:00
|
|
|
private func sendMessage() {
|
2022-04-25 12:44:24 +04:00
|
|
|
logger.debug("ChatView sendMessage")
|
|
|
|
Task {
|
|
|
|
logger.debug("ChatView sendMessage: in Task")
|
2022-12-17 14:02:07 +00:00
|
|
|
_ = await sendMessageAsync(nil, live: false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
2022-09-21 17:18:48 +04:00
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
if let savedFile = saveImage(chosenImages[last]) {
|
|
|
|
sent = await send(.image(text: msgText, image: images[last]), quoted: quoted, file: savedFile, live: live)
|
2022-10-10 10:40:30 +01:00
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
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)
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
2022-10-10 10:40:30 +01:00
|
|
|
}
|
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
await MainActor.run { clearState(live: live) }
|
|
|
|
return sent
|
2022-10-10 10:40:30 +01:00
|
|
|
|
|
|
|
func sending() async {
|
|
|
|
await MainActor.run { composeState.disabled = true }
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
|
|
if composeState.disabled { composeState.inProgress = true }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-17 14:02:07 +00:00
|
|
|
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? {
|
2022-10-10 10:40:30 +01:00
|
|
|
if let chatItem = await apiSendMessage(
|
|
|
|
type: chat.chatInfo.chatType,
|
|
|
|
id: chat.chatInfo.apiId,
|
|
|
|
file: file,
|
|
|
|
quotedItemId: quoted,
|
2022-12-17 14:02:07 +00:00
|
|
|
msg: mc,
|
|
|
|
live: live
|
2022-10-10 10:40:30 +01:00
|
|
|
) {
|
|
|
|
await MainActor.run {
|
2022-09-21 17:18:48 +04:00
|
|
|
chatModel.addChatItem(chat.chatInfo, chatItem)
|
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
return chatItem
|
2022-04-19 12:29:03 +04:00
|
|
|
}
|
2022-12-17 14:02:07 +00:00
|
|
|
return nil
|
2022-04-19 12:29:03 +04:00
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
}
|
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
private func startVoiceMessageRecording() async {
|
|
|
|
startingRecording = true
|
|
|
|
chatModel.stopPreviousRecPlay.toggle()
|
|
|
|
let fileName = generateNewFileName("voice", "m4a")
|
|
|
|
audioRecorder = AudioRecorder(
|
|
|
|
onTimer: { voiceMessageRecordingTime = $0 },
|
|
|
|
onFinishRecording: {
|
|
|
|
updateComposeVMRFinished()
|
|
|
|
if let fileSize = fileSize(getAppFilePath(fileName)) {
|
|
|
|
logger.debug("onFinishRecording recording file size = \(fileSize)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
)
|
2022-11-25 12:30:24 +04:00
|
|
|
if let recStartError = await audioRecorder?.start(fileName: fileName) {
|
|
|
|
switch recStartError {
|
|
|
|
case .permission:
|
2022-11-29 12:41:48 +04:00
|
|
|
AlertManager.shared.showAlert(Alert(
|
|
|
|
title: Text("No permission to record voice message"),
|
|
|
|
message: Text("To record voice message please grant permission to use Microphone."),
|
|
|
|
primaryButton: .default(Text("Open Settings")) {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
secondaryButton: .cancel()
|
|
|
|
))
|
2022-11-25 12:30:24 +04:00
|
|
|
case let .error(error):
|
|
|
|
AlertManager.shared.showAlertMsg(
|
|
|
|
title: "Unable to record voice message",
|
|
|
|
message: "Error: \(error)"
|
|
|
|
)
|
|
|
|
}
|
2022-11-24 21:18:28 +04:00
|
|
|
} else {
|
|
|
|
composeState = composeState.copy(
|
|
|
|
preview: .voicePreview(recordingFileName: fileName, duration: 0),
|
|
|
|
voiceMessageRecordingState: .recording
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func finishVoiceMessageRecording() {
|
|
|
|
audioRecorder?.stop()
|
|
|
|
audioRecorder = nil
|
|
|
|
updateComposeVMRFinished()
|
|
|
|
if let fileName = composeState.voiceMessageRecordingFileName,
|
|
|
|
let fileSize = fileSize(getAppFilePath(fileName)) {
|
|
|
|
logger.debug("finishVoiceMessageRecording recording file size = \(fileSize)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-25 21:05:14 +04:00
|
|
|
private func allowVoiceMessagesToContact() {
|
|
|
|
if case let .direct(contact) = chat.chatInfo {
|
|
|
|
Task {
|
|
|
|
do {
|
|
|
|
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
|
|
|
|
prefs.voice = Preference(allow: .yes)
|
|
|
|
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
|
|
|
await MainActor.run {
|
|
|
|
chatModel.updateContact(toContact)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-24 21:18:28 +04:00
|
|
|
// ? maybe we shouldn't have duration in ComposePreview.voicePreview
|
|
|
|
private func updateComposeVMRFinished() {
|
|
|
|
var preview = composeState.preview
|
|
|
|
if let recordingFileName = composeState.voiceMessageRecordingFileName,
|
|
|
|
let recordingTime = voiceMessageRecordingTime {
|
|
|
|
preview = .voicePreview(recordingFileName: recordingFileName, duration: Int(recordingTime.rounded()))
|
|
|
|
}
|
|
|
|
composeState = composeState.copy(
|
|
|
|
preview: preview,
|
|
|
|
voiceMessageRecordingState: .finished
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
private func cancelVoiceMessageRecording(_ fileName: String) {
|
2022-11-29 15:23:54 +04:00
|
|
|
stopPlayback.toggle()
|
|
|
|
audioRecorder?.stop()
|
2022-11-24 21:18:28 +04:00
|
|
|
removeFile(fileName)
|
|
|
|
clearState()
|
|
|
|
}
|
|
|
|
|
2022-12-17 14:02:07 +00:00
|
|
|
private func clearState(live: Bool = false) {
|
|
|
|
if live {
|
|
|
|
composeState.disabled = false
|
|
|
|
composeState.inProgress = false
|
|
|
|
} else {
|
|
|
|
composeState = ComposeState()
|
|
|
|
resetLinkPreview()
|
|
|
|
}
|
2022-10-10 10:40:30 +01:00
|
|
|
chosenImages = []
|
2022-05-06 21:10:32 +04:00
|
|
|
chosenFile = nil
|
2022-11-24 21:18:28 +04:00
|
|
|
audioRecorder = nil
|
|
|
|
voiceMessageRecordingTime = nil
|
2022-11-29 15:23:54 +04:00
|
|
|
startingRecording = false
|
2022-05-06 21:10:32 +04:00
|
|
|
}
|
|
|
|
|
2022-04-11 09:39:04 +01:00
|
|
|
private func showLinkPreview(_ s: String) {
|
|
|
|
prevLinkUrl = linkUrl
|
|
|
|
linkUrl = parseMessage(s)
|
|
|
|
if let url = linkUrl {
|
2022-11-24 21:18:28 +04:00
|
|
|
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
2022-04-11 09:39:04 +01:00
|
|
|
pendingLinkUrl = url
|
|
|
|
if prevLinkUrl == url {
|
|
|
|
loadLinkPreview(url)
|
|
|
|
} else {
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
|
|
loadLinkPreview(url)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
2022-04-25 12:44:24 +04:00
|
|
|
composeState = composeState.copy(preview: .noPreview)
|
2022-04-11 09:39:04 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func parseMessage(_ msg: String) -> URL? {
|
2022-06-24 13:52:20 +01:00
|
|
|
let parsedMsg = parseSimpleXMarkdown(msg)
|
|
|
|
let uri = parsedMsg?.first(where: { ft in
|
|
|
|
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
|
|
|
|
})
|
|
|
|
if let uri = uri { return URL(string: uri.text) }
|
|
|
|
else { return nil }
|
2022-04-11 09:39:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
private func isSimplexLink(_ link: String) -> Bool {
|
|
|
|
link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")
|
|
|
|
}
|
|
|
|
|
2022-04-25 12:44:24 +04:00
|
|
|
private func cancelLinkPreview() {
|
2022-11-24 21:18:28 +04:00
|
|
|
if let uri = composeState.linkPreview?.uri.absoluteString {
|
2022-04-11 09:39:04 +01:00
|
|
|
cancelledLinks.insert(uri)
|
|
|
|
}
|
2022-05-30 08:59:04 +01:00
|
|
|
pendingLinkUrl = nil
|
2022-04-25 12:44:24 +04:00
|
|
|
composeState = composeState.copy(preview: .noPreview)
|
2022-04-11 09:39:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
private func loadLinkPreview(_ url: URL) {
|
|
|
|
if pendingLinkUrl == url {
|
2022-05-30 08:59:04 +01:00
|
|
|
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
|
2022-04-25 12:44:24 +04:00
|
|
|
getLinkPreview(url: url) { linkPreview in
|
|
|
|
if let linkPreview = linkPreview,
|
|
|
|
pendingLinkUrl == url {
|
|
|
|
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
|
2022-04-08 18:17:10 +01:00
|
|
|
pendingLinkUrl = nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-11 09:39:04 +01:00
|
|
|
private func resetLinkPreview() {
|
2022-04-08 18:17:10 +01:00
|
|
|
linkUrl = nil
|
|
|
|
prevLinkUrl = nil
|
|
|
|
pendingLinkUrl = nil
|
|
|
|
cancelledLinks = []
|
|
|
|
}
|
2022-04-25 12:44:24 +04:00
|
|
|
|
|
|
|
private func checkLinkPreview() -> MsgContent {
|
|
|
|
switch (composeState.preview) {
|
|
|
|
case let .linkPreview(linkPreview: linkPreview):
|
|
|
|
if let url = parseMessage(composeState.message),
|
2022-05-30 08:59:04 +01:00
|
|
|
let linkPreview = linkPreview,
|
2022-04-25 12:44:24 +04:00
|
|
|
url == linkPreview.uri {
|
|
|
|
return .link(text: composeState.message, preview: linkPreview)
|
|
|
|
} else {
|
|
|
|
return .text(composeState.message)
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
return .text(composeState.message)
|
|
|
|
}
|
|
|
|
}
|
2022-03-17 09:42:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
struct ComposeView_Previews: PreviewProvider {
|
|
|
|
static var previews: some View {
|
2022-04-25 12:44:24 +04:00
|
|
|
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
|
|
|
@State var composeState = ComposeState(message: "hello")
|
2022-03-17 09:42:59 +00:00
|
|
|
@FocusState var keyboardVisible: Bool
|
|
|
|
|
2022-03-25 22:26:05 +04:00
|
|
|
return Group {
|
|
|
|
ComposeView(
|
2022-04-25 12:44:24 +04:00
|
|
|
chat: chat,
|
|
|
|
composeState: $composeState,
|
|
|
|
keyboardVisible: $keyboardVisible
|
2022-03-25 22:26:05 +04:00
|
|
|
)
|
2022-11-24 21:18:28 +04:00
|
|
|
.environmentObject(ChatModel())
|
2022-03-25 22:26:05 +04:00
|
|
|
ComposeView(
|
2022-04-25 12:44:24 +04:00
|
|
|
chat: chat,
|
|
|
|
composeState: $composeState,
|
|
|
|
keyboardVisible: $keyboardVisible
|
2022-03-25 22:26:05 +04:00
|
|
|
)
|
2022-11-24 21:18:28 +04:00
|
|
|
.environmentObject(ChatModel())
|
2022-03-25 22:26:05 +04:00
|
|
|
}
|
2022-03-17 09:42:59 +00:00
|
|
|
}
|
|
|
|
}
|