feat: add pinned rooms and groups to the sidebar

This commit is contained in:
2026-10-05 07:19:31 +07:00
parent acb07126c9
commit c544dd4e36
3 changed files with 238 additions and 46 deletions
+9
View File
@@ -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;
+50
View File
@@ -46,6 +46,8 @@ setting_accessors! {
pub nip4e: bool,
pub trusted_relays: Vec<String>,
pub file_server: Url,
pub pinned_rooms: Vec<u64>,
pub pinned_groups: Vec<String>,
}
/// 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<u64>,
/// Pinned groups, by cache tag
#[serde(default)]
pub pinned_groups: Vec<String>,
}
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>) {
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>) {
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();
});
}
}
+179 -46
View File
@@ -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<Room> },
Group { group: Entity<Group> },
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<bool>,
/// Same as `groups_open` for the chats section.
chats_open: Option<bool>,
/// 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<usize>,
@@ -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()
}
}
})