feat: new search experience (#58)

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
2026-10-06 03:00:21 +00:00
parent 2a404591d8
commit 2bce050c3b
21 changed files with 2134 additions and 1295 deletions
+17
View File
@@ -19,11 +19,15 @@ use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
mod message;
mod room;
mod search;
pub use message::*;
pub use room::*;
pub use search::SearchMessage;
pub use state::FileAttachment;
use crate::search::MessageSearchIndex;
/// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -82,6 +86,8 @@ pub struct ChatRegistry {
seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
/// Mapping of unwrapped event ids to their gift wrap event ids
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
/// In-memory message content index for search
search: Arc<RwLock<MessageSearchIndex>>,
/// True while the initial event backlog is still loading
tracking: Arc<AtomicBool>,
/// Channel for sending signals to the UI.
@@ -170,6 +176,7 @@ impl ChatRegistry {
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
search: Arc::default(),
tracking: Arc::new(AtomicBool::new(true)),
matcher: CachedMatcher(SkimMatcherV2::default()),
signal_rx: rx,
@@ -646,10 +653,16 @@ impl ChatRegistry {
}
}
/// Search the content of all indexed messages, newest first.
pub fn search_messages(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
self.search.read().unwrap().search(terms, limit)
}
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.search.write().unwrap().clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -715,6 +728,7 @@ impl ChatRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let search = self.search.clone();
cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
@@ -746,6 +760,7 @@ impl ChatRegistry {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some()
{
search.write().unwrap().insert(&rumor);
if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key)
{
@@ -782,6 +797,8 @@ impl ChatRegistry {
/// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
self.search.write().unwrap().insert(&message.rumor);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
+133
View File
@@ -0,0 +1,133 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::sync::Arc;
use common::EventExt;
use nostr_sdk::prelude::*;
/// A chat message kept in memory for content search.
#[derive(Debug, Clone)]
pub struct SearchMessage {
/// ID of the room the message belongs to
pub room: u64,
/// Author of the message
pub author: PublicKey,
/// The content of the message
pub content: Arc<str>,
/// When the message was created
pub created_at: Timestamp,
}
#[derive(Debug)]
struct IndexedMessage {
message: SearchMessage,
/// Lowercase content so repeated searches don't re-allocate per message
normalized: Arc<str>,
}
/// In-memory index of chat message rumors, keyed by room and rumor id.
#[derive(Debug, Default)]
pub struct MessageSearchIndex(BTreeMap<u64, BTreeMap<EventId, Arc<IndexedMessage>>>);
impl MessageSearchIndex {
/// Index a rumor for search, ignoring reactions and empty content.
pub fn insert(&mut self, rumor: &UnsignedEvent) {
if !is_chat(rumor.kind) || rumor.content.trim().is_empty() {
return;
}
let Some(id) = rumor.id else { return };
let indexed = Arc::new(IndexedMessage {
message: SearchMessage {
room: rumor.uniq_id(),
author: rumor.pubkey,
content: rumor.content.as_str().into(),
created_at: rumor.created_at,
},
normalized: rumor.content.to_lowercase().into(),
});
self.0
.entry(rumor.uniq_id())
.or_default()
.entry(id)
.or_insert(indexed);
}
/// Find messages whose content contains all of the terms, newest first.
pub fn search(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
let mut matches: Vec<&Arc<IndexedMessage>> = self
.0
.values()
.flat_map(|messages| messages.values())
.filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term)))
.collect();
matches.sort_by_key(|indexed| Reverse(indexed.message.created_at));
matches.truncate(limit);
matches
.into_iter()
.map(|indexed| indexed.message.clone())
.collect()
}
/// Drop all indexed messages.
pub fn clear(&mut self) {
self.0.clear();
}
}
fn is_chat(kind: Kind) -> bool {
kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15)
}
#[cfg(test)]
mod tests {
use super::*;
fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent {
let sender = Keys::generate();
let peer = Keys::generate().public_key();
let mut rumor = EventBuilder::new(kind, content)
.tag(Tag::public_key(peer))
.finalize_unsigned(sender.public_key());
rumor.ensure_id();
rumor.created_at = Timestamp::from(created_at);
rumor
}
#[test]
fn search_matches_all_terms_newest_first_and_deduplicates() {
let mut index = MessageSearchIndex::default();
let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10);
let second = rumor(Kind::PrivateDirectMessage, "hello again", 20);
let reaction = rumor(Kind::Reaction, "hello world", 30);
index.insert(&first);
index.insert(&first);
index.insert(&second);
index.insert(&reaction);
let hits = index.search(&["hello".to_owned()], 10);
assert_eq!(hits.len(), 2);
assert_eq!(&*hits[0].content, "hello again");
assert_eq!(hits[0].created_at, Timestamp::from(20));
let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10);
assert_eq!(hits.len(), 1);
let hits = index.search(&["hello".to_owned()], 1);
assert_eq!(hits.len(), 1);
}
#[test]
fn empty_content_is_not_indexed() {
let mut index = MessageSearchIndex::default();
index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10));
assert!(index.search(&["hello".to_owned()], 10).is_empty());
}
}
+21 -2
View File
@@ -88,6 +88,7 @@ pub struct Group {
oldest: Option<Timestamp>,
loading: bool,
loading_more: bool,
active: bool,
refused: Option<String>,
tasks: SmallVec<[Task<()>; 2]>,
}
@@ -115,6 +116,7 @@ impl Group {
oldest: None,
loading: true,
loading_more: false,
active: false,
refused: None,
tasks: smallvec![],
}
@@ -196,6 +198,14 @@ impl Group {
self.loading_more
}
pub(crate) fn is_active(&self) -> bool {
self.active
}
pub(crate) fn activate(&mut self) {
self.active = true;
}
pub fn oldest(&self) -> Option<Timestamp> {
self.oldest
}
@@ -496,16 +506,25 @@ impl Group {
})
}
pub(crate) fn load_envelopes(&self, cx: &App) -> Task<Result<Vec<Event>>> {
pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task<Result<Vec<Event>>> {
let cache_tag = self.key.cache_tag();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let filter = Filter::new()
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
if state_only {
let kinds = STATE_KINDS
.iter()
.chain(MEMBERSHIP_KINDS.iter())
.map(|kind| kind.to_string());
filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds);
}
let mut events: Vec<Event> = client
.database()
.query(filter)
+77 -4
View File
@@ -549,7 +549,7 @@ impl GroupsRegistry {
);
self.subscribe(key.clone(), cx);
self.reload(key, None, cx);
self.load_state(key, cx);
}
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
@@ -679,7 +679,7 @@ impl GroupsRegistry {
};
let logged = key.clone();
let load = group.read(cx).load_envelopes(cx);
let load = group.read(cx).load_envelopes(cx, false);
self.tasks.push(cx.spawn(async move |_this, cx| {
if let Some(event) = event
@@ -704,6 +704,71 @@ impl GroupsRegistry {
}));
}
/// Load only the group state (metadata, members, membership) from the cache.
fn load_state(&mut self, key: GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(&key, cx) else {
return;
};
let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else {
return;
};
let logged = key.clone();
let load = group.read(cx).load_envelopes(cx, true);
self.tasks
.push(cx.spawn(async move |_this, cx| match load.await {
Ok(events) => {
group.update(cx, |group, cx| {
group.hydrate(events, me, cx);
});
}
Err(error) => {
log::warn!("nip29: loading {} state failed: {error}", logged.id());
}
}));
}
/// Cache and absorb a single event without rebuilding the group.
fn absorb(&mut self, key: GroupKey, event: Event, cx: &mut Context<Self>) {
let Some(group) = self.group(&key, cx) else {
return;
};
let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else {
return;
};
self.tasks.push(cx.spawn(async move |_this, cx| {
if let Err(error) = group
.read_with(cx, |group, cx| group.save_envelope(event.clone(), cx))
.await
{
log::warn!("nip29: caching failed: {error}");
return;
}
group.update(cx, |group, cx| {
group.hydrate(vec![event], me, cx);
});
}));
}
/// Load the full group content on first open.
pub fn activate(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(key, cx) else {
return;
};
if group.read(cx).is_active() {
return;
}
group.update(cx, |group, _cx| group.activate());
self.reload(key.clone(), None, cx);
}
fn forget_event(&mut self, group: Entity<Group>, event: Event, cx: &mut Context<Self>) {
let Some(target) = event.tags.event_ids().next() else {
return;
@@ -761,7 +826,15 @@ impl GroupsRegistry {
return;
};
self.reload(key, Some(event), cx);
let active = self
.group(&key, cx)
.is_some_and(|group| group.read(cx).is_active());
if active {
self.reload(key, Some(event), cx);
} else {
self.absorb(key, event, cx);
}
}
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
@@ -819,7 +892,7 @@ impl GroupsRegistry {
}
if id == fork_subscription_id() {
log::warn!("nip29: the admins' group lists were refused: {reason}");
log::debug!("nip29: the admins' group lists were refused: {reason}");
return;
}
+7
View File
@@ -135,6 +135,13 @@ fn opens_run(rows: &[Row], index: usize) -> bool {
}
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel> {
let key = group.read(cx).key().clone();
let groups = GroupsRegistry::global(cx);
groups.update(cx, |registry, cx| {
registry.activate(&key, cx);
});
cx.new(|cx| GroupPanel::new(group, window, cx))
}
+2
View File
@@ -56,6 +56,7 @@ pub enum IconName {
Message,
Moon,
Plus,
PlusChat,
PlusCircle,
Profile,
Reset,
@@ -139,6 +140,7 @@ impl IconNamed for IconName {
Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg",
+3 -3
View File
@@ -990,7 +990,7 @@ impl PopupMenu {
let this = MenuItemElement::new(ix, &group_name)
.relative()
.text_sm()
.text_xs()
.py_0()
.px(INNER_PADDING)
.rounded(radius)
@@ -1063,7 +1063,7 @@ impl PopupMenu {
})
.disabled(*disabled)
.h(item_height)
.gap_x_1()
.gap_x_1p5()
.children(Self::render_icon(
has_left_icon,
is_left_check,
@@ -1110,7 +1110,7 @@ impl PopupMenu {
.min_h(item_height)
.size_full()
.items_center()
.gap_x_1()
.gap_x_1p5()
.children(Self::render_icon(
has_left_icon,
false,
+1 -1
View File
@@ -105,7 +105,7 @@ impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
v_flex().w_full().h_10().child(
v_flex().h_10().w_full().items_center().child(
h_flex()
.id(self.id)
.h_9()
+310
View File
@@ -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<String>,
},
}
impl Sidebar {
/// Show the group view, replacing the main list.
pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
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<Self>) {
if !self.group_open {
return;
}
self.group_open = false;
cx.notify();
}
pub(super) fn render_group(
&mut self,
group: Entity<Group>,
cx: &mut Context<Self>,
) -> 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<usize>,
rows: &[GroupRow],
group: &WeakEntity<Group>,
admin: bool,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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<Self>) -> 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<Group>,
admin: bool,
cx: &mut Context<Self>,
) -> 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()
}
}
+156
View File
@@ -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<Self>,
) -> Stateful<Div> {
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<AutoUpdater>, cx: &mut App) -> AnyElement {
let status = updater.read(cx).status();
let up_to_date = updater.read(cx).up_to_date();
let staged = updater.read(cx).staged();
h_flex()
.gap_2()
.when(!up_to_date, |this| {
this.child(
Button::new("update")
.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()
}
}
+222
View File
@@ -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>) {
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<Self>) {
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<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = 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<Self>) {
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<Self>) {
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<PublicKey> = 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>) {
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<SidebarRow> {
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<SidebarRow> {
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<Self>) -> 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()
}
}
+240
View File
@@ -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<Self>) -> 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<SidebarRow> {
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))
}
}
File diff suppressed because it is too large Load Diff
@@ -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()
}
+392
View File
@@ -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<Room>, dock: &WeakEntity<DockArea>, 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<usize>,
rows: &[SidebarRow],
cx: &Context<Sidebar>,
) -> Vec<AnyElement> {
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<Room>,
},
Group {
group: Entity<Group>,
},
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),
}
+315
View File
@@ -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<Room>),
Group(Entity<Group>),
Message(MessageHit),
}
/// A message found by searching the indexed chat history.
#[derive(Clone)]
pub(super) struct MessageHit {
pub(super) room: Entity<Room>,
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<Entity<Group>> {
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>) {
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<Self>) {
let query = self.find_input.read(cx).value().to_string();
let terms: Vec<String> = 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::<Vec<_>>();
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<Self>) {
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<Self>,
) -> 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>) {
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>) {
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<Self>) -> 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<Self>) -> 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<SidebarRow> {
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
}
}
-16
View File
@@ -1,16 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::SharedString;
/// 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(crate) 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()
}