Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@
4C5250D221E7BD7D00CE3D95 /* PhoneNumberValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5250D121E7BD7D00CE3D95 /* PhoneNumberValidator.swift */; };
4C5250D421E7C51900CE3D95 /* PhoneNumberValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5250D321E7C51900CE3D95 /* PhoneNumberValidatorTest.swift */; };
4C6E6C6924241C00009DE948 /* ConversationViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */; };
3E565959CEC44FCC87B772A9 /* VoiceMessageInProgressDraftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E115EB2837FD4F3FAE205C18 /* VoiceMessageInProgressDraftTest.swift */; };
4C751BE523FA0284002A8AF1 /* ContactSupportActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C751BE423FA0284002A8AF1 /* ContactSupportActionSheet.swift */; };
4C83AC4223C55D9C00D4F2E6 /* SignalBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */; };
4C8A6DFC22E5499300469AE7 /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8A6DFB22E5499300469AE7 /* MediaZoomAnimationController.swift */; };
Expand Down Expand Up @@ -4827,6 +4828,7 @@
4C63CBFF210A620B003AE45C /* SignalTSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalTSan.supp; sourceTree = "<group>"; };
4C6E446822AEDDEE007982E6 /* NewAccountDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAccountDiscovery.swift; sourceTree = "<group>"; };
4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewControllerTest.swift; sourceTree = "<group>"; };
E115EB2837FD4F3FAE205C18 /* VoiceMessageInProgressDraftTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageInProgressDraftTest.swift; sourceTree = "<group>"; };
4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalUBSan.supp; sourceTree = "<group>"; };
4C751BE423FA0284002A8AF1 /* ContactSupportActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSupportActionSheet.swift; sourceTree = "<group>"; };
4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalBaseTest.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -9439,6 +9441,7 @@
F97A2EE828247C1300610669 /* BadgeIssueSheetStateTest.swift */,
4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */,
3463532E256EA525003C5428 /* ConversationViewTest.swift */,
E115EB2837FD4F3FAE205C18 /* VoiceMessageInProgressDraftTest.swift */,
88D6E94125535482003142D9 /* CVTextTest.swift */,
F96B66B22912B8B7004FFFAA /* DonateViewControllerTest.swift */,
F99D2C8A2926F0DD00748CCB /* DonationPaymentDetailsViewControllerTest.swift */,
Expand Down Expand Up @@ -18884,6 +18887,7 @@
4C9D347B23679C25006A4307 /* ContactStreamTest.swift in Sources */,
4C6E6C6924241C00009DE948 /* ConversationViewControllerTest.swift in Sources */,
34635330256EA52A003C5428 /* ConversationViewTest.swift in Sources */,
3E565959CEC44FCC87B772A9 /* VoiceMessageInProgressDraftTest.swift in Sources */,
88D6E94325535D49003142D9 /* CVTextTest.swift in Sources */,
F93999F628C81F2100E34899 /* DataMessagePaddingTests.swift in Sources */,
3494BBE026E66FC30079B11B /* DateUtilTest.swift in Sources */,
Expand Down
114 changes: 108 additions & 6 deletions Signal/ConversationView/ConversationInputToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ protocol ConversationInputToolbarDelegate: AnyObject {

func voiceMemoGestureWasInterrupted()

func voiceMemoGestureDidPause()

func voiceMemoGestureDidResume()

func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft)

// MARK: Attachments
Expand Down Expand Up @@ -914,7 +918,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
if isShowingVoiceMemoUI {
let showSendButton: Bool = {
switch voiceMemoRecordingState {
case .recordingLocked, .draft:
case .recordingLocked, .recordingPaused, .draft:
true
default:
false
Expand Down Expand Up @@ -2398,6 +2402,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
case idle
case recordingHeld
case recordingLocked
case recordingPaused
case draft
}

Expand All @@ -2421,6 +2426,9 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
private var voiceMemoStartTime: Date?
private var voiceMemoUpdateTimer: Timer?
private var voiceMemoTooltipView: UIView?
private var voiceMemoPauseResumeButton: UIButton?
private var voiceMemoPauseBeginTime: Date?
private var voiceMemoPausedDuration: TimeInterval = 0
private lazy var voiceMemoDurationLabel: UILabel = {
let label = UILabel()
label.textAlignment = .left
Expand Down Expand Up @@ -2673,6 +2681,9 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {

voiceMemoRecordingState = .idle
voiceMemoDraft = nil
voiceMemoPauseResumeButton = nil
voiceMemoPauseBeginTime = nil
voiceMemoPausedDuration = 0

voiceMemoUpdateTimer?.invalidate()
voiceMemoUpdateTimer = nil
Expand Down Expand Up @@ -2718,11 +2729,50 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
cancelButton.configuration?.title = CommonStrings.cancelButton
cancelButton.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
cancelButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cancelButton")

let pauseButton = UIButton(configuration: .borderless())
pauseButton.alpha = 0
pauseButton.configuration?.baseForegroundColor = Style.primaryTextColor
pauseButton.configuration?.contentInsets = .init(margin: 8)
pauseButton.configuration?.title = OWSLocalizedString(
"VOICE_MESSAGE_PAUSE_BUTTON",
comment: "Button to pause an in-progress voice message recording.",
)
pauseButton.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
pauseButton.accessibilityLabel = OWSLocalizedString(
"VOICE_MESSAGE_PAUSE_BUTTON_ACCESSIBILITY_LABEL",
comment: "Accessibility label for the button which pauses voice message recording.",
)
pauseButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "pauseButton")
pauseButton.addAction(UIAction { [weak self] _ in
guard let self else { return }
switch self.voiceMemoRecordingState {
case .recordingLocked:
self.voiceMemoRecordingState = .recordingPaused
self.pauseVoiceMemoTimer()
self.updateVoiceMemoPauseResumeButton()
self.inputToolbarDelegate?.voiceMemoGestureDidPause()
case .recordingPaused:
self.voiceMemoRecordingState = .recordingLocked
self.resumeVoiceMemoTimer()
self.updateVoiceMemoPauseResumeButton()
self.inputToolbarDelegate?.voiceMemoGestureDidResume()
default:
break
}
}, for: .primaryActionTriggered)
voiceMemoPauseResumeButton = pauseButton

voiceMemoContentView.addSubview(cancelButton)
voiceMemoContentView.addSubview(pauseButton)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
pauseButton.translatesAutoresizingMaskIntoConstraints = false
voiceMemoContentView.addConstraints([
cancelButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor),
cancelButton.trailingAnchor.constraint(equalTo: voiceMemoContentView.trailingAnchor, constant: -16),

pauseButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor),
pauseButton.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
])

voiceMemoCancelLabel.removeFromSuperview()
Expand All @@ -2740,6 +2790,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
self.voiceMemoLockView.transform = .scale(scale)

cancelButton.alpha = 1
pauseButton.alpha = 1
},
completion: { _ in
self.voiceMemoRedRecordingCircle.removeFromSuperview()
Expand All @@ -2750,6 +2801,57 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
)
}

private func pauseVoiceMemoTimer() {
AssertIsOnMainThread()

voiceMemoPauseBeginTime = Date()
voiceMemoUpdateTimer?.invalidate()
voiceMemoUpdateTimer = nil
}

private func resumeVoiceMemoTimer() {
AssertIsOnMainThread()

if let pauseBegin = voiceMemoPauseBeginTime {
voiceMemoPausedDuration += Date().timeIntervalSince(pauseBegin)
voiceMemoPauseBeginTime = nil
}

voiceMemoUpdateTimer?.invalidate()
voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
self.updateVoiceMemoDurationLabel()
}
}

private func updateVoiceMemoPauseResumeButton() {
AssertIsOnMainThread()

guard let pauseButton = voiceMemoPauseResumeButton else { return }
let isPaused = voiceMemoRecordingState == .recordingPaused
pauseButton.configuration?.title = isPaused
? OWSLocalizedString(
"VOICE_MESSAGE_RESUME_BUTTON",
comment: "Button to resume a paused voice message recording.",
)
: OWSLocalizedString(
"VOICE_MESSAGE_PAUSE_BUTTON",
comment: "Button to pause an in-progress voice message recording.",
)
pauseButton.accessibilityLabel = isPaused
? OWSLocalizedString(
"VOICE_MESSAGE_RESUME_BUTTON_ACCESSIBILITY_LABEL",
comment: "Accessibility label for the button which resumes voice message recording.",
)
: OWSLocalizedString(
"VOICE_MESSAGE_PAUSE_BUTTON_ACCESSIBILITY_LABEL",
comment: "Accessibility label for the button which pauses voice message recording.",
)
}

private func setVoiceMemoUICancelAlpha(_ cancelAlpha: CGFloat) {
AssertIsOnMainThread()

Expand All @@ -2770,8 +2872,8 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
return
}

let durationSeconds = abs(voiceMemoStartTime.timeIntervalSinceNow)
voiceMemoDurationLabel.text = OWSFormat.formatDurationSeconds(Int(round(durationSeconds)))
let elapsed = abs(voiceMemoStartTime.timeIntervalSinceNow) - voiceMemoPausedDuration
voiceMemoDurationLabel.text = OWSFormat.formatDurationSeconds(Int(round(max(0, elapsed))))
}

func showVoiceMemoTooltip() {
Expand Down Expand Up @@ -2826,7 +2928,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
owsFailDebug("while recording held, shouldn't be possible to restart gesture.")
inputToolbarDelegate?.voiceMemoGestureDidCancel()

case .recordingLocked, .draft:
case .recordingLocked, .recordingPaused, .draft:
owsFailDebug("once locked, shouldn't be possible to interact with gesture.")
inputToolbarDelegate?.voiceMemoGestureDidCancel()
}
Expand Down Expand Up @@ -2866,7 +2968,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
inputToolbarDelegate?.voiceMemoGestureDidLock()
setVoiceMemoUICancelAlpha(0)

case .recordingLocked, .draft:
case .recordingLocked, .recordingPaused, .draft:
// already locked
break

Expand Down Expand Up @@ -2909,7 +3011,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
voiceMemoRecordingState = .idle
inputToolbarDelegate?.voiceMemoGestureDidComplete()

case .recordingLocked, .draft:
case .recordingLocked, .recordingPaused, .draft:
// Continue recording.
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,20 @@ extension ConversationViewController: ConversationInputToolbarDelegate {
cancelRecordingVoiceMessage()
}

public func voiceMemoGestureDidPause() {
AssertIsOnMainThread()
Logger.info("")

pauseRecordingVoiceMessage()
}

public func voiceMemoGestureDidResume() {
AssertIsOnMainThread()
Logger.info("")

resumeRecordingVoiceMessage()
}

public func voiceMemoGestureWasInterrupted() {
AssertIsOnMainThread()
Logger.info("")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ extension ConversationViewController {
}
}

func pauseRecordingVoiceMessage() {
AssertIsOnMainThread()

guard let inProgressVoiceMessage = viewState.inProgressVoiceMessage else { return }
inProgressVoiceMessage.pauseRecording()
ImpactHapticFeedback.impactOccurred(style: .light)
}

func resumeRecordingVoiceMessage() {
AssertIsOnMainThread()

guard let inProgressVoiceMessage = viewState.inProgressVoiceMessage else { return }
do {
try inProgressVoiceMessage.resumeRecording()
} catch {
owsFailDebug("Failed to resume recording voice message: \(error)")
cancelRecordingVoiceMessage()
return
}
ImpactHapticFeedback.impactOccurred(style: .light)
}

func cancelRecordingVoiceMessage() {
AssertIsOnMainThread()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft {

var isRecording: Bool { audioRecorder?.isRecording ?? false }

private(set) var isPaused: Bool = false

func startRecording() throws {
AssertIsOnMainThread()

Expand Down Expand Up @@ -89,6 +91,41 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft {
}
}

func pauseRecording() {
AssertIsOnMainThread()

guard isRecording else {
owsFailDebug("Attempted to pause when not recording")
return
}

isPaused = true
audioRecorder?.pause()

MainActor.assumeIsolated {
sleepManager.removeBlock(blockObject: sleepBlockObject)
}
}

func resumeRecording() throws {
AssertIsOnMainThread()

guard isPaused, let audioRecorder else {
owsFailDebug("Attempted to resume when not paused or recorder is missing")
return
}

isPaused = false

MainActor.assumeIsolated {
sleepManager.addBlock(blockObject: sleepBlockObject)
}

guard audioRecorder.record() else {
throw OWSAssertionError("audioRecorder couldn't resume recording.")
}
}

func stopRecording() {
AssertIsOnMainThread()

Expand All @@ -98,6 +135,7 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft {

guard let audioRecorder else { return }
self.audioRecorder = nil
isPaused = false

self.duration = audioRecorder.currentTime

Expand All @@ -118,6 +156,7 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft {

guard let audioRecorder else { return }
self.audioRecorder = nil
isPaused = false

self.duration = audioRecorder.currentTime

Expand Down
Loading