Files
coop/crates/workspace/src/sidebar/mod.rs
T
2026-09-25 09:12:35 +07:00

859 lines
32 KiB
Rust

use std::ops::Range;
use std::rc::Rc;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt;
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px,
retain_all, uniform_list,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::nav::Nav;
use ui::nav_item::NavItem;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::tab::Tab;
use ui::tab::tab_bar::TabBar;
use ui::{
Disableable, Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING,
WindowExtension, h_flex, title_bar_drag_handlers, v_flex,
};
use crate::Command;
use crate::dialogs::import;
mod tab;
mod utils;
use tab::SidebarTab;
pub(crate) use utils::{nav_avatar, nav_icon, pick_banner};
pub enum SidebarRow {
Room { room: Entity<Room> },
Community { community: Entity<Community> },
}
/// A collapsible group of rows in the sidebar's community view.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CommunitySection {
Channels,
Admins,
Members,
}
/// A row in the sidebar's community view.
pub enum CommunityRow {
Section(CommunitySection),
Channel {
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
},
Member {
public_key: PublicKey,
},
}
pub struct Sidebar {
focus_handle: FocusHandle,
scroll_handles: [UniformListScrollHandle; 2],
/// Scroll state of the community's channel and member lists
community_scroll: UniformListScrollHandle,
/// The dock the sidebar opens its panels in
dock: WeakEntity<DockArea>,
/// Background shown behind the signed-out screen, picked at random
banner: SharedString,
active_tab: SidebarTab,
/// The community the sidebar is browsing, if any
community: Option<WeakEntity<Community>>,
channels_open: bool,
admins_open: bool,
members_open: bool,
new_requests: bool,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl Sidebar {
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
let nostr = NostrRegistry::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.subscribe_in(
&communities,
window,
|_this, _communities, event, window, cx| {
if let CommunityEvent::Error(error) = event {
window.push_notification(Notification::error(error.clone()), cx);
}
},
));
subscriptions.push(
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
// Re-pick the background each time the signed-out screen is shown.
if let StateEvent::NoSigner = event {
this.banner = pick_banner();
cx.notify();
}
}),
);
Self {
focus_handle: cx.focus_handle(),
scroll_handles: [
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
],
community_scroll: UniformListScrollHandle::new(),
dock,
banner: pick_banner(),
active_tab: SidebarTab::Inbox,
community: None,
channels_open: true,
admins_open: true,
members_open: true,
new_requests: false,
_subscriptions: subscriptions,
}
}
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
if self.active_tab == tab {
return;
}
self.active_tab = tab;
cx.notify();
}
/// Leave the community view, returning the sidebar to its tab list.
fn reset_community(&mut self, cx: &mut Context<Self>) {
if self.community.take().is_none() {
return;
}
cx.notify();
}
fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(current_user, cx);
let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed();
let name = profile.name();
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))
})
.child(
Button::new("current-user")
.child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
.small(),
)
.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("Inbox", IconName::Inbox, Box::new(Command::ShowInbox))
.menu_with_icon(
"Search",
IconName::Search,
Box::new(Command::ShowSearch),
)
.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))
.separator()
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
.child(div().flex_1())
.when_some(AutoUpdater::try_global(cx), |this, updater| {
this.child(self.render_updater(updater, cx))
})
.when(self.community.is_some(), |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
.tooltip("Back")
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.reset_community(cx);
})),
)
})
}
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
let status = updater.read(cx).status();
let up_to_date = updater.read(cx).up_to_date();
let staged = updater.read(cx).staged();
h_flex()
.gap_2()
.when(!up_to_date, |this| {
this.child(
Button::new("update-status")
.icon(IconName::ArrowDownCircle)
.tooltip(status)
.small()
.warning()
.disabled(true),
)
})
.when(staged, |this| {
this.child(
Button::new("restart-to-update")
.icon(IconName::ArrowDownCircle)
.tooltip("Quit and relaunch into the installed update")
.small()
.ghost()
.on_click(move |_, _window, cx| {
updater.update(cx, |this, cx| {
this.restart(cx);
});
}),
)
})
.into_any_element()
}
fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement {
let sidebar = cx.entity().downgrade();
let active_tab = self.active_tab;
let rows = Rc::new(self.rows_for(active_tab, cx));
let scroll_handle = &self.scroll_handles[active_tab.index()];
v_flex()
.size_full()
.flex_1()
.min_h_0()
.gap_2()
.child(
div().px_2().child(
TabBar::new("sidebar-tabs")
.segmented(true)
.selected_index(active_tab.index())
.child(Tab::new().label(SidebarTab::Inbox.label()))
.child(Tab::new().label(SidebarTab::Communities.label()))
.on_click({
let sidebar = sidebar.clone();
move |index, _window, cx| {
let Some(tab) = SidebarTab::ALL.get(*index).copied() else {
return;
};
if let Err(error) =
sidebar.update(cx, |this, cx| this.select_tab(tab, cx))
{
log::error!("Failed to switch sidebar tab: {error}");
}
}
}),
),
)
.map(|this| match active_tab {
SidebarTab::Inbox => this.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new(
"new-chat",
"New Chat",
Icon::new(IconName::Message).small(),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::NewChat), cx)
}),
)
.child(
NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
.when(self.new_requests, |this| {
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
})
.on_click({
let sidebar = sidebar.clone();
move |_event, window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.new_requests = false;
cx.notify();
}) {
log::error!("Failed to clear new requests: {error}");
}
window.dispatch_action(Box::new(Command::ShowRequests), cx);
}
}),
)
.child(
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowContactList), cx)
}),
),
),
SidebarTab::Communities => this.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new(
"new-community",
"New Community",
Icon::new(IconName::Group).small(),
)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::NewCommunity), cx)
}),
)
.child(
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
),
),
})
.child(
div()
.px_4()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.child(active_tab.list_title()),
)
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
active_tab.list_id(),
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(scroll_handle)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(scroll_handle)),
)
.into_any_element()
}
fn render_community(
&mut self,
community: Entity<Community>,
cx: &mut Context<Self>,
) -> AnyElement {
let (banner, rows) = {
let community = community.read(cx);
let owner = community.state().owner;
let mut admins = Vec::new();
let mut members = Vec::new();
for public_key in community.members() {
if community.control().roles.is_staff(public_key, &owner) {
admins.push(*public_key);
} else {
members.push(*public_key);
}
}
let active = community.active_channel();
let banner = community.banner();
let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)];
if self.channels_open {
rows.extend(
community
.channels()
.iter()
.map(|channel| CommunityRow::Channel {
id: channel.id,
name: channel.name.clone().into(),
private: channel.private,
selected: active == Some(channel.id),
}),
);
}
rows.push(CommunityRow::Section(CommunitySection::Admins));
if self.admins_open {
rows.extend(
admins
.into_iter()
.map(|public_key| CommunityRow::Member { public_key }),
);
}
rows.push(CommunityRow::Section(CommunitySection::Members));
if self.members_open {
rows.extend(
members
.into_iter()
.map(|public_key| CommunityRow::Member { public_key }),
);
}
(banner, rows)
};
let rows = Rc::new(rows);
v_flex()
.flex_1()
.min_h_0()
.w_full()
.gap_2()
.when_some(banner, |this, banner| {
this.child(
div().px_2().flex_shrink_0().child(
img(banner)
.w_full()
.h_20()
.rounded(cx.theme().radius_lg)
.object_fit(ObjectFit::Cover),
),
)
})
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
"community-rows",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_community_rows(range, rows.as_slice(), &community, cx)
}),
)
.track_scroll(&self.community_scroll)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.community_scroll)),
)
.into_any_element()
}
fn render_community_rows(
&self,
range: Range<usize>,
rows: &[CommunityRow],
community: &Entity<Community>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
rows.get(range)
.into_iter()
.flatten()
.map(|row| match row {
CommunityRow::Section(section) => self.section_row(section, cx),
CommunityRow::Member { public_key } => self.member_row(public_key, cx),
CommunityRow::Channel {
id,
name,
private,
selected,
} => self.channel_row(*id, name.clone(), *private, *selected, community, cx),
})
.collect()
}
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab {
SidebarTab::Inbox => {
let chat = ChatRegistry::global(cx);
chat.read(cx)
.rooms(&RoomKind::Ongoing, cx)
.into_iter()
.map(|room| SidebarRow::Room { room })
.collect()
}
SidebarTab::Communities => {
let registry = CommunityRegistry::global(cx);
registry
.read(cx)
.communities()
.iter()
.cloned()
.map(|community| SidebarRow::Community { community })
.collect()
}
}
}
fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Sidebar>,
) -> Vec<AnyElement> {
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Room { room } => {
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 created_at = room.read(cx).created_at.to_ago();
let dock = self.dock.clone();
let room = room.clone();
Nav::new(SharedString::from(format!("room-{index}")))
.label(name)
.text_sm()
.font_medium()
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
this.prefix(avatar)
})
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(created_at),
)
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
window,
cx,
);
})
.into_any_element()
}
SidebarRow::Community { community } => {
let name = community.read(cx).name();
let seed = community.read(cx).id().to_hex();
let picture = community.read(cx).icon();
let dock = self.dock.clone();
let sidebar = cx.entity().downgrade();
let community = community.clone();
Nav::new(SharedString::from(format!("com-{index}")))
.label(name)
.text_sm()
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
this.prefix(avatar)
})
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(community_ui::init(
community.clone(),
window,
cx,
)),
DockPlacement::Center,
window,
cx,
);
if let Err(error) = sidebar.update(cx, |this, cx| {
this.community = Some(community.downgrade());
cx.notify();
}) {
log::error!("Failed to show community in sidebar: {error}");
}
})
.into_any_element()
}
}
})
.collect()
}
fn section_row(&self, section: &CommunitySection, cx: &mut Context<Sidebar>) -> AnyElement {
let section = *section;
let (label, open) = match section {
CommunitySection::Channels => ("Channels", self.channels_open),
CommunitySection::Admins => ("Admins", self.admins_open),
CommunitySection::Members => ("Members", self.members_open),
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
Nav::new(label)
.label(label)
.suffix(nav_icon(icon, cx))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _ev, _window, cx| {
match section {
CommunitySection::Channels => this.channels_open = !this.channels_open,
CommunitySection::Admins => this.admins_open = !this.admins_open,
CommunitySection::Members => this.members_open = !this.members_open,
}
cx.notify();
}))
.into_any_element()
}
fn channel_row(
&self,
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
community: &Entity<Community>,
cx: &mut Context<Sidebar>,
) -> AnyElement {
let community = community.clone();
let icon = if private {
IconName::Lock
} else {
IconName::Hashtag
};
Nav::new(id.to_hex())
.label(name)
.prefix(nav_icon(icon, cx))
.text_sm()
.font_medium()
.selected(selected)
.on_click(cx.listener(move |_this, _event, _window, cx| {
community.update(cx, |community, cx| {
community.set_active_channel(id, cx);
});
cx.notify();
}))
.into_any_element()
}
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(person.avatar_seed()), person.avatar(), cx),
|this, avatar| this.prefix(avatar),
)
.into_any_element()
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
"Sidebar".into()
}
fn title(&self, _cx: &App) -> AnyElement {
SharedString::from("Sidebar").into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
false
}
fn zoomable(&self, _cx: &App) -> bool {
false
}
}
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 current_user = nostr.read(cx).current_user();
let logged_in = current_user.is_some();
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
let community = self
.community
.clone()
.and_then(|community| community.upgrade());
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
.relative()
.gap_2()
.bg(cx.theme().surface_background)
.border_r_1()
.border_color(cx.theme().border_variant)
.when_some(current_user.as_ref(), |this, current_user| {
this.child(title_bar_drag_handlers(
self.render_user(current_user, cx),
window,
cx,
))
})
.when(!logged_in, |this| {
this.relative()
.child(title_bar_drag_handlers(
div()
.id("onboarding-drag")
.absolute()
.top_0()
.left_0()
.h(TABBAR_HEIGHT)
.w_full(),
window,
cx,
))
.child(
div().absolute().inset_0().child(
img(self.banner.clone())
.size_full()
.object_fit(ObjectFit::Cover),
),
)
.child(
v_flex()
.size_full()
.justify_end()
.gap_4()
.p_4()
.child(img("brand/headline.png").max_w_48())
.child(
Button::new("import")
.label("Import Identity")
.custom(
ButtonCustomVariant::new(window, cx)
.color(gpui::white())
.foreground(gpui::black())
.hover(gpui::white().opacity(0.9))
.active(gpui::white().opacity(0.8)),
)
.large()
.font_semibold()
.on_click(|_, window, cx| {
import::open(window, cx);
}),
),
)
})
.map(|this| match community {
Some(community) => this.child(self.render_community(community, cx)),
None => this.child(self.render_tabs(cx)),
})
.when(loading && logged_in, |this| {
this.child(
div()
.absolute()
.bottom_4()
.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..."),
),
)
})
.into_any_element()
}
}