PTT consent: live voice off by default; slide-away-to-cancel

Live push-to-talk defaulted ON in both directions: holding the mic in
a public channel streamed your voice to everyone in radio range before
you released, and inbound bursts auto-played out of the speaker. A
single buried toggle defaulting on was not consent — both behaviors
are now opt-in (PTTSettings.liveVoiceEnabled defaults false), and the
settings copy says exactly what turning it on does.

Recording could also not be aborted one-handed: release always sent,
and the HUD's cancel button required lifting the finger — which sent.
Sliding the finger off the mic button (>60pt) now arms cancel — the
HUD flips from the timer to "release to cancel" — and sliding back
re-arms send. The armed flag resets on every exit path (cancel,
finish, panic), pinned by a new test.

2 new strings + 1 updated, all 30 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-11 09:23:19 +02:00
parent 3e46829f32
commit 0dacb3629c
5 changed files with 479 additions and 34 deletions

View File

@ -16,11 +16,16 @@ import AppKit
/// User preference for live push-to-talk voice. One switch controls both
/// directions: streaming your holds live, and auto-playing inbound bursts.
/// Off means voice messages behave exactly like classic voice notes.
///
/// Off by default: live mode sends audio to other people BEFORE the hold is
/// released and plays strangers' voices out of the speaker unprompted
/// both are consequences someone must opt into, not discover. A single
/// buried toggle defaulting on was not consent.
enum PTTSettings {
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
static var liveVoiceEnabled: Bool {
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? false }
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
}

View File

@ -15625,6 +15625,378 @@
}
}
},
"voice.hud.release_to_cancel" : {
"comment" : "Recording HUD label while the finger has slid off the mic button; releasing now discards the recording",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ارفع إصبعك للإلغاء"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "বাতিল করতে ছেড়ে দাও"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "loslassen zum abbrechen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "release to cancel"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "suelta para cancelar"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "برای لغو رها کن"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitawan para kanselahin"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "relâche pour annuler"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "שחרור לביטול"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "रद्द करने के लिए छोड़ दो"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "lepas untuk membatalkan"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "rilascia per annullare"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "離すとキャンセル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "손을 떼면 취소"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "lepaskan untuk batal"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "रद्द गर्न छोड"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "laat los om te annuleren"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "puść, aby anulować"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "solta para cancelar"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "solte para cancelar"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "отпусти, чтобы отменить"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "släpp för att avbryta"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "ரத்து செய்ய விடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ปล่อยเพื่อยกเลิก"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "iptal için bırak"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "відпусти, щоб скасувати"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "منسوخ کرنے کے لیے چھوڑ دو"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "thả ra để hủy"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "松开取消"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "放開取消"
}
}
}
},
"voice.hud.slide_to_cancel" : {
"comment" : "Recording HUD hint that sliding the finger off the mic button cancels instead of sending",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "اسحب بعيدًا للإلغاء"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "বাতিল করতে আঙুল সরাও"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "zum abbrechen wegziehen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "slide away to cancel"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "desliza para cancelar"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "برای لغو انگشتت را بکش"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "i-slide palayo para kanselahin"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "glisse pour annuler"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "החלקה הצידה לביטול"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "रद्द करने के लिए दूर खिसकाओ"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "geser menjauh untuk membatalkan"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "scorri via per annullare"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "スライドでキャンセル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "밀어서 취소"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "leret menjauh untuk batal"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "रद्द गर्न टाढा सार"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "veeg weg om te annuleren"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "przesuń, aby anulować"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "desliza para cancelar"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "deslize para cancelar"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "сдвинь, чтобы отменить"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "dra bort för att avbryta"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "ரத்து செய்ய விலக்கி நகர்த்து"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เลื่อนออกเพื่อยกเลิก"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "iptal için kaydır"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "посунь, щоб скасувати"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "منسوخ کرنے کے لیے دور سرکاؤ"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "trượt ra để hủy"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "滑开取消"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "滑開取消"
}
}
}
},
"content.delivery.reason.legacy_media_consent_required" : {
"comment" : "Failure reason when a legacy private-media send lacks per-send consent",
"extractionState" : "manual",
@ -33116,181 +33488,181 @@
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "يُبث أثناء التحدث؛ الصوت المباشر الوارد يُشغَّل تلقائيًا"
"value" : "متوقف افتراضيًا. عند تفعيله، يُبث صوتك إلى الآخرين أثناء كلامك — قبل أن ترفع إصبعك — ويُشغَّل الصوت المباشر الوارد بصوت مسموع تلقائيًا."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "কথা বলার সাথে সাথে স্ট্রিম হয়; আগত লাইভ ভয়েস স্বয়ংক্রিয়ভাবে চলে"
"value" : "ডিফল্টভাবে বন্ধ। চালু থাকলে তোমার কণ্ঠ কথা বলার সঙ্গে সঙ্গেই অন্যদের কাছে স্ট্রিম হয় — বোতাম ছাড়ার আগেই — আর আসা লাইভ ভয়েস স্বয়ংক্রিয়ভাবে জোরে বাজে।"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "überträgt live beim sprechen; eingehende live-stimme wird automatisch abgespielt"
"value" : "standardmäßig aus. wenn aktiviert, wird deine stimme schon beim sprechen an andere gestreamt — noch bevor du loslässt — und eingehende live-stimmen werden automatisch laut abgespielt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "streams as you speak; incoming live voice plays automatically"
"value" : "off by default. when on, your voice streams to people as you speak — before you release — and incoming live voice plays out loud automatically."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "transmite mientras hablas; la voz en vivo entrante se reproduce automáticamente"
"value" : "desactivado por defecto. si lo activas, tu voz se transmite a la gente mientras hablas — antes de soltar — y la voz en directo entrante se reproduce en voz alta automáticamente."
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "همان‌طور که حرف می‌زنی پخش می‌شود؛ صدای زندهٔ دریافتی خودکار پخش می‌شود"
"value" : "به‌طور پیش‌فرض خاموش است. وقتی روشن باشد، صدایت همان لحظه که حرف می‌زنی — پیش از رها کردن — برای دیگران پخش می‌شود و صدای زنده‌ی دریافتی هم به‌طور خودکار با صدای بلند پخش می‌شود."
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "nag-i-stream habang nagsasalita ka; awtomatikong tumutugtog ang papasok na live na boses"
"value" : "naka-off bilang default. kapag naka-on, naist-stream ang boses mo sa iba habang nagsasalita ka — bago mo pa bitawan — at awtomatikong tumutugtog nang malakas ang papasok na live na boses."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "diffuse pendant que vous parlez ; la voix en direct entrante est lue automatiquement"
"value" : "désactivé par défaut. une fois activé, ta voix est diffusée aux autres pendant que tu parles — avant même de relâcher — et la voix en direct reçue est lue à voix haute automatiquement."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "משדר בזמן שאתה מדבר; קול חי נכנס מושמע אוטומטית"
"value" : "כבוי כברירת מחדל. כשהוא פועל, הקול שלך משודר לאחרים תוך כדי דיבור — עוד לפני שחרור הכפתור — וקול חי נכנס מושמע בקול רם אוטומטית."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "बोलते समय स्ट्रीम होता है; आने वाली लाइव आवाज़ अपने आप चलती है"
"value" : "डिफ़ॉल्ट रूप से बंद। चालू होने पर तुम्हारी आवाज़ बोलते-बोलते ही लोगों तक स्ट्रीम होती है — बटन छोड़ने से पहले ही — और आने वाली लाइव आवाज़ अपने आप ज़ोर से बजती है।"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "streaming saat Anda berbicara; suara langsung yang masuk diputar otomatis"
"value" : "nonaktif secara default. saat aktif, suaramu langsung dialirkan ke orang lain saat kamu bicara — sebelum tombol dilepas — dan suara langsung yang masuk otomatis diputar dengan keras."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "trasmette mentre parli; la voce in diretta in arrivo viene riprodotta automaticamente"
"value" : "disattivato per impostazione predefinita. quando è attivo, la tua voce viene trasmessa agli altri mentre parli — prima ancora di rilasciare — e la voce dal vivo in arrivo viene riprodotta ad alta voce automaticamente."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "話しながらライブ配信。受信したライブ音声は自動再生されます"
"value" : "デフォルトはオフ。オンにすると、ボタンを離す前から話している声がそのまま相手に配信され、受信したライブ音声は自動的にスピーカーで再生されます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "말하는 동안 실시간 전송됩니다. 수신된 라이브 음성은 자동 재생됩니다"
"value" : "기본은 꺼짐. 켜면 버튼에서 손을 떼기 전부터 말하는 동안 목소리가 상대에게 실시간으로 전송되고, 수신된 라이브 음성은 자동으로 소리 내어 재생돼요."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "strim semasa anda bercakap; suara langsung masuk dimainkan secara automatik"
"value" : "dimatikan secara lalai. apabila dihidupkan, suara kamu distrim kepada orang lain semasa kamu bercakap — sebelum kamu lepaskan — dan suara langsung yang masuk dimainkan dengan kuat secara automatik."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "बोल्दै गर्दा स्ट्रिम हुन्छ; आगमन लाइभ आवाज स्वतः बज्छ"
"value" : "पूर्वनिर्धारित रूपमा बन्द। खुला हुँदा तिम्रो आवाज बोल्दै गर्दा नै — बटन छोड्नुअघि नै — अरूसम्म स्ट्रिम हुन्छ, र आएको लाइभ आवाज आफैँ ठूलो स्वरमा बज्छ।"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "streamt terwijl je praat; inkomende live spraak wordt automatisch afgespeeld"
"value" : "standaard uit. indien aan wordt je stem al tijdens het praten naar anderen gestreamd — nog vóór je loslaat — en binnenkomende live spraak wordt automatisch hardop afgespeeld."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "przesyła na żywo podczas mówienia; przychodzący głos na żywo odtwarza się automatycznie"
"value" : "domyślnie wyłączone. po włączeniu twój głos jest przesyłany innym już w trakcie mówienia — zanim puścisz przycisk — a przychodzący głos na żywo jest automatycznie odtwarzany na głos."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "transmite enquanto fala; a voz ao vivo recebida é reproduzida automaticamente"
"value" : "desligado por predefinição. quando ativado, a tua voz é transmitida às outras pessoas enquanto falas — antes de soltares — e a voz em direto recebida é reproduzida em voz alta automaticamente."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "transmite enquanto você fala; a voz ao vivo recebida toca automaticamente"
"value" : "desativado por padrão. quando ativado, sua voz é transmitida às pessoas enquanto você fala — antes de soltar — e a voz ao vivo recebida toca em voz alta automaticamente."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "передаёт, пока вы говорите; входящий живой голос воспроизводится автоматически"
"value" : "по умолчанию выключено. когда включено, твой голос передаётся другим прямо во время разговора — ещё до того, как ты отпустишь кнопку, — а входящий живой голос автоматически проигрывается вслух."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "strömmar medan du pratar; inkommande live-röst spelas upp automatiskt"
"value" : "av som standard. när det är på strömmas din röst till andra medan du pratar — innan du släpper — och inkommande live-röst spelas automatiskt upp högt."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "நீங்கள் பேசும்போதே நேரலையாக அனுப்பப்படும்; உள்வரும் நேரலை குரல் தானாக ஒலிக்கும்"
"value" : "இயல்பாக முடக்கப்பட்டுள்ளது. இயக்கினால், நீ பேசும்போதே — பொத்தானை விடுவதற்கு முன்பே — உன் குரல் மற்றவர்களுக்கு ஸ்ட்ரீம் ஆகும்; வரும் நேரடி குரல் தானாக சத்தமாக ஒலிக்கும்."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "สตรีมขณะพูด เสียงสดขาเข้าจะเล่นอัตโนมัติ"
"value" : "ปิดไว้เป็นค่าเริ่มต้น เมื่อเปิด เสียงของคุณจะสตรีมถึงคนอื่นระหว่างที่พูด — ก่อนปล่อยปุ่ม — และเสียงสดที่เข้ามาจะเล่นออกลำโพงโดยอัตโนมัติ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "siz konuşurken canlı iletilir; gelen canlı ses otomatik çalınır"
"value" : "varsayılan olarak kapalı. açıkken sesin, sen konuşurken — daha bırakmadan — başkalarına aktarılır ve gelen canlı ses otomatik olarak sesli çalınır."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "передає, поки ви говорите; вхідний живий голос відтворюється автоматично"
"value" : "типово вимкнено. коли ввімкнено, твій голос транслюється іншим просто під час розмови — ще до того, як ти відпустиш кнопку, — а вхідний живий голос автоматично відтворюється вголос."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "بولتے وقت لائیو نشر ہوتا ہے؛ موصول ہونے والی لائیو آواز خود بخود چلتی ہے"
"value" : "طے شدہ طور پر بند۔ آن ہونے پر تمہاری آواز بولتے ہی — بٹن چھوڑنے سے پہلے — لوگوں تک اسٹریم ہوتی ہے، اور آنے والی لائیو آواز خودبخود بلند آواز میں چلتی ہے۔"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "phát trực tiếp khi bạn nói; giọng nói trực tiếp đến sẽ tự phát"
"value" : "mặc định tắt. khi bật, giọng của bạn được truyền trực tiếp tới mọi người ngay lúc bạn nói — trước cả khi thả tay — và giọng nói trực tiếp nhận được sẽ tự động phát ra loa."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "边说边实时传输;收到的实时语音会自动播放"
"value" : "默认关闭。开启后,你说话的同时——还没松开按钮——声音就会实时传给对方,收到的实时语音也会自动外放播放。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "邊說邊即時傳輸;收到的即時語音會自動播放"
"value" : "預設關閉。開啟後,你說話的同時——還沒放開按鈕——聲音就會即時傳給對方,收到的即時語音也會自動以擴音播放。"
}
}
}

View File

@ -59,6 +59,12 @@ final class VoiceRecordingViewModel: ObservableObject {
/// composer switches its recording HUD to the LIVE treatment.
@Published private(set) var isLiveStreaming = false
/// Armed when the finger slides off the mic button mid-hold: releasing
/// then cancels instead of sending. Before this existed, release ALWAYS
/// sent the HUD's cancel button required lifting the finger, which
/// sent. A recording (or live stream) could not be aborted one-handed.
@Published private(set) var isCancelArmed = false
/// Supplies the capture backend per press. `ChatViewModel` swaps in a
/// live push-to-talk session when the current DM peer can hear it now.
var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() }
@ -77,8 +83,14 @@ final class VoiceRecordingViewModel: ObservableObject {
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func setCancelArmed(_ armed: Bool) {
guard isCancelArmed != armed else { return }
isCancelArmed = armed
}
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
isCancelArmed = false
holdGeneration &+= 1
let generation = holdGeneration
let session = sessionProvider()
@ -162,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject {
}
func finish(completion: ((URL) -> Void)?) {
isCancelArmed = false
let previousState = state
switch previousState {
@ -220,6 +233,7 @@ final class VoiceRecordingViewModel: ObservableObject {
}
func cancel() {
isCancelArmed = false
finish(completion: nil)
}
@ -231,6 +245,7 @@ final class VoiceRecordingViewModel: ObservableObject {
activeSession = nil
state = .idle
isLiveStreaming = false
isCancelArmed = false
session?.panicCancelSynchronously()
}

View File

@ -220,7 +220,11 @@ private extension ContentComposerView {
TimelineView(.periodic(from: .now, by: 0.05)) { context in
// Live streaming means audio is heard as you speak the HUD
// must make that unmistakable, not just show a timer.
if voiceRecordingVM.isLiveStreaming {
if voiceRecordingVM.isCancelArmed {
Text(String(localized: "voice.hud.release_to_cancel", defaultValue: "release to cancel", comment: "Recording HUD label while the finger has slid off the mic button; releasing now discards the recording"))
.bitchatFont(size: 13, weight: .bold)
.foregroundColor(.secondary)
} else if voiceRecordingVM.isLiveStreaming {
Text(
"live \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Recording HUD label while a voice message streams live to the recipient"
@ -236,6 +240,12 @@ private extension ContentComposerView {
.foregroundColor(.red)
}
}
if !voiceRecordingVM.isCancelArmed {
Text(String(localized: "voice.hud.slide_to_cancel", defaultValue: "slide away to cancel", comment: "Recording HUD hint that sliding the finger off the mic button cancels instead of sending"))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
Button(action: voiceRecordingVM.cancel) {
Label(String(localized: "common.cancel", comment: "Cancel action in the voice recording HUD"), systemImage: "xmark.circle")
@ -346,11 +356,22 @@ private extension ContentComposerView {
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
.onChanged { value in
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
// Slide-away-to-cancel: release always used to
// send, and the HUD cancel button required
// lifting the finger which sent. Sliding off
// the button arms cancel; sliding back re-arms
// send.
let distance = hypot(value.translation.width, value.translation.height)
voiceRecordingVM.setCancelArmed(distance > 60)
}
.onEnded { _ in
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
if voiceRecordingVM.isCancelArmed {
voiceRecordingVM.cancel()
} else {
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
}
}
)
)

View File

@ -453,3 +453,35 @@ struct VoiceRecorderTests {
#expect(session.activationCalls == [true, false, true, false])
}
}
// MARK: - Slide-to-cancel state
/// Pins the slide-away-to-cancel arming semantics on the recording view
/// model: release used to ALWAYS send (the HUD cancel button required
/// lifting the finger which sent), so the armed flag must reset on every
/// exit path or a stale value could discard a wanted recording.
struct VoiceRecordingCancelArmingTests {
@Test @MainActor
func cancelArmingTogglesAndResetsOnEveryExitPath() {
let vm = VoiceRecordingViewModel()
#expect(!vm.isCancelArmed)
vm.setCancelArmed(true)
#expect(vm.isCancelArmed)
vm.setCancelArmed(false)
#expect(!vm.isCancelArmed)
vm.setCancelArmed(true)
vm.cancel()
#expect(!vm.isCancelArmed)
vm.setCancelArmed(true)
vm.finish(completion: nil)
#expect(!vm.isCancelArmed)
vm.setCancelArmed(true)
vm.panicWipe()
#expect(!vm.isCancelArmed)
}
}