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 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, }; 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 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::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::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 crate::Command; use crate::dialogs::{import, profile, screening}; /// 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, }, } pub struct Sidebar { focus_handle: FocusHandle, list_scroll: UniformListScrollHandle, dock: WeakEntity, /// Random background shown behind the signed-out screen. banner: SharedString, /// The group whose information the sidebar is browsing, if any. group: Option>, /// Whether the sidebar is showing the group's information instead of its list. group_open: bool, /// Scroll state of the group's member list. group_scroll: UniformListScrollHandle, /// Whether the group view's admins section is expanded. admins_open: bool, /// Whether the group view's members section is expanded. members_open: bool, /// Whether the main list's pinned section is expanded. pins_open: bool, /// Whether the user expanded or collapsed the main list's groups section. groups_open: Option, /// Same as `groups_open` for the chats section. chats_open: Option, /// Whether the main list is showing contacts to start a chat with. contacts_open: bool, /// The signed-in user's contacts, loaded when the contact picker opens. contacts: Option>, /// Contacts selected in the contact picker. selected_contacts: HashSet, /// Scroll state of the contact picker's list. contacts_scroll: UniformListScrollHandle, /// Whether the main list is showing chat requests instead of chats and groups. requests_open: bool, new_requests: bool, requests_scroll: UniformListScrollHandle, /// Whether the search bar is shown below the header. search_open: bool, /// Whether searches also cover global (relay) profiles. global_open: bool, /// Debounced delay for the global search while typing. find_debouncer: DebouncedDelay, /// Search input state. find_input: Entity, /// Search results: rooms and groups by name, then messages by content. results: Vec, /// Whether a global (relay) search is in progress. global_finding: bool, /// Profiles found by the last global search, if one was triggered. global_results: Option>, /// The async global profile search. global_task: Option>, /// Scroll state of the search results list. search_scroll: UniformListScrollHandle, tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } impl Sidebar { pub fn new(window: &mut Window, dock: WeakEntity, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); let find_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Type to search") .clean_on_escape() }); 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(&groups, window, |_this, _, event, window, cx| { match event { GroupsEvent::Updated => { // TODO: find a better way? cx.notify(); } GroupsEvent::Error(error) => { window.push_notification(Notification::error(error.clone()), cx); } _ => {} }; }), ); subscriptions.push( cx.subscribe_in(&nostr, window, |this, _, event, window, cx| { if let StateEvent::NoSigner = event { this.banner = pick_banner(); if this.search_open { this.toggle_search(window, cx); } cx.notify(); } }), ); subscriptions.push(cx.subscribe_in( &find_input, window, |this, state, event, window, cx| match event { InputEvent::PressEnter { .. } => { this.search(cx); if this.global_open { this.global_search(cx); } } InputEvent::Change => { if state.read(cx).value().is_empty() { this.clear_results(cx); } else { this.search(cx); if this.global_open { this.find_debouncer.fire_new( Duration::from_millis(FIND_DELAY), window, cx, |this, window, cx| this.debounced_global(window, cx), ); } } } _ => {} }, )); Self { focus_handle: cx.focus_handle(), list_scroll: UniformListScrollHandle::new(), dock, banner: pick_banner(), group: None, group_open: false, group_scroll: UniformListScrollHandle::new(), admins_open: true, members_open: true, pins_open: true, groups_open: None, chats_open: None, contacts_open: false, contacts: None, selected_contacts: HashSet::new(), contacts_scroll: UniformListScrollHandle::new(), requests_open: false, requests_scroll: UniformListScrollHandle::new(), new_requests: false, search_open: false, global_open: false, find_debouncer: DebouncedDelay::new(), find_input, results: Vec::new(), global_finding: false, global_results: None, global_task: None, search_scroll: UniformListScrollHandle::new(), tasks: smallvec![], _subscriptions: subscriptions, } } /// 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); let Some(group) = groups.read(cx).group(key, cx) else { return; }; self.show_group(&group, cx); } /// Hide the group view if the sidebar is showing the given group. pub(crate) fn hide_group_meta(&mut self, key: &GroupKey, cx: &mut Context) { let showing = self .group .as_ref() .and_then(|group| group.upgrade()) .is_some_and(|group| group.read(cx).key() == key); if showing { 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 { 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 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) -> 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 group = self.group.clone().and_then(|group| group.upgrade()); let searching = self.search_open && !self.find_input.read(cx).value().is_empty(); 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(self.search_open, |this| this.child(self.render_find(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 group { Some(group) if self.group_open && !searching => { this.child(self.render_group(group, cx)) } _ if searching => this.child(self.render_results(cx)), _ => this.child(self.render_list(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() } }