ios: voice messages (#1389)

* experiments

* audio recording in swiftui

* recording encapsulated

* permission + playback

* stopAudioPlayback on cancel

* method names

* check permission in recording start

* run timer on main thread

* remove obsolete view

* don't call playback timer callback unless player is playing

* compose + send view + preview + send

* animation + improve state + quality

* fix recording not stopping in time

* animate to end

* remove recorder delegate, fix cancelling during recording

* replace print with log

* recording start error constructor

* CIVoiceView file

* chat item wip

* chat item wip

* refactor settings

* layout

* send correct duration

* item previews

* more background, animation

* more layout

* more layout, send button conditions

* context, preview, quote, notification texts

* chat item actions

* use isEmpty

* remove comment

* uncomment file.loaded

* more layout, hold to record

* more layout

* preview player stop on disappear

* more layout

* comment

* only one player or recording

* remove voice message on chat close

* fix state bug

* remove commented code

* length 30

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
JRoberts
2022-11-24 21:18:28 +04:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent 4485d46307
commit a6e4e68bc5
22 changed files with 1099 additions and 171 deletions
@@ -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)
@@ -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")))
+141
View File
@@ -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?()
}
}
+2
View File
@@ -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<Int64, () -> Void> = [:]
+9 -2
View File
@@ -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 {
@@ -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)
}
}
@@ -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))
@@ -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)
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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
@@ -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)
}
+27 -14
View File
@@ -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"),
@@ -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())
}
}
}
@@ -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())
}
}
@@ -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
}
+2 -2
View File
@@ -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)
}
}
@@ -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:
+18 -12
View File
@@ -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 = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
5C58BCCC292AA93700AF9E4F /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C58BCCD292AA93700AF9E4F /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = "<group>"; };
5C58BCCE292AA93700AF9E4F /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C58BCCF292AA93700AF9E4F /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = "<group>"; };
5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = "<group>"; };
5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = "<group>"; };
@@ -272,6 +266,7 @@
5C93293429241CD90090FFF9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = "<group>"; };
5C93293629241CDA0090FFF9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecPlay.swift; sourceTree = "<group>"; };
5C9329402929248A0090FFF9 /* ScanSMPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanSMPServer.swift; sourceTree = "<group>"; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
@@ -351,6 +346,9 @@
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; };
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -486,6 +484,7 @@
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */,
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */,
5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */,
5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */,
);
path = Model;
sourceTree = "<group>";
@@ -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 = "<group>";
@@ -688,6 +689,7 @@
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */,
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */,
);
path = ComposeMessage;
sourceTree = "<group>";
@@ -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;
+54 -34
View File
@@ -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 {
+21 -7
View File
@@ -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()
+9 -13
View File
@@ -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
}
}