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
2 changes: 1 addition & 1 deletion crates/chatd/src/adapters/telegram/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
98 changes: 79 additions & 19 deletions crates/tinyclient/src/gui/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,11 @@ pub struct Composer {
anchors: Rc<Anchors>,
/// The recording of this conversation, as chatd said it (`Change::Recording`).
recording: Option<Rec>,
/// 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<Task<()>>,
/// 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<Rec>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Self>) -> Option<gpui::Stateful<Div>> {
if !on {
return None;
Expand All @@ -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<Key> {
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<Self>) {
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>) {
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<Self>) {
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>) {
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
Expand Down Expand Up @@ -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| {
Expand Down
58 changes: 49 additions & 9 deletions crates/tinyclient/src/gui/composer/rec.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -22,13 +23,30 @@ pub(in crate::gui) fn on_press(recording: &Option<Recording>, 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<Recording>, target: &Key) -> Option<RecordRequest> {
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<Recording>, 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<Recording>, 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)
Expand Down Expand Up @@ -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]
Expand Down
45 changes: 45 additions & 0 deletions e2e/scenarios/telegram-forum-landing/drive.linux
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions e2e/scenarios/telegram-forum-landing/drive.macos
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions e2e/scenarios/telegram-forum-landing/drive.windows
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions e2e/scenarios/telegram-forum-landing/scenario.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading