From 33899853aa5cd5b92a21e8426368b5c232e29ba4 Mon Sep 17 00:00:00 2001 From: Vincent Bernardoff Date: Sat, 17 Oct 2026 18:37:34 +0200 Subject: [PATCH] fix: expand followed threads from the sidebar count Turn the inert thread count into an expansion toggle. Include read and overflow followed threads, offer a collapse action, and keep the open thread visible when folded. Scope expansion to the account and channel for the session. Add regression coverage and update the design docs. Co-Authored-By: GPT-6 --- crates/tinyclient/src/gui/sidebar.rs | 12 +++-- crates/tinyclient/src/gui/sidebar/tree.rs | 56 ++++++++++++++++++++--- docs/design/polish/02-discord-slack.md | 2 +- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index d5e9d09..26ce6ca 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -111,6 +111,7 @@ pub struct State { opened: HashSet, closed: HashSet, quiet_shown: HashSet, + threads_shown: HashSet, muted_shown: HashSet, opted_shown: HashSet, /// The tree on show, flat. Rebuilt on collapse and when its data changes. @@ -176,6 +177,7 @@ impl State { opened: HashSet::new(), closed: HashSet::new(), quiet_shown: HashSet::new(), + threads_shown: HashSet::new(), muted_shown: HashSet::new(), opted_shown: HashSet::new(), rows: Rc::new(Vec::new()), @@ -438,6 +440,7 @@ impl Shell { opened: Some(&state.opened), closed: Some(&state.closed), quiet_shown: Some(&state.quiet_shown), + threads_shown: Some(&state.threads_shown), unread: state.home_unread && space.network == Network::Slack, }; tree::flatten(space, &tree.groups, &tree.conversations, self.open.as_ref(), depth, &rules) @@ -547,6 +550,11 @@ impl Shell { (tree::Reveal::OptedOut, Some(space)) => { state.opted_shown.insert(space); } + (tree::Reveal::Threads(channel), _) => { + if !state.threads_shown.remove(&channel) { + state.threads_shown.insert(channel); + } + } (tree::Reveal::Quiet(group), _) => { state.quiet_shown.insert(group); } @@ -868,15 +876,13 @@ impl Shell { self.action_row(("threads", ix), Some("icons/messages-square.svg"), "Threads".into(), *count > 0, trailing, focused) .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| this.activate(ix, cx))) } - Row::Fold { words, depth, reveal } => { - let quiet = *reveal == tree::Reveal::Threads; + Row::Fold { words, depth, .. } => { let content = div() .children(indent(*depth, 8.)) .gap(px(8.)) .child(div().size(px(20.)).flex_none().flex().items_center().justify_center().child(glyph("icons/ellipsis.svg", 14., theme::text_faint()))) .child(div().flex_1().min_w_0().truncate().type_ui().text_color(theme::text_faint()).child(words.clone())); list_row(("fold", ix), height, false, focused, content) - .when(quiet, |row| row.cursor_default()) .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| this.activate(ix, cx))) } Row::More { words, depth } => { diff --git a/crates/tinyclient/src/gui/sidebar/tree.rs b/crates/tinyclient/src/gui/sidebar/tree.rs index af9ea48..fa5f7ac 100644 --- a/crates/tinyclient/src/gui/sidebar/tree.rs +++ b/crates/tinyclient/src/gui/sidebar/tree.rs @@ -56,8 +56,8 @@ pub enum Reveal { OptedOut, /// "12 quiet channels" at the end of a Slack section. Quiet(String), - /// "4 more threads" under a channel: opens nothing yet, it says how many. - Threads, + /// Toggle followed threads under this channel. + Threads(Key), } #[derive(Clone, PartialEq, Debug)] @@ -96,6 +96,8 @@ pub struct Rules<'a> { pub closed: Option<&'a HashSet>, /// Slack sections whose quiet channels are shown. pub quiet_shown: Option<&'a HashSet>, + /// Channels whose followed threads are expanded this session. + pub threads_shown: Option<&'a HashSet>, /// The home's Unread segment: only rows with news, the open one and starred ones. pub unread: bool, } @@ -130,7 +132,8 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] let mut quiet_threads: HashMap<&str, usize> = HashMap::new(); for thread in conversations.iter().filter(|c| is_thread(c) && c.parent.as_ref().is_some_and(in_tree)) { let parent = thread.parent.as_deref().unwrap_or_default(); - let shown = open == Some(&thread.key) || (!slack && thread.followed && thread.badge() != Badge::None); + let expanded = rules.threads_shown.is_some_and(|shown| shown.contains(&Key { id: parent.into(), ..space.clone() })); + let shown = open == Some(&thread.key) || (!slack && thread.followed && (expanded || thread.badge() != Badge::None)); if shown { threads.entry(parent).or_default().push(thread); } else if !slack && thread.followed { @@ -164,15 +167,16 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] } } rows.push(Row::Conversation { conversation: Rc::clone(conversation), depth }); + let expanded = rules.threads_shown.is_some_and(|shown| shown.contains(&conversation.key)); for (ix, thread) in under.iter().enumerate() { - if ix < THREADS_SHOWN || open == Some(&thread.key) { + if expanded || ix < THREADS_SHOWN || open == Some(&thread.key) { rows.push(Row::Conversation { conversation: Rc::clone(thread), depth: depth + 1 }); } } let more = under.iter().enumerate().filter(|(ix, thread)| *ix >= THREADS_SHOWN && open != Some(&thread.key)).count() + quiet_threads.get(conversation.key.id.as_str()).copied().unwrap_or(0); - if more > 0 && !collapsed { - let words = if more == 1 { "1 more thread".to_owned() } else { format!("{more} more threads") }; - rows.push(Row::Fold { words, reveal: Reveal::Threads, depth: depth + 1 }); + if !slack && !collapsed && (more > 0 || expanded) { + let words = if expanded { "Show fewer threads".to_owned() } else if more == 1 { "1 more thread".to_owned() } else { format!("{more} more threads") }; + rows.push(Row::Fold { words, reveal: Reveal::Threads(conversation.key.clone()), depth: depth + 1 }); } if let Some(inside) = conversation.occupants.as_ref().filter(|_| occupied(conversation)) { rows.extend(inside.people.iter().take(OCCUPANTS_SHOWN).map(|occupant| Row::Occupant { occupant: occupant.clone(), depth: depth + 1 })); @@ -398,6 +402,44 @@ mod tests { assert_eq!(rows(&[], all, Some("t5")), ["general", ".t0", ".t1", ".t2", ".t5", "2 more threads"]); } + #[test] + fn expanding_threads_reveals_read_and_overflow_threads_only_for_that_channel() { + use ConversationKind::{Channel, Thread}; + let mut all = vec![channel("general", None, Channel), channel("other", None, Channel)]; + for n in 0..7 { + let mut thread = channel(&format!("t{n}"), None, Thread); + thread.parent = Some(if n == 6 { "other" } else { "general" }.into()); + thread.followed = n != 5; + thread.unread = u32::from(n < 4); + all.push(thread); + } + let shown = HashSet::from([key("general")]); + let rules = Rules { threads_shown: Some(&shown), ..Rules::default() }; + assert_eq!(rows_with(&[], all.clone(), None, &rules), ["general", ".t0", ".t1", ".t2", ".t3", ".t4", "Show fewer threads", "other", "1 more thread"]); + // Collapsing keeps an open overflow thread visible, and hides read threads again. + assert_eq!(rows(&[], all.clone(), Some("t3")), ["general", ".t0", ".t1", ".t2", ".t3", "1 more thread", "other", "1 more thread"]); + let foreign = HashSet::from([Key { account: "another-account".into(), ..key("general") }]); + let rules = Rules { threads_shown: Some(&foreign), ..Rules::default() }; + assert_eq!(rows_with(&[], all, None, &rules), ["general", ".t0", ".t1", ".t2", "2 more threads", "other", "1 more thread"]); + } + + #[test] + fn one_read_thread_can_be_revealed_and_its_fold_targets_the_parent() { + use ConversationKind::{Channel, Thread}; + let mut thread = channel("read", None, Thread); + thread.parent = Some("general".into()); + thread.followed = true; + let all = vec![Rc::new(channel("general", None, Channel)), Rc::new(thread)]; + let collapsed = flatten(&key("s"), &[], &all, None, 0, &Rules::default()); + assert_eq!(shape(&collapsed), ["general", "1 more thread"]); + assert!(matches!(&collapsed[1], Row::Fold { reveal: Reveal::Threads(parent), .. } if parent == &key("general"))); + let shown = HashSet::from([key("general")]); + let rules = Rules { threads_shown: Some(&shown), ..Rules::default() }; + let expanded = flatten(&key("s"), &[], &all, None, 0, &rules); + assert_eq!(shape(&expanded), ["general", ".read", "Show fewer threads"]); + assert!(matches!(&expanded[2], Row::Fold { reveal: Reveal::Threads(parent), .. } if parent == &key("general"))); + } + #[test] fn hide_muted_and_opt_in_hide_rows_behind_footers_that_reveal_them() { use ConversationKind::Channel; diff --git a/docs/design/polish/02-discord-slack.md b/docs/design/polish/02-discord-slack.md index f121b29..2d0ce7f 100644 --- a/docs/design/polish/02-discord-slack.md +++ b/docs/design/polish/02-discord-slack.md @@ -37,7 +37,7 @@ Top to bottom, one scroll: - **At most one row under it**: a live event or one within 24 h (calendar glyph, title, time `text_faint` right). P2. - **Order** is Discord's (`position`, text before voice). Category headers are section headers. No unread-first sort. - **Kinds, by glyph only**: `#` text · megaphone announcement · speaker voice · radio stage · two-bubble forum · image media. **Private**: the kind glyph with a lock 12 at its lower right on a 1 pt plate of the row colour. **Read-only**: no row mark; the conversation replaces the composer with a line. **Muted**: faint, no glyph. **NSFW**: unchanged row (the gate is in the conversation). -- **Followed threads** (only joined and unread or open, at most 3): corner glyph 14, name, count badge; then "n more threads" action sub-row opening the list in the right pane. A thread whose starter is gone uses its own name. +- **Followed threads** (only joined and unread or open, at most 3): corner glyph 14, name, count badge; then "n more threads" action sub-row expanding all followed threads under that channel, including read ones. "Show fewer threads" collapses back to the three-row limit; the open thread stays visible. Expansion is remembered per channel for the session. A thread whose starter is gone uses its own name. - **Voice**: occupied channel `text_soft` with `4/12` when it has a user limit; occupants as 28 sub-rows (avatar 20, server nickname, trailing mic-off / deafen 14 `danger`, screen share or camera 14 `text_soft`), 6 then "+n", friends first. Empty voice channel: `text_faint`, nothing trailing. Stage: "Live · 2 speaking" `text_faint`. - **Footers** (28 action rows, `text_faint`): "Show 41 muted channels" when *Hide muted channels* is on in Discord (reveals for the session); "Browse 212 more channels" for onboarding opt-outs (from the stored tree). - **Collapse**: muted categories start collapsed; over 80 visible channels, every category without a number starts collapsed. Collapsed still shows the open row, unread unmuted channels, rows with a number and voice channels with people. The user's own collapse wins after and is kept per server.