From c544dd4e367470fd66e8fcab0e2040122a12b6d3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 5 Oct 2026 07:19:31 +0700 Subject: [PATCH] feat: add pinned rooms and groups to the sidebar --- crates/nip29/src/protocol.rs | 9 ++ crates/settings/src/lib.rs | 50 +++++++ crates/workspace/src/sidebar/mod.rs | 225 ++++++++++++++++++++++------ 3 files changed, 238 insertions(+), 46 deletions(-) diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index cb175b40..6f14714c 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -801,6 +801,15 @@ pub enum Membership { } impl Membership { + pub fn status(&self) -> Option<&'static str> { + match self { + Self::Pending { .. } => Some("Requested"), + Self::Refused { .. } => Some("Refused"), + Self::Removed => Some("Removed"), + Self::Member | Self::Unknown => None, + } + } + pub fn from_events(events: &[Event], me: PublicKey) -> Self { let mut latest: Option<&Event> = None; diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 2e3b53d8..b8625609 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,6 +46,8 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, + pub pinned_rooms: Vec, + pub pinned_groups: Vec, } /// Signer kind @@ -130,6 +132,14 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, + + /// Pinned chat rooms, by conversation ID + #[serde(default)] + pub pinned_rooms: Vec, + + /// Pinned groups, by cache tag + #[serde(default)] + pub pinned_groups: Vec, } impl Default for Settings { @@ -142,6 +152,8 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), + pinned_rooms: vec![], + pinned_groups: vec![], } } } @@ -324,4 +336,42 @@ impl AppSettings { } }); } + + /// Check if the chat room is pinned + pub fn pinned_room(&self, id: u64, cx: &App) -> bool { + self.inner.read(cx).pinned_rooms.contains(&id) + } + + /// Pin or unpin the chat room + pub fn toggle_pinned_room(&mut self, id: u64, cx: &mut Context) { + self.inner.update(cx, |this, cx| { + if let Some(index) = this.pinned_rooms.iter().position(|pinned| *pinned == id) { + this.pinned_rooms.remove(index); + } else { + this.pinned_rooms.push(id); + } + cx.notify(); + }); + } + + /// Check if the group is pinned by its cache tag + pub fn pinned_group(&self, tag: &str, cx: &App) -> bool { + self.inner + .read(cx) + .pinned_groups + .iter() + .any(|pinned| pinned == tag) + } + + /// Pin or unpin the group by its cache tag + pub fn toggle_pinned_group(&mut self, tag: &str, cx: &mut Context) { + self.inner.update(cx, |this, cx| { + if let Some(index) = this.pinned_groups.iter().position(|pinned| pinned == tag) { + this.pinned_groups.remove(index); + } else { + this.pinned_groups.push(tag.to_owned()); + } + cx.notify(); + }); + } } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index f1b2cd73..b4206857 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -13,7 +13,7 @@ use gpui::{ SharedString, Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px, relative, retain_all, uniform_list, }; -use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership}; +use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; @@ -25,7 +25,7 @@ use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; use ui::dialog::DialogButtonProps; use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::indicator::Indicator; -use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; use ui::nav::Nav; use ui::nav_item::NavItem; use ui::notification::Notification; @@ -45,15 +45,17 @@ pub(crate) use utils::pick_banner; /// A collapsible section of the sidebar's main list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ListSection { - Chats, + Pins, Groups, + Chats, } impl ListSection { fn label(self) -> &'static str { match self { - Self::Chats => "Chats", + Self::Pins => "Pinned", Self::Groups => "Groups", + Self::Chats => "Chats", } } } @@ -63,6 +65,7 @@ pub enum SidebarRow { Room { room: Entity }, Group { group: Entity }, Contact { public_key: PublicKey }, + Empty(&'static str), } /// A collapsible section of the group view. @@ -108,10 +111,12 @@ pub struct Sidebar { /// Whether the group view's members section is expanded. members_open: bool, - /// Whether the main list's chats section is expanded. - chats_open: bool, - /// Whether the main list's groups section is expanded. - groups_open: bool, + /// Whether the main list's pinned section is expanded. + pins_open: bool, + /// Whether the user expanded or collapsed the main list's groups section. + groups_open: Option, + /// Same as `groups_open` for the chats section. + chats_open: Option, /// Whether the main list is showing contacts to start a chat with. contacts_open: bool, @@ -183,8 +188,9 @@ impl Sidebar { group_scroll: UniformListScrollHandle::new(), admins_open: true, members_open: true, - chats_open: true, - groups_open: true, + pins_open: true, + groups_open: None, + chats_open: None, contacts_open: false, contacts: None, selected_contacts: HashSet::new(), @@ -708,32 +714,80 @@ impl Sidebar { let mut rows = Vec::new(); let chat = ChatRegistry::global(cx); - rows.push(SidebarRow::Section(ListSection::Chats)); - if self.chats_open { - rows.extend( - chat.read(cx) - .rooms(&RoomKind::Ongoing, cx) - .into_iter() - .map(|room| SidebarRow::Room { room }), - ); - } + let pinned_rooms = AppSettings::get_pinned_rooms(cx); + + let (pinned_rooms, rooms): (Vec<_>, Vec<_>) = chat + .read(cx) + .rooms(&RoomKind::Ongoing, cx) + .into_iter() + .partition(|room| pinned_rooms.contains(&room.read(cx).id)); let registry = GroupsRegistry::global(cx); + let pinned_group_ids = AppSettings::get_pinned_groups(cx); + + let (pinned_groups, groups): (Vec<_>, Vec<_>) = registry + .read(cx) + .groups() + .to_vec() + .into_iter() + .partition(|group| { + let tag = group.read(cx).key().cache_tag(); + pinned_group_ids.iter().any(|pinned| pinned == &tag) + }); + + rows.push(SidebarRow::Section(ListSection::Pins)); + if self.pins_open { + if pinned_rooms.is_empty() && pinned_groups.is_empty() { + rows.push(SidebarRow::Empty("Nothing pinned yet")); + } else { + rows.extend( + pinned_groups + .into_iter() + .map(|group| SidebarRow::Group { group }), + ); + rows.extend( + pinned_rooms + .into_iter() + .map(|room| SidebarRow::Room { room }), + ); + } + } + rows.push(SidebarRow::Section(ListSection::Groups)); - if self.groups_open { - rows.extend( - registry - .read(cx) - .groups() - .iter() - .cloned() - .map(|group| SidebarRow::Group { group }), - ); + if self.is_groups_open(cx) { + if groups.is_empty() { + rows.push(SidebarRow::Empty("No groups yet")); + } else { + rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); + } + } + + rows.push(SidebarRow::Section(ListSection::Chats)); + if self.is_chats_open(cx) { + if rooms.is_empty() { + rows.push(SidebarRow::Empty("No chats yet")); + } else { + rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); + } } rows } + /// Whether anything is pinned; drives the sections' default expansion. + fn pinned_any(cx: &App) -> bool { + !AppSettings::get_pinned_rooms(cx).is_empty() + || !AppSettings::get_pinned_groups(cx).is_empty() + } + + fn is_chats_open(&self, cx: &App) -> bool { + self.chats_open.unwrap_or_else(|| !Self::pinned_any(cx)) + } + + fn is_groups_open(&self, cx: &App) -> bool { + self.groups_open.unwrap_or_else(|| !Self::pinned_any(cx)) + } + fn render_rows( &self, range: Range, @@ -753,8 +807,9 @@ impl Sidebar { SidebarRow::Section(section) => { let section = *section; let open = match section { - ListSection::Chats => self.chats_open, - ListSection::Groups => self.groups_open, + ListSection::Pins => self.pins_open, + ListSection::Groups => self.is_groups_open(cx), + ListSection::Chats => self.is_chats_open(cx), }; let icon = if open { IconName::CaretDown @@ -771,13 +826,33 @@ impl Sidebar { .text_color(cx.theme().text_placeholder) .on_click(cx.listener(move |this, _event, _window, cx| { match section { - ListSection::Chats => this.chats_open = !this.chats_open, - ListSection::Groups => this.groups_open = !this.groups_open, + ListSection::Pins => this.pins_open = !this.pins_open, + ListSection::Groups => { + this.groups_open = Some(!this.is_groups_open(cx)) + } + ListSection::Chats => { + this.chats_open = Some(!this.is_chats_open(cx)) + } } cx.notify(); })) .into_any_element() } + SidebarRow::Empty(text) => h_flex() + .h_10() + .w_full() + .px_1p5() + .py_1() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .justify_center() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(*text) + .into_any_element(), SidebarRow::Room { room } => { let name = room.read(cx).display_name(cx); let picture = room.read(cx).display_image(cx); @@ -785,10 +860,13 @@ impl Sidebar { let created_at = room.read(cx).created_at.to_ago(); let kind = room.read(cx).kind; let peer = room.read(cx).display_member(cx).public_key(); + let id = room.read(cx).id; + let member = room.read(cx).members().first().copied(); + let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); let dock = self.dock.clone(); let room = room.clone(); - Nav::new(index) + let nav = Nav::new(index) .label(name) .text_sm() .font_medium() @@ -832,8 +910,33 @@ impl Sidebar { }) }); } - }) - .into_any_element() + }); + + ContextMenu::new( + format!("room-menu-{index}"), + nav, + move |menu, _window, _cx| { + let menu = menu.item( + PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) + .on_click(move |_, _window, cx| { + AppSettings::global(cx).update(cx, |settings, cx| { + settings.toggle_pinned_room(id, cx); + }); + }), + ); + + if let Some(member) = member { + menu.item(PopupMenuItem::new("View profile").on_click( + move |_, window, cx| { + profile::open(member, window, cx); + }, + )) + } else { + menu + } + }, + ) + .into_any_element() } SidebarRow::Contact { public_key } => { let persons = PersonRegistry::global(cx); @@ -872,17 +975,18 @@ impl Sidebar { let name = group.read(cx).display_name(); let picture = group.read(cx).display_image(); let seed = group.read(cx).key().cache_tag(); + let tag = seed.clone(); + let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx); + let weak_group = group.downgrade(); - let suffix = match group.read(cx).membership() { - Membership::Pending { .. } => Some("Requested"), - Membership::Refused { .. } => Some("Refused"), - Membership::Removed => Some("Removed"), - Membership::Unknown | Membership::Member => None, - } - .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) - .map(SharedString::from); + let suffix = group + .read(cx) + .membership() + .status() + .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) + .map(SharedString::from); - Nav::new(index) + let nav = Nav::new(index) .label(name) .text_sm() .font_medium() @@ -917,8 +1021,37 @@ impl Sidebar { }) { log::error!("Failed to show the group in the sidebar: {error}"); } - }) - .into_any_element() + }); + + ContextMenu::new( + format!("group-menu-{index}"), + nav, + move |menu, _window, _cx| { + menu.item( + PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) + .on_click({ + let tag = tag.clone(); + move |_, _window, cx| { + AppSettings::global(cx).update( + cx, + |settings, cx| { + settings.toggle_pinned_group(&tag, cx); + }, + ); + } + }), + ) + .item( + PopupMenuItem::new("Leave").on_click({ + let group = weak_group.clone(); + move |_, window, cx| { + nip29_ui::confirm_leave(group.clone(), window, cx); + } + }), + ) + }, + ) + .into_any_element() } } })