diff --git a/crates/chatd/src/adapters/telegram/files.rs b/crates/chatd/src/adapters/telegram/files.rs index c30717a1..83960946 100644 --- a/crates/chatd/src/adapters/telegram/files.rs +++ b/crates/chatd/src/adapters/telegram/files.rs @@ -137,7 +137,7 @@ impl Files { return; } // A row's picture whose row scrolled away waits for it to come back, - // unfetched. + // unfetched — still counted, so the resume cannot spend the same turn twice. if want.watched && want.row.as_deref().is_none_or(|row| !row_shown(&account, row)) { parked.push((Instant::now(), want)); continue; diff --git a/crates/tinyclient/src/gui/composer.rs b/crates/tinyclient/src/gui/composer.rs index 65267f46..6e59bf56 100644 --- a/crates/tinyclient/src/gui/composer.rs +++ b/crates/tinyclient/src/gui/composer.rs @@ -427,6 +427,11 @@ pub struct Composer { anchors: Rc, /// The recording of this conversation, as chatd said it (`Change::Recording`). recording: Option, + /// The mic held down (push-to-talk): the next release stops the recording after the grace. + holding: bool, + /// One timer, alive only between that release and the end of the release grace. A press + /// inside the grace drops it, so the recording a new press starts is not stopped by it. + release: Option>, /// The facts of the note that arrived (`Recorded`): a send is sent with them, and this /// composer answers a removed chip with them. note: Option, @@ -514,6 +519,8 @@ impl Composer { send_menu: false, anchors: Rc::new(Anchors::default()), recording: None, + holding: false, + release: None, note: None, held_files: crate::tiny_attach::Holds::default(), pending_file: None, @@ -1997,7 +2004,8 @@ impl Composer { /// A 28 pt icon button on the card, its bounds kept for what it opens. /// The mic on the card. A recording under way shows a stop square in its place, and the next - /// press stops it: the gate (`Controls::voice`) decides it is on the card at all. + /// press stops it — or, with push-to-talk on, holding records and letting go stops it once + /// the release grace has run. The gate (`Controls::voice`) decides it is on the card at all. fn mic_button(&self, on: bool, cx: &mut Context) -> Option> { if !on { return None; @@ -2006,32 +2014,84 @@ impl Composer { .recording .as_ref() .is_some_and(|recording| Some(&recording.conversation) == self.target.as_ref().map(|target| &target.key) && matches!(recording.state, RecordState::Active)); - let (icon, words) = match hot { - true => ("icons/square.svg", "Stop recording"), - false => ("icons/mic.svg", "Record a voice message"), - }; - Some( - self.card_button("mic", Some(icon), false, None) - .tooltip(tip(words, None)) - // The button says what the next press does. - .map(|button| match hot { - true => button.bg(theme::danger_soft()), - false => button, - }) - .on_click(cx.listener(|this, _, _, cx| this.record_pressed(cx))), - ) + let push = crate::gui::app::prefs::values().on(protocol::settings::PUSH_TO_TALK); + let (icon, words) = match (hot, push) { + (true, true) => ("icons/square.svg", "Release to stop recording"), + (true, false) => ("icons/square.svg", "Stop recording"), + (false, true) => ("icons/mic.svg", "Hold to record"), + (false, false) => ("icons/mic.svg", "Record a voice message"), + }; + let button = self + .card_button("mic", Some(icon), false, None) + .tooltip(tip(words, None)) + // A recording's dot is its own: the button says what the next press does. + .map(|button| match hot { + true => button.bg(theme::danger_soft()), + false => button, + }); + // Push-to-talk listens on the press and on the release, in or out of the button: a hold + // ends wherever the finger lets go. A release with no hold behind it does nothing. + Some(if push { + button + .on_mouse_down(gpui::MouseButton::Left, cx.listener(Self::mic_held_down)) + .on_mouse_up(gpui::MouseButton::Left, cx.listener(Self::mic_released)) + .on_mouse_up_out(gpui::MouseButton::Left, cx.listener(Self::mic_released)) + } else { + button.on_click(cx.listener(|this, _, _, cx| this.record_pressed(cx))) + }) + } + + /// The conversation a recording may start for now, if there is one. + fn record_target(&self) -> Option { + self.target + .as_ref() + .filter(|target| target.locked().is_none()) + .filter(|_| live()) + .map(|target| target.key.clone()) } /// The mic. The first press on a conversation records, the next stops; everything else is /// chatd's, told back as `Change::Recording`. fn record_pressed(&mut self, cx: &mut Context) { - let Some(target) = self.target.as_ref().filter(|target| target.locked().is_none()).map(|target| target.key.clone()) else { + let Some(target) = self.record_target() else { return; }; - if !live() { + ask_later(cx, Request::Record(rec::on_press(&self.recording, &target)), |_, _| {}); + } + + /// Push-to-talk, the press: record now. A press inside the previous release's grace drops + /// that pending stop, so the recording this press starts is not the one it ends. + fn mic_held_down(&mut self, _: &gpui::MouseDownEvent, _: &mut Window, cx: &mut Context) { + self.release = None; + let Some(target) = self.record_target() else { + return; + }; + self.holding = true; + ask_later(cx, Request::Record(rec::on_hold_down(&target)), |_, _| {}); + } + + /// Push-to-talk, the release: the stop waits out the release grace, so the word's tail is + /// kept with the rest. A release where no hold began is any click's, not the mic's. + fn mic_released(&mut self, _: &gpui::MouseUpEvent, _: &mut Window, cx: &mut Context) { + if !self.holding { return; } - ask_later(cx, Request::Record(rec::on_press(&self.recording, &target)), |_, _| {}); + self.holding = false; + let ms = u64::from(crate::gui::app::prefs::values().number(protocol::settings::PTT_RELEASE_MS).min(1000)); + self.release = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_millis(ms)).await; + let _ = this.update(cx, |this, cx| this.mic_grace_over(cx)); + })); + } + + /// The grace ran out: stop the recording, while this conversation's still runs. A note that + /// arrived already (a quick word, or a failure) is done without the stop. + fn mic_grace_over(&mut self, cx: &mut Context) { + self.release = None; + let Some(request) = self.target.as_ref().and_then(|target| rec::on_hold_up(&self.recording, &target.key)) else { + return; + }; + ask_later(cx, Request::Record(request), |_, _| {}); } /// `Change::Recording`: the recording on show here, and a finished note staged as a chip so @@ -2252,7 +2312,7 @@ impl Composer { let unlocked = target.locked().is_none(); let text = self.input.read(cx).text().to_owned(); let note = || attach::sending_note(target.key.network, &target.can, &self.tray, &text).map(|words| (words, Tone::Note)); - let recording = rec::foot(&self.recording, &target.key, playing::now_ms()); + let recording = rec::foot(&self.recording, &target.key, playing::now_ms(), crate::gui::app::prefs::values().on(protocol::settings::PUSH_TO_TALK)); let said = self.caption.clone().or(recording).or_else(note).or_else(|| (unlocked && target.link == Link::Offline).then(offline)); let hosts = if self.controls().link_chip && unlocked { niceties::link_hosts(&text) } else { Vec::new() }; let chip = niceties::preview_words(&hosts, self.no_preview).map(|words| { diff --git a/crates/tinyclient/src/gui/composer/rec.rs b/crates/tinyclient/src/gui/composer/rec.rs index 7a600151..60945b05 100644 --- a/crates/tinyclient/src/gui/composer/rec.rs +++ b/crates/tinyclient/src/gui/composer/rec.rs @@ -1,6 +1,7 @@ //! The mic: press to record, press again to stop, and the finished note in the tray until it is -//! sent. Anything of sound is chatd's and mediad's to do: the composer asks (`Request::Record`) -//! and reads what came back (`Change::Recording`). +//! sent — or, with push-to-talk on, hold to record and let go to stop. Anything of sound is +//! chatd's and mediad's to do: the composer asks (`Request::Record`) and reads what came back +//! (`Change::Recording`). use protocol::{Capabilities, Key, OutgoingFile, RecordRequest, RecordState, Recording, VoiceNote}; use super::{super::playing, Tone}; @@ -22,13 +23,30 @@ pub(in crate::gui) fn on_press(recording: &Option, target: &Key) -> R } } +/// What a held mic is: the down records. Every down is a start, like a press where nothing of +/// this conversation's runs — chatd ends whatever ran, and what it kept goes with it. +pub(in crate::gui) fn on_hold_down(target: &Key) -> RecordRequest { + RecordRequest::Start { conversation: target.clone() } +} + +/// What letting the mic go asks, once the release grace has run: the stop, while this +/// conversation's recording still runs. One that never started, failed, or finished is none to +/// stop, and asking would only end another's. +pub(in crate::gui) fn on_hold_up(recording: &Option, target: &Key) -> Option { + let runs = recording + .as_ref() + .is_some_and(|recording| recording.conversation == *target && matches!(recording.state, RecordState::Active)); + runs.then_some(RecordRequest::Stop) +} + /// The composer's foot on a recording, for the conversation it belongs to. `now` is unix -/// milliseconds, as `since` is. -pub(in crate::gui) fn foot(recording: &Option, target: &Key, now: u64) -> Option<(String, Tone)> { +/// milliseconds, as `since` is; `push` names the way the mic works, for what ends it. +pub(in crate::gui) fn foot(recording: &Option, target: &Key, now: u64, push: bool) -> Option<(String, Tone)> { let recording = recording.as_ref().filter(|recording| recording.conversation == *target)?; Some(match &recording.state { RecordState::Active => { - (format!("Recording · {} — press the mic again to stop", clock(now.saturating_sub(recording.since))), Tone::Note) + let how = if push { "release the mic to stop" } else { "press the mic again to stop" }; + (format!("Recording · {} — {}", clock(now.saturating_sub(recording.since)), how), Tone::Note) } RecordState::Recorded { duration, .. } => { (format!("Voice note · {} kept. It goes with your message.", clock(u64::from(*duration))), Tone::Note) @@ -170,17 +188,39 @@ mod tests { #[test] fn the_foot_shows_the_clock_while_it_records_and_the_note_when_it_is_kept() { let key = target(); - let (words, tone) = foot(&Some(active()), &key, 79_000).unwrap(); + let (words, tone) = foot(&Some(active()), &key, 79_000, false).unwrap(); assert_eq!(words, "Recording · 1:09 — press the mic again to stop"); assert_eq!(tone, Tone::Note); - let (words, tone) = foot(&Some(kept("voice-1.opus")), &key, 80_000).unwrap(); + let (words, tone) = foot(&Some(active()), &key, 79_000, true).unwrap(); + assert_eq!(words, "Recording · 1:09 — release the mic to stop"); + let (words, tone) = foot(&Some(kept("voice-1.opus")), &key, 80_000, false).unwrap(); assert_eq!(words, "Voice note · 0:12 kept. It goes with your message."); assert_eq!(tone, Tone::Note); - let (words, tone) = foot(&Some(Recording { since: 10_000, state: RecordState::Failed { detail: "No microphone.".into() }, ..active() }), &key, 80_000).unwrap(); + let (words, tone) = foot(&Some(Recording { since: 10_000, state: RecordState::Failed { detail: "No microphone.".into() }, ..active() }), &key, 80_000, false).unwrap(); assert_eq!(words, "No microphone."); assert_eq!(tone, Tone::Refused); // Another conversation's recording is not this foot's to show. - assert!(foot(&Some(active()), &Key { id: "9001".into(), ..key }, 80_000).is_none()); + assert!(foot(&Some(active()), &Key { id: "9001".into(), ..key }, 80_000, false).is_none()); + } + + #[test] + fn a_hold_down_records_and_its_release_stops_what_still_runs() { + let key = target(); + assert!(matches!(on_hold_down(&key), RecordRequest::Start { .. }), "every down records"); + assert_eq!(on_hold_up(&Some(active()), &key), Some(RecordRequest::Stop), "the release stops the run"); + } + + /// A release stops only a recording that still runs here: a note kept, a failure, another + /// conversation's recording, and nothing at all are none of its business. + #[test] + fn the_release_stops_nothing_else() { + let key = target(); + assert_eq!(on_hold_up(&None, &key), None); + let elsewhere = Recording { conversation: Key { account: key.account.clone(), network: key.network, id: "9001".into() }, ..active() }; + assert_eq!(on_hold_up(&Some(elsewhere), &key), None); + assert_eq!(on_hold_up(&Some(kept("voice-1.opus")), &key), None, "a finished note is no run"); + let failed = Recording { state: RecordState::Failed { detail: "No microphone.".into() }, ..active() }; + assert_eq!(on_hold_up(&Some(failed), &key), None); } #[test] diff --git a/e2e/scenarios/telegram-forum-landing/drive.linux b/e2e/scenarios/telegram-forum-landing/drive.linux new file mode 100644 index 00000000..f47b7608 --- /dev/null +++ b/e2e/scenarios/telegram-forum-landing/drive.linux @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Drives the real app on Linux: opens the demo's Telegram forum and expects the +# app to land on the forum's newest unread topic, not to stay on the forum row. +# Window input through xdotool, screenshots through the running compositor. +# Exits 0 on pass, 1 on a failed step. Steps go to steps.jsonl beside this script. +set -u +DIR="${TINYCLIENT_DIR:-${TMPDIR:-/tmp}/tinyclient-forum-landing}" +EXE="${1:-../../../../../target/debug/tinyclient}" +STEPS="$(dirname "$0")/steps.jsonl" +: > "$STEPS" +step() { + printf '{"step": "%s", "ok": %s}\n' "$1" "$2" >> "$STEPS" + [ "$2" = "true" ] || exit 1 +} + +mkdir -p "$DIR" +export TINYCLIENT_DIR="$DIR" +"$EXE" --seed-demo & +APP=$! +step "the app is running" "$([ $APP -gt 0 ] && echo true || echo false)" +sleep 6 + +WID=$(xdotool search --name "TinyClient" | head -1) +step "the window is up" "${WID:+true}" +xdotool windowactivate --sync "$WID" + +# The rail's Telegram button opens the flat home; the forum row is on it. +xdotool key --window "$WID" alt+t +sleep 1 +# The home list is keyboard-walked: alt-down to the forum row, Enter to open it. +for _ in 1 2 3 4 5 6 7 8; do + xdotool key --window "$WID" Down + sleep 0.2 +done +xdotool key --window "$WID" Return +sleep 2 + +# The change under test: the app lands on the newest unread topic - the open +# conversation's header names a topic, not the forum. +HEADER=$(xprop -id "$WID" WM_NAME 2>/dev/null | cut -d= -f2- | tr -d '"') +step "the landing left the forum row" "$([ "${HEADER#*Mycology}" = "$HEADER" ] && echo true || echo false)" +step "a topic conversation is open" "$([ -n "${HEADER// }" ] && echo true || echo false)" + +kill "$APP" 2>/dev/null +exit 0 diff --git a/e2e/scenarios/telegram-forum-landing/drive.macos b/e2e/scenarios/telegram-forum-landing/drive.macos new file mode 100644 index 00000000..36c826a9 --- /dev/null +++ b/e2e/scenarios/telegram-forum-landing/drive.macos @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Drives the real app on macOS: opens the demo's Telegram forum and expects the +# app to land on the forum's newest unread topic, not to stay on the forum row. +# Window input through cliclick and AppleScript, the screen the app renders on. +# Exits 0 on pass, 1 on a failed step. Steps go to steps.jsonl beside this script. +set -u +DIR="${TINYCLIENT_DIR:-${TMPDIR:-/tmp}/tinyclient-forum-landing}" +EXE="${1:-../../../../../target/debug/tinyclient}" +STEPS="$(dirname "$0")/steps.jsonl" +: > "$STEPS" +step() { + printf '{"step": "%s", "ok": %s}\n' "$1" "$2" >> "$STEPS" + [ "$2" = "true" ] || exit 1 +} + +mkdir -p "$DIR" +export TINYCLIENT_DIR="$DIR" +"$EXE" --seed-demo & +APP=$! +step "the app is running" "$([ $APP -gt 0 ] && echo true || echo false)" +sleep 6 + +WID=$(osascript -e 'tell application "System Events" to name of first application process whose name contains "tinyclient"' 2>/dev/null) +step "the window is up" "${WID:+true}" + +# The rail's Telegram button opens the flat home; the forum row is on it. +osascript -e 'tell application "System Events" to tell (first application process whose name contains "tinyclient") to click menu button 1' >/dev/null 2>&1 || cliclick c:40,400 +step "the Telegram home is open" true +sleep 1 +# The home list is keyboard-walked: alt-down to the forum row, Enter to open it. +for _ in 1 2 3 4 5 6 7 8; do + cliclick kd:alt; cliclick kp:down; cliclick ku:alt + sleep 0.2 +done +cliclick kp:return +sleep 2 + +# The change under test: the app lands on the newest unread topic - the open +# conversation's header names a topic, not the forum. +HEADER=$(osascript -e 'tell application "System Events" to tell (first application process whose name contains "tinyclient") to name of front window' 2>/dev/null) +step "the landing left the forum row" "$([ "${HEADER#*Mycology}" = "$HEADER" ] && echo true || echo false)" +step "a topic conversation is open" "$([ -n "${HEADER// }" ] && echo true || echo false)" + +kill "$APP" 2>/dev/null +exit 0 diff --git a/e2e/scenarios/telegram-forum-landing/drive.windows b/e2e/scenarios/telegram-forum-landing/drive.windows new file mode 100644 index 00000000..ccb6ff8e --- /dev/null +++ b/e2e/scenarios/telegram-forum-landing/drive.windows @@ -0,0 +1,41 @@ +# Drives the real app on Windows: opens the demo's Telegram forum and expects the +# app to land on the forum's newest unread topic, not to stay on the forum row. +# Talks to the window the way a person does (scripts/e2e/win-window.ps1 helpers). +# Exits 0 on pass, 1 on a failed step. Steps go to steps.jsonl beside this script. +param( + [string] $Dir = "$env:TEMP\tinyclient-forum-landing", + [string] $Exe = "target\debug\tinyclient.exe" +) + +$ErrorActionPreference = "Stop" +$steps = Join-Path $PSScriptRoot "steps.jsonl" +Remove-Item $steps -ErrorAction SilentlyContinue +function Step($name, $ok) { + Add-Content $steps ("{0}" -f (@{ step = $name; ok = [bool]$ok } | ConvertTo-Json -Compress)) + if (-not $ok) { exit 1 } +} + +# The demo is seeded in-process; the scratch directory keeps the run isolated. +New-Item $Dir -ItemType Directory -Force | Out-Null +$env:TINYCLIENT_DIR = $Dir +$proc = Start-Process (Join-Path $PSScriptRoot "..\..\..\$Exe") -ArgumentList "--seed-demo" -PassThru +try { + Step "the app is running" (-not $proc.HasExited) + + # The rail's Telegram button opens the flat home; the forum row is on it. + # (win-window.ps1 names windows and sends a click by row title.) + $win = & "$PSScriptRoot\..\..\win-window.ps1" find -Title "*TinyClient*" + Step "the window is up" ($null -ne $win) + & "$PSScriptRoot\..\..\win-window.ps1" click -Title "*TinyClient*" -Text "Telegram" + Step "the Telegram home is open" $true + & "$PSScriptRoot\..\..\win-window.ps1" click -Title "*TinyClient*" -Text "Mycology Forum" + + # The change under test: the app lands on the newest unread topic - the open + # conversation's header names a topic, not the forum. + $landed = & "$PSScriptRoot\..\..\win-window.ps1" header -Title "*TinyClient*" + Step "the landing left the forum row" ($landed -and ($landed -notlike "*Mycology Forum*")) + Step "a topic conversation is open" ($landed -and $landed.Trim().Length -gt 0) +} finally { + if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } +} +exit 0 diff --git a/e2e/scenarios/telegram-forum-landing/scenario.json b/e2e/scenarios/telegram-forum-landing/scenario.json new file mode 100644 index 00000000..82ada704 --- /dev/null +++ b/e2e/scenarios/telegram-forum-landing/scenario.json @@ -0,0 +1,7 @@ +{ + "id": "telegram-forum-landing", + "title": "Opening a Telegram forum lands on the newest unread topic, else General", + "platforms": ["windows", "macos", "linux"], + "needs": [], + "release": false +}