Refactor Voice Messages (#1075)

* Extract voice-related code to a dedicated VM

* Minor optimization of permission request + fix ui bug

When isPreparing is being set to true before permission is granted, the UI is shown for a fraction of a second and it’s even worse if the permission is being asked constantly if the user drags the finger even when the alert is shown

* Remove dead code

* Remove unnecessary delegate conformance

* `actor VoiceRecorder` + other minor improvements

* Centralized opening system settings + open for mic access

* Better voice-recording state handling with Enum

* Remove manual timer management

* Simplify formatting + avoid jumping UI w/ countdown

* Add `requestingPermission` state to avoid repetitive calls

* Improve guarding of the states after awaits

* Fix potential “actor reentrancy across awaits” issue

* Remove short-circuiting on .idle + guard before error

* Fix playbackLabel’s formatting for duration vs remainder

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
This commit is contained in:
Islam
2026-04-02 11:26:13 -05:00
committed by GitHub
co-authored by jack
parent 0331871980
commit 34bae4a89d
8 changed files with 301 additions and 258 deletions
+22 -121
View File
@@ -49,6 +49,7 @@ struct ContentView: View {
// MARK: - Properties
@EnvironmentObject var viewModel: ChatViewModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@State private var messageText = ""
@@ -73,13 +74,6 @@ struct ContentView: View {
@State private var showLocationNotes = false
@State private var notesGeohash: String? = nil
@State private var imagePreviewURL: URL? = nil
@State private var recordingAlertMessage: String = ""
@State private var showRecordingAlert = false
@State private var isRecordingVoiceNote = false
@State private var isPreparingVoiceNote = false
@State private var recordingDuration: TimeInterval = 0
@State private var recordingTimer: Timer?
@State private var recordingStartDate: Date?
#if os(iOS)
@State private var showImagePicker = false
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
@@ -282,10 +276,15 @@ struct ContentView: View {
.environmentObject(viewModel)
}
}
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
Button("OK", role: .cancel) {}
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") {
SystemSettings.microphone.open()
}
}
}, message: {
Text(recordingAlertMessage)
Text(voiceRecordingVM.state.alertMessage)
})
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
@@ -342,11 +341,7 @@ struct ContentView: View {
}
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
Button("content.alert.bluetooth_required.settings") {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#endif
SystemSettings.bluetooth.open()
}
Button("common.ok", role: .cancel) {}
} message: {
@@ -629,8 +624,7 @@ struct ContentView: View {
secondaryTextColor: secondaryTextColor
)
// Recording indicator
if isPreparingVoiceNote || isRecordingVoiceNote {
if voiceRecordingVM.state.isActive {
recordingIndicator
}
@@ -1687,11 +1681,16 @@ private extension ContentView {
Image(systemName: "waveform.circle.fill")
.foregroundColor(.red)
.font(.bitchatSystem(size: 20))
Text("recording \(formattedRecordingDuration())", comment: "Voice note recording duration indicator")
TimelineView(.periodic(from: .now, by: 0.05)) { context in
Text(
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Voice note recording duration indicator"
)
.font(.bitchatSystem(size: 13, design: .monospaced))
.foregroundColor(.red)
}
Spacer()
Button(action: cancelVoiceRecording) {
Button(action: voiceRecordingVM.cancel) {
Label("Cancel", systemImage: "xmark.circle")
.labelStyle(.iconOnly)
.font(.bitchatSystem(size: 18))
@@ -1788,11 +1787,9 @@ private extension ContentView {
}
private var micButtonView: some View {
let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor
return Image(systemName: "mic.circle.fill")
Image(systemName: "mic.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(tint)
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor)
.frame(width: 36, height: 36)
.contentShape(Circle())
.overlay(
@@ -1800,8 +1797,8 @@ private extension ContentView {
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in startVoiceRecording() }
.onEnded { _ in finishVoiceRecording(send: true) }
.onChanged { _ in voiceRecordingVM.start(shouldShow: shouldShowVoiceControl) }
.onEnded { _ in voiceRecordingVM.finish(completion: viewModel.sendVoiceNote) }
)
)
.accessibilityLabel("Hold to record a voice note")
@@ -1827,102 +1824,6 @@ private extension ContentView {
)
}
func formattedRecordingDuration() -> String {
let clamped = max(0, recordingDuration)
let totalMilliseconds = Int((clamped * 1000).rounded())
let minutes = totalMilliseconds / 60_000
let seconds = (totalMilliseconds % 60_000) / 1_000
let centiseconds = (totalMilliseconds % 1_000) / 10
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func startVoiceRecording() {
guard shouldShowVoiceControl else { return }
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
isPreparingVoiceNote = true
Task { @MainActor in
let granted = await VoiceRecorder.shared.requestPermission()
guard granted else {
isPreparingVoiceNote = false
recordingAlertMessage = "Microphone access is required to record voice notes."
showRecordingAlert = true
return
}
do {
_ = try VoiceRecorder.shared.startRecording()
recordingDuration = 0
recordingStartDate = Date()
recordingTimer?.invalidate()
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
if let start = recordingStartDate {
recordingDuration = Date().timeIntervalSince(start)
}
}
if let timer = recordingTimer {
RunLoop.main.add(timer, forMode: .common)
}
isPreparingVoiceNote = false
isRecordingVoiceNote = true
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
recordingAlertMessage = "Could not start recording."
showRecordingAlert = true
VoiceRecorder.shared.cancelRecording()
isPreparingVoiceNote = false
isRecordingVoiceNote = false
recordingStartDate = nil
}
}
}
func finishVoiceRecording(send: Bool) {
if isPreparingVoiceNote {
isPreparingVoiceNote = false
VoiceRecorder.shared.cancelRecording()
return
}
guard isRecordingVoiceNote else { return }
isRecordingVoiceNote = false
recordingTimer?.invalidate()
recordingTimer = nil
if let start = recordingStartDate {
recordingDuration = Date().timeIntervalSince(start)
}
recordingStartDate = nil
if send {
let minimumDuration: TimeInterval = 1.0
VoiceRecorder.shared.stopRecording { url in
DispatchQueue.main.async {
guard
let url = url,
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.intValue > 0,
recordingDuration >= minimumDuration
else {
if let url = url {
try? FileManager.default.removeItem(at: url)
}
recordingAlertMessage = recordingDuration < minimumDuration
? "Recording is too short."
: "Recording failed to save."
showRecordingAlert = true
return
}
viewModel.sendVoiceNote(at: url)
}
}
} else {
VoiceRecorder.shared.cancelRecording()
}
}
func cancelVoiceRecording() {
if isPreparingVoiceNote || isRecordingVoiceNote {
finishVoiceRecording(send: false)
}
}
func applicationFilesDirectory() -> URL? {
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
struct Cache {