mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 15:05:24 +00:00
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:
+22
-121
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user