Skip to content
Merged
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
46 changes: 36 additions & 10 deletions crates/tinyclient/src/gui/sidebar/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -158,6 +158,9 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc<Conversation>]
let listed: Vec<&Rc<Conversation>> = conversations.iter().filter(|c| !is_thread(c) && visible(c)).collect();

let emit = |rows: &mut Vec<Row>, members: &[&Rc<Conversation>], 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) {
Expand Down Expand Up @@ -241,7 +244,9 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc<Conversation>]
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 });
Expand Down Expand Up @@ -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);
Expand All @@ -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::<Vec<_>>();
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]
Expand All @@ -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<Conversation> = (0..90).map(|n| channel(&format!("c{n}"), Some("big"), Channel)).collect();
many[3].mentions = 1;
Expand Down
5 changes: 3 additions & 2 deletions docs/design/polish/02-discord-slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
Loading