diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 592b6dc276..83507d19b5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1051,7 +1051,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { withApi { receiveFile(file.fileId) } } else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { - withApi { receiveFile(file.fileId) } + withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent } if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(cInfo, cItem) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index 89c4d35ae8..605a91521b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -58,7 +58,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder { rec.setAudioChannels(1) rec.setAudioSamplingRate(16000) rec.setAudioEncodingBitRate(16000) - rec.setMaxDuration(-1) + rec.setMaxDuration(-1) // TODO set limit rec.setMaxFileSize(recordedBytesLimit) val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a"))) diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift new file mode 100644 index 0000000000..907566fdbb --- /dev/null +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -0,0 +1,141 @@ +// +// AudioRecPlay.swift +// SimpleX (iOS) +// +// Created by Evgeny on 19/11/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import AVFoundation +import SwiftUI +import SimpleXChat + +class AudioRecorder { + var onTimer: ((TimeInterval) -> Void)? + var onFinishRecording: (() -> Void)? + + var audioRecorder: AVAudioRecorder? + var recordingTimer: Timer? + + init(onTimer: @escaping ((TimeInterval) -> Void), onFinishRecording: @escaping (() -> Void)) { + self.onTimer = onTimer + self.onFinishRecording = onFinishRecording + } + + enum StartError { + case permission + case error(String) + } + + func start(fileName: String) async -> StartError? { + let av = AVAudioSession.sharedInstance() + if !(await checkPermission()) { return .permission } + do { + try av.setCategory(AVAudioSession.Category.playAndRecord, options: .defaultToSpeaker) + try av.setActive(true) + let settings: [String : Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 12000, + AVEncoderBitRateKey: 12000, + AVNumberOfChannelsKey: 1 + ] + audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings) + audioRecorder?.record(forDuration: maxVoiceMessageLength) + + await MainActor.run { + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + guard let time = self.audioRecorder?.currentTime else { return } + self.onTimer?(time) + if time >= maxVoiceMessageLength { + self.stop() + self.onFinishRecording?() + } + } + } + return nil + } catch let error { + logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)") + return .error(error.localizedDescription) + } + } + + func stop() { + if let recorder = audioRecorder { + recorder.stop() + } + audioRecorder = nil + if let timer = recordingTimer { + timer.invalidate() + } + recordingTimer = nil + } + + private func checkPermission() async -> Bool { + let av = AVAudioSession.sharedInstance() + switch av.recordPermission { + case .granted: return true + case .denied: return false + case .undetermined: + return await withCheckedContinuation { cont in + DispatchQueue.main.async { + av.requestRecordPermission { allowed in + cont.resume(returning: allowed) + } + } + } + @unknown default: return false + } + } +} + +class AudioPlayer: NSObject, AVAudioPlayerDelegate { + var onTimer: ((TimeInterval) -> Void)? + var onFinishPlayback: (() -> Void)? + + var audioPlayer: AVAudioPlayer? + var playbackTimer: Timer? + + init(onTimer: @escaping ((TimeInterval) -> Void), onFinishPlayback: @escaping (() -> Void)) { + self.onTimer = onTimer + self.onFinishPlayback = onFinishPlayback + } + + func start(fileName: String) { + audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName)) + audioPlayer?.delegate = self + audioPlayer?.prepareToPlay() + audioPlayer?.play() + + playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + if self.audioPlayer?.isPlaying ?? false { + guard let time = self.audioPlayer?.currentTime else { return } + self.onTimer?(time) + } + } + } + + func pause() { + audioPlayer?.pause() + } + + func play() { + audioPlayer?.play() + } + + func stop() { + if let player = audioPlayer { + player.stop() + } + audioPlayer = nil + if let timer = playbackTimer { + timer.invalidate() + } + playbackTimer = nil + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stop() + self.onFinishPlayback?() + } +} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e76c935a92..7b15456ae7 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -52,6 +52,8 @@ final class ChatModel: ObservableObject { @Published var showCallView = false // currently showing QR code @Published var connReqInv: String? + // audio recording and playback + @Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches var callWebView: WKWebView? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3cab973b36..a5a7b23cff 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -974,15 +974,22 @@ func processReceivedMsg(_ res: ChatResponse) async { Task { await receiveFile(fileId: file.fileId) } + } else if case .voice = cItem.content.msgContent, // TODO check inlineFileMode != IFMSent + let file = cItem.file, + file.fileSize <= maxImageSize, + UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) { + Task { + await receiveFile(fileId: file.fileId) + } } - if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent { + if !cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } case let .chatItemStatusUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem var res = false - if !cItem.isDeletedContent() { + if !cItem.isDeletedContent { res = m.upsertChatItem(cInfo, cItem) } if res { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index a2204cb9b0..28740a8cf4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -54,7 +54,7 @@ struct CICallItemView: View { @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View { HStack { Image(systemName: "phone.down") - Text(CICallStatus.durationText(duration)).foregroundColor(.secondary) + Text(durationText(duration)).foregroundColor(.secondary) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 7ccc272e8b..d6cd580224 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -79,7 +79,7 @@ struct CIFileView: View { ) case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") - if let filePath = getLoadedFilePath(file){ + if let filePath = getLoadedFilePath(file) { let url = URL(fileURLWithPath: filePath) showShareSheet(items: [url]) } @@ -148,7 +148,7 @@ struct CIFileView_Previews: PreviewProvider { quotedItem: nil, file: nil ) - Group{ + Group { ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index b6de98d816..91d95f3f2a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -15,7 +15,7 @@ struct CIMetaView: View { var body: some View { HStack(alignment: .center, spacing: 4) { - if !chatItem.isDeletedContent() { + if !chatItem.isDeletedContent { if chatItem.meta.itemEdited { statusImage("pencil", metaColor, 9) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift new file mode 100644 index 0000000000..f99ac40ee0 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -0,0 +1,246 @@ +// +// CIVoiceView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 22.11.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CIVoiceView: View { + var chatItem: ChatItem + let recordingFile: CIFile? + let duration: Int + @State var playbackState: VoiceMessagePlaybackState = .noPlayback + @State var playbackTime: TimeInterval? + + var body: some View { + VStack ( + alignment: chatItem.chatDir.sent ? .trailing : .leading, + spacing: 6 + ) { + HStack { + if chatItem.chatDir.sent { + playerTime() + .frame(width: 50, alignment: .leading) + player() + } else { + player() + playerTime() + .frame(width: 50, alignment: .leading) + } + } + CIMetaView(chatItem: chatItem) + .padding(.leading, chatItem.chatDir.sent ? 0 : 12) + .padding(.trailing, chatItem.chatDir.sent ? 12 : 0) + } + .padding(.bottom, 8) + } + + private func player() -> some View { + VoiceMessagePlayer( + chatItem: chatItem, + recordingFile: recordingFile, + recordingTime: TimeInterval(duration), + showBackground: true, + playbackState: $playbackState, + playbackTime: $playbackTime + ) + } + + private func playerTime() -> some View { + VoiceMessagePlayerTime( + recordingTime: TimeInterval(duration), + playbackState: $playbackState, + playbackTime: $playbackTime + ) + .foregroundColor(.secondary) + } +} + +struct VoiceMessagePlayerTime: View { + var recordingTime: TimeInterval + @Binding var playbackState: VoiceMessagePlaybackState + @Binding var playbackTime: TimeInterval? + + var body: some View { + switch playbackState { + case .noPlayback: + Text(voiceMessageTime(recordingTime)) + case .playing: + Text(voiceMessageTime_(playbackTime)) + case .paused: + Text(voiceMessageTime_(playbackTime)) + } + } +} + +struct VoiceMessagePlayer: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.colorScheme) var colorScheme + var chatItem: ChatItem + var recordingFile: CIFile? + var recordingTime: TimeInterval + var showBackground: Bool + + @State private var audioPlayer: AudioPlayer? + @Binding var playbackState: VoiceMessagePlaybackState + @Binding var playbackTime: TimeInterval? + @State private var startingPlayback: Bool = false + + var body: some View { + ZStack { + if let recordingFile = recordingFile { + switch recordingFile.fileStatus { + case .sndStored: playbackButton() + case .sndTransfer: playbackButton() + case .sndComplete: playbackButton() + case .sndCancelled: playbackButton() + case .rcvInvitation: loadingIcon() + case .rcvAccepted: loadingIcon() + case .rcvTransfer: loadingIcon() + case .rcvComplete: playbackButton() + case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) + } + } else { + playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) + } + } + .onDisappear { + audioPlayer?.stop() + } + .onChange(of: chatModel.stopPreviousRecPlay) { _ in + if !startingPlayback { + audioPlayer?.stop() + playbackState = .noPlayback + playbackTime = TimeInterval(0) + } else { + startingPlayback = false + } + } + } + + @ViewBuilder private func playbackButton() -> some View { + switch playbackState { + case .noPlayback: + Button { + if let recordingFileName = getLoadedFileName(recordingFile) { + startPlayback(recordingFileName) + } + } label: { + playPauseIcon("play.fill") + } + case .playing: + Button { + audioPlayer?.pause() + playbackState = .paused + } label: { + playPauseIcon("pause.fill") + } + case .paused: + Button { + audioPlayer?.play() + playbackState = .playing + } label: { + playPauseIcon("play.fill") + } + } + } + + private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View { + ZStack { + Image(systemName: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(color) + .padding(.leading, image == "play.fill" ? 4 : 0) + .frame(width: 56, height: 56) + .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear) + .clipShape(Circle()) + if recordingTime > 0 { + ProgressCircle(length: recordingTime, progress: $playbackTime) + .frame(width: 52, height: 52) // this + ProgressCircle lineWidth = background circle diameter + } + } + } + + private struct ProgressCircle: View { + var length: TimeInterval + @Binding var progress: TimeInterval? + + var body: some View { + Circle() + .trim(from: 0, to: ((progress ?? TimeInterval(0)) / length)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 4) + ) + .rotationEffect(.degrees(-90)) + .animation(.linear, value: progress) + } + } + + private func loadingIcon() -> some View { + ProgressView() + .frame(width: 30, height: 30) + .frame(width: 56, height: 56) + .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear) + .clipShape(Circle()) + } + + private func startPlayback(_ recordingFileName: String) { + startingPlayback = true + chatModel.stopPreviousRecPlay.toggle() + audioPlayer = AudioPlayer( + onTimer: { playbackTime = $0 }, + onFinishPlayback: { + playbackState = .noPlayback + playbackTime = TimeInterval(0) + } + ) + audioPlayer?.start(fileName: recordingFileName) + playbackTime = TimeInterval(0) + playbackState = .playing + } +} + +struct CIVoiceView_Previews: PreviewProvider { + static var previews: some View { + let sentVoiceMessage: ChatItem = ChatItem( + chatDir: .directSnd, + meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false), + content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)), + quotedItem: nil, + file: CIFile.getSample(fileStatus: .sndComplete) + ) + let voiceMessageWtFile = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false), + content: .rcvMsgContent(msgContent: .voice(text: "", duration: 30)), + quotedItem: nil, + file: nil + ) + Group { + CIVoiceView( + chatItem: ChatItem.getVoiceMsgContentSample(), + recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), + duration: 30, + playbackState: .playing, + playbackTime: TimeInterval(20) + ) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample()) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer)) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile) + .environmentObject(ChatModel()) + } + .previewLayout(.fixed(width: 360, height: 360)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift new file mode 100644 index 0000000000..d9ca2d2863 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -0,0 +1,76 @@ +// +// FramedCIVoiceView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 22.11.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +import SwiftUI +import SimpleXChat + +struct FramedCIVoiceView: View { + var chatItem: ChatItem + let recordingFile: CIFile? + let duration: Int + @State var playbackState: VoiceMessagePlaybackState = .noPlayback + @State var playbackTime: TimeInterval? + + var body: some View { + HStack { + VoiceMessagePlayer( + chatItem: chatItem, + recordingFile: recordingFile, + recordingTime: TimeInterval(duration), + showBackground: false, + playbackState: $playbackState, + playbackTime: $playbackTime + ) + VoiceMessagePlayerTime( + recordingTime: TimeInterval(duration), + playbackState: $playbackState, + playbackTime: $playbackTime + ) + .foregroundColor(.secondary) + .frame(width: 50, alignment: .leading) + } + .padding(.top, 6) + .padding(.leading, 6) + .padding(.trailing, 12) + .padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0) + } +} + +struct FramedCIVoiceView_Previews: PreviewProvider { + static var previews: some View { + let sentVoiceMessage: ChatItem = ChatItem( + chatDir: .directSnd, + meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false), + content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)), + quotedItem: nil, + file: CIFile.getSample(fileStatus: .sndComplete) + ) + let voiceMessageWithQuote: ChatItem = ChatItem( + chatDir: .directSnd, + meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false), + content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)), + quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv), + file: CIFile.getSample(fileStatus: .sndComplete) + ) + Group { + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there")) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer)) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")) + .environmentObject(ChatModel()) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote) + .environmentObject(ChatModel()) + } + .previewLayout(.fixed(width: 360, height: 360)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index c17f5fbd04..72331bd716 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -67,6 +67,12 @@ struct FramedItemView: View { } else { ciMsgContentView (chatItem, showMember) } + case let .voice(text, duration): + FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration) + .overlay(DetermineWidth()) + if text != "" { + ciMsgContentView (chatItem, showMember) + } case let .file(text): ciFileView(chatItem, text) case let .link(_, preview): @@ -112,27 +118,29 @@ struct FramedItemView: View { @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { let v = ZStack(alignment: .topTrailing) { - if case let .image(_, image) = qi.content, - let data = Data(base64Encoded: dropImagePrefix(image)), - let uiImage = UIImage(data: data) { - ciQuotedMsgView(qi) - .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 68, height: 68) - .clipped() - } else if case .file = qi.content { + switch (qi.content) { + case let .image(_, image): + if let data = Data(base64Encoded: dropImagePrefix(image)), + let uiImage = UIImage(data: data) { + ciQuotedMsgView(qi) + .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 68, height: 68) + .clipped() + } else { + ciQuotedMsgView(qi) + } + case .file: ciQuotedMsgView(qi) .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) - Image(systemName: "doc.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .padding(.top, 6) - .padding(.trailing, 4) - } else { + ciQuoteIconView("doc.fill") + case .voice: + ciQuotedMsgView(qi) + .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) + ciQuoteIconView("mic.fill") + default: ciQuotedMsgView(qi) } } @@ -163,6 +171,16 @@ struct FramedItemView: View { .padding(.horizontal, 12) } + private func ciQuoteIconView(_ image: String) -> some View { + Image(systemName: image) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .frame(width: 18, height: 18) + .padding(.top, 6) + .padding(.trailing, 6) + } + private func membership() -> GroupMember? { switch chatInfo { case let .group(groupInfo: groupInfo): return groupInfo.membership diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 3875745459..b7bcd1d943 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -42,6 +42,9 @@ struct ChatItemView: View { @ViewBuilder private func contentItemView() -> some View { if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) { EmojiItemView(chatItem: chatItem) + } else if chatItem.quotedItem == nil && chatItem.content.text.isEmpty, + case let .voice(_, duration) = chatItem.content.msgContent { + CIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration) } else { FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index d8f59be88e..c8b363a813 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -414,14 +414,14 @@ struct ChatView: View { private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading var menu: [UIAction] = [] - if ci.isMsgContent() { + if let mc = ci.content.msgContent { menu.append(contentsOf: [ UIAction( title: NSLocalizedString("Reply", comment: "chat item action"), image: UIImage(systemName: "arrowshape.turn.up.left") ) { _ in withAnimation { - if composeState.editing() { + if composeState.editing { composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) } else { composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) @@ -451,18 +451,31 @@ struct ChatView: View { } } ]) - if case .image = ci.content.msgContent, - let image = getLoadedImage(ci.file) { - menu.append( - UIAction( - title: NSLocalizedString("Save", comment: "chat item action"), - image: UIImage(systemName: "square.and.arrow.down") - ) { _ in - UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) - } - ) + if let filePath = getLoadedFilePath(ci.file) { + if case .image = ci.content.msgContent, + let image = UIImage(contentsOfFile: filePath) { + menu.append( + UIAction( + title: NSLocalizedString("Save", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.down") + ) { _ in + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + ) + } else { + menu.append( + UIAction( + title: NSLocalizedString("Save", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.down") + ) { _ in + let fileURL = URL(fileURLWithPath: filePath) + showShareSheet(items: [fileURL]) + } + ) + } } - if ci.meta.editable { + if ci.meta.editable, + !mc.isVoice { menu.append( UIAction( title: NSLocalizedString("Edit", comment: "chat item action"), @@ -484,7 +497,7 @@ struct ChatView: View { deletingItem = ci } ) - } else if ci.isDeletedContent() { + } else if ci.isDeletedContent { menu.append( UIAction( title: NSLocalizedString("Delete", comment: "chat item action"), diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 8d216e27cb..e0d96e0583 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -13,6 +13,7 @@ enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) case imagePreviews(imagePreviews: [String]) + case voicePreview(recordingFileName: String, duration: Int) case filePreview(fileName: String) } @@ -22,10 +23,18 @@ enum ComposeContextItem { case editingItem(chatItem: ChatItem) } +enum VoiceMessageRecordingState { + case noRecording + case recording + case finished +} + struct ComposeState { var message: String var preview: ComposePreview var contextItem: ComposeContextItem + var voiceMessageRecordingState: VoiceMessageRecordingState + var voiceMessageAllowed: Bool var inProgress = false var disabled = false var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) @@ -33,66 +42,87 @@ struct ComposeState { init( message: String = "", preview: ComposePreview = .noPreview, - contextItem: ComposeContextItem = .noContextItem + contextItem: ComposeContextItem = .noContextItem, + voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording, + voiceMessageAllowed: Bool = true // TODO based on preference ) { self.message = message self.preview = preview self.contextItem = contextItem + self.voiceMessageRecordingState = voiceMessageRecordingState + self.voiceMessageAllowed = voiceMessageAllowed } init(editingItem: ChatItem) { self.message = editingItem.content.text self.preview = chatItemPreview(chatItem: editingItem) self.contextItem = .editingItem(chatItem: editingItem) + if let emc = editingItem.content.msgContent, + case .voice = emc { + self.voiceMessageRecordingState = .finished + } else { + self.voiceMessageRecordingState = .noRecording + } + self.voiceMessageAllowed = false } func copy( message: String? = nil, preview: ComposePreview? = nil, - contextItem: ComposeContextItem? = nil + contextItem: ComposeContextItem? = nil, + voiceMessageRecordingState: VoiceMessageRecordingState? = nil ) -> ComposeState { ComposeState( message: message ?? self.message, preview: preview ?? self.preview, - contextItem: contextItem ?? self.contextItem + contextItem: contextItem ?? self.contextItem, + voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState ) } - func editing() -> Bool { + var editing: Bool { switch contextItem { case .editingItem: return true default: return false } } - func sendEnabled() -> Bool { + var sendEnabled: Bool { switch preview { - case .imagePreviews: - return true - case .filePreview: - return true - default: - return !message.isEmpty + case .imagePreviews: return true + case .voicePreview: return voiceMessageRecordingState == .finished + case .filePreview: return true + default: return !message.isEmpty } } - func linkPreviewAllowed() -> Bool { + var linkPreviewAllowed: Bool { switch preview { - case .imagePreviews: - return false - case .filePreview: - return false - default: - return useLinkPreviews + case .imagePreviews: return false + case .voicePreview: return false + case .filePreview: return false + default: return useLinkPreviews } } - func linkPreview() -> LinkPreview? { + var linkPreview: LinkPreview? { switch preview { - case let .linkPreview(linkPreview): - return linkPreview - default: - return nil + 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 } } } @@ -104,8 +134,10 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { chatItemPreview = .noPreview case let .link(_, preview: preview): chatItemPreview = .linkPreview(linkPreview: preview) - case let .image(_, image: image): + case let .image(_, image): chatItemPreview = .imagePreviews(imagePreviews: [image]) + case let .voice(_, duration): + chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration) case .file: chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "") default: @@ -131,12 +163,17 @@ struct ComposeView: View { @State var chosenImages: [UIImage] = [] @State private var showFileImporter = false @State var chosenFile: URL? = nil + + @State private var audioRecorder: AudioRecorder? + @State private var voiceMessageRecordingTime: TimeInterval? + @State private var startingRecording: Bool = false var body: some View { VStack(spacing: 0) { contextItemView() - switch (composeState.editing(), composeState.preview) { + switch (composeState.editing, composeState.preview) { case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed default: previewView() } HStack (alignment: .bottom) { @@ -146,7 +183,7 @@ struct ComposeView: View { Image(systemName: "paperclip") .resizable() } - .disabled(composeState.editing()) + .disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording) .frame(width: 25, height: 25) .padding(.bottom, 12) .padding(.leading, 12) @@ -156,6 +193,12 @@ struct ComposeView: View { sendMessage() resetLinkPreview() }, + startVoiceMessageRecording: { + Task { + await startVoiceMessageRecording() + } + }, + finishVoiceMessageRecording: { finishVoiceMessageRecording() }, keyboardVisible: $keyboardVisible ) .padding(.trailing, 12) @@ -163,7 +206,7 @@ struct ComposeView: View { } } .onChange(of: composeState.message) { _ in - if composeState.linkPreviewAllowed() { + if composeState.linkPreviewAllowed { if composeState.message.count > 0 { showLinkPreview(composeState.message) } else { @@ -250,6 +293,21 @@ struct ComposeView: View { } } } + .onDisappear { + audioRecorder?.stop() + if let fileName = composeState.voiceMessageRecordingFileName { + cancelVoiceMessageRecording(fileName) + } + } + .onChange(of: chatModel.stopPreviousRecPlay) { _ in + if !startingRecording { + if composeState.voiceMessageRecordingState == .recording { + finishVoiceMessageRecording() + } + } else { + startingRecording = false + } + } } @ViewBuilder func previewView() -> some View { @@ -265,7 +323,15 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) chosenImages = [] }, - cancelEnabled: !composeState.editing()) + cancelEnabled: !composeState.editing) + case let .voicePreview(recordingFileName, _): + ComposeVoiceView( + recordingFileName: recordingFileName, + recordingTime: $voiceMessageRecordingTime, + recordingState: $composeState.voiceMessageRecordingState, + cancelVoiceMessage: { cancelVoiceMessageRecording($0) }, + cancelEnabled: !composeState.editing + ) case let .filePreview(fileName: fileName): ComposeFileView( fileName: fileName, @@ -273,7 +339,7 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) chosenFile = nil }, - cancelEnabled: !composeState.editing()) + cancelEnabled: !composeState.editing) } } @@ -354,6 +420,8 @@ struct ComposeView: View { if !sent { await send(.text(composeState.message), quoted: quoted) } + case let .voicePreview(recordingFileName, duration): + await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName) case .filePreview: if let fileURL = chosenFile, let savedFile = saveFileFromURL(fileURL) { @@ -386,6 +454,57 @@ struct ComposeView: View { } } + 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)") + } + } + ) + if let err = await audioRecorder?.start(fileName: fileName) { + print(err) // TODO show alert + } 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)") + } + } + + // ? 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) { + removeFile(fileName) + clearState() + } + private func clearState() { composeState = ComposeState() linkUrl = nil @@ -394,6 +513,9 @@ struct ComposeView: View { cancelledLinks = [] chosenImages = [] chosenFile = nil + audioRecorder?.stop() + audioRecorder = nil + voiceMessageRecordingTime = nil } private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent { @@ -404,6 +526,8 @@ struct ComposeView: View { 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, _): @@ -415,7 +539,7 @@ struct ComposeView: View { prevLinkUrl = linkUrl linkUrl = parseMessage(s) if let url = linkUrl { - if url != composeState.linkPreview()?.uri && url != pendingLinkUrl { + if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url if prevLinkUrl == url { loadLinkPreview(url) @@ -444,7 +568,7 @@ struct ComposeView: View { } private func cancelLinkPreview() { - if let uri = composeState.linkPreview()?.uri.absoluteString { + if let uri = composeState.linkPreview?.uri.absoluteString { cancelledLinks.insert(uri) } pendingLinkUrl = nil @@ -499,11 +623,13 @@ struct ComposeView_Previews: PreviewProvider { composeState: $composeState, keyboardVisible: $keyboardVisible ) + .environmentObject(ChatModel()) ComposeView( chat: chat, composeState: $composeState, keyboardVisible: $keyboardVisible ) + .environmentObject(ChatModel()) } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift new file mode 100644 index 0000000000..71893fbc5c --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift @@ -0,0 +1,190 @@ +// +// ComposeVoiceView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 21.11.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum VoiceMessagePlaybackState { + case noPlayback + case playing + case paused +} + +func voiceMessageTime(_ time: TimeInterval) -> String { + let min = Int(time / 60) + let sec = Int(time.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", min, sec) +} + +func voiceMessageTime_(_ time: TimeInterval?) -> String { + return voiceMessageTime(time ?? TimeInterval(0)) +} + +struct ComposeVoiceView: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.colorScheme) var colorScheme + var recordingFileName: String + @Binding var recordingTime: TimeInterval? + @Binding var recordingState: VoiceMessageRecordingState + let cancelVoiceMessage: ((String) -> Void) + let cancelEnabled: Bool + + @State private var audioPlayer: AudioPlayer? + @State private var playbackState: VoiceMessagePlaybackState = .noPlayback + @State private var playbackTime: TimeInterval? + @State private var startingPlayback: Bool = false + + private static let previewHeight: CGFloat = 50 + + var body: some View { + ZStack { + if recordingState != .finished { + recordingMode() + } else { + playbackMode() + } + } + .padding(.vertical, 1) + .frame(height: ComposeVoiceView.previewHeight) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + .onDisappear { + audioPlayer?.stop() + } + .onChange(of: chatModel.stopPreviousRecPlay) { _ in + if !startingPlayback { + audioPlayer?.stop() + playbackState = .noPlayback + playbackTime = TimeInterval(0) + } else { + startingPlayback = false + } + } + } + + private func recordingMode() -> some View { + ZStack { + HStack(alignment: .center, spacing: 8) { + playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) + Text(voiceMessageTime_(recordingTime)) + Spacer() + if cancelEnabled { + cancelButton() + } + } + .padding(.trailing, 12) + + ProgressBar(length: maxVoiceMessageLength, progress: $recordingTime) + } + } + + private func playbackMode() -> some View { + ZStack { + HStack(alignment: .center, spacing: 8) { + switch playbackState { + case .noPlayback: + Button { + startPlayback() + } label: { + playPauseIcon("play.fill") + } + Text(voiceMessageTime_(recordingTime)) + case .playing: + Button { + audioPlayer?.pause() + playbackState = .paused + } label: { + playPauseIcon("pause.fill") + } + Text(voiceMessageTime_(playbackTime)) + case .paused: + Button { + audioPlayer?.play() + playbackState = .playing + } label: { + playPauseIcon("play.fill") + } + Text(voiceMessageTime_(playbackTime)) + } + Spacer() + if cancelEnabled { + cancelButton() + } + } + .padding(.trailing, 12) + + if let recordingLength = recordingTime { + ProgressBar(length: recordingLength, progress: $playbackTime) + } + } + } + + private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View { + Image(systemName: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(color) + .padding(.leading, 12) + } + + private func cancelButton() -> some View { + Button { + audioPlayer?.stop() + cancelVoiceMessage(recordingFileName) + } label: { + Image(systemName: "multiply") + } + } + + private struct ProgressBar: View { + var length: TimeInterval + @Binding var progress: TimeInterval? + + var body: some View { + GeometryReader { geometry in + ZStack { + Rectangle() + .fill(Color.accentColor) + .frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4) + .animation(.linear, value: progress) + } + .frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding + } + } + } + + private func startPlayback() { + startingPlayback = true + chatModel.stopPreviousRecPlay.toggle() + audioPlayer = AudioPlayer( + onTimer: { playbackTime = $0 }, + onFinishPlayback: { + playbackState = .noPlayback + playbackTime = recordingTime // animate progress bar to the end + } + ) + audioPlayer?.start(fileName: recordingFileName) + playbackTime = TimeInterval(0) + playbackState = .playing + } +} + +struct ComposeVoiceView_Previews: PreviewProvider { + static var previews: some View { + ComposeVoiceView( + recordingFileName: "voice.m4a", + recordingTime: Binding.constant(TimeInterval(20)), + recordingState: Binding.constant(VoiceMessageRecordingState.recording), + cancelVoiceMessage: { _ in }, + cancelEnabled: true + ) + .environmentObject(ChatModel()) + } +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 2fada6ad53..479155f8bf 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -12,6 +12,9 @@ import SimpleXChat struct SendMessageView: View { @Binding var composeState: ComposeState var sendMessage: () -> Void + var startVoiceMessageRecording: (() -> Void)? = nil + var finishVoiceMessageRecording: (() -> Void)? = nil + @State private var longPressingVMR = false @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @State private var teHeight: CGFloat = 42 @@ -23,24 +26,34 @@ struct SendMessageView: View { ZStack { HStack(alignment: .bottom) { ZStack(alignment: .leading) { - let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading - Text(composeState.message) - .lineLimit(10) - .font(teFont) - .multilineTextAlignment(alignment) - .foregroundColor(.clear) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .matchedGeometryEffect(id: "te", in: namespace) - .background(GeometryReader(content: updateHeight)) - TextEditor(text: $composeState.message) - .focused($keyboardVisible) - .font(teFont) - .textInputAutocapitalization(.sentences) - .multilineTextAlignment(alignment) - .padding(.horizontal, 5) - .allowsTightening(false) - .frame(height: teHeight) + if case .voicePreview = composeState.preview { + Text("Voice message…") + .font(teFont.italic()) + .multilineTextAlignment(.leading) + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } else { + let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading + Text(composeState.message) + .lineLimit(10) + .font(teFont) + .multilineTextAlignment(alignment) + .foregroundColor(.clear) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .matchedGeometryEffect(id: "te", in: namespace) + .background(GeometryReader(content: updateHeight)) + TextEditor(text: $composeState.message) + .focused($keyboardVisible) + .font(teFont) + .textInputAutocapitalization(.sentences) + .multilineTextAlignment(alignment) + .padding(.horizontal, 5) + .allowsTightening(false) + .frame(height: teHeight) + } } if (composeState.inProgress) { @@ -49,14 +62,18 @@ struct SendMessageView: View { .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - Button(action: { sendMessage() }) { - Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill") - .resizable() - .foregroundColor(.accentColor) + let vmrs = composeState.voiceMessageRecordingState + if composeState.voiceMessageAllowed, + composeState.message.isEmpty, + !composeState.editing, + (composeState.noPreview && vmrs == .noRecording) + || (vmrs == .recording && longPressingVMR) { + recordVoiceMessageButton() + } else if vmrs == .recording && !longPressingVMR { + finishVoiceMessageRecordingButton() + } else { + sendMessageButton() } - .disabled(!composeState.sendEnabled() || composeState.disabled) - .frame(width: 29, height: 29) - .padding([.bottom, .trailing], 4) } } @@ -67,14 +84,60 @@ struct SendMessageView: View { .padding(.vertical, 8) } - func updateHeight(_ g: GeometryProxy) -> Color { + private func sendMessageButton() -> some View { + Button(action: { sendMessage() }) { + Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill") + .resizable() + .foregroundColor(.accentColor) + } + .disabled(!composeState.sendEnabled || composeState.disabled) + .frame(width: 29, height: 29) + .padding([.bottom, .trailing], 4) + } + + private func recordVoiceMessageButton() -> some View { + Button(action: { + if !longPressingVMR { + startVoiceMessageRecording?() + } else { + finishVoiceMessageRecording?() + } + longPressingVMR = false + }) { + Image(systemName: "mic") + .foregroundColor(.secondary) + } + .simultaneousGesture( + LongPressGesture() + .onEnded { _ in + longPressingVMR = true + startVoiceMessageRecording?() + } + ) + .disabled(composeState.disabled) + .frame(width: 29, height: 29) + .padding([.bottom, .trailing], 4) + + } + + private func finishVoiceMessageRecordingButton() -> some View { + Button(action: { finishVoiceMessageRecording?() }) { + Image(systemName: "stop.fill") + .foregroundColor(.accentColor) + } + .disabled(composeState.disabled) + .frame(width: 29, height: 29) + .padding([.bottom, .trailing], 4) + } + + private func updateHeight(_ g: GeometryProxy) -> Color { DispatchQueue.main.async { teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) teFont = isShortEmoji(composeState.message) ? composeState.message.count < 4 - ? largeEmojiFont - : mediumEmojiFont - : .body + ? largeEmojiFont + : mediumEmojiFont + : .body } return Color.clear } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index e563cdc3f2..5017391f90 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -17,7 +17,7 @@ struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @State var composeState: ComposeState = ComposeState() + @State var composeState: ComposeState = ComposeState(voiceMessageAllowed: false) @FocusState private var keyboardVisible: Bool @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @@ -120,7 +120,7 @@ struct TerminalView: View { } } } - composeState = ComposeState() + composeState = ComposeState(voiceMessageAllowed: false) } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ef0939ccc3..0390721492 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -223,8 +223,15 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification let inline = privacyTransferImagesInlineGroupDefault.get() cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem } - } - return cItem.isCall() ? nil : (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) + } else if case .voice = cItem.content.msgContent { // TODO check inlineFileMode != IFMSent + if let file = cItem.file, + file.fileSize <= maxImageSize, + privacyAcceptImagesGroupDefault.get() { + let inline = privacyTransferImagesInlineGroupDefault.get() + cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem + } + } + return cItem.isCall ? nil : (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) case let .callInvitation(invitation): return (invitation.contact.id, createCallInvitationNtf(invitation)) default: diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6fd2a4c404..c8a7b2e2d2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,11 +43,6 @@ 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; - 5C58BCD0292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C58BCCB292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */; }; - 5C58BCD1292AA93700AF9E4F /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C58BCCC292AA93700AF9E4F /* libgmp.a */; }; - 5C58BCD2292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C58BCCD292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */; }; - 5C58BCD3292AA93700AF9E4F /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C58BCCE292AA93700AF9E4F /* libffi.a */; }; - 5C58BCD4292AA93700AF9E4F /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C58BCCF292AA93700AF9E4F /* libgmpxx.a */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; @@ -66,6 +61,7 @@ 5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293429241CD90090FFF9 /* libgmpxx.a */; }; 5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */; }; 5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293629241CDA0090FFF9 /* libgmp.a */; }; + 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */; }; 5C9329412929248A0090FFF9 /* ScanSMPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9329402929248A0090FFF9 /* ScanSMPServer.swift */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; @@ -141,6 +137,9 @@ 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; }; 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; + 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; + 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; }; + 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; }; 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; }; 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; @@ -249,11 +248,6 @@ 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = ""; }; - 5C58BCCB292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a"; sourceTree = ""; }; - 5C58BCCC292AA93700AF9E4F /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C58BCCD292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = ""; }; - 5C58BCCE292AA93700AF9E4F /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C58BCCF292AA93700AF9E4F /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = ""; }; 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = ""; }; 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; @@ -272,6 +266,7 @@ 5C93293429241CD90090FFF9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = ""; }; 5C93293629241CDA0090FFF9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecPlay.swift; sourceTree = ""; }; 5C9329402929248A0090FFF9 /* ScanSMPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanSMPServer.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; @@ -351,6 +346,9 @@ 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = ""; }; 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = ""; }; + 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = ""; }; + 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = ""; }; + 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; }; 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; @@ -486,6 +484,7 @@ 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, + 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, ); path = Model; sourceTree = ""; @@ -668,6 +667,7 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */, 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, 5C029EA72837DBB3004A9677 /* CICallItemView.swift */, @@ -675,6 +675,7 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */, 6440C9FF288857A10062C672 /* CIEventView.swift */, 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */, + 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */, ); path = ChatItem; sourceTree = ""; @@ -688,6 +689,7 @@ 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, 6454036E2822A9750090DDFF /* ComposeFileView.swift */, 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */, + 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -920,6 +922,7 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, + 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, @@ -930,6 +933,7 @@ 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, @@ -980,6 +984,7 @@ 5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */, 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, + 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, @@ -1003,6 +1008,7 @@ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanSMPServer.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, + 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */, @@ -1253,7 +1259,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -1295,7 +1301,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index de52d72700..d7533ce2c9 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1256,11 +1256,10 @@ public struct ChatItem: Identifiable, Decodable { public var timestampText: Text { meta.timestampText } public var text: String { - get { - switch (content.text, file) { - case let ("", .some(file)): return file.fileName - default: return content.text - } + switch (content.text, content.msgContent, file) { + case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))" + case let ("", _, .some(file)): return file.fileName + default: return content.text } } @@ -1269,15 +1268,7 @@ public struct ChatItem: Identifiable, Decodable { return false } - public func isMsgContent() -> Bool { - switch content { - case .sndMsgContent: return true - case .rcvMsgContent: return true - default: return false - } - } - - public func isDeletedContent() -> Bool { + public var isDeletedContent: Bool { switch content { case .sndDeleted: return true case .rcvDeleted: return true @@ -1285,7 +1276,7 @@ public struct ChatItem: Identifiable, Decodable { } } - public func isCall() -> Bool { + public var isCall: Bool { switch content { case .sndCall: return true case .rcvCall: return true @@ -1333,6 +1324,16 @@ public struct ChatItem: Identifiable, Decodable { ) } + public static func getVoiceMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "voice.m4a", fileSize: Int64 = 65536, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem { + ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false), + content: .rcvMsgContent(msgContent: .voice(text: text, duration: 30)), + quotedItem: nil, + file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus) + ) + } + public static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem { ChatItem( chatDir: .directRcv, @@ -1544,7 +1545,12 @@ public struct CIQuote: Decodable, ItemContent { public var content: MsgContent public var formattedText: [FormattedText]? - public var text: String { get { content.text } } + public var text: String { + switch (content.text, content) { + case let ("", .voice(_, duration)): return durationText(duration) + default: return content.text + } + } public func getSender(_ membership: GroupMember?) -> String? { switch (chatDir) { @@ -1611,28 +1617,33 @@ public enum MsgContent { case text(String) case link(text: String, preview: LinkPreview) case image(text: String, image: String) + case voice(text: String, duration: Int) case file(String) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) public var text: String { - get { - switch self { - case let .text(text): return text - case let .link(text, _): return text - case let .image(text, _): return text - case let .file(text): return text - case let .unknown(_, text): return text - } + switch self { + case let .text(text): return text + case let .link(text, _): return text + case let .image(text, _): return text + case let .voice(text, _): return text + case let .file(text): return text + case let .unknown(_, text): return text + } + } + + public var isVoice: Bool { + switch self { + case .voice: return true + default: return false } } var cmdString: String { - get { - switch self { - case let .text(text): return "text \(text)" - default: return "json \(encodeJSON(self))" - } + switch self { + case let .text(text): return "text \(text)" + default: return "json \(encodeJSON(self))" } } @@ -1641,6 +1652,7 @@ public enum MsgContent { case text case preview case image + case duration } } @@ -1661,6 +1673,10 @@ extension MsgContent: Decodable { let text = try container.decode(String.self, forKey: CodingKeys.text) let image = try container.decode(String.self, forKey: CodingKeys.image) self = .image(text: text, image: image) + case "voice": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let duration = try container.decode(Int.self, forKey: CodingKeys.duration) + self = .voice(text: text, duration: duration) case "file": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .file(text) @@ -1689,6 +1705,10 @@ extension MsgContent: Encodable { try container.encode("image", forKey: .type) try container.encode(text, forKey: .text) try container.encode(image, forKey: .image) + case let .voice(text, duration): + try container.encode("voice", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(duration, forKey: .duration) case let .file(text): try container.encode("file", forKey: .type) try container.encode(text, forKey: .text) @@ -1760,7 +1780,7 @@ public enum FormatColor: String, Decodable { } // Struct to use with simplex API -public struct LinkPreview: Codable { +public struct LinkPreview: Codable, Equatable { public init(uri: URL, title: String, description: String = "", image: String) { self.uri = uri self.title = title @@ -1814,14 +1834,14 @@ public enum CICallStatus: String, Decodable { case .accepted: return NSLocalizedString("accepted call", comment: "call status") case .negotiated: return NSLocalizedString("connecting call", comment: "call status") case .progress: return NSLocalizedString("call in progress", comment: "call status") - case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), CICallStatus.durationText(sec)) + case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), durationText(sec)) case .error: return NSLocalizedString("call error", comment: "call status") } } +} - public static func durationText(_ sec: Int) -> String { - String(format: "%02d:%02d", sec / 60, sec % 60) - } +public func durationText(_ sec: Int) -> String { + String(format: "%02d:%02d", sec / 60, sec % 60) } public enum MsgErrorType: Decodable { diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 64bf6f74e0..b150297f8d 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -17,6 +17,8 @@ public let maxImageSize: Int64 = 236700 public let maxFileSize: Int64 = 8000000 +public let maxVoiceMessageLength = TimeInterval(30) + private let CHAT_DB: String = "_chat.db" private let AGENT_DB: String = "_agent.db" @@ -73,7 +75,7 @@ public func deleteAppFiles() { } } -func fileSize(_ url: URL) -> Int? { // in bytes +public func fileSize(_ url: URL) -> Int? { // in bytes do { let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) return val.totalFileAllocatedSize ?? val.fileAllocatedSize @@ -157,15 +159,22 @@ public func getAppFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } -func getAppFilePath(_ fileName: String) -> URL { +public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } -public func getLoadedFilePath(_ file: CIFile?) -> String? { +public func getLoadedFileName(_ file: CIFile?) -> String? { if let file = file, file.loaded, - let savedFile = file.filePath { - return getAppFilePath(savedFile).path + let fileName = file.filePath { + return fileName + } + return nil +} + +public func getLoadedFilePath(_ file: CIFile?) -> String? { + if let fileName = getLoadedFileName(file) { + return getAppFilePath(fileName).path } return nil } @@ -198,13 +207,18 @@ public func saveFileFromURL(_ url: URL) -> String? { public func saveImage(_ uiImage: UIImage) -> String? { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { - let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") - let fileName = uniqueCombine("IMG_\(timestamp).jpg") + let fileName = generateNewFileName("IMG", "jpg") return saveFile(imageDataResized, fileName) } return nil } +public func generateNewFileName(_ prefix: String, _ ext: String) -> String { + let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") + let fileName = uniqueCombine("\(prefix)_\(timestamp).\(ext)") + return fileName +} + extension Date { func getFormattedDate(_ format: String) -> String { let df = DateFormatter() diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 1c148bdfac..e859d880a8 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -162,21 +162,17 @@ public func createNotification(categoryIdentifier: String, title: String, subtit } func hideSecrets(_ cItem: ChatItem) -> String { - if cItem.content.text != "" { - if let md = cItem.formattedText { - var res = "" - for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text - } + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text } - return res - } else { - return cItem.content.text } + return res } else { - return cItem.file?.fileName ?? "" + return cItem.text } }