diff --git a/crates/workspace/src/sidebar/group_view.rs b/crates/workspace/src/sidebar/group_view.rs new file mode 100644 index 00000000..bde29a49 --- /dev/null +++ b/crates/workspace/src/sidebar/group_view.rs @@ -0,0 +1,310 @@ +use std::ops::Range; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, ClipboardItem, Context, Entity, IntoElement, ParentElement, SharedString, Styled, + WeakEntity, div, px, relative, uniform_list, +}; +use nip29::Group; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::nav::Nav; +use ui::scroll::Scrollbar; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; + +use super::Sidebar; +use crate::dialogs::profile; + +/// A collapsible section of the group view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GroupSection { + Admins, + Members, +} + +impl GroupSection { + fn label(self) -> &'static str { + match self { + Self::Admins => "Admins", + Self::Members => "Members", + } + } +} + +/// A row in the group view. +enum GroupRow { + Section(GroupSection), + Member { + public_key: PublicKey, + roles: Vec, + }, +} + +impl Sidebar { + /// Show the group view, replacing the main list. + pub(super) fn show_group(&mut self, group: &Entity, cx: &mut Context) { + self.group = Some(group.downgrade()); + self.group_open = true; + cx.notify(); + } + + /// Leave the group view, returning the sidebar to its main list. + pub(super) fn hide_group(&mut self, cx: &mut Context) { + if !self.group_open { + return; + } + + self.group_open = false; + cx.notify(); + } + + pub(super) fn render_group( + &mut self, + group: Entity, + cx: &mut Context, + ) -> AnyElement { + let admin = NostrRegistry::global(cx) + .read(cx) + .current_user() + .is_some_and(|me| group.read(cx).admins().contains(&me)); + + let (name, seed, picture, total_members, rows) = { + let group = group.read(cx); + let admins = group.admins(); + + let mut admin_rows = Vec::new(); + let mut member_rows = Vec::new(); + + for (public_key, roles) in admins.entries() { + admin_rows.push(GroupRow::Member { + public_key: *public_key, + roles: roles.clone(), + }); + } + + for public_key in group.members().iter() { + if admins.contains(public_key) { + continue; + } + + member_rows.push(GroupRow::Member { + public_key: *public_key, + roles: Vec::new(), + }); + } + + let mut rows = Vec::new(); + + if !admin_rows.is_empty() { + rows.push(GroupRow::Section(GroupSection::Admins)); + if self.admins_open { + rows.append(&mut admin_rows); + } + } + + if !member_rows.is_empty() { + rows.push(GroupRow::Section(GroupSection::Members)); + if self.members_open { + rows.append(&mut member_rows); + } + } + + ( + group.display_name(), + group.key().cache_tag(), + group.display_image(), + SharedString::from(format!("{} members", group.total_members())), + rows, + ) + }; + + let rows = Rc::new(rows); + let member_group = group.downgrade(); + + v_flex() + .flex_1() + .min_h_0() + .w_full() + .gap_2() + .child( + h_flex() + .px_2() + .flex_shrink_0() + .justify_between() + .child( + h_flex() + .pl_0p5() + .gap_1() + .flex_shrink_1() + .child(Avatar::from_source(picture).seed(seed).small()) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_xs() + .truncate() + .line_height(relative(1.2)) + .font_semibold() + .child(name), + ) + .child( + div() + .text_size(px(10.)) + .text_color(cx.theme().text_muted) + .child(total_members), + ), + ), + ) + .child( + h_flex() + .gap_1() + .child(nip29_ui::actions(group.downgrade(), cx)), + ), + ) + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "group-rows", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_group_rows( + range, + rows.as_slice(), + &member_group, + admin, + cx, + ) + }), + ) + .track_scroll(&self.group_scroll) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.group_scroll)), + ) + .into_any_element() + } + + fn render_group_rows( + &self, + range: Range, + rows: &[GroupRow], + group: &WeakEntity, + admin: bool, + cx: &mut Context, + ) -> Vec { + rows.get(range) + .into_iter() + .flatten() + .map(|row| match row { + GroupRow::Section(section) => self.section_row(*section, cx), + GroupRow::Member { public_key, roles } => { + self.member_row(public_key, roles, group, admin, cx) + } + }) + .collect() + } + + fn section_row(&self, section: GroupSection, cx: &mut Context) -> AnyElement { + let open = match section { + GroupSection::Admins => self.admins_open, + GroupSection::Members => self.members_open, + }; + let icon = if open { + IconName::CaretDown + } else { + IconName::CaretRight + }; + let label = section.label(); + + Nav::new(label) + .label(label) + .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .on_click(cx.listener(move |this, _event, _window, cx| { + match section { + GroupSection::Admins => this.admins_open = !this.admins_open, + GroupSection::Members => this.members_open = !this.members_open, + } + cx.notify(); + })) + .into_any_element() + } + + fn member_row( + &self, + public_key: &PublicKey, + roles: &[String], + group: &WeakEntity, + admin: bool, + cx: &mut Context, + ) -> AnyElement { + let hide_avatar = AppSettings::get_hide_avatar(cx); + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + + let group = group.clone(); + + Nav::new(public_key.to_hex()) + .label(person.name()) + .text_sm() + .font_medium() + .clickable(true) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .when(!roles.is_empty(), |this| { + this.suffix( + div() + .truncate() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(SharedString::from(roles.join(", "))), + ) + }) + .dropdown_menu(move |menu, _window, _cx| { + let menu = menu + .item( + PopupMenuItem::new("View profile").on_click(move |_, window, cx| { + profile::open(public_key, window, cx); + }), + ) + .item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| { + let npub = public_key.to_bech32().unwrap_or_default(); + cx.write_to_clipboard(ClipboardItem::new_string(npub)); + })); + + if !admin { + return menu; + } + + menu.separator() + .item(PopupMenuItem::new("Remove").on_click({ + let group = group.clone(); + move |_event, window, cx| { + nip29_ui::remove_member(group.clone(), public_key, window, cx); + } + })) + }) + .into_any_element() + } +} diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs new file mode 100644 index 00000000..bc7d8b51 --- /dev/null +++ b/crates/workspace/src/sidebar/header.rs @@ -0,0 +1,156 @@ +use auto_update::AutoUpdater; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, + Stateful, Styled, div, px, +}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use theme::TABBAR_HEIGHT; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::menu::DropdownMenu; +use ui::{Disableable, IconName, Selectable, Sizable, TRAFFIC_LIGHT_PADDING, h_flex}; + +use super::Sidebar; +use crate::Command; + +impl Sidebar { + pub(super) fn render_user( + &self, + current_user: &PublicKey, + cx: &mut Context, + ) -> Stateful
{ + let updater = AutoUpdater::try_global(cx); + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(current_user, cx); + let avatar = profile.avatar(); + let avatar_seed = profile.avatar_seed(); + + 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| { + this.min_w(px(256.)) + .menu_with_icon( + "Profile", + IconName::Profile, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Relays", + IconName::Relay, + Box::new(Command::ShowRelayList), + ) + .separator() + .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme)) + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) + }), + ) + .child(div().flex_1()) + .when_some(updater, |this, updater| { + this.child(self.render_updater(updater, cx)) + }) + .child( + Button::new("sidebar-search") + .icon(IconName::Search) + .tooltip("Search") + .selected(self.search_open) + .ghost() + .small() + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_search(window, cx); + })), + ) + .child( + Button::new("sidebar-new") + .icon(IconName::PlusChat) + .tooltip("New") + .ghost() + .small() + .dropdown_menu(|menu, _window, _cx| { + menu.menu_with_icon( + "New Group", + IconName::Group, + Box::new(Command::NewGroup), + ) + .menu_with_icon("Join Group", IconName::Door, Box::new(Command::JoinGroup)) + .separator() + .menu_with_icon( + "New Chat", + IconName::Message, + Box::new(Command::NewChat), + ) + }), + ) + .when(self.group_open, |this| { + this.child( + Button::new("sidebar-back") + .icon(IconName::ArrowLeft) + .tooltip("Back") + .ghost() + .small() + .on_click(cx.listener(|this, _event, _window, cx| { + this.hide_group(cx); + })), + ) + }) + } + + fn render_updater(&self, updater: Entity, 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") + .icon(IconName::ArrowDownCircle) + .tooltip(status) + .small() + .ghost() + .disabled(true), + ) + }) + .when(staged, |this| { + this.child( + Button::new("restart") + .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() + } +} diff --git a/crates/workspace/src/sidebar/inbox.rs b/crates/workspace/src/sidebar/inbox.rs new file mode 100644 index 00000000..649d9769 --- /dev/null +++ b/crates/workspace/src/sidebar/inbox.rs @@ -0,0 +1,222 @@ +use anyhow::Error; +use chat::{ChatRegistry, Room, RoomKind}; +use gpui::{ + AnyElement, App, AppContext, Context, IntoElement, ParentElement, SharedString, Styled, Task, + Window, div, +}; +use nostr_sdk::prelude::*; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{DockPlacement, PanelHandle}; +use ui::notification::Notification; +use ui::{StyledExt, WindowExtension, h_flex, v_flex}; + +use super::Sidebar; +use super::rows::SidebarRow; + +impl Sidebar { + /// Leave the contact picker and clear its selection. + fn close_contacts(&mut self) { + self.contacts_open = false; + self.selected_contacts.clear(); + } + + /// Show or hide the contact picker in the inbox list. + pub(super) fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context) { + self.contacts_open = !self.contacts_open; + + if !self.contacts_open { + self.selected_contacts.clear(); + } else { + self.close_requests(); + if self.contacts.is_none() { + self.load_contacts(window, cx); + } + } + + cx.notify(); + } + + /// Load the signed-in user's contact list into the contact picker. + fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(public_key) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.contacts = Some(contacts); + cx.notify(); + })?; + } + Err(error) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + /// Toggle a contact's selection in the contact picker. + pub(super) fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context) { + if !self.selected_contacts.remove(public_key) { + self.selected_contacts.insert(public_key.to_owned()); + } + cx.notify(); + } + + /// Start a chat with the contacts selected in the picker. + pub(super) fn create_chat(&mut self, window: &mut Window, cx: &mut Context) { + if self.selected_contacts.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + let dock = self.dock.clone(); + + let receivers: Vec = self.selected_contacts.iter().copied().collect(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let room = async_chat.update_in(cx, |chat, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + chat.track_room(&room, cx); + room + })?; + + cx.update(|window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + })?; + + // Leave the picker now that the chat is open + this.update(cx, |this, cx| { + this.close_contacts(); + cx.notify(); + })?; + + Ok(()) + })); + } + + /// Leave the requests list in the inbox. + fn close_requests(&mut self) { + self.requests_open = false; + } + + /// Show or hide the chat requests in the inbox list. + pub(super) fn toggle_requests(&mut self, cx: &mut Context) { + self.requests_open = !self.requests_open; + + if self.requests_open { + self.close_contacts(); + self.new_requests = false; + } + + cx.notify(); + } + + /// The signed-in user's contacts shown in the picker. + pub(super) fn contacts_rows(&self) -> Vec { + self.contacts + .clone() + .unwrap_or_default() + .into_iter() + .map(|public_key| SidebarRow::Contact { public_key }) + .collect() + } + + /// The chat requests awaiting a response. + pub(super) fn requests_rows(&self, cx: &App) -> Vec { + ChatRegistry::global(cx) + .read(cx) + .rooms(&RoomKind::Request, cx) + .into_iter() + .map(|room| SidebarRow::Room { room }) + .collect() + } + + /// The overlay with the create button and selection count. + pub(super) fn render_create_overlay(&self, cx: &mut Context) -> AnyElement { + v_flex() + .absolute() + .bottom_0() + .left_0() + .justify_center() + .h_20() + .w_full() + .px_4() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .child( + Button::new("create-chat") + .label(if self.selected_contacts.len() > 1 { + "Create Group Chat" + } else { + "Create Chat" + }) + .primary() + .on_click(cx.listener(|this, _ev, window, cx| { + this.create_chat(window, cx); + })), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child("Selected") + .child( + div() + .font_semibold() + .child(SharedString::from(self.selected_contacts.len().to_string())), + ), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs new file mode 100644 index 00000000..d8c8acf2 --- /dev/null +++ b/crates/workspace/src/sidebar/list.rs @@ -0,0 +1,240 @@ +use std::rc::Rc; + +use chat::{ChatRegistry, RoomKind}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, px, + uniform_list, +}; +use nip29::GroupsRegistry; +use settings::AppSettings; +use theme::ActiveTheme; +use ui::nav_item::NavItem; +use ui::scroll::Scrollbar; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; + +use super::Sidebar; +use super::rows::SidebarRow; +use crate::Command; + +/// A collapsible section of the sidebar's main list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ListSection { + Pins, + Groups, + Chats, +} + +impl ListSection { + pub(super) fn label(self) -> &'static str { + match self { + Self::Pins => "Pinned", + Self::Groups => "Groups", + Self::Chats => "Chats", + } + } +} + +impl Sidebar { + pub(super) fn render_list(&mut self, cx: &mut Context) -> AnyElement { + let requests_open = self.requests_open; + let contacts_open = self.contacts_open && !requests_open; + let rows = Rc::new(self.rows_for(cx)); + + // Whether to show the create button for the contact picker + let create_button = contacts_open && !self.selected_contacts.is_empty(); + + let (list_id, scroll_handle, list_title) = if contacts_open { + ("sidebar-contacts", &self.contacts_scroll, Some("Contacts")) + } else if requests_open { + ("sidebar-requests", &self.requests_scroll, Some("Requests")) + } else { + ("sidebar-list", &self.list_scroll, None) + }; + + let request_count = ChatRegistry::global(cx) + .read(cx) + .count(&RoomKind::Request, cx); + + v_flex() + .size_full() + .flex_1() + .min_h_0() + .relative() + .gap_2() + .child( + v_flex() + .px_2() + .gap_1() + .child( + NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small()) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Command::ShowBrowse), cx) + }), + ) + .child( + NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) + .when(request_count > 0, |this| { + this.suffix( + h_flex() + .gap_1() + .items_center() + .child( + h_flex() + .py_0p5() + .px_1() + .min_w_6() + .justify_center() + .text_size(px(10.)) + .text_color(cx.theme().text_muted) + .text_center() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child(SharedString::from( + request_count.to_string(), + )), + ) + .when(self.new_requests && !requests_open, |this| { + this.child( + div().size_1().rounded_full().bg(cx.theme().cursor), + ) + }), + ) + }) + .when(requests_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_requests(cx); + })), + ) + .child( + NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) + .when(contacts_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_contacts(window, cx); + })), + ), + ) + .when_some(list_title, |this, list_title| { + this.child( + div() + .px_4() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .child(list_title), + ) + }) + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + 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)), + ) + .when(create_button, |this| { + this.child(self.render_create_overlay(cx)) + }) + .into_any_element() + } + + fn rows_for(&self, cx: &App) -> Vec { + if self.contacts_open { + return self.contacts_rows(); + } + + if self.requests_open { + return self.requests_rows(cx); + } + + let mut rows = Vec::new(); + + let chat = ChatRegistry::global(cx); + 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.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() + } + + pub(super) fn is_chats_open(&self, cx: &App) -> bool { + self.chats_open.unwrap_or_else(|| !Self::pinned_any(cx)) + } + + pub(super) fn is_groups_open(&self, cx: &App) -> bool { + self.groups_open.unwrap_or_else(|| !Self::pinned_any(cx)) + } +} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index dc6fcd97..c5e8b5fc 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,184 +1,36 @@ use std::collections::HashSet; -use std::ops::Range; -use std::rc::Rc; -use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Error; -use auto_update::AutoUpdater; -use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, TimestampExt}; +use chat::{ChatEvent, ChatRegistry}; +use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, - Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, - div, img, px, relative, retain_all, uniform_list, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, + StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, + retain_all, }; use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; -use person::PersonRegistry; -use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::{FIND_DELAY, FIND_LIMIT, NostrRegistry, StateEvent}; +use state::{FIND_DELAY, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT}; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; -use ui::dialog::DialogButtonProps; -use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; +use ui::dock::{DockArea, Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::input::{Input, InputEvent, InputState}; -use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; -use ui::nav::Nav; -use ui::nav_item::NavItem; +use ui::input::{InputEvent, InputState}; use ui::notification::Notification; -use ui::scroll::Scrollbar; -use ui::{ - Disableable, Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, - WindowExtension, h_flex, title_bar_drag_handlers, v_flex, -}; +use ui::{Sizable, StyledExt, WindowExtension, h_flex, title_bar_drag_handlers, v_flex}; -use crate::Command; -use crate::dialogs::{import, profile, screening}; +mod group_view; +mod header; +mod inbox; +mod list; +mod onboarding; +mod rows; +mod search; -/// A collapsible section of the sidebar's main list. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListSection { - Pins, - Groups, - Chats, -} - -impl ListSection { - fn label(self) -> &'static str { - match self { - Self::Pins => "Pinned", - Self::Groups => "Groups", - Self::Chats => "Chats", - } - } -} - -pub enum SidebarRow { - Section(ListSection), - Room { - room: Entity, - }, - Group { - group: Entity, - }, - Contact { - public_key: PublicKey, - }, - /// A profile found by global search. - Profile { - public_key: PublicKey, - }, - Message { - hit: MessageHit, - }, - /// A non-interactive label, such as a search results section. - Heading(&'static str), - Empty(&'static str), -} - -/// An entry in the sidebar's search results. -enum SearchResult { - Room(Entity), - Group(Entity), - Message(MessageHit), -} - -/// A message found by searching the indexed chat history. -#[derive(Clone)] -pub(crate) struct MessageHit { - room: Entity, - author: PublicKey, - content: SharedString, - created_at: Timestamp, -} - -/// Open a room in the dock, screening new requests when enabled. -fn open_room(room: &Entity, dock: &WeakEntity, window: &mut Window, cx: &mut App) { - ui::dock::add_panel_to( - dock, - PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - - if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { - let peer = room.read(cx).display_member(cx).public_key(); - let view = screening::init(peer, window, cx); - - window.open_dialog(cx, move |this, _window, _cx| { - this.confirm() - .child(view.clone()) - .button_props( - DialogButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - true - }) - }); - } -} - -/// Find groups whose display name contains all of the terms. -fn find_groups(terms: &[String], cx: &App) -> Vec> { - GroupsRegistry::global(cx) - .read(cx) - .groups() - .iter() - .filter(|group| { - let name = group.read(cx).display_name().to_lowercase(); - terms.iter().all(|term| name.contains(term.as_str())) - }) - .cloned() - .collect() -} - -/// Brand backgrounds shown behind the signed-out screen; one is picked at random. -const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; - -/// Pick one of the signed-out backgrounds at random. -fn pick_banner() -> SharedString { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.subsec_nanos()) - .unwrap_or_default(); - let index = nanos as usize % SIGNED_OUT_BANNERS.len(); - SIGNED_OUT_BANNERS[index].into() -} - -/// A collapsible section of the group view. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum GroupSection { - Admins, - Members, -} - -impl GroupSection { - fn label(self) -> &'static str { - match self { - Self::Admins => "Admins", - Self::Members => "Members", - } - } -} - -/// A row in the group view. -enum GroupRow { - Section(GroupSection), - Member { - public_key: PublicKey, - roles: Vec, - }, -} +use search::SearchResult; pub struct Sidebar { focus_handle: FocusHandle, @@ -283,7 +135,7 @@ impl Sidebar { subscriptions.push( cx.subscribe_in(&nostr, window, |this, _, event, window, cx| { if let StateEvent::NoSigner = event { - this.banner = pick_banner(); + this.banner = onboarding::pick_banner(); if this.search_open { this.toggle_search(window, cx); } @@ -327,7 +179,7 @@ impl Sidebar { focus_handle: cx.focus_handle(), list_scroll: UniformListScrollHandle::new(), dock, - banner: pick_banner(), + banner: onboarding::pick_banner(), group: None, group_open: false, group_scroll: UniformListScrollHandle::new(), @@ -357,13 +209,6 @@ impl Sidebar { } } - /// Show the group view, replacing the main list. - fn show_group(&mut self, group: &Entity, cx: &mut Context) { - self.group = Some(group.downgrade()); - self.group_open = true; - cx.notify(); - } - /// Show the group view without toggling it closed. pub(crate) fn show_group_meta(&mut self, key: &GroupKey, cx: &mut Context) { let groups = GroupsRegistry::global(cx); @@ -387,1353 +232,6 @@ impl Sidebar { self.hide_group(cx); } } - - /// Leave the group view, returning the sidebar to its main list. - fn hide_group(&mut self, cx: &mut Context) { - if !self.group_open { - return; - } - - self.group_open = false; - cx.notify(); - } - - /// Leave the contact picker and clear its selection. - fn close_contacts(&mut self) { - self.contacts_open = false; - self.selected_contacts.clear(); - } - - /// Show or hide the contact picker in the inbox list. - fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context) { - self.contacts_open = !self.contacts_open; - - if !self.contacts_open { - self.selected_contacts.clear(); - } else { - self.close_requests(); - if self.contacts.is_none() { - self.load_contacts(window, cx); - } - } - - cx.notify(); - } - - /// Leave the requests list in the inbox. - fn close_requests(&mut self) { - self.requests_open = false; - } - - /// Show or hide the chat requests in the inbox list. - fn toggle_requests(&mut self, cx: &mut Context) { - self.requests_open = !self.requests_open; - - if self.requests_open { - self.close_contacts(); - self.new_requests = false; - } - - cx.notify(); - } - - /// Load the signed-in user's contact list into the contact picker. - fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task, Error>> = cx.background_spawn(async move { - let filter = Filter::new() - .author(public_key) - .kind(Kind::ContactList) - .limit(1); - - let contacts: Vec = client - .database() - .query(filter) - .await? - .into_iter() - .next() - .map(|event| event.tags.public_keys().collect()) - .unwrap_or_default(); - - Ok(contacts) - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.contacts = Some(contacts); - cx.notify(); - })?; - } - Err(error) => { - cx.update(|window, cx| { - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?; - } - } - - Ok(()) - })); - } - - /// Toggle a contact's selection in the contact picker. - fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context) { - if !self.selected_contacts.remove(public_key) { - self.selected_contacts.insert(public_key.to_owned()); - } - cx.notify(); - } - - /// Start a chat with the contacts selected in the picker. - fn create_chat(&mut self, window: &mut Window, cx: &mut Context) { - if self.selected_contacts.is_empty() { - return; - } - - let nostr = NostrRegistry::global(cx); - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - let chat = ChatRegistry::global(cx); - let async_chat = chat.downgrade(); - let dock = self.dock.clone(); - - let receivers: Vec = self.selected_contacts.iter().copied().collect(); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let room = async_chat.update_in(cx, |chat, _window, cx| { - let room = cx.new(|_| { - Room::new(public_key, receivers) - .organize(&public_key) - .kind(RoomKind::Ongoing) - }); - chat.track_room(&room, cx); - room - })?; - - cx.update(|window, cx| { - ui::dock::add_panel_to( - &dock, - PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - })?; - - // Leave the picker now that the chat is open - this.update(cx, |this, cx| { - this.close_contacts(); - cx.notify(); - })?; - - Ok(()) - })); - } - - /// Show or hide the search bar below the header. - fn toggle_search(&mut self, window: &mut Window, cx: &mut Context) { - self.search_open = !self.search_open; - - if self.search_open { - self.find_input - .update(cx, |input, cx| input.focus(window, cx)); - } else { - self.find_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - self.global_open = false; - self.clear_results(cx); - } - - cx.notify(); - } - - /// Search rooms and groups by name, then indexed messages by content. - fn search(&mut self, cx: &mut Context) { - let query = self.find_input.read(cx).value().to_string(); - let terms: Vec = query - .to_lowercase() - .split_whitespace() - .map(str::to_owned) - .collect(); - - if terms.is_empty() { - return; - } - - let chat = ChatRegistry::global(cx); - let rooms = chat.read(cx).find(&query, cx); - let groups = find_groups(&terms, cx); - - let messages = chat - .read(cx) - .search_messages(&terms, FIND_LIMIT) - .into_iter() - .filter_map(|matched| { - let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?; - - Some(MessageHit { - room, - author: matched.author, - content: matched.content.into(), - created_at: matched.created_at, - }) - }) - .collect::>(); - - self.results = rooms - .into_iter() - .map(SearchResult::Room) - .chain(groups.into_iter().map(SearchResult::Group)) - .chain(messages.into_iter().map(SearchResult::Message)) - .collect(); - cx.notify(); - } - - /// Search profiles on the indexer relays for the current query. - fn global_search(&mut self, cx: &mut Context) { - if !self.global_open { - return; - } - - let query = self.find_input.read(cx).value().trim().to_string(); - - if query.is_empty() { - return; - } - - let nostr = NostrRegistry::global(cx); - let find_profiles = nostr.read(cx).search(&query, cx); - - self.global_finding = true; - self.global_results = None; - cx.notify(); - - self.global_task = Some(cx.spawn(async move |this, cx| { - let result = find_profiles.await; - - this.update(cx, |this, cx| { - this.global_finding = false; - this.global_results = Some(match result { - Ok(profiles) => { - // Relays may return the same profile more than once - let mut seen = HashSet::new(); - profiles - .into_iter() - .filter(|key| seen.insert(*key)) - .collect() - } - Err(error) => { - log::debug!("Global search failed: {error}"); - Vec::new() - } - }); - cx.notify(); - }) - .ok(); - })); - } - - /// Trigger a global search once the input debounce delay has passed. - fn debounced_global(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, _window, cx| { - this.global_search(cx); - }) - .ok(); - }) - } - - /// Toggle whether searches also cover global (relay) profiles. - fn toggle_global(&mut self, cx: &mut Context) { - self.global_open = !self.global_open; - - if self.global_open { - self.global_search(cx); - } else { - self.global_finding = false; - self.global_results = None; - self.global_task = None; - } - - cx.notify(); - } - - /// Discard the search results. - fn clear_results(&mut self, cx: &mut Context) { - self.results.clear(); - self.global_finding = false; - self.global_results = None; - self.global_task = None; - cx.notify(); - } - - fn render_user(&self, current_user: &PublicKey, cx: &mut Context) -> Stateful
{ - let updater = AutoUpdater::try_global(cx); - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(current_user, cx); - let avatar = profile.avatar(); - let avatar_seed = profile.avatar_seed(); - - 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| { - this.min_w(px(256.)) - .menu_with_icon( - "Profile", - IconName::Profile, - Box::new(Command::ShowProfile), - ) - .menu_with_icon( - "Relays", - IconName::Relay, - Box::new(Command::ShowRelayList), - ) - .separator() - .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme)) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) - }), - ) - .child(div().flex_1()) - .when_some(updater, |this, updater| { - this.child(self.render_updater(updater, cx)) - }) - .child( - Button::new("sidebar-search") - .icon(IconName::Search) - .tooltip("Search") - .selected(self.search_open) - .ghost() - .small() - .on_click(cx.listener(|this, _event, window, cx| { - this.toggle_search(window, cx); - })), - ) - .child( - Button::new("sidebar-new") - .icon(IconName::PlusChat) - .tooltip("New") - .ghost() - .small() - .dropdown_menu(|menu, _window, _cx| { - menu.menu_with_icon( - "New Group", - IconName::Group, - Box::new(Command::NewGroup), - ) - .menu_with_icon("Join Group", IconName::Door, Box::new(Command::JoinGroup)) - .separator() - .menu_with_icon( - "New Chat", - IconName::Message, - Box::new(Command::NewChat), - ) - }), - ) - .when(self.group_open, |this| { - this.child( - Button::new("sidebar-back") - .icon(IconName::ArrowLeft) - .tooltip("Back") - .ghost() - .small() - .on_click(cx.listener(|this, _event, _window, cx| { - this.hide_group(cx); - })), - ) - }) - } - - fn render_updater(&self, updater: Entity, 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") - .icon(IconName::ArrowDownCircle) - .tooltip(status) - .small() - .ghost() - .disabled(true), - ) - }) - .when(staged, |this| { - this.child( - Button::new("restart") - .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_find(&self, cx: &mut Context) -> AnyElement { - h_flex() - .px_2() - .flex_shrink_0() - .w_full() - .child( - h_flex() - .px_1p5() - .gap_1() - .w_full() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child( - Input::new(&self.find_input) - .appearance(false) - .small() - .text_xs(), - ) - .child( - Button::new("sidebar-global") - .icon(IconName::Compass) - .tooltip(if self.global_open { - "Disable global search" - } else { - "Enable global search" - }) - .small() - .ghost() - .selected(self.global_open) - .loading(self.global_finding) - .on_click(cx.listener(|this, _event, _window, cx| { - this.toggle_global(cx); - })), - ), - ) - .into_any_element() - } - - fn render_results(&self, cx: &mut Context) -> AnyElement { - let rows = Rc::new(self.results_for()); - - v_flex() - .flex_1() - .min_h_0() - .relative() - .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - "sidebar-search", - rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_rows(range, rows.as_slice(), cx) - }), - ) - .track_scroll(&self.search_scroll) - .h_full() - .px_2(), - ) - .child(Scrollbar::vertical(&self.search_scroll)), - ) - .into_any_element() - } - - fn results_for(&self) -> Vec { - let mut rooms = Vec::new(); - let mut groups = Vec::new(); - let mut messages = Vec::new(); - - for result in &self.results { - match result { - SearchResult::Room(room) => rooms.push(room.clone()), - SearchResult::Group(group) => groups.push(group.clone()), - SearchResult::Message(hit) => messages.push(hit.clone()), - } - } - - let mut rows = Vec::new(); - - rows.push(SidebarRow::Heading("Groups")); - if groups.is_empty() { - rows.push(SidebarRow::Empty("No groups found")); - } else { - rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); - } - - rows.push(SidebarRow::Heading("Rooms")); - if rooms.is_empty() { - rows.push(SidebarRow::Empty("No rooms found")); - } else { - rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); - } - - rows.push(SidebarRow::Heading("Messages")); - if messages.is_empty() { - rows.push(SidebarRow::Empty("No messages found")); - } else { - rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); - } - - // Global profiles only appear once a global search was triggered - if self.global_finding || self.global_results.is_some() { - rows.push(SidebarRow::Heading("Global")); - - match self.global_results.as_ref() { - Some(profiles) if profiles.is_empty() => { - rows.push(SidebarRow::Empty("No profiles found")); - } - Some(profiles) => { - rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile { - public_key: *public_key, - })); - } - None => rows.push(SidebarRow::Empty("Searching...")), - } - } - - rows - } - - fn render_list(&mut self, cx: &mut Context) -> AnyElement { - let requests_open = self.requests_open; - let contacts_open = self.contacts_open && !requests_open; - let rows = Rc::new(self.rows_for(cx)); - - // Whether to show the create button for the contact picker - let create_button = contacts_open && !self.selected_contacts.is_empty(); - - let (list_id, scroll_handle, list_title) = if contacts_open { - ("sidebar-contacts", &self.contacts_scroll, Some("Contacts")) - } else if requests_open { - ("sidebar-requests", &self.requests_scroll, Some("Requests")) - } else { - ("sidebar-list", &self.list_scroll, None) - }; - - let request_count = ChatRegistry::global(cx) - .read(cx) - .count(&RoomKind::Request, cx); - - v_flex() - .size_full() - .flex_1() - .min_h_0() - .relative() - .gap_2() - .child( - v_flex() - .px_2() - .gap_1() - .child( - NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small()) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(Command::ShowBrowse), cx) - }), - ) - .child( - NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) - .when(request_count > 0, |this| { - this.suffix( - h_flex() - .gap_1() - .items_center() - .child( - h_flex() - .py_0p5() - .px_1() - .min_w_6() - .justify_center() - .text_size(px(10.)) - .text_color(cx.theme().text_muted) - .text_center() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child(SharedString::from( - request_count.to_string(), - )), - ) - .when(self.new_requests && !requests_open, |this| { - this.child( - div().size_1().rounded_full().bg(cx.theme().cursor), - ) - }), - ) - }) - .when(requests_open, |this| { - this.bg(cx.theme().ghost_element_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.toggle_requests(cx); - })), - ) - .child( - NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) - .when(contacts_open, |this| { - this.bg(cx.theme().ghost_element_active) - }) - .on_click(cx.listener(|this, _event, window, cx| { - this.toggle_contacts(window, cx); - })), - ), - ) - .when_some(list_title, |this, list_title| { - this.child( - div() - .px_4() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .child(list_title), - ) - }) - .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - 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)), - ) - .when(create_button, |this| { - this.child( - v_flex() - .absolute() - .bottom_0() - .left_0() - .justify_center() - .h_20() - .w_full() - .px_4() - .border_t_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .child( - Button::new("create-chat") - .label(if self.selected_contacts.len() > 1 { - "Create Group Chat" - } else { - "Create Chat" - }) - .primary() - .on_click(cx.listener(|this, _ev, window, cx| { - this.create_chat(window, cx); - })), - ) - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().text_muted) - .child("Selected") - .child(div().font_semibold().child(SharedString::from( - self.selected_contacts.len().to_string(), - ))), - ), - ) - }) - .into_any_element() - } - - fn rows_for(&self, cx: &App) -> Vec { - if self.contacts_open { - return self - .contacts - .clone() - .unwrap_or_default() - .into_iter() - .map(|public_key| SidebarRow::Contact { public_key }) - .collect(); - } - - if self.requests_open { - let chat = ChatRegistry::global(cx); - return chat - .read(cx) - .rooms(&RoomKind::Request, cx) - .into_iter() - .map(|room| SidebarRow::Room { room }) - .collect(); - } - - let mut rows = Vec::new(); - - let chat = ChatRegistry::global(cx); - 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.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, - rows: &[SidebarRow], - cx: &Context, - ) -> Vec { - let hide_avatar = AppSettings::get_hide_avatar(cx); - - rows.get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(offset, row)| { - let index = range.start + offset; - - match row { - SidebarRow::Section(section) => { - let section = *section; - let label = section.label(); - - let open = match section { - 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 - } else { - IconName::CaretRight - }; - - Nav::new(label) - .label(label) - .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .on_click(cx.listener(move |this, _event, _window, cx| { - match section { - 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::Heading(label) => { - let label = *label; - - Nav::new(label) - .label(label) - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .into_any_element() - } - SidebarRow::Empty(text) => v_flex() - .h_10() - .w_full() - .px_1p5() - .items_center() - .child( - h_flex() - .h_9() - .w_full() - .justify_center() - .border_1() - .border_dashed() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .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); - let seed = room.read(cx).display_image_seed(cx); - let created_at = room.read(cx).created_at.to_ago(); - 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(); - - let nav = Nav::new(index) - .label(name) - .text_sm() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) - }) - .suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(created_at), - ) - .on_click(move |_event, window, cx| { - open_room(&room, &dock, window, cx); - }); - - 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); - let person = persons.read(cx).get(public_key, cx); - let selected = self.selected_contacts.contains(public_key); - let sidebar = cx.entity().downgrade(); - let public_key = public_key.to_owned(); - - Nav::new(index) - .label(person.name()) - .text_sm() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .selected(selected) - .on_click(move |_event, _window, cx| { - if let Err(error) = sidebar.update(cx, |this, cx| { - this.toggle_contact(&public_key, cx); - }) { - log::error!("Failed to select contact: {error}"); - } - }) - .into_any_element() - } - SidebarRow::Profile { public_key } => { - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); - let public_key = public_key.to_owned(); - - Nav::new(index) - .label(person.name()) - .text_sm() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .on_click(move |_event, window, cx| { - profile::open(public_key, window, cx); - }) - .into_any_element() - } - SidebarRow::Group { group } => { - let dock = self.dock.clone(); - let sidebar = cx.entity().downgrade(); - let group = group.clone(); - - 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 = group - .read(cx) - .membership() - .status() - .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) - .map(SharedString::from); - - let nav = Nav::new(index) - .label(name) - .text_sm() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) - }) - .when_some(suffix, |this, suffix| { - this.suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(suffix), - ) - }) - .on_click(move |_event, window, cx| { - ui::dock::add_panel_to( - &dock, - PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - - if let Err(error) = sidebar.update(cx, |this, cx| { - this.show_group(&group, cx); - }) { - log::error!("Failed to show the group in the sidebar: {error}"); - } - }); - - 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() - } - SidebarRow::Message { hit } => { - let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx); - let name = hit.room.read(cx).display_name(cx); - let detail = format!("{} · {}", name, hit.created_at.to_ago()); - let dock = self.dock.clone(); - let room = hit.room.clone(); - let content = hit.content.clone(); - - Nav::new(index) - .label(content) - .text_sm() - .font_medium() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(detail), - ) - .on_click(move |_event, window, cx| { - open_room(&room, &dock, window, cx); - }) - .into_any_element() - } - } - }) - .collect() - } - - fn render_group(&mut self, group: Entity, cx: &mut Context) -> AnyElement { - let admin = NostrRegistry::global(cx) - .read(cx) - .current_user() - .is_some_and(|me| group.read(cx).admins().contains(&me)); - - let (name, seed, picture, total_members, rows) = { - let group = group.read(cx); - let admins = group.admins(); - - let mut admin_rows = Vec::new(); - let mut member_rows = Vec::new(); - - for (public_key, roles) in admins.entries() { - admin_rows.push(GroupRow::Member { - public_key: *public_key, - roles: roles.clone(), - }); - } - - for public_key in group.members().iter() { - if admins.contains(public_key) { - continue; - } - - member_rows.push(GroupRow::Member { - public_key: *public_key, - roles: Vec::new(), - }); - } - - let mut rows = Vec::new(); - - if !admin_rows.is_empty() { - rows.push(GroupRow::Section(GroupSection::Admins)); - if self.admins_open { - rows.append(&mut admin_rows); - } - } - - if !member_rows.is_empty() { - rows.push(GroupRow::Section(GroupSection::Members)); - if self.members_open { - rows.append(&mut member_rows); - } - } - - ( - group.display_name(), - group.key().cache_tag(), - group.display_image(), - SharedString::from(format!("{} members", group.total_members())), - rows, - ) - }; - - let rows = Rc::new(rows); - let member_group = group.downgrade(); - - v_flex() - .flex_1() - .min_h_0() - .w_full() - .gap_2() - .child( - h_flex() - .px_2() - .flex_shrink_0() - .justify_between() - .child( - h_flex() - .pl_0p5() - .gap_1() - .flex_shrink_1() - .child(Avatar::from_source(picture).seed(seed).small()) - .child( - v_flex() - .flex_1() - .min_w_0() - .child( - div() - .text_xs() - .truncate() - .line_height(relative(1.2)) - .font_semibold() - .child(name), - ) - .child( - div() - .text_size(px(10.)) - .text_color(cx.theme().text_muted) - .child(total_members), - ), - ), - ) - .child( - h_flex() - .gap_1() - .child(nip29_ui::actions(group.downgrade(), cx)), - ), - ) - .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - "group-rows", - rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_group_rows( - range, - rows.as_slice(), - &member_group, - admin, - cx, - ) - }), - ) - .track_scroll(&self.group_scroll) - .h_full() - .px_2(), - ) - .child(Scrollbar::vertical(&self.group_scroll)), - ) - .into_any_element() - } - - fn render_group_rows( - &self, - range: Range, - rows: &[GroupRow], - group: &WeakEntity, - admin: bool, - cx: &mut Context, - ) -> Vec { - rows.get(range) - .into_iter() - .flatten() - .map(|row| match row { - GroupRow::Section(section) => self.section_row(*section, cx), - GroupRow::Member { public_key, roles } => { - self.member_row(public_key, roles, group, admin, cx) - } - }) - .collect() - } - - fn section_row(&self, section: GroupSection, cx: &mut Context) -> AnyElement { - let open = match section { - GroupSection::Admins => self.admins_open, - GroupSection::Members => self.members_open, - }; - let icon = if open { - IconName::CaretDown - } else { - IconName::CaretRight - }; - let label = section.label(); - - Nav::new(label) - .label(label) - .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .on_click(cx.listener(move |this, _event, _window, cx| { - match section { - GroupSection::Admins => this.admins_open = !this.admins_open, - GroupSection::Members => this.members_open = !this.members_open, - } - cx.notify(); - })) - .into_any_element() - } - - fn member_row( - &self, - public_key: &PublicKey, - roles: &[String], - group: &WeakEntity, - admin: bool, - cx: &mut Context, - ) -> AnyElement { - let hide_avatar = AppSettings::get_hide_avatar(cx); - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); - let public_key = *public_key; - - let group = group.clone(); - - Nav::new(public_key.to_hex()) - .label(person.name()) - .text_sm() - .font_medium() - .clickable(true) - .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .when(!roles.is_empty(), |this| { - this.suffix( - div() - .truncate() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(SharedString::from(roles.join(", "))), - ) - }) - .dropdown_menu(move |menu, _window, _cx| { - let menu = menu - .item( - PopupMenuItem::new("View profile").on_click(move |_, window, cx| { - profile::open(public_key, window, cx); - }), - ) - .item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| { - let npub = public_key.to_bech32().unwrap_or_default(); - cx.write_to_clipboard(ClipboardItem::new_string(npub)); - })); - - if !admin { - return menu; - } - - menu.separator() - .item(PopupMenuItem::new("Remove").on_click({ - let group = group.clone(); - move |_event, window, cx| { - nip29_ui::remove_member(group.clone(), public_key, window, cx); - } - })) - }) - .into_any_element() - } } impl Panel for Sidebar { @@ -1810,30 +308,7 @@ impl Render for Sidebar { .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); - }), - ), - ) + .child(onboarding::render_signed_out(window, cx)) }) .map(|this| match group { Some(group) if self.group_open && !searching => { diff --git a/crates/workspace/src/sidebar/onboarding.rs b/crates/workspace/src/sidebar/onboarding.rs new file mode 100644 index 00000000..2a168c5f --- /dev/null +++ b/crates/workspace/src/sidebar/onboarding.rs @@ -0,0 +1,47 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img}; +use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; +use ui::{Sizable, StyledExt, v_flex}; + +use crate::dialogs::import; + +/// Brand backgrounds shown behind the signed-out screen; one is picked at random. +const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; + +/// Pick one of the signed-out backgrounds at random. +pub(super) fn pick_banner() -> SharedString { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.subsec_nanos()) + .unwrap_or_default(); + let index = nanos as usize % SIGNED_OUT_BANNERS.len(); + SIGNED_OUT_BANNERS[index].into() +} + +/// The headline and identity import shown while signed out. +pub(super) fn render_signed_out(window: &mut Window, cx: &mut App) -> AnyElement { + 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); + }), + ) + .into_any_element() +} diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs new file mode 100644 index 00000000..7fc64d65 --- /dev/null +++ b/crates/workspace/src/sidebar/rows.rs @@ -0,0 +1,392 @@ +use std::ops::Range; + +use chat::{Room, RoomKind}; +use common::TimestampExt; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity, + Window, div, +}; +use nip29::Group; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::dialog::DialogButtonProps; +use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle}; +use ui::menu::{ContextMenu, PopupMenuItem}; +use ui::nav::Nav; +use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +use super::Sidebar; +use super::list::ListSection; +use super::search::MessageHit; +use crate::dialogs::{profile, screening}; + +/// Open a room in the dock, screening new requests when enabled. +fn open_room(room: &Entity, dock: &WeakEntity, window: &mut Window, cx: &mut App) { + ui::dock::add_panel_to( + dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + + if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { + let peer = room.read(cx).display_member(cx).public_key(); + let view = screening::init(peer, window, cx); + + window.open_dialog(cx, move |this, _window, _cx| { + this.confirm() + .child(view.clone()) + .button_props( + DialogButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + true + }) + }); + } +} + +impl Sidebar { + pub(super) fn render_rows( + &self, + range: Range, + rows: &[SidebarRow], + cx: &Context, + ) -> Vec { + let hide_avatar = AppSettings::get_hide_avatar(cx); + + rows.get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(offset, row)| { + let index = range.start + offset; + + match row { + SidebarRow::Section(section) => { + let section = *section; + let label = section.label(); + + let open = match section { + 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 + } else { + IconName::CaretRight + }; + + Nav::new(label) + .label(label) + .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .on_click(cx.listener(move |this, _event, _window, cx| { + match section { + 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::Heading(label) => { + let label = *label; + + Nav::new(label) + .label(label) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .into_any_element() + } + SidebarRow::Empty(text) => v_flex() + .h_10() + .w_full() + .px_1p5() + .items_center() + .child( + h_flex() + .h_9() + .w_full() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .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); + let seed = room.read(cx).display_image_seed(cx); + let created_at = room.read(cx).created_at.to_ago(); + 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(); + + let nav = Nav::new(index) + .label(name) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + }) + .suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(created_at), + ) + .on_click(move |_event, window, cx| { + open_room(&room, &dock, window, cx); + }); + + 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); + let person = persons.read(cx).get(public_key, cx); + let selected = self.selected_contacts.contains(public_key); + let sidebar = cx.entity().downgrade(); + let public_key = public_key.to_owned(); + + Nav::new(index) + .label(person.name()) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .selected(selected) + .on_click(move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.toggle_contact(&public_key, cx); + }) { + log::error!("Failed to select contact: {error}"); + } + }) + .into_any_element() + } + SidebarRow::Profile { public_key } => { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let public_key = public_key.to_owned(); + + Nav::new(index) + .label(person.name()) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .on_click(move |_event, window, cx| { + profile::open(public_key, window, cx); + }) + .into_any_element() + } + SidebarRow::Group { group } => { + let dock = self.dock.clone(); + let sidebar = cx.entity().downgrade(); + let group = group.clone(); + + 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 = group + .read(cx) + .membership() + .status() + .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) + .map(SharedString::from); + + let nav = Nav::new(index) + .label(name) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + }) + .when_some(suffix, |this, suffix| { + this.suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(suffix), + ) + }) + .on_click(move |_event, window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + + if let Err(error) = sidebar.update(cx, |this, cx| { + this.show_group(&group, cx); + }) { + log::error!("Failed to show the group in the sidebar: {error}"); + } + }); + + 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() + } + SidebarRow::Message { hit } => { + let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx); + let name = hit.room.read(cx).display_name(cx); + let detail = format!("{} · {}", name, hit.created_at.to_ago()); + let dock = self.dock.clone(); + let room = hit.room.clone(); + let content = hit.content.clone(); + + Nav::new(index) + .label(content) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(detail), + ) + .on_click(move |_event, window, cx| { + open_room(&room, &dock, window, cx); + }) + .into_any_element() + } + } + }) + .collect() + } +} + +/// A row shown in the sidebar's lists. +pub(super) enum SidebarRow { + Section(ListSection), + Room { + room: Entity, + }, + Group { + group: Entity, + }, + Contact { + public_key: PublicKey, + }, + /// A profile found by global search. + Profile { + public_key: PublicKey, + }, + Message { + hit: MessageHit, + }, + /// A non-interactive label, such as a search results section. + Heading(&'static str), + Empty(&'static str), +} diff --git a/crates/workspace/src/sidebar/search.rs b/crates/workspace/src/sidebar/search.rs new file mode 100644 index 00000000..5724ce1d --- /dev/null +++ b/crates/workspace/src/sidebar/search.rs @@ -0,0 +1,315 @@ +use std::collections::HashSet; +use std::rc::Rc; + +use chat::{ChatRegistry, Room}; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task, + Window, div, uniform_list, +}; +use nip29::{Group, GroupsRegistry}; +use nostr_sdk::prelude::*; +use state::{FIND_LIMIT, NostrRegistry}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::input::Input; +use ui::scroll::Scrollbar; +use ui::{IconName, Selectable, Sizable, h_flex, v_flex}; + +use super::Sidebar; +use super::rows::SidebarRow; + +/// An entry in the sidebar's search results. +pub(super) enum SearchResult { + Room(Entity), + Group(Entity), + Message(MessageHit), +} + +/// A message found by searching the indexed chat history. +#[derive(Clone)] +pub(super) struct MessageHit { + pub(super) room: Entity, + pub(super) author: PublicKey, + pub(super) content: SharedString, + pub(super) created_at: Timestamp, +} + +/// Find groups whose display name contains all of the terms. +fn find_groups(terms: &[String], cx: &App) -> Vec> { + GroupsRegistry::global(cx) + .read(cx) + .groups() + .iter() + .filter(|group| { + let name = group.read(cx).display_name().to_lowercase(); + terms.iter().all(|term| name.contains(term.as_str())) + }) + .cloned() + .collect() +} + +impl Sidebar { + /// Show or hide the search bar below the header. + pub(super) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search_open = !self.search_open; + + if self.search_open { + self.find_input + .update(cx, |input, cx| input.focus(window, cx)); + } else { + self.find_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + self.global_open = false; + self.clear_results(cx); + } + + cx.notify(); + } + + /// Search rooms and groups by name, then indexed messages by content. + pub(super) fn search(&mut self, cx: &mut Context) { + let query = self.find_input.read(cx).value().to_string(); + let terms: Vec = query + .to_lowercase() + .split_whitespace() + .map(str::to_owned) + .collect(); + + if terms.is_empty() { + return; + } + + let chat = ChatRegistry::global(cx); + let rooms = chat.read(cx).find(&query, cx); + let groups = find_groups(&terms, cx); + + let messages = chat + .read(cx) + .search_messages(&terms, FIND_LIMIT) + .into_iter() + .filter_map(|matched| { + let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?; + + Some(MessageHit { + room, + author: matched.author, + content: matched.content.into(), + created_at: matched.created_at, + }) + }) + .collect::>(); + + self.results = rooms + .into_iter() + .map(SearchResult::Room) + .chain(groups.into_iter().map(SearchResult::Group)) + .chain(messages.into_iter().map(SearchResult::Message)) + .collect(); + cx.notify(); + } + + /// Search profiles on the indexer relays for the current query. + pub(super) fn global_search(&mut self, cx: &mut Context) { + if !self.global_open { + return; + } + + let query = self.find_input.read(cx).value().trim().to_string(); + + if query.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let find_profiles = nostr.read(cx).search(&query, cx); + + self.global_finding = true; + self.global_results = None; + cx.notify(); + + self.global_task = Some(cx.spawn(async move |this, cx| { + let result = find_profiles.await; + + this.update(cx, |this, cx| { + this.global_finding = false; + this.global_results = Some(match result { + Ok(profiles) => { + // Relays may return the same profile more than once + let mut seen = HashSet::new(); + profiles + .into_iter() + .filter(|key| seen.insert(*key)) + .collect() + } + Err(error) => { + log::debug!("Global search failed: {error}"); + Vec::new() + } + }); + cx.notify(); + }) + .ok(); + })); + } + + /// Trigger a global search once the input debounce delay has passed. + pub(super) fn debounced_global( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, _window, cx| { + this.global_search(cx); + }) + .ok(); + }) + } + + /// Toggle whether searches also cover global (relay) profiles. + pub(super) fn toggle_global(&mut self, cx: &mut Context) { + self.global_open = !self.global_open; + + if self.global_open { + self.global_search(cx); + } else { + self.global_finding = false; + self.global_results = None; + self.global_task = None; + } + + cx.notify(); + } + + /// Discard the search results. + pub(super) fn clear_results(&mut self, cx: &mut Context) { + self.results.clear(); + self.global_finding = false; + self.global_results = None; + self.global_task = None; + cx.notify(); + } + + pub(super) fn render_find(&self, cx: &mut Context) -> AnyElement { + h_flex() + .px_2() + .flex_shrink_0() + .w_full() + .child( + h_flex() + .px_1p5() + .gap_1() + .w_full() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child( + Input::new(&self.find_input) + .appearance(false) + .small() + .text_xs(), + ) + .child( + Button::new("sidebar-global") + .icon(IconName::Compass) + .tooltip(if self.global_open { + "Disable global search" + } else { + "Enable global search" + }) + .small() + .ghost() + .selected(self.global_open) + .loading(self.global_finding) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_global(cx); + })), + ), + ) + .into_any_element() + } + + pub(super) fn render_results(&self, cx: &mut Context) -> AnyElement { + let rows = Rc::new(self.results_for()); + + v_flex() + .flex_1() + .min_h_0() + .relative() + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "sidebar-search", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), + ) + .track_scroll(&self.search_scroll) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.search_scroll)), + ) + .into_any_element() + } + + fn results_for(&self) -> Vec { + let mut rooms = Vec::new(); + let mut groups = Vec::new(); + let mut messages = Vec::new(); + + for result in &self.results { + match result { + SearchResult::Room(room) => rooms.push(room.clone()), + SearchResult::Group(group) => groups.push(group.clone()), + SearchResult::Message(hit) => messages.push(hit.clone()), + } + } + + let mut rows = Vec::new(); + + rows.push(SidebarRow::Heading("Groups")); + if groups.is_empty() { + rows.push(SidebarRow::Empty("No groups found")); + } else { + rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); + } + + rows.push(SidebarRow::Heading("Rooms")); + if rooms.is_empty() { + rows.push(SidebarRow::Empty("No rooms found")); + } else { + rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); + } + + rows.push(SidebarRow::Heading("Messages")); + if messages.is_empty() { + rows.push(SidebarRow::Empty("No messages found")); + } else { + rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); + } + + // Global profiles only appear once a global search was triggered + if self.global_finding || self.global_results.is_some() { + rows.push(SidebarRow::Heading("Global")); + + match self.global_results.as_ref() { + Some(profiles) if profiles.is_empty() => { + rows.push(SidebarRow::Empty("No profiles found")); + } + Some(profiles) => { + rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile { + public_key: *public_key, + })); + } + None => rows.push(SidebarRow::Empty("Searching...")), + } + } + + rows + } +}