diff --git a/crates/tinyclient/src/gui/sidebar/tree.rs b/crates/tinyclient/src/gui/sidebar/tree.rs index fa5f7ac..0b58d63 100644 --- a/crates/tinyclient/src/gui/sidebar/tree.rs +++ b/crates/tinyclient/src/gui/sidebar/tree.rs @@ -2,8 +2,8 @@ //! a Slack workspace's sections, a team's channels. Rebuilt on collapse and on change. The //! network's settings are kept here (`design/polish/02-discord-slack.md` §4–5): opt-in and //! "hide muted channels", muted categories collapsed, only followed threads with news, Slack's -//! "unread only" sections, its quiet channels folded, its apps in their own group. A collapsed -//! group never hides what needs me (rule 3). +//! "unread only" sections, its quiet channels folded, its apps in their own group. Collapsed Discord categories summarize hidden activity on the header; Slack +//! sections retain attention exceptions. use protocol::{Badge, Conversation, ConversationKind, Group, GroupKind, Key, Network, Occupant, Special}; use std::collections::{HashMap, HashSet}; @@ -108,7 +108,7 @@ impl Rules<'_> { } } -/// Needs me, or is where I am: never hidden by a fold. +/// Needs me, or is where I am: retained by Slack folds and channel visibility filters. fn needs_me(c: &Conversation, open: Option<&Key>) -> bool { open == Some(&c.key) || matches!(c.badge(), Badge::Count(_)) || (c.badge() == Badge::Dot && !c.muted) } @@ -158,6 +158,9 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] let listed: Vec<&Rc> = conversations.iter().filter(|c| !is_thread(c) && visible(c)).collect(); let emit = |rows: &mut Vec, members: &[&Rc], depth: u8, collapsed: bool| { + if collapsed && space.network == Network::Discord && !slack { + return; + } for conversation in members { let under = threads.get(conversation.key.id.as_str()).map(Vec::as_slice).unwrap_or_default(); if collapsed && !needs_me(conversation, open) && !occupied(conversation) { @@ -241,7 +244,9 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] rollup: if collapsed { rollup } else { Rollup::default() }, depth, }); - emit(&mut rows, &members, depth, collapsed); + // Discord category children stay nested beneath their header. + let child_depth = depth + u8::from(!slack && space.network == Network::Discord); + emit(&mut rows, &members, child_depth, collapsed); if !quiet.is_empty() && !collapsed { let words = if quiet.len() == 1 { "1 quiet channel".to_owned() } else { format!("{} quiet channels", quiet.len()) }; rows.push(Row::Fold { words, reveal: Reveal::Quiet(group.id.clone()), depth }); @@ -346,11 +351,11 @@ mod tests { ], None, ); - assert_eq!(tree, ["rules", "orphan", "vtext", "general", "help", "vvoice", "lounge"]); + assert_eq!(tree, ["rules", "orphan", "vtext", ".general", ".help", "vvoice", ".lounge"]); } #[test] - fn a_collapsed_group_keeps_what_needs_me_and_says_what_else_is_inside() { + fn collapsed_discord_categories_hide_children_and_keep_attention_on_the_header() { use ConversationKind::Channel; let mut mentioned = channel("design", Some("text"), Channel); (mentioned.unread, mentioned.mentions) = (4, 2); @@ -362,8 +367,29 @@ mod tests { (muted.muted, muted.unread) = (true, 9); let groups = [group("text", 0, true), group("quiet", 1, true)]; let all = vec![mentioned, unread, channel("help", Some("text"), Channel), lounge, muted]; - // The mention, the unread channel, voice with people and the open row stay. - assert_eq!(rows(&groups, all, Some("help")), [">text(2)", "design", "general", "help", "lounge", "+2", ">quiet"]); + // Even the selected channel, mentions and occupied voice channels fold away. + assert_eq!(rows(&groups, all.clone(), Some("help")), [">text(2)", ">quiet"]); + let slack = Rules { network: Some(Network::Slack), ..Rules::default() }; + assert_eq!(rows_with(&groups, all, Some("help"), &slack), [">text(2)", "design", "general", "help", "lounge", "+2", ">quiet"]); + } + + #[test] + fn a_collapsed_category_hides_the_open_thread_and_expands_with_nested_depths() { + use ConversationKind::{Channel, Thread}; + let mut thread = channel("thread", None, Thread); + thread.parent = Some("general".into()); + let conversations = [channel("loose", None, Channel), channel("general", Some("text"), Channel), channel("quiet", Some("text"), Channel), thread].into_iter().map(Rc::new).collect::>(); + let groups = [group("text", 0, true)]; + let open = key("thread"); + let folded = flatten(&key("s"), &groups, &conversations, Some(&open), 2, &Rules::default()); + assert!(matches!(&folded[0], Row::Conversation { depth: 2, .. })); + assert!(matches!(&folded[1], Row::Group { depth: 2, collapsed: true, .. })); + assert_eq!(folded.len(), 2, "the selected thread and all channel children stay folded"); + let opened = HashSet::from(["text".into()]); + let expanded = flatten(&key("s"), &groups, &conversations, Some(&open), 2, &Rules { opened: Some(&opened), ..Rules::default() }); + assert!(matches!(&expanded[2], Row::Conversation { depth: 3, .. })); + assert!(matches!(&expanded[3], Row::Conversation { depth: 4, .. })); + assert!(matches!(&expanded[4], Row::Conversation { depth: 3, .. })); } #[test] @@ -372,10 +398,10 @@ mod tests { let mut muted = group("muted", 0, false); muted.muted = true; let tree = vec![channel("a", Some("muted"), Channel), channel("b", Some("text"), Channel)]; - assert_eq!(rows(&[muted.clone(), group("text", 1, false)], tree.clone(), None), [">muted", "vtext", "b"]); + assert_eq!(rows(&[muted.clone(), group("text", 1, false)], tree.clone(), None), [">muted", "vtext", ".b"]); let opened = HashSet::from(["muted".to_owned()]); let rules = Rules { opened: Some(&opened), ..Rules::default() }; - assert_eq!(rows_with(&[muted, group("text", 1, false)], tree, None, &rules), ["vmuted", "a", "vtext", "b"]); + assert_eq!(rows_with(&[muted, group("text", 1, false)], tree, None, &rules), ["vmuted", ".a", "vtext", ".b"]); let mut many: Vec = (0..90).map(|n| channel(&format!("c{n}"), Some("big"), Channel)).collect(); many[3].mentions = 1; diff --git a/docs/design/polish/02-discord-slack.md b/docs/design/polish/02-discord-slack.md index 47d9be3..82b1385 100644 --- a/docs/design/polish/02-discord-slack.md +++ b/docs/design/polish/02-discord-slack.md @@ -40,7 +40,8 @@ Top to bottom, one scroll: - **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. +- **Hierarchy**: Discord category channels are indented one 16 pt level under their header, when expanded. Threads and voice participants indent one further level. Ungrouped channels remain at the header level; expanding or collapsing never changes a channel's indentation. Slack sections keep their existing flat layout. +- **Collapse**: muted categories start collapsed; over 80 visible channels, every category without a number starts collapsed. A collapsed Discord category hides all its channels, threads and voice participants, including the selected channel; the conversation remains open. Hidden unread activity and mentions are represented on the category header. The user's own collapse wins after and is kept per server. - **300 channels** (frame 4): 10 headers, 9 rows that need you, 2 footers = 21 rows, 588 pt, one screen. ## 5. Slack home (`--mode slack`), frames 5–6 @@ -59,7 +60,7 @@ Opens in the sidebar from the Threads row (`‹ Threads`, workspace name `text_f | Strip anatomy (place name + chevron menu, `All · Unread`), jump strip | Discord: Friends, Message requests, In voice, Servers, folders. Slack: Threads, sections, Apps, quiet channels | | 28 and 48 rows, x 36 name line, section header, action rows, badges, tags | Discord DMs are 48 (who is around matters); Slack DMs are 28 inside sections (people are filed with channels) | | Unread = 600 + dot; to me = unfilled count; mention = accent pill; muted = faint, no glyph | Discord keeps muted rows in place; Slack sorts them last (each network's own rule) | -| Collapsed groups never hide what needs me | Discord's words: server, category, friends. Slack's: workspace, section, huddle, Connect | +| Collapsed Discord categories summarize hidden activity on the header; Slack sections retain their attention exceptions | Discord's words: server, category, friends. Slack's: workspace, section, huddle, Connect | ## 8. Empty, loading, error (frames 8–9); nothing animates | List | Empty | Loading / first sign-in | Error |