use std::collections::HashSet; use anyhow::Error; use chat::{ChatEvent, ChatRegistry}; use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px, retain_all, }; use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use person::Directory; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _}; use ui::dock::{DockArea, Panel, PanelEvent}; use ui::indicator::Indicator; use ui::input::{InputEvent, InputState}; use ui::notification::Notification; use ui::{Sizable, StyledExt, WindowExtension, h_flex, title_bar_drag_handlers, v_flex}; mod group_view; mod header; mod inbox; mod list; mod onboarding; mod rows; mod search; use search::SearchResult; 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>, /// Person directory for contacts, search hits, and group members directory: Entity, /// 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); } GroupsEvent::Created { warning: Some(warning), .. } => { window.push_notification(Notification::warning(warning.clone()), cx); } _ => {} }; }), ); subscriptions.push( cx.subscribe_in( &nostr, window, |this, state, event, window, cx| match event { StateEvent::NoSigner => { this.banner = onboarding::pick_banner(); if this.search_open { this.toggle_search(window, cx); } cx.notify(); } StateEvent::SignerChanged => { if let Some(current_user) = state.read(cx).current_user() { this.directory.update(cx, |directory, cx| { directory.track([current_user], cx); }); } } _ => {} }, ), ); 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: onboarding::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, directory: Directory::new(cx), 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 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); } } } 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(px(theme::SPACE)) .bg(cx.theme().surface) .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(onboarding::render_signed_out(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(px(theme::SPACE)) .w_full() .h_9() .justify_center() .bg(cx.theme().bg.opacity(0.85)) .when(cx.theme().shadow, |this| this.shadow_md()) .rounded_full() .text_style(TextStyle::Caption) .font_semibold() .text_color(cx.theme().text_muted) .child(Indicator::new().small().color(cx.theme().accent)) .child("Getting messages..."), ), ) }) .into_any_element() } }