Files
coop/crates/workspace/src/sidebar/mod.rs
T
2026-09-18 17:03:17 +07:00

581 lines
22 KiB
Rust

use std::collections::BTreeSet;
use std::ops::Range;
use std::rc::Rc;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
};
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem};
use ui::nav_item::NavItem;
use ui::scroll::Scrollbar;
use ui::{
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers,
v_flex,
};
use crate::Command;
mod entry;
mod tree;
pub(crate) use entry::RoomEntry;
use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities};
/// Sidebar.
pub struct Sidebar {
focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
/// Whether there are new chat requests
new_requests: bool,
/// Expanded tree sections
expanded: BTreeSet<TreeSection>,
/// Pinned room ids, in pin order
pinned_rooms: Vec<u64>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = AppSettings::global(cx).read(cx).entity().clone();
let chat = ChatRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
this.new_requests = true;
cx.notify();
};
}),
);
subscriptions.push(cx.observe(&settings, move |this, _settings, cx| {
this.restore_state(cx);
}));
Self {
focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(),
new_requests: false,
expanded: load_expanded(cx),
pinned_rooms: AppSettings::get_pinned_rooms(cx),
_subscriptions: subscriptions,
}
}
fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>) {
if !self.expanded.remove(&section) {
self.expanded.insert(section);
}
if section == TreeSection::Requests {
self.new_requests = false;
}
self.save_expanded(cx);
cx.notify();
}
fn is_expanded(&self, section: TreeSection) -> bool {
self.expanded.contains(&section)
}
fn restore_state(&mut self, cx: &mut Context<Self>) {
let pinned_rooms = AppSettings::get_pinned_rooms(cx);
let expanded = load_expanded(cx);
if self.pinned_rooms == pinned_rooms && self.expanded == expanded {
return;
}
self.pinned_rooms = pinned_rooms;
self.expanded = expanded;
cx.notify();
}
fn save_expanded(&self, cx: &mut App) {
let keys = self
.expanded
.iter()
.map(|section| section.key().to_string())
.collect();
AppSettings::update_expanded_sections(Some(keys), cx);
}
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>) {
if !self.pinned_rooms.contains(&room_id) {
self.pinned_rooms.push(room_id);
}
self.expanded.insert(TreeSection::Pins);
AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx);
self.save_expanded(cx);
cx.notify();
}
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>) {
self.pinned_rooms.retain(|id| *id != room_id);
AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx);
cx.notify();
}
fn is_pinned(&self, room_id: u64) -> bool {
self.pinned_rooms.contains(&room_id)
}
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow> {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
let mut rows = Vec::new();
let pinned: Vec<Entity<Room>> = self
.pinned_rooms
.iter()
.filter_map(|room_id| chat.room(room_id, cx))
.filter_map(|room| room.upgrade())
.collect();
if !pinned.is_empty() {
rows.push(SidebarRow::Section {
section: TreeSection::Pins,
count: pinned.len(),
});
if self.is_expanded(TreeSection::Pins) {
rows.extend(pinned.into_iter().map(|room| SidebarRow::Room {
room,
depth: 1,
pinned: true,
}));
}
}
let requests = chat.rooms(&RoomKind::Request, cx);
rows.push(SidebarRow::Section {
section: TreeSection::Requests,
count: requests.len(),
});
if self.is_expanded(TreeSection::Requests) {
if requests.is_empty() {
rows.push(SidebarRow::Hint {
text: "No pending requests".into(),
depth: 1,
});
} else {
rows.extend(requests.into_iter().map(|room| {
let pinned = self.is_pinned(room.read(cx).id);
SidebarRow::Room {
room,
depth: 1,
pinned,
}
}));
}
}
let communities = dummy_communities();
rows.push(SidebarRow::Section {
section: TreeSection::Community,
count: communities.len(),
});
if self.is_expanded(TreeSection::Community) {
if communities.is_empty() {
rows.push(SidebarRow::Hint {
text: "No communities yet".into(),
depth: 1,
});
} else {
rows.extend(
communities
.iter()
.map(|entry| SidebarRow::Community { entry, depth: 1 }),
);
}
}
let messages = chat.rooms(&RoomKind::Ongoing, cx);
rows.push(SidebarRow::Section {
section: TreeSection::Messages,
count: messages.len(),
});
if self.is_expanded(TreeSection::Messages) {
if messages.is_empty() {
rows.push(SidebarRow::Hint {
text: "No conversations yet".into(),
depth: 1,
});
} else {
rows.extend(messages.into_iter().map(|room| {
let pinned = self.is_pinned(room.read(cx).id);
SidebarRow::Room {
room,
depth: 1,
pinned,
}
}));
}
}
rows
}
fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Self>,
) -> Vec<AnyElement> {
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section { section, count } => {
let section = *section;
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Section,
section.label(),
)
.caret(if self.is_expanded(section) {
IconName::CaretDown
} else {
IconName::CaretRight
})
.icon(section.icon())
.count(*count)
.when(
section == TreeSection::Requests && self.new_requests,
|this| this.dot(),
)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_section(section, cx);
}))
.into_any_element()
}
SidebarRow::Room {
room,
depth,
pinned,
} => {
let pinned = *pinned;
let room_id = room.read(cx).id;
let public_key = room.read(cx).display_member(cx).public_key();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let kind = room.read(cx).kind;
let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone();
let handler = cx.listener(move |_this, _event, window, cx| {
ChatRegistry::global(cx).update(cx, |chat, cx| {
chat.emit_room(&room_clone, window, cx);
});
});
let entry = RoomEntry::new(index)
.name(name)
.avatar(picture)
.seed(seed)
.public_key(public_key)
.kind(kind)
.created_at(created_at)
.depth(*depth)
.on_click(handler);
let sidebar = cx.entity().downgrade();
ContextMenu::new(
ElementId::NamedInteger("room-context-menu".into(), index as u64),
entry,
move |this, _window, _cx| {
let sidebar = sidebar.clone();
if pinned {
this.item(PopupMenuItem::new("Unpin").on_click(
move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |sidebar, cx| {
sidebar.unpin_room(room_id, cx);
}) {
log::error!("Failed to unpin room: {error}");
}
},
))
} else {
this.item(PopupMenuItem::new("Pin").on_click(
move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |sidebar, cx| {
sidebar.pin_room(room_id, cx);
}) {
log::error!("Failed to pin room: {error}");
}
},
))
}
},
)
.into_any_element()
}
SidebarRow::Community { entry, depth } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
entry.name,
)
.depth(*depth)
.avatar(entry.name)
.into_any_element(),
SidebarRow::Hint { text, depth } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Hint,
text.clone(),
)
.depth(*depth)
.into_any_element(),
}
})
.collect()
}
fn render_user(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
title_bar_drag_handlers(
h_flex()
.id("sidebar-user")
.w_full()
.h(TABBAR_HEIGHT)
.flex_shrink_0()
.items_center()
.gap_2()
.px_2()
.when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Import your identity to continue")),
)
})
.when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed();
let name = profile.name();
this.child(
Button::new("current-user")
.child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
.xsmall(),
)
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone();
let avatar_seed = avatar_seed.clone();
let name = name.clone();
this.min_w(px(256.))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
.xsmall(),
)
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
}),
window,
cx,
)
}
}
fn load_expanded(cx: &App) -> BTreeSet<TreeSection> {
let Some(keys) = AppSettings::get_expanded_sections(cx) else {
return BTreeSet::from([TreeSection::Community, TreeSection::Messages]);
};
keys.iter()
.filter_map(|key| TreeSection::from_key(key.as_str()))
.collect()
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
"Sidebar".into()
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some();
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading() && logged_in;
let rows = Rc::new(self.tree_rows(cx));
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
.gap_2()
.child(self.render_user(window, cx))
.child(
v_flex()
.px_2()
.py_1()
.gap_1()
.child(
NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small())
.on_click(|_event, _window, cx| {
cx.dispatch_action(&Command::ShowInbox)
}),
)
.child(
NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_event, _window, cx| {
cx.dispatch_action(&Command::ShowBrowse)
}),
)
.child(
NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small())
.on_click(|_event, _window, cx| {
cx.dispatch_action(&Command::ShowSearch)
}),
),
)
.child(
v_flex()
.size_full()
.flex_1()
.gap_1()
.child(
uniform_list(
"sidebar-tree",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(&self.scroll_handle)
.flex_1()
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.scroll_handle)),
)
.when(loading, |this| {
this.child(
div()
.absolute()
.bottom_2()
.left_0()
.h_9()
.w_full()
.px_8()
.child(
h_flex()
.gap_2()
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().icon_accent))
.child("Getting messages..."),
),
)
})
}
}