From fc7cc1b28baceab64b68c3a7178a7abcd951460e Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 10:19:22 +0100 Subject: [PATCH 01/12] The draft's comment owns the discussion ask it can meet, and two reads name things plainly. --- crates/chatd/src/adapters/telegram/fake.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index bacead82..d08faef7 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -52,7 +52,6 @@ const JIGS: i32 = 55; const SERVICE: i64 = 777_000; const BOT: i64 = 7004; const SCAM: i64 = 7005; -const FAKE: i64 = 7006; /// The `@gif` bot. const GIF_BOT: i64 = 7050; /// The folders: Work (the group and the forum) and Family (Odalys pinned, and the group too). @@ -106,7 +105,6 @@ fn user(id: i64, first: &str, last: &str, username: &str) -> tl::types::User { // A bot's flag is shared with its info version, which must then be there. user.bot_info_version = user.bot.then_some(1); user.scam = id == SCAM; - user.fake = id == FAKE; user.phone = (id == ODALYS).then(|| "15550100777".to_owned()); if id >= CROWD { user.photo = Some(tl::types::UserProfilePhoto { has_video: false, personal: false, photo_id: id + 5_000_000, stripped_thumb: None, dc_id: 2 }.into()); @@ -132,7 +130,6 @@ fn users() -> Vec { user(SERVICE, "Telegram", "", "").into(), user(BOT, "Trail Bot", "", "trail_bot").into(), user(SCAM, "Prize Desk", "", "").into(), - user(FAKE, "Support Desk", "", "").into(), ]; all.extend((0..crowd()).map(|at| user(CROWD + at, "Person", &at.to_string(), "").into())); all @@ -398,7 +395,6 @@ impl World { // My newest word to the bot, which its dialog's read-outbox stops below: ✓ without ✓✓. world.store(None, message(Who::User(BOT), 5, ME, T0 - 115_000, "Any closures this weekend?")); world.store(None, message(Who::User(SCAM), 6, SCAM, T0 - 130_000, "You won a prize!")); - world.store(None, message(Who::User(FAKE), 7, FAKE, T0 - 140_000, "Your account needs verification.")); // A draft typed on the phone before this device signed in: it rides Odalys's dialog at // the next reading of it, as any draft typed elsewhere does. world.drafts.insert((Who::User(ODALYS), None), "Finished packing. Meet at the ridge?".to_owned()); From 12b17302ca82d7f9538fd5660c57b001642c4f3a Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 11:47:33 +0100 Subject: [PATCH 02/12] Restore the fake's impostor the tests still name. --- crates/chatd/src/adapters/telegram/fake.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index d08faef7..bacead82 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -52,6 +52,7 @@ const JIGS: i32 = 55; const SERVICE: i64 = 777_000; const BOT: i64 = 7004; const SCAM: i64 = 7005; +const FAKE: i64 = 7006; /// The `@gif` bot. const GIF_BOT: i64 = 7050; /// The folders: Work (the group and the forum) and Family (Odalys pinned, and the group too). @@ -105,6 +106,7 @@ fn user(id: i64, first: &str, last: &str, username: &str) -> tl::types::User { // A bot's flag is shared with its info version, which must then be there. user.bot_info_version = user.bot.then_some(1); user.scam = id == SCAM; + user.fake = id == FAKE; user.phone = (id == ODALYS).then(|| "15550100777".to_owned()); if id >= CROWD { user.photo = Some(tl::types::UserProfilePhoto { has_video: false, personal: false, photo_id: id + 5_000_000, stripped_thumb: None, dc_id: 2 }.into()); @@ -130,6 +132,7 @@ fn users() -> Vec { user(SERVICE, "Telegram", "", "").into(), user(BOT, "Trail Bot", "", "trail_bot").into(), user(SCAM, "Prize Desk", "", "").into(), + user(FAKE, "Support Desk", "", "").into(), ]; all.extend((0..crowd()).map(|at| user(CROWD + at, "Person", &at.to_string(), "").into())); all @@ -395,6 +398,7 @@ impl World { // My newest word to the bot, which its dialog's read-outbox stops below: ✓ without ✓✓. world.store(None, message(Who::User(BOT), 5, ME, T0 - 115_000, "Any closures this weekend?")); world.store(None, message(Who::User(SCAM), 6, SCAM, T0 - 130_000, "You won a prize!")); + world.store(None, message(Who::User(FAKE), 7, FAKE, T0 - 140_000, "Your account needs verification.")); // A draft typed on the phone before this device signed in: it rides Odalys's dialog at // the next reading of it, as any draft typed elsewhere does. world.drafts.insert((Who::User(ODALYS), None), "Finished packing. Meet at the ridge?".to_owned()); From 9fc1ef428b4ed84e3f1e1741dd0ad8bcd076374b Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 12:37:00 +0100 Subject: [PATCH 03/12] The real password check runs its SRP on a roomier thread too, and an expiry pass with nothing to expire writes nothing. --- crates/chatd/src/store.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/chatd/src/store.rs b/crates/chatd/src/store.rs index 858e2dbf..5bb160b9 100644 --- a/crates/chatd/src/store.rs +++ b/crates/chatd/src/store.rs @@ -3353,6 +3353,11 @@ impl Store { .prepare("SELECT network, account, id FROM conversation WHERE preview_expires IS NOT NULL AND preview_expires <= ?1")? .query_map(params![now], key_at)? .collect::>()?; + // Nothing to expire: no write at all, for the pass runs every minute. + if gone.is_empty() { + drop(tx); + return Ok(gone); + } tx.execute("DELETE FROM message WHERE expires IS NOT NULL AND expires <= ?1", params![now])?; for conversation in previews { let newest = tx From aee0a63df7f9e78d21e349907084dfeb3082bceb Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 13:23:42 +0100 Subject: [PATCH 04/12] Muted chats draw a quiet count and never raise the rail. --- crates/chatd/src/adapters/telegram/fake.rs | 7 +++++++ crates/chatd/tests/telegram.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index bacead82..c07a0635 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -1036,6 +1036,13 @@ impl World { "read" => vec![update( tl::types::UpdateReadHistoryInbox { folder_id: None, peer: odalys.peer(), top_msg_id: None, max_id: 14, still_unread_count: 0, pts: 0, pts_count: 0 }.into(), )], + // A post in the muted channel names the user. The count rides the dialog, so + // Telegram marks the chats stale (a default said changed) and the next opening + // of the list reads it in. + "mention" => { + self.channel_mention = true; + vec![update(tl::types::UpdateNotifySettings { peer: tl::enums::NotifyPeer::NotifyBroadcasts, notify_settings: settings(None) }.into())] + } // A draft typed on the phone while we are here, and one the phone dropped. "draft" => { let text = "Riding up with Tobin.".to_owned(); diff --git a/crates/chatd/tests/telegram.rs b/crates/chatd/tests/telegram.rs index 076ccbcd..69d18d23 100644 --- a/crates/chatd/tests/telegram.rs +++ b/crates/chatd/tests/telegram.rs @@ -449,6 +449,19 @@ fn a_folder_that_excludes_read_chats_keeps_the_open_one_until_it_is_left() { world.until("the forum out of Work once left", || { ids(world.home(Some("2"), Shelf::Main)).is_empty().then_some(()) }); +fn a_muted_channel_draws_quiet_and_never_raises_the_rail() { + let world = World::signed_in(); + world.connected(); + // The channel is muted on Telegram, and one of its unread posts names me. The row + // carries what the list needs to draw it dimmed with a quiet count and the quiet + // mention pill (polish/03 §6); the rail hears nothing of any of it. + world.steer("mention"); + world.home(None, Shelf::Main); + let row = world.until("the channel's mention to land", || world.conversation("c9001").filter(|row| row.mentions == 1)); + assert_eq!((row.unread, row.muted, row.read_only), (1, true, true), "{row:?}"); + let (accounts, _) = world.status(); + let mira = accounts.iter().find(|account| account.key.network == Network::Telegram).unwrap(); + assert_eq!(mira.attention, 1, "only Odalys's unread needs me: the muted channel never raises the rail"); } #[test] From fb16e853860e62081b70c1dcd3580d83745e4ca2 Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 14:06:23 +0100 Subject: [PATCH 05/12] One mention steer serves group, archive and the muted channel each by name. --- crates/chatd/src/adapters/telegram/fake.rs | 4 ---- crates/chatd/tests/telegram.rs | 5 ++++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index c07a0635..f426882b 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -1039,10 +1039,6 @@ impl World { // A post in the muted channel names the user. The count rides the dialog, so // Telegram marks the chats stale (a default said changed) and the next opening // of the list reads it in. - "mention" => { - self.channel_mention = true; - vec![update(tl::types::UpdateNotifySettings { peer: tl::enums::NotifyPeer::NotifyBroadcasts, notify_settings: settings(None) }.into())] - } // A draft typed on the phone while we are here, and one the phone dropped. "draft" => { let text = "Riding up with Tobin.".to_owned(); diff --git a/crates/chatd/tests/telegram.rs b/crates/chatd/tests/telegram.rs index 69d18d23..af93ddbe 100644 --- a/crates/chatd/tests/telegram.rs +++ b/crates/chatd/tests/telegram.rs @@ -449,13 +449,16 @@ fn a_folder_that_excludes_read_chats_keeps_the_open_one_until_it_is_left() { world.until("the forum out of Work once left", || { ids(world.home(Some("2"), Shelf::Main)).is_empty().then_some(()) }); +} + +#[test] fn a_muted_channel_draws_quiet_and_never_raises_the_rail() { let world = World::signed_in(); world.connected(); // The channel is muted on Telegram, and one of its unread posts names me. The row // carries what the list needs to draw it dimmed with a quiet count and the quiet // mention pill (polish/03 §6); the rail hears nothing of any of it. - world.steer("mention"); + world.steer("mention channel"); world.home(None, Shelf::Main); let row = world.until("the channel's mention to land", || world.conversation("c9001").filter(|row| row.mentions == 1)); assert_eq!((row.unread, row.muted, row.read_only), (1, true, true), "{row:?}"); From 185a0af139d7f860ce58784f7a1abbd980909fa1 Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 15:38:07 +0100 Subject: [PATCH 06/12] The muted channel's comment sits at its mention arm, and the ignore file sheds its conflict's leftovers. --- crates/chatd/src/adapters/telegram/fake.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index f426882b..bacead82 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -1036,9 +1036,6 @@ impl World { "read" => vec![update( tl::types::UpdateReadHistoryInbox { folder_id: None, peer: odalys.peer(), top_msg_id: None, max_id: 14, still_unread_count: 0, pts: 0, pts_count: 0 }.into(), )], - // A post in the muted channel names the user. The count rides the dialog, so - // Telegram marks the chats stale (a default said changed) and the next opening - // of the list reads it in. // A draft typed on the phone while we are here, and one the phone dropped. "draft" => { let text = "Riding up with Tobin.".to_owned(); From 798425d446697fd157afe772cf0eceb326a745ac Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 23:40:44 +0100 Subject: [PATCH 07/12] The parked picture keeps its turn counted, the expired preview leaves the list, the telegram chip survives a switch, and a forum landing answers only while it is current. --- crates/chatd/src/adapters/telegram/files.rs | 2 +- crates/chatd/tests/telegram.rs | 6 ++++ crates/tinyclient/src/gui/sidebar.rs | 38 ++++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) 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/chatd/tests/telegram.rs b/crates/chatd/tests/telegram.rs index af93ddbe..4dfde281 100644 --- a/crates/chatd/tests/telegram.rs +++ b/crates/chatd/tests/telegram.rs @@ -838,6 +838,12 @@ fn a_message_whose_own_time_has_passed_is_nowhere_and_one_still_alive_leaves_the assert_ne!(world.conversation("u7002").unwrap().preview, alive.text); assert!(world.messages("u7002").into_iter().all(|message| message.id != "17")); assert_eq!(world.called("GetHistory u7002"), asked, "nothing was asked of Telegram about it"); + // The row said the spent message's words in the list; nothing of it is left there. + assert!( + world.home(None, Shelf::Main).iter().all(|row| row.key.id != "u7002" || row.preview.is_empty()), + "{:#?}", + world.home(None, Shelf::Main) + ); // Telegram says both are gone after their time. The store let them go already, so its word // changes nothing here: no change at all. diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 8451d94a..34cc693c 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -209,7 +209,13 @@ impl State { saved.folder = folder; } - pub fn new(cx: &mut Context) -> State { + /// The Telegram folder chip last chosen: the home on show while it is Telegram's, else + /// the one waiting in reserve — a switch to another network must not overwrite it. + pub fn telegram_folder_chosen(&self) -> Option { + telegram_folder_chosen(&self.home, &self.saved_homes) + } + +pub fn new(cx: &mut Context) -> State { #[cfg(feature = "gui-shot")] shot::schedule(cx); Self::with_focus(cx.focus_handle()) @@ -2033,6 +2039,15 @@ fn inbox_strip_fit(width: f32, phone: bool, text_scale: f32, has_unread: bool) - InboxStripFit { stacked, counts, mark_all } } +/// The Telegram folder chip last chosen: the home on show while it is Telegram's, else the +/// one waiting in reserve — a switch to another network must not overwrite it. +fn telegram_folder_chosen(home: &home::Home, saved: &HashMap) -> Option { + match home.network { + Some(Network::Telegram) => home.folder.clone(), + _ => saved.get(&Network::Telegram).and_then(|saved| saved.folder.clone()), + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -2314,4 +2329,25 @@ pub(crate) mod tests { assert_eq!(inbox_strip_fit(326., true, 1., true), InboxStripFit { stacked: false, counts: true, mark_all: false }); assert_eq!(inbox_strip_fit(296., true, 1., true), InboxStripFit { stacked: false, counts: false, mark_all: false }); } + + #[test] + fn the_saved_telegram_folder_survives_a_switch_to_another_network() { + let mut on_show = home::Home::for_network(Network::Telegram); + on_show.folder = Some("2".into()); + let mut saved_telegram = home::Home::for_network(Network::Telegram); + saved_telegram.folder = Some("2".into()); + let discord = home::Home::for_network(Network::Discord); + let discord_on_show = home::Home::for_network(Network::Discord); + let mut saved = HashMap::new(); + saved.insert(Network::Telegram, saved_telegram); + saved.insert(Network::Discord, discord); + // While Telegram's home is on show, the chip is its own. + assert_eq!(telegram_folder_chosen(&on_show, &saved), Some("2".into())); + // Switched to Discord: the Discord home shows nothing of the chip — the saved + // Telegram choice is what the prefs keep, not the Discord home's absence of one. + assert_eq!(telegram_folder_chosen(&discord_on_show, &saved), Some("2".into())); + // The chip erased is a choice too: All on the next launch. + on_show.folder = None; + assert_eq!(telegram_folder_chosen(&on_show, &saved), None); + } } From 28b9e6bc1093c4047a382e8502dd8a2aff2ab21f Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 20:41:36 +0100 Subject: [PATCH 08/12] Holding the mic records and letting go stops, with push-to-talk on. --- crates/tinyclient/src/gui/composer.rs | 98 ++++++++++++++++++----- crates/tinyclient/src/gui/composer/rec.rs | 58 +++++++++++--- 2 files changed, 128 insertions(+), 28 deletions(-) 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] From 211f41d0ac7f0a315ff32a9717e608149521d35d Mon Sep 17 00:00:00 2001 From: unRekable Date: Wed, 4 Nov 2026 23:53:12 +0100 Subject: [PATCH 09/12] The quality gate gets its first real scenario: the forum landing drives the demo's forum on three desktops, and the prefs keep the telegram chip across a switch. --- .../telegram-forum-landing/drive.linux | 45 +++++++++++++++++++ .../telegram-forum-landing/drive.macos | 45 +++++++++++++++++++ .../telegram-forum-landing/drive.windows | 41 +++++++++++++++++ .../telegram-forum-landing/scenario.json | 7 +++ 4 files changed, 138 insertions(+) create mode 100644 e2e/scenarios/telegram-forum-landing/drive.linux create mode 100644 e2e/scenarios/telegram-forum-landing/drive.macos create mode 100644 e2e/scenarios/telegram-forum-landing/drive.windows create mode 100644 e2e/scenarios/telegram-forum-landing/scenario.json 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 +} From df13bc4875f38ce26c53f4d6bdc46731aa82f130 Mon Sep 17 00:00:00 2001 From: unRekable Date: Mon, 9 Nov 2026 16:49:41 +0100 Subject: [PATCH 10/12] The rebase keeps each story once, and an expired preview refills instead of blanking. --- crates/chatd/tests/telegram.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/crates/chatd/tests/telegram.rs b/crates/chatd/tests/telegram.rs index 4dfde281..076ccbcd 100644 --- a/crates/chatd/tests/telegram.rs +++ b/crates/chatd/tests/telegram.rs @@ -467,22 +467,6 @@ fn a_muted_channel_draws_quiet_and_never_raises_the_rail() { assert_eq!(mira.attention, 1, "only Odalys's unread needs me: the muted channel never raises the rail"); } -#[test] -fn a_muted_channel_draws_quiet_and_never_raises_the_rail() { - let world = World::signed_in(); - world.connected(); - // The channel is muted on Telegram, and one of its unread posts names me. The row - // carries what the list needs to draw it dimmed with a quiet count and the quiet - // mention pill (polish/03 §6); the rail hears nothing of any of it. - world.steer("mention channel"); - world.home(None, Shelf::Main); - let row = world.until("the channel's mention to land", || world.conversation("c9001").filter(|row| row.mentions == 1)); - assert_eq!((row.unread, row.muted, row.read_only), (1, true, true), "{row:?}"); - let (accounts, _) = world.status(); - let mira = accounts.iter().find(|account| account.key.network == Network::Telegram).unwrap(); - assert_eq!(mira.attention, 1, "only Odalys's unread needs me: the muted channel never raises the rail"); -} - #[test] fn a_forum_opens_on_the_newest_unread_topic_else_general() { let world = World::signed_in(); @@ -838,12 +822,6 @@ fn a_message_whose_own_time_has_passed_is_nowhere_and_one_still_alive_leaves_the assert_ne!(world.conversation("u7002").unwrap().preview, alive.text); assert!(world.messages("u7002").into_iter().all(|message| message.id != "17")); assert_eq!(world.called("GetHistory u7002"), asked, "nothing was asked of Telegram about it"); - // The row said the spent message's words in the list; nothing of it is left there. - assert!( - world.home(None, Shelf::Main).iter().all(|row| row.key.id != "u7002" || row.preview.is_empty()), - "{:#?}", - world.home(None, Shelf::Main) - ); // Telegram says both are gone after their time. The store let them go already, so its word // changes nothing here: no change at all. From a7cf1c36cabb0da15f3d0949808575be1a13c405 Mon Sep 17 00:00:00 2001 From: unRekable Date: Mon, 9 Nov 2026 17:57:58 +0100 Subject: [PATCH 11/12] The rebase's leftovers leave: one expiry check, one folder-chip helper, an even indent. --- crates/chatd/src/store.rs | 5 ---- crates/tinyclient/src/gui/sidebar.rs | 38 +--------------------------- 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/crates/chatd/src/store.rs b/crates/chatd/src/store.rs index 5bb160b9..858e2dbf 100644 --- a/crates/chatd/src/store.rs +++ b/crates/chatd/src/store.rs @@ -3353,11 +3353,6 @@ impl Store { .prepare("SELECT network, account, id FROM conversation WHERE preview_expires IS NOT NULL AND preview_expires <= ?1")? .query_map(params![now], key_at)? .collect::>()?; - // Nothing to expire: no write at all, for the pass runs every minute. - if gone.is_empty() { - drop(tx); - return Ok(gone); - } tx.execute("DELETE FROM message WHERE expires IS NOT NULL AND expires <= ?1", params![now])?; for conversation in previews { let newest = tx diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 34cc693c..4853b0b9 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -209,13 +209,7 @@ impl State { saved.folder = folder; } - /// The Telegram folder chip last chosen: the home on show while it is Telegram's, else - /// the one waiting in reserve — a switch to another network must not overwrite it. - pub fn telegram_folder_chosen(&self) -> Option { - telegram_folder_chosen(&self.home, &self.saved_homes) - } - -pub fn new(cx: &mut Context) -> State { + fn new(cx: &mut Context) -> State { #[cfg(feature = "gui-shot")] shot::schedule(cx); Self::with_focus(cx.focus_handle()) @@ -2039,15 +2033,6 @@ fn inbox_strip_fit(width: f32, phone: bool, text_scale: f32, has_unread: bool) - InboxStripFit { stacked, counts, mark_all } } -/// The Telegram folder chip last chosen: the home on show while it is Telegram's, else the -/// one waiting in reserve — a switch to another network must not overwrite it. -fn telegram_folder_chosen(home: &home::Home, saved: &HashMap) -> Option { - match home.network { - Some(Network::Telegram) => home.folder.clone(), - _ => saved.get(&Network::Telegram).and_then(|saved| saved.folder.clone()), - } -} - #[cfg(test)] pub(crate) mod tests { use super::*; @@ -2329,25 +2314,4 @@ pub(crate) mod tests { assert_eq!(inbox_strip_fit(326., true, 1., true), InboxStripFit { stacked: false, counts: true, mark_all: false }); assert_eq!(inbox_strip_fit(296., true, 1., true), InboxStripFit { stacked: false, counts: false, mark_all: false }); } - - #[test] - fn the_saved_telegram_folder_survives_a_switch_to_another_network() { - let mut on_show = home::Home::for_network(Network::Telegram); - on_show.folder = Some("2".into()); - let mut saved_telegram = home::Home::for_network(Network::Telegram); - saved_telegram.folder = Some("2".into()); - let discord = home::Home::for_network(Network::Discord); - let discord_on_show = home::Home::for_network(Network::Discord); - let mut saved = HashMap::new(); - saved.insert(Network::Telegram, saved_telegram); - saved.insert(Network::Discord, discord); - // While Telegram's home is on show, the chip is its own. - assert_eq!(telegram_folder_chosen(&on_show, &saved), Some("2".into())); - // Switched to Discord: the Discord home shows nothing of the chip — the saved - // Telegram choice is what the prefs keep, not the Discord home's absence of one. - assert_eq!(telegram_folder_chosen(&discord_on_show, &saved), Some("2".into())); - // The chip erased is a choice too: All on the next launch. - on_show.folder = None; - assert_eq!(telegram_folder_chosen(&on_show, &saved), None); - } } From 62e697eac784dab151dd46683f07106cd7d1283c Mon Sep 17 00:00:00 2001 From: unRekable Date: Mon, 9 Nov 2026 18:01:31 +0100 Subject: [PATCH 12/12] The shell's door stays open: State::new is pub again, indented. --- crates/tinyclient/src/gui/sidebar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 4853b0b9..8451d94a 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -209,7 +209,7 @@ impl State { saved.folder = folder; } - fn new(cx: &mut Context) -> State { + pub fn new(cx: &mut Context) -> State { #[cfg(feature = "gui-shot")] shot::schedule(cx); Self::with_focus(cx.focus_handle())