diff --git a/Linphone/Assets.xcassets/pause-fill.imageset/Contents.json b/Linphone/Assets.xcassets/pause-fill.imageset/Contents.json
new file mode 100644
index 000000000..abc6d6ada
--- /dev/null
+++ b/Linphone/Assets.xcassets/pause-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "pause-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg b/Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg
new file mode 100644
index 000000000..784dd71dd
--- /dev/null
+++ b/Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/stop-fill.imageset/Contents.json b/Linphone/Assets.xcassets/stop-fill.imageset/Contents.json
new file mode 100644
index 000000000..4ed79abba
--- /dev/null
+++ b/Linphone/Assets.xcassets/stop-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "stop-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg b/Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg
new file mode 100644
index 000000000..91291bef4
--- /dev/null
+++ b/Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift
index bb71fef4c..580b37bbb 100644
--- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift
@@ -81,7 +81,7 @@ struct ContactsInnerFragment: View {
showingSheet: $showingSheet, startCallFunc: {_ in })}
.safeAreaInset(edge: .top, content: {
Spacer()
- .frame(height: 14)
+ .frame(height: 12)
})
.listStyle(.plain)
.overlay(
diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift
index 3cfb8e069..138615747 100644
--- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift
+++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift
@@ -33,7 +33,6 @@ struct ChatBubbleView: View {
@State private var ticker = Ticker()
@State private var isPressed: Bool = false
@State private var timePassed: TimeInterval?
- @State private var sliderValue: Double = 0.5
var body: some View {
HStack {
@@ -160,8 +159,8 @@ struct ChatBubbleView: View {
if !eventLogMessage.message.text.isEmpty {
Text(eventLogMessage.message.text)
- .foregroundStyle(Color.grayMain2c700)
- .default_text_style(styleSize: 16)
+ .foregroundStyle(Color.grayMain2c700)
+ .default_text_style(styleSize: 16)
}
HStack(alignment: .center) {
@@ -397,14 +396,10 @@ struct ChatBubbleView: View {
.clipped()
} else if eventLogMessage.message.attachments.first!.type == .voiceRecording {
CustomSlider(
- value: $sliderValue,
- range: 0...1,
- thumbColor: .blue,
- trackColor: .gray,
- trackHeight: 8,
- cornerRadius: 10
+ conversationViewModel: conversationViewModel,
+ eventLogMessage: eventLogMessage
)
- .padding()
+ .frame(width: geometryProxy.size.width - 160, height: 50)
} else {
HStack {
VStack {
@@ -609,6 +604,122 @@ extension View {
}
}
+struct CustomSlider: View {
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ let eventLogMessage: EventLogMessage
+
+ @State private var value: Double = 0.0
+ @State private var isPlaying: Bool = false
+ @State private var timer: Timer?
+
+ var minTrackColor: Color = .white.opacity(0.5)
+ var maxTrackGradient: Gradient = Gradient(colors: [Color.orangeMain300, Color.orangeMain500])
+
+ var body: some View {
+ GeometryReader { geometry in
+ let radius = geometry.size.height * 0.5
+ ZStack(alignment: .leading) {
+ LinearGradient(
+ gradient: maxTrackGradient,
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ HStack {
+ Rectangle()
+ .foregroundColor(minTrackColor)
+ .frame(width: self.value * geometry.size.width / 100, height: geometry.size.height)
+ .animation(self.value > 0 ? .linear(duration: 0.1) : nil, value: self.value)
+ }
+
+ HStack {
+ Button(
+ action: {
+ if isPlaying {
+ conversationViewModel.pauseVoiceRecordPlayer()
+ pauseProgress()
+ } else {
+ conversationViewModel.startVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full)
+ playProgress()
+ }
+ },
+ label: {
+ Image(isPlaying ? "pause-fill" : "play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 20, height: 20)
+ }
+ )
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+
+ Spacer()
+
+ HStack {
+ Text((eventLogMessage.message.attachments.first!.duration/1000).convertDurationToString())
+ .default_text_style(styleSize: 16)
+ .padding(.horizontal, 5)
+ }
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+ }
+ .padding(.horizontal, 10)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: radius))
+ .onDisappear {
+ resetProgress()
+ }
+ }
+ }
+
+ private func playProgress() {
+ isPlaying = true
+ self.value = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full)
+ timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
+ if self.value < 100.0 {
+ let valueTmp = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full)
+ if self.value > 90 && self.value == valueTmp {
+ self.value = 100
+ } else {
+ if valueTmp == 0 && !conversationViewModel.isPlayingVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full) {
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ } else {
+ self.value = valueTmp
+ }
+ }
+ } else {
+ resetProgress()
+ }
+ }
+ }
+
+ // Pause the progress
+ private func pauseProgress() {
+ isPlaying = false
+ stopProgress()
+ }
+
+ // Reset the progress
+ private func resetProgress() {
+ conversationViewModel.stopVoiceRecordPlayer()
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ }
+
+ // Stop the progress and invalidate the timer
+ private func stopProgress() {
+ timer?.invalidate()
+ timer = nil
+ }
+}
+
/*
#Preview {
ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0)
diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift
index 5a79c70d3..e8a3e663e 100644
--- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift
+++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift
@@ -50,6 +50,7 @@ struct ConversationFragment: View {
@State private var isShowCamera = false
@State private var mediasIsLoading = false
+ @State private var voiceRecordingInProgress = false
@State private var isShowConversationForwardMessageFragment = false
@@ -102,6 +103,7 @@ struct ConversationFragment: View {
ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend)
.edgesIgnoringSafeArea(.all)
}
+ .background(Color.gray100.ignoresSafeArea(.keyboard))
} else {
innerView(geometry: geometry)
.background(.white)
@@ -141,6 +143,7 @@ struct ConversationFragment: View {
.fullScreenCover(isPresented: $isShowCamera) {
ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend)
}
+ .background(Color.gray100.ignoresSafeArea(.keyboard))
}
}
}
@@ -513,117 +516,123 @@ struct ConversationFragment: View {
}
HStack(spacing: 0) {
- Button {
- } label: {
- Image("smiley")
- .renderingMode(.template)
- .resizable()
- .foregroundStyle(Color.grayMain2c500)
- .frame(width: 28, height: 28, alignment: .leading)
- .padding(.all, 6)
- .padding(.top, 4)
- }
- .padding(.horizontal, isMessageTextFocused ? 0 : 2)
-
- Button {
- self.isShowPhotoLibrary = true
- self.mediasIsLoading = true
- } label: {
- Image("paperclip")
- .renderingMode(.template)
- .resizable()
- .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500)
- .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading)
- .padding(.all, isMessageTextFocused ? 0 : 6)
- .padding(.top, 4)
- .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading)
- }
- .padding(.horizontal, isMessageTextFocused ? 0 : 2)
-
- Button {
- self.isShowCamera = true
- } label: {
- Image("camera")
- .renderingMode(.template)
- .resizable()
- .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500)
- .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading)
- .padding(.all, isMessageTextFocused ? 0 : 6)
- .padding(.top, 4)
- .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading)
- }
- .padding(.horizontal, isMessageTextFocused ? 0 : 2)
-
- HStack {
- if #available(iOS 16.0, *) {
- TextField("Say something...", text: $conversationViewModel.messageText, axis: .vertical)
- .default_text_style(styleSize: 15)
- .focused($isMessageTextFocused)
- .padding(.vertical, 5)
- } else {
- ZStack(alignment: .leading) {
- TextEditor(text: $conversationViewModel.messageText)
- .multilineTextAlignment(.leading)
- .frame(maxHeight: 160)
- .fixedSize(horizontal: false, vertical: true)
+ if !voiceRecordingInProgress {
+ Button {
+ } label: {
+ Image("smiley")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ }
+ .padding(.horizontal, isMessageTextFocused ? 0 : 2)
+
+ Button {
+ self.isShowPhotoLibrary = true
+ self.mediasIsLoading = true
+ } label: {
+ Image("paperclip")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500)
+ .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading)
+ .padding(.all, isMessageTextFocused ? 0 : 6)
+ .padding(.top, 4)
+ .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading)
+ }
+ .padding(.horizontal, isMessageTextFocused ? 0 : 2)
+
+ Button {
+ self.isShowCamera = true
+ } label: {
+ Image("camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500)
+ .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading)
+ .padding(.all, isMessageTextFocused ? 0 : 6)
+ .padding(.top, 4)
+ .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading)
+ }
+ .padding(.horizontal, isMessageTextFocused ? 0 : 2)
+
+ HStack {
+ if #available(iOS 16.0, *) {
+ TextField("Say something...", text: $conversationViewModel.messageText, axis: .vertical)
.default_text_style(styleSize: 15)
.focused($isMessageTextFocused)
-
- if conversationViewModel.messageText.isEmpty {
- Text("Say something...")
- .padding(.leading, 4)
- .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0)
- .foregroundStyle(Color.gray300)
+ .padding(.vertical, 5)
+ } else {
+ ZStack(alignment: .leading) {
+ TextEditor(text: $conversationViewModel.messageText)
+ .multilineTextAlignment(.leading)
+ .frame(maxHeight: 160)
+ .fixedSize(horizontal: false, vertical: true)
.default_text_style(styleSize: 15)
+ .focused($isMessageTextFocused)
+
+ if conversationViewModel.messageText.isEmpty {
+ Text("Say something...")
+ .padding(.leading, 4)
+ .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0)
+ .foregroundStyle(Color.gray300)
+ .default_text_style(styleSize: 15)
+ }
+ }
+ .onTapGesture {
+ isMessageTextFocused = true
}
}
- .onTapGesture {
- isMessageTextFocused = true
- }
- }
-
- if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty {
- Button {
- } label: {
- Image("microphone")
- .renderingMode(.template)
- .resizable()
- .foregroundStyle(Color.grayMain2c500)
- .frame(width: 28, height: 28, alignment: .leading)
- .padding(.all, 6)
- .padding(.top, 4)
- }
- } else {
- Button {
- if conversationViewModel.displayedConversationHistorySize > 0 {
- NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
+
+ if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty {
+ Button {
+ voiceRecordingInProgress = true
+ } label: {
+ Image("microphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
}
- conversationViewModel.sendMessage()
- } label: {
- Image("paper-plane-tilt")
- .renderingMode(.template)
- .resizable()
- .foregroundStyle(Color.orangeMain500)
- .frame(width: 28, height: 28, alignment: .leading)
- .padding(.all, 6)
- .padding(.top, 4)
- .rotationEffect(.degrees(45))
+ } else {
+ Button {
+ if conversationViewModel.displayedConversationHistorySize > 0 {
+ NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
+ }
+ conversationViewModel.sendMessage()
+ } label: {
+ Image("paper-plane-tilt")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ .rotationEffect(.degrees(45))
+ }
+ .padding(.trailing, 4)
}
- .padding(.trailing, 4)
}
+ .padding(.leading, 15)
+ .padding(.trailing, 5)
+ .padding(.vertical, 6)
+ .frame(maxWidth: .infinity, minHeight: 55)
+ .background(.white)
+ .cornerRadius(30)
+ .overlay(
+ RoundedRectangle(cornerRadius: 30)
+ .inset(by: 0.5)
+ .stroke(Color.gray200, lineWidth: 1.5)
+ )
+ .padding(.horizontal, 4)
+ } else {
+ VoiceRecorderPlayer(conversationViewModel: conversationViewModel, voiceRecordingInProgress: $voiceRecordingInProgress)
+ .frame(maxHeight: 60)
}
- .padding(.leading, 15)
- .padding(.trailing, 5)
- .padding(.vertical, 6)
- .frame(maxWidth: .infinity, minHeight: 55)
- .background(.white)
- .cornerRadius(30)
- .overlay(
- RoundedRectangle(cornerRadius: 30)
- .inset(by: 0.5)
- .stroke(Color.gray200, lineWidth: 1.5)
- )
- .padding(.horizontal, 4)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(.top, 12)
@@ -1010,6 +1019,187 @@ struct ImagePicker: UIViewControllerRepresentable {
}
}
+struct VoiceRecorderPlayer: View {
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ @Binding var voiceRecordingInProgress: Bool
+
+ @StateObject var audioRecorder = AudioRecorder()
+
+ @State private var value: Double = 0.0
+ @State private var isPlaying: Bool = false
+ @State private var isRecording: Bool = true
+ @State private var timer: Timer?
+
+ var minTrackColor: Color = .white.opacity(0.5)
+ var maxTrackGradient: Gradient = Gradient(colors: [Color.orangeMain300, Color.orangeMain500])
+
+ var body: some View {
+ GeometryReader { geometry in
+ let radius = geometry.size.height * 0.5
+ HStack {
+ Button(
+ action: {
+ self.audioRecorder.stopVoiceRecorder()
+ voiceRecordingInProgress = false
+ },
+ label: {
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25)
+ }
+ )
+ .padding(10)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+
+ ZStack(alignment: .leading) {
+ LinearGradient(
+ gradient: maxTrackGradient,
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ .frame(width: geometry.size.width - 110, height: 50)
+ HStack {
+ if !isRecording {
+ Rectangle()
+ .foregroundColor(minTrackColor)
+ .frame(width: self.value * (geometry.size.width - 110) / 100, height: 50)
+ } else {
+ Rectangle()
+ .foregroundColor(minTrackColor)
+ .frame(width: CGFloat(audioRecorder.soundPower) * (geometry.size.width - 110) / 100, height: 50)
+ }
+ }
+
+ HStack {
+ Button(
+ action: {
+ if isRecording {
+ self.audioRecorder.stopVoiceRecorder()
+ isRecording = false
+ } else if isPlaying {
+ conversationViewModel.pauseVoiceRecordPlayer()
+ pauseProgress()
+ } else {
+ if audioRecorder.audioFilename != nil {
+ conversationViewModel.startVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!)
+ playProgress()
+ }
+ }
+ },
+ label: {
+ Image(isRecording ? "stop-fill" : (isPlaying ? "pause-fill" : "play-fill"))
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 20, height: 20)
+ }
+ )
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+
+ Spacer()
+
+ HStack {
+ if isRecording {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(isRecording ? Color.redDanger500 : Color.orangeMain500)
+ .frame(width: 18, height: 18)
+ }
+
+ Text(Int(audioRecorder.recordingTime).convertDurationToString())
+ .default_text_style(styleSize: 16)
+ .padding(.horizontal, 5)
+ }
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+ }
+ .padding(.horizontal, 10)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: radius))
+
+ Button {
+ if conversationViewModel.displayedConversationHistorySize > 0 {
+ NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
+ }
+ conversationViewModel.sendMessage(audioRecorder: self.audioRecorder)
+ voiceRecordingInProgress = false
+ } label: {
+ Image("paper-plane-tilt")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ .rotationEffect(.degrees(45))
+ }
+ .padding(.trailing, 4)
+ }
+ .padding(.horizontal, 4)
+ .padding(.vertical, 5)
+ .onAppear {
+ self.audioRecorder.startRecording()
+ }
+ .onDisappear {
+ self.audioRecorder.stopVoiceRecorder()
+ resetProgress()
+ }
+ }
+ }
+
+ private func playProgress() {
+ isPlaying = true
+ if audioRecorder.audioFilename != nil {
+ self.value = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!)
+ timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
+ if self.value < 100.0 {
+ let valueTmp = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!)
+ if self.value > 90 && self.value == valueTmp {
+ self.value = 100
+ } else {
+ if valueTmp == 0 && !conversationViewModel.isPlayingVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!) {
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ } else {
+ self.value = valueTmp
+ }
+ }
+ } else {
+ resetProgress()
+ }
+ }
+ }
+ }
+
+ // Pause the progress
+ private func pauseProgress() {
+ isPlaying = false
+ stopProgress()
+ }
+
+ // Reset the progress
+ private func resetProgress() {
+ conversationViewModel.stopVoiceRecordPlayer()
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ }
+
+ // Stop the progress and invalidate the timer
+ private func stopProgress() {
+ timer?.invalidate()
+ timer = nil
+ }
+}
/*
#Preview {
ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), sections: [MessagesSection], ids: [""])
diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift
index acf2aa2c4..bbc9dc750 100644
--- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift
+++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift
@@ -166,7 +166,7 @@ struct ConversationsListFragment: View {
}
.safeAreaInset(edge: .top, content: {
Spacer()
- .frame(height: 14)
+ .frame(height: 12)
})
.listStyle(.plain)
.overlay(
diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift
index 05dfa1be4..a155afe45 100644
--- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift
+++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift
@@ -119,6 +119,7 @@ struct UIList: UIViewRepresentable {
tableView.showsVerticalScrollIndicator = true
tableView.estimatedSectionHeaderHeight = 1
tableView.estimatedSectionFooterHeight = UITableView.automaticDimension
+ tableView.keyboardDismissMode = .interactive
tableView.backgroundColor = UIColor(.white)
tableView.scrollsToTop = true
diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift
index 6b57fd927..8d2d83c5a 100644
--- a/Linphone/UI/Main/Conversations/Model/Attachment.swift
+++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift
@@ -83,16 +83,18 @@ public struct Attachment: Codable, Identifiable, Hashable {
public let thumbnail: URL
public let full: URL
public let type: AttachmentType
+ public let duration: Int
- public init(id: String, name: String, thumbnail: URL, full: URL, type: AttachmentType) {
+ public init(id: String, name: String, thumbnail: URL, full: URL, type: AttachmentType, duration: Int = 0) {
self.id = id
self.name = name
self.thumbnail = thumbnail
self.full = full
self.type = type
+ self.duration = duration
}
- public init(id: String, name: String, url: URL, type: AttachmentType) {
- self.init(id: id, name: name, thumbnail: url, full: url, type: type)
+ public init(id: String, name: String, url: URL, type: AttachmentType, duration: Int = 0) {
+ self.init(id: id, name: name, thumbnail: url, full: url, type: type, duration: duration)
}
}
diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift
index 8c17d611b..34309dc91 100644
--- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift
+++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift
@@ -26,7 +26,6 @@ import AVFoundation
// swiftlint:disable line_length
// swiftlint:disable type_body_length
// swiftlint:disable cyclomatic_complexity
-
class ConversationViewModel: ObservableObject {
private var coreContext = CoreContext.shared
@@ -50,9 +49,16 @@ class ConversationViewModel: ObservableObject {
@Published var isShowSelectedMessageToDisplayDetails: Bool = false
@Published var selectedMessageToDisplayDetails: EventLogMessage?
+ @Published var selectedMessageToPlayVoiceRecording: EventLogMessage?
@Published var selectedMessage: EventLogMessage?
@Published var messageToReply: EventLogMessage?
+ @Published var sheetCategories: [SheetCategory] = []
+
+ var vrpManager: VoiceRecordPlayerManager?
+ @Published var isPlaying = false
+ @Published var progress: Double = 0.0
+
struct SheetCategory: Identifiable {
let id = UUID()
let name: String
@@ -66,8 +72,6 @@ class ConversationViewModel: ObservableObject {
var isMe: Bool = false
}
- @Published var sheetCategories: [SheetCategory] = []
-
init() {}
func addConversationDelegate() {
@@ -103,11 +107,13 @@ class ConversationViewModel: ObservableObject {
statusTmp = .sending
}
- if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) {
- if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp {
- DispatchQueue.main.async {
- self.objectWillChange.send()
- self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp
+ if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty {
+ if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) {
+ if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp {
+ DispatchQueue.main.async {
+ self.objectWillChange.send()
+ self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp
+ }
}
}
}
@@ -317,7 +323,8 @@ class ConversationViewModel: ObservableObject {
id: UUID().uuidString,
name: content.name!,
url: path!,
- type: typeTmp
+ type: typeTmp,
+ duration: typeTmp == . voiceRecording ? content.fileDuration : 0
)
attachmentNameList += ", \(content.name!)"
attachmentList.append(attachment)
@@ -532,7 +539,8 @@ class ConversationViewModel: ObservableObject {
id: UUID().uuidString,
name: content.name!,
url: path!,
- type: typeTmp
+ type: typeTmp,
+ duration: typeTmp == . voiceRecording ? content.fileDuration : 0
)
attachmentNameList += ", \(content.name!)"
attachmentList.append(attachment)
@@ -744,7 +752,8 @@ class ConversationViewModel: ObservableObject {
id: UUID().uuidString,
name: content.name!,
url: path!,
- type: typeTmp
+ type: typeTmp,
+ duration: typeTmp == . voiceRecording ? content.fileDuration : 0
)
attachmentNameList += ", \(content.name!)"
attachmentList.append(attachment)
@@ -1029,7 +1038,8 @@ class ConversationViewModel: ObservableObject {
id: UUID().uuidString,
name: content.name!,
url: path!,
- type: typeTmp
+ type: typeTmp,
+ duration: typeTmp == . voiceRecording ? content.fileDuration : 0
)
attachmentNameList += ", \(content.name!)"
attachmentList.append(attachment)
@@ -1198,7 +1208,7 @@ class ConversationViewModel: ObservableObject {
}
}
- func sendMessage() {
+ func sendMessage(audioRecorder: AudioRecorder? = nil) {
coreContext.doOnCoreQueue { _ in
do {
var message: ChatMessage?
@@ -1219,75 +1229,74 @@ class ConversationViewModel: ObservableObject {
}
}
- /*
- if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) {
- stopVoiceRecorder()
- val content = voiceMessageRecorder.createContent()
- if (content != null) {
- Log.i(
- "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}"
- )
- message.addContent(content)
- } else {
- Log.e("$TAG Voice recording content couldn't be created!")
- }
- } else {
- */
- self.mediasToSend.forEach { attachment in
+ if audioRecorder != nil {
do {
- let content = try Factory.Instance.createContent()
-
- switch attachment.type {
- case .image:
- content.type = "image"
- /*
- case .audio:
- content.type = "audio"
- */
- case .video:
- content.type = "video"
- /*
- case .pdf:
- content.type = "application"
- case .plainText:
- content.type = "text"
- */
- default:
- content.type = "file"
- }
-
- // content.subtype = attachment.type == .plainText ? "plain" : FileUtils.getExtensionFromFileName(attachment.fileName)
- content.subtype = attachment.full.pathExtension
-
- content.name = attachment.full.lastPathComponent
+ audioRecorder!.stopVoiceRecorder()
+ let content = try audioRecorder!.linphoneAudioRecorder.createContent()
+ Log.info(
+ "[ConversationViewModel] Voice recording content created, file name is \(content.name ?? "") and duration is \(content.fileDuration)"
+ )
if message != nil {
-
- let path = FileManager.default.temporaryDirectory.appendingPathComponent((attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
- let newPath = URL(string: FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString
- + (attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
- /*
- let data = try Data(contentsOf: path)
- let decodedData: () = try data.write(to: path)
- */
-
- do {
- if FileManager.default.fileExists(atPath: newPath!.path) {
- try FileManager.default.removeItem(atPath: newPath!.path)
- }
- try FileManager.default.moveItem(atPath: path.path, toPath: newPath!.path)
-
- let filePathTmp = newPath?.absoluteString
- content.filePath = String(filePathTmp!.dropFirst(7))
- message!.addFileContent(content: content)
- } catch {
- Log.error(error.localizedDescription)
- }
+ message!.addContent(content: content)
+ }
+ }
+ } else {
+ self.mediasToSend.forEach { attachment in
+ do {
+ let content = try Factory.Instance.createContent()
+
+ switch attachment.type {
+ case .image:
+ content.type = "image"
+ /*
+ case .audio:
+ content.type = "audio"
+ */
+ case .video:
+ content.type = "video"
+ /*
+ case .pdf:
+ content.type = "application"
+ case .plainText:
+ content.type = "text"
+ */
+ default:
+ content.type = "file"
+ }
+
+ // content.subtype = attachment.type == .plainText ? "plain" : FileUtils.getExtensionFromFileName(attachment.fileName)
+ content.subtype = attachment.full.pathExtension
+
+ content.name = attachment.full.lastPathComponent
+
+ if message != nil {
+
+ let path = FileManager.default.temporaryDirectory.appendingPathComponent((attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+ let newPath = URL(string: FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString
+ + (attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+ /*
+ let data = try Data(contentsOf: path)
+ let decodedData: () = try data.write(to: path)
+ */
+
+ do {
+ if FileManager.default.fileExists(atPath: newPath!.path) {
+ try FileManager.default.removeItem(atPath: newPath!.path)
+ }
+ try FileManager.default.moveItem(atPath: path.path, toPath: newPath!.path)
+
+ let filePathTmp = newPath?.absoluteString
+ content.filePath = String(filePathTmp!.dropFirst(7))
+ message!.addFileContent(content: content)
+ } catch {
+ Log.error(error.localizedDescription)
+ }
+ }
+ } catch {
}
- } catch {
}
}
- // }
if message != nil && !message!.contents.isEmpty {
Log.info("[ConversationViewModel] Sending message")
@@ -1621,49 +1630,335 @@ class ConversationViewModel: ObservableObject {
}
}
}
-}
-
-struct CustomSlider: View {
- @Binding var value: Double
- var range: ClosedRange
- var thumbColor: Color
- var trackColor: Color
- var trackHeight: CGFloat
- var cornerRadius: CGFloat
- var body: some View {
- VStack {
- ZStack {
- // Slider track with rounded corners
- Rectangle()
- .fill(trackColor)
- .frame(height: trackHeight)
- .cornerRadius(cornerRadius)
-
- // Progress track to show the current value
- Rectangle()
- .fill(thumbColor.opacity(0.5))
- .frame(width: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * UIScreen.main.bounds.width, height: trackHeight)
- .cornerRadius(cornerRadius)
-
- // Thumb (handle) with rounded appearance
- Circle()
- .fill(thumbColor)
- .frame(width: 30, height: 30)
- .offset(x: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * UIScreen.main.bounds.width - 20)
- .gesture(DragGesture(minimumDistance: 0)
- .onChanged { gesture in
- let sliderWidth = UIScreen.main.bounds.width
- let dragX = gesture.location.x
- let newValue = range.lowerBound + Double(dragX / sliderWidth) * (range.upperBound - range.lowerBound)
- value = min(max(newValue, range.lowerBound), range.upperBound)
- }
- )
+ func startVoiceRecordPlayer(voiceRecordPath: URL) {
+ coreContext.doOnCoreQueue { core in
+ if self.vrpManager == nil || self.vrpManager!.voiceRecordPath != voiceRecordPath {
+ self.vrpManager = VoiceRecordPlayerManager(core: core, voiceRecordPath: voiceRecordPath)
+ }
+
+ if self.vrpManager != nil {
+ self.vrpManager!.startVoiceRecordPlayer()
+ }
+ }
+ }
+
+ func getPositionVoiceRecordPlayer(voiceRecordPath: URL) -> Double {
+ if self.vrpManager != nil && self.vrpManager!.voiceRecordPath == voiceRecordPath {
+ return self.vrpManager!.positionVoiceRecordPlayer()
+ } else {
+ return 0
+ }
+ }
+
+ func isPlayingVoiceRecordPlayer(voiceRecordPath: URL) -> Bool {
+ if self.vrpManager != nil && self.vrpManager!.voiceRecordPath == voiceRecordPath {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ func pauseVoiceRecordPlayer() {
+ coreContext.doOnCoreQueue { _ in
+ if self.vrpManager != nil {
+ self.vrpManager!.pauseVoiceRecordPlayer()
+ }
+ }
+ }
+
+ func stopVoiceRecordPlayer() {
+ coreContext.doOnCoreQueue { _ in
+ if self.vrpManager != nil {
+ self.vrpManager!.stopVoiceRecordPlayer()
}
}
- .padding(.horizontal, 20)
}
}
// swiftlint:enable line_length
// swiftlint:enable type_body_length
// swiftlint:enable cyclomatic_complexity
+
+class VoiceRecordPlayerManager {
+ private var core: Core
+ var voiceRecordPath: URL
+ private var voiceRecordPlayer: Player?
+ //private var isPlayingVoiceRecord = false
+ private var voiceRecordAudioFocusRequest: AVAudioSession?
+ //private var voiceRecordPlayerPosition: Double = 0
+ //private var voiceRecordingDuration: TimeInterval = 0
+
+ init(core: Core, voiceRecordPath: URL) {
+ self.core = core
+ self.voiceRecordPath = voiceRecordPath
+ }
+
+ private func initVoiceRecordPlayer() {
+ print("Creating player for voice record")
+ do {
+ voiceRecordPlayer = try core.createLocalPlayer(soundCardName: getSpeakerSoundCard(core: core), videoDisplayName: nil, windowId: nil)
+ } catch {
+ print("Couldn't create local player!")
+ }
+
+ print("Voice record player created")
+ print("Opening voice record file [\(voiceRecordPath.absoluteString)]")
+
+ do {
+ try voiceRecordPlayer!.open(filename: String(voiceRecordPath.absoluteString.dropFirst(7)))
+ print("Player opened file at [\(voiceRecordPath.absoluteString)]")
+ } catch {
+ print("Player failed to open file at [\(voiceRecordPath.absoluteString)]")
+ }
+ }
+
+ func startVoiceRecordPlayer() {
+ if voiceRecordAudioFocusRequest == nil {
+ voiceRecordAudioFocusRequest = AVAudioSession.sharedInstance()
+ if let request = voiceRecordAudioFocusRequest {
+ try? request.setActive(true)
+ }
+ }
+
+ if isPlayerClosed() {
+ print("Player closed, let's open it first")
+ initVoiceRecordPlayer()
+
+ if voiceRecordPlayer!.state == .Closed {
+ print("It seems the player fails to open the file, abort playback")
+ // Handle the failure (e.g. show a toast)
+ return
+ }
+ }
+
+ do {
+ try voiceRecordPlayer!.start()
+ print("Playing voice record")
+ } catch {
+ print("Player failed to start voice recording")
+ }
+ }
+
+ func positionVoiceRecordPlayer() -> Double {
+ if !isPlayerClosed() {
+ return Double(voiceRecordPlayer!.currentPosition) / Double(voiceRecordPlayer!.duration) * 100
+ } else {
+ return 0.0
+ }
+ }
+
+ func pauseVoiceRecordPlayer() {
+ if !isPlayerClosed() {
+ print("Pausing voice record")
+ try? voiceRecordPlayer?.pause()
+ }
+ }
+
+ private func isPlayerClosed() -> Bool {
+ return voiceRecordPlayer == nil || voiceRecordPlayer?.state == .Closed
+ }
+
+ func stopVoiceRecordPlayer() {
+ if !isPlayerClosed() {
+ print("Stopping voice record")
+ try? voiceRecordPlayer?.pause()
+ try? voiceRecordPlayer?.seek(timeMs: 0)
+ voiceRecordPlayer?.close()
+ }
+
+ if let request = voiceRecordAudioFocusRequest {
+ try? request.setActive(false)
+ voiceRecordAudioFocusRequest = nil
+ }
+ }
+
+ func getSpeakerSoundCard(core: Core) -> String? {
+ var speakerCard: String? = nil
+ var earpieceCard: String? = nil
+ core.audioDevices.forEach { device in
+ if (device.hasCapability(capability: .CapabilityPlay)) {
+ if (device.type == .Speaker) {
+ speakerCard = device.id
+ } else if (device.type == .Earpiece) {
+ earpieceCard = device.id
+ }
+ }
+ }
+ return speakerCard != nil ? speakerCard : earpieceCard
+ }
+
+ func changeRouteToSpeaker() {
+ core.outputAudioDevice = core.audioDevices.first { $0.type == AudioDevice.Kind.Speaker }
+ UIDevice.current.isProximityMonitoringEnabled = false
+ }
+}
+
+class AudioRecorder: NSObject, ObservableObject {
+ var linphoneAudioRecorder: Recorder!
+ var recordingSession: AVAudioSession?
+ @Published var isRecording = false
+ @Published var audioFilename: URL?
+ @Published var audioFilenameAAC: URL?
+ @Published var recordingTime: TimeInterval = 0
+ @Published var soundPower: Float = 0
+
+ var timer: Timer?
+
+ func startRecording() {
+ recordingSession = AVAudioSession.sharedInstance()
+ CoreContext.shared.doOnCoreQueue { core in
+ core.activateAudioSession(activated: true)
+ }
+
+ if recordingSession != nil {
+ do {
+ try recordingSession!.setCategory(.playAndRecord, mode: .default)
+ try recordingSession!.setActive(true)
+ recordingSession!.requestRecordPermission { allowed in
+ if allowed {
+ self.initVoiceRecorder()
+ } else {
+ print("Permission to record not granted.")
+ }
+ }
+ } catch {
+ print("Failed to setup recording session.")
+ }
+ }
+ }
+
+ private func initVoiceRecorder() {
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("[ConversationViewModel] [AudioRecorder] Creating voice message recorder")
+ let recorderParams = try? core.createRecorderParams()
+ if recorderParams != nil {
+ recorderParams!.fileFormat = MediaFileFormat.Mkv
+
+ let recordingAudioDevice = self.getAudioRecordingDeviceIdForVoiceMessage()
+ recorderParams!.audioDevice = recordingAudioDevice
+ Log.info(
+ "[ConversationViewModel] [AudioRecorder] Using device \(recorderParams!.audioDevice?.id ?? "Error id") to make the voice message recording"
+ )
+
+ self.linphoneAudioRecorder = try? core.createRecorder(params: recorderParams!)
+ Log.info("[ConversationViewModel] [AudioRecorder] Voice message recorder created")
+
+ self.startVoiceRecorder()
+ }
+ }
+ }
+
+ func startVoiceRecorder() {
+ switch linphoneAudioRecorder.state {
+ case .Running:
+ Log.warn("[ConversationViewModel] [AudioRecorder] Recorder is already recording")
+ case .Paused:
+ Log.warn("[ConversationViewModel] [AudioRecorder] Recorder is paused, resuming recording")
+ try? linphoneAudioRecorder.start()
+ case .Closed:
+ var extensionFileFormat: String = ""
+ switch linphoneAudioRecorder.params?.fileFormat {
+ case .Smff:
+ extensionFileFormat = "smff"
+ case .Mkv:
+ extensionFileFormat = "mka"
+ default:
+ extensionFileFormat = "wav"
+ }
+
+ let tempFileName = "voice-recording-\(Int(Date().timeIntervalSince1970)).\(extensionFileFormat)"
+ audioFilename = FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").appendingPathComponent(tempFileName)
+
+ if audioFilename != nil {
+ Log.warn("[ConversationViewModel] [AudioRecorder] Recorder is closed, starting recording in \(audioFilename!.absoluteString)")
+ try? linphoneAudioRecorder.open(file: String(audioFilename!.absoluteString.dropFirst(7)))
+ try? linphoneAudioRecorder.start()
+ }
+
+ startTimer()
+
+ DispatchQueue.main.async {
+ self.isRecording = true
+ }
+ }
+ }
+
+ func stopVoiceRecorder() {
+ if linphoneAudioRecorder.state == .Running {
+ Log.info("[ConversationViewModel] [AudioRecorder] Closing voice recorder")
+ try? linphoneAudioRecorder.pause()
+ linphoneAudioRecorder.close()
+ }
+
+ stopTimer()
+
+ DispatchQueue.main.async {
+ self.isRecording = false
+ }
+
+ if let request = recordingSession {
+ Log.info("[ConversationViewModel] [AudioRecorder] Releasing voice recording audio focus request")
+ try? request.setActive(false)
+ recordingSession = nil
+ CoreContext.shared.doOnCoreQueue { core in
+ core.activateAudioSession(activated: false)
+ }
+ }
+ }
+
+ func startTimer() {
+ DispatchQueue.main.async {
+ self.recordingTime = 0
+ let maxVoiceRecordDuration = Config.voiceRecordingMaxDuration
+ self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in // More frequent updates
+ self.recordingTime += 0.1
+ self.updateSoundPower()
+ let duration = self.linphoneAudioRecorder.duration
+ if duration >= maxVoiceRecordDuration {
+ print("[ConversationViewModel] [AudioRecorder] Max duration for voice recording exceeded (\(maxVoiceRecordDuration)ms), stopping.")
+ self.stopVoiceRecorder()
+ }
+ }
+ }
+ }
+
+ func stopTimer() {
+ self.timer?.invalidate()
+ self.timer = nil
+ }
+
+ func updateSoundPower() {
+ let soundPowerTmp = linphoneAudioRecorder.captureVolume * 1000 // Capture sound power
+ soundPower = soundPowerTmp < 10 ? 0 : (soundPowerTmp > 100 ? 100 : (soundPowerTmp - 10))
+ }
+
+ func getAudioRecordingDeviceIdForVoiceMessage() -> AudioDevice? {
+ // In case no headset/hearing aid/bluetooth is connected, use microphone sound card
+ // If none are available, default one will be used
+ var headsetCard: AudioDevice?
+ var bluetoothCard: AudioDevice?
+ var microphoneCard: AudioDevice?
+
+ CoreContext.shared.doOnCoreQueue { core in
+ for device in core.audioDevices {
+ if device.hasCapability(capability: .CapabilityRecord) {
+ switch device.type {
+ case .Headphones, .Headset:
+ headsetCard = device
+ case .Bluetooth, .HearingAid:
+ bluetoothCard = device
+ case .Microphone:
+ microphoneCard = device
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ Log.info("Found headset/headphones/hearingAid sound card [\(String(describing: headsetCard))], "
+ + "bluetooth sound card [\(String(describing: bluetoothCard))] and microphone card [\(String(describing: microphoneCard))]")
+
+ return headsetCard ?? bluetoothCard ?? microphoneCard
+ }
+}
diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift
index 1b1d87e5e..753489b06 100644
--- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift
+++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift
@@ -144,7 +144,7 @@ struct HistoryListFragment: View {
}
.safeAreaInset(edge: .top, content: {
Spacer()
- .frame(height: 14)
+ .frame(height: 12)
})
.listStyle(.plain)
.overlay(
diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift
index 55a769af4..80319a5d7 100644
--- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift
+++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift
@@ -162,7 +162,7 @@ struct MeetingsFragment: View {
}
.safeAreaInset(edge: .top, content: {
Spacer()
- .frame(height: 14)
+ .frame(height: 12)
})
.listStyle(.plain)
.overlay(
diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift
index 51bd40ed7..697b25221 100644
--- a/Linphone/Utils/Extensions/ConfigExtension.swift
+++ b/Linphone/Utils/Extensions/ConfigExtension.swift
@@ -57,5 +57,7 @@ extension Config {
static let defaultPass = Config.get().getString(section: "app", key: "pass", defaultString: "")
static let pushNotificationsInterval = Config.get().getInt(section: "net", key: "pn-call-remote-push-interval", defaultValue: 3)
+
+ static let voiceRecordingMaxDuration = Config.get().getInt(section: "app", key: "voice_recording_max_duration", defaultValue: 600000)
}
diff --git a/Linphone/Utils/Extensions/StringExtension.swift b/Linphone/Utils/Extensions/StringExtension.swift
index 47c681f12..c3db23298 100644
--- a/Linphone/Utils/Extensions/StringExtension.swift
+++ b/Linphone/Utils/Extensions/StringExtension.swift
@@ -24,3 +24,17 @@ extension String {
return NSLocalizedString(self, comment: comment != nil ? comment! : self)
}
}
+
+extension String {
+ var isOnlyEmojis: Bool {
+ let filteredText = self.filter { !$0.isWhitespace }
+ return !filteredText.isEmpty && filteredText.allSatisfy { $0.isEmoji }
+ }
+}
+
+extension Character {
+ var isEmoji: Bool {
+ guard let scalar = unicodeScalars.first else { return false }
+ return scalar.properties.isEmoji && (scalar.value > 0x238C || unicodeScalars.count > 1)
+ }
+}