diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 2e3b53d8..c00c97d7 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,8 +46,11 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, + pub recent_communities: Vec, } +const RECENT_COMMUNITIES_CAP: usize = 10; + /// Signer kind #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum SignerKind { @@ -130,6 +133,10 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, + + /// Recently opened community ids, newest first + #[serde(default)] + pub recent_communities: Vec, } impl Default for Settings { @@ -142,6 +149,7 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), + recent_communities: vec![], } } } @@ -324,4 +332,14 @@ impl AppSettings { } }); } + + /// Move a community to the front of the recently opened list + pub fn record_recent_community(&mut self, id: String, cx: &mut Context) { + self.inner.update(cx, |this, cx| { + this.recent_communities.retain(|existing| existing != &id); + this.recent_communities.insert(0, id); + this.recent_communities.truncate(RECENT_COMMUNITIES_CAP); + cx.notify(); + }); + } } diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index ffdf97bf..0dbfcca0 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -1,4 +1,3 @@ pub mod import; pub mod restore; -pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs deleted file mode 100644 index 6ae2fdab..00000000 --- a/crates/workspace/src/dialogs/screening.rs +++ /dev/null @@ -1,553 +0,0 @@ -use std::collections::HashMap; - -use anyhow::Error; -use common::TimestampExt; -use gpui::prelude::FluentBuilder; -use gpui::{ - App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, - SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list, -}; -use instant::Duration; -use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry, shorten_pubkey}; -use smallvec::{SmallVec, smallvec}; -use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::indicator::Indicator; -use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; - -pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Screening::new(public_key, window, cx)) -} - -/// Screening -pub struct Screening { - /// Public Key of the person being screened. - public_key: PublicKey, - - /// Whether the person's address is verified. - verified: bool, - - /// Whether the person is followed by current user. - followed: bool, - - /// Last time the person was active. - last_active: Option, - - /// All mutual contacts of the person being screened. - mutual_contacts: Vec, - - /// Async tasks - tasks: SmallVec<[Task<()>; 3]>, - - /// Subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl Screening { - pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let mut subscriptions = smallvec![]; - - subscriptions.push(cx.on_release_in(window, move |this, window, cx| { - this.tasks.clear(); - window.close_all_modals(cx); - })); - - cx.defer_in(window, |this, _window, cx| { - this.check_contact(cx); - this.check_wot(cx); - this.check_last_activity(cx); - this.verify_identifier(cx); - }); - - Self { - public_key, - verified: false, - followed: false, - last_active: None, - mutual_contacts: vec![], - tasks: smallvec![], - _subscriptions: subscriptions, - } - } - - fn check_contact(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.public_key; - - let Some(current_user) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task> = cx.background_spawn(async move { - // Check if user is in contact list - let filter = Filter::new() - .author(current_user) - .kind(Kind::ContactList) - .limit(1); - - let followed = client - .database() - .query(filter) - .await - .unwrap_or_default() - .into_iter() - .next() - .map(|event| event.tags.public_keys().any(|k| k == public_key)) - .unwrap_or(false); - - Ok(followed) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await.unwrap_or(false); - - this.update(cx, |this, cx| { - this.followed = result; - cx.notify(); - }) - .ok(); - })); - } - - fn check_wot(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.public_key; - - let Some(current_user) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task, Error>> = cx.background_spawn(async move { - // Check mutual contacts - let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); - let mut mutual_contacts = vec![]; - - if let Ok(events) = client.database().query(filter).await { - for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { - mutual_contacts.push(event.pubkey); - } - } - - Ok(mutual_contacts) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.mutual_contacts = contacts; - cx.notify(); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to fetch mutual contacts: {}", e); - } - }; - })); - } - - fn check_last_activity(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.public_key; - - let task: Task> = cx.background_spawn(async move { - let filter = Filter::new().author(public_key).limit(1); - let mut activity: Option = None; - - // Construct target for subscription - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .into_iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect(); - - if let Ok(mut stream) = client - .stream_events(target) - .timeout(Duration::from_secs(TIMEOUT)) - .await - { - while let Some((_url, event)) = stream.next().await { - if let Ok(event) = event { - activity = Some(event.created_at); - } - } - } - - activity - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - this.last_active = result; - cx.notify(); - }) - .ok(); - })); - } - - fn verify_identifier(&mut self, cx: &mut Context) { - let http_client = cx.http_client(); - let public_key = self.public_key; - - // Skip if the user doesn't have a NIP-05 identifier - let Some(address) = self.address(cx) else { - return; - }; - - let task: Task> = - cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await.unwrap_or(false); - - this.update(cx, |this, cx| { - this.verified = result; - cx.notify(); - }) - .ok(); - })); - } - - fn profile(&self, cx: &Context) -> Person { - let persons = PersonRegistry::global(cx); - persons.read(cx).get(&self.public_key, cx) - } - - fn address(&self, cx: &Context) -> Option { - self.profile(cx) - .metadata() - .nip05 - .and_then(|addr| Nip05Address::parse(&addr).ok()) - } - - fn open_njump(&mut self, _window: &mut Window, cx: &mut Context) { - let Ok(bech32) = self.profile(cx).public_key().to_bech32(); - cx.open_url(&format!("https://njump.me/{bech32}")); - } - - fn report(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = self.public_key; - - let task: Task> = cx.background_spawn(async move { - let tag = Tag::from(Nip56Tag::PublicKey { - public_key, - report: Report::Impersonation, - }); - - let event = EventBuilder::new(Kind::Reporting, "") - .tag(tag) - .finalize_async(&signer) - .await?; - - // Send the report to the public relays - client.send_event(&event).to(BOOTSTRAP_RELAYS).await?; - - Ok(()) - }); - - self.tasks.push(cx.spawn_in(window, async move |_, cx| { - if task.await.is_ok() { - cx.update(|window, cx| { - window.close_modal(cx); - window.push_notification("Report submitted successfully", cx); - }) - .ok(); - } - })); - } - - fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let contacts = self.mutual_contacts.clone(); - - window.open_modal(cx, move |this, _window, _cx| { - let contacts = contacts.clone(); - let total = contacts.len(); - - this.title("Mutual contacts").child( - v_flex().gap_1().pb_2().child( - uniform_list("contacts", total, move |range, _window, cx| { - let persons = PersonRegistry::global(cx); - let mut items = Vec::with_capacity(total); - - for ix in range { - let Some(contact) = contacts.get(ix) else { - continue; - }; - let profile = persons.read(cx).get(contact, cx); - - items.push( - h_flex() - .h_11() - .w_full() - .px_2() - .gap_1p5() - .rounded(cx.theme().radius) - .text_sm() - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .child( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .small(), - ) - .child(profile.name()), - ); - } - - items - }) - .h(px(300.)), - ), - ) - }); - } -} - -impl Render for Screening { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - const CONTACT: &str = "This person is one of your contacts."; - const NOT_CONTACT: &str = "This person is not one of your contacts."; - const NO_ACTIVITY: &str = "This person hasn't had any activity."; - const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate."; - const NO_MUTUAL: &str = "You don't have any mutual contacts."; - const NIP05_MATCH: &str = "The address matches the user's public key."; - const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; - const NO_NIP05: &str = "This person has not set up their friendly address"; - - let profile = self.profile(cx); - let shorten_pubkey = shorten_pubkey(self.public_key, 8); - - let last_active = self.last_active.map(|_| true); - let mutuals = self.mutual_contacts.len(); - let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); - - v_flex() - .gap_4() - .child( - v_flex() - .gap_3() - .items_center() - .justify_center() - .text_center() - .child( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .large(), - ) - .child( - div() - .font_semibold() - .line_height(relative(1.25)) - .child(profile.name()), - ), - ) - .child( - h_flex() - .gap_3() - .child( - h_flex() - .p_1() - .flex_1() - .h_7() - .justify_center() - .rounded_full() - .bg(cx.theme().elevated_surface_background) - .text_sm() - .truncate() - .text_ellipsis() - .text_center() - .line_height(relative(1.)) - .child(shorten_pubkey), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("njump") - .icon(IconName::Link) - .label("njump.me") - .secondary() - .small() - .rounded() - .on_click(cx.listener(move |this, _e, window, cx| { - this.open_njump(window, cx); - })), - ) - .child( - Button::new("report") - .tooltip("Report as a scam or impostor") - .icon(IconName::Warning) - .small() - .warning() - .rounded() - .on_click(cx.listener(move |this, _e, window, cx| { - this.report(window, cx); - })), - ), - ), - ) - .child( - v_flex() - .gap_3() - .child( - h_flex() - .items_start() - .gap_2() - .text_sm() - .child(status_badge(Some(self.followed), cx)) - .child( - v_flex().text_sm().child("Contact").child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if self.followed { - SharedString::from(CONTACT) - } else { - SharedString::from(NOT_CONTACT) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .text_sm() - .child(status_badge(last_active, cx)) - .child( - v_flex() - .text_sm() - .child( - h_flex() - .gap_0p5() - .child("Activity on Public Relays") - .child( - Button::new("active") - .icon(IconName::Info) - .xsmall() - .ghost() - .rounded() - .tooltip(RELAY_INFO), - ), - ) - .child( - div() - .w_full() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .map(|this| { - if let Some(t) = self.last_active { - this.child(SharedString::from(format!( - "Last active: {}.", - t.to_human_time() - ))) - } else { - this.child(SharedString::from(NO_ACTIVITY)) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .child(status_badge(Some(self.verified), cx)) - .child( - v_flex() - .text_sm() - .child({ - if let Some(addr) = self.address(cx) { - SharedString::from(format!("{} validation", addr)) - } else { - SharedString::from( - "Friendly Address (NIP-05) validation", - ) - } - }) - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if self.address(cx).is_some() { - if self.verified { - SharedString::from(NIP05_MATCH) - } else { - SharedString::from(NIP05_NOT_MATCH) - } - } else { - SharedString::from(NO_NIP05) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .child(status_badge(Some(mutuals > 0), cx)) - .child( - h_flex() - .text_sm() - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if mutuals > 0 { - SharedString::from(mutuals_str) - } else { - SharedString::from(NO_MUTUAL) - } - }), - ) - .child( - Button::new("mutuals") - .icon(IconName::Info) - .xsmall() - .ghost() - .rounded() - .disabled(mutuals == 0) - .on_click(cx.listener(move |this, _, window, cx| { - this.mutual_contacts(window, cx); - })), - ), - ), - ), - ) - } -} - -fn status_badge(status: Option, cx: &App) -> Div { - h_flex() - .size_6() - .justify_center() - .flex_shrink_0() - .map(|this| { - if let Some(status) = status { - this.child(Icon::new(IconName::CheckCircle).small().text_color({ - if status { - cx.theme().icon_accent - } else { - cx.theme().icon_muted - } - })) - } else { - this.child(Indicator::new().small()) - } - }) -} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 5d1c05da..39da4b7c 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -4,14 +4,15 @@ use std::rc::Rc; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, RoomKind}; use common::TimestampExt; -use community::{CommunityEvent, CommunityRegistry}; +use community::{Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Context, ElementId, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; @@ -91,6 +92,17 @@ impl Sidebar { cx.notify(); } + fn open_community(&mut self, community: Entity, cx: &mut Context) { + let id = community.read(cx).id().to_hex(); + let settings = AppSettings::global(cx); + + settings.update(cx, |settings, cx| { + settings.record_recent_community(id, cx); + }); + + cx.notify(); + } + fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let current_user = nostr.read(cx).current_user(); @@ -195,11 +207,78 @@ impl Sidebar { } } +fn recent_communities(cx: &App) -> Vec> { + const LIMIT: usize = 3; + + let registry = CommunityRegistry::global(cx); + let communities = registry.read(cx).communities(); + let recent = AppSettings::get_recent_communities(cx); + + let mut rows: Vec> = recent + .iter() + .filter_map(|id| { + communities + .iter() + .find(|community| community.read(cx).id().to_hex() == *id) + }) + .take(LIMIT) + .cloned() + .collect(); + + if rows.is_empty() { + rows = communities.iter().take(LIMIT).cloned().collect(); + } + + rows +} + fn rows_for(tab: SidebarTab, cx: &App) -> Vec { match tab { - SidebarTab::Recents => vec![SidebarRow::Hint { - text: "Nothing recent yet".into(), - }], + SidebarTab::Recents => { + let chat = ChatRegistry::global(cx); + let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, cx); + let registry = CommunityRegistry::global(cx); + let community_count = registry.read(cx).communities().len(); + let communities = recent_communities(cx); + + if communities.is_empty() && rooms.is_empty() { + return vec![SidebarRow::Hint { + text: "Nothing recent yet".into(), + }]; + } + + let mut rows = vec![SidebarRow::Section { + label: "Communities".into(), + count: community_count, + }]; + + rows.extend( + communities + .into_iter() + .map(|community| SidebarRow::Community { community }), + ); + rows.push(SidebarRow::Action { + label: "Show all communities".into(), + tab: SidebarTab::Communities, + }); + + rows.push(SidebarRow::Section { + label: "Chats".into(), + count: rooms.len(), + }); + rows.extend( + rooms + .into_iter() + .take(5) + .map(|room| SidebarRow::Room { room }), + ); + rows.push(SidebarRow::Action { + label: "Show all chats".into(), + tab: SidebarTab::Chats, + }); + + rows + } SidebarTab::Chats => { let chat = ChatRegistry::global(cx); let chat = chat.read(cx); @@ -288,15 +367,34 @@ fn render_rows(range: Range, rows: &[SidebarRow], cx: &Context) .into_any_element() } SidebarRow::Community { community } => { - let community = community.read(cx); + let name = community.read(cx).name(); + let seed = community.read(cx).id().to_hex(); + let picture = community.read(cx).icon(); + let community = community.clone(); TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), TreeRowKind::Community, - community.name(), + name, ) - .avatar(community.id().to_hex()) - .picture(community.icon()) + .avatar(seed) + .picture(picture) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.open_community(community.clone(), cx); + })) + .into_any_element() + } + SidebarRow::Action { label, tab } => { + let tab = *tab; + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Action, + label.clone(), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.select_tab(tab, cx); + })) .into_any_element() } SidebarRow::Hint { text } => TreeRow::new( diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 7b8fc465..a161512d 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -12,11 +12,26 @@ use theme::ActiveTheme; use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; +use super::tab::SidebarTab; + pub enum SidebarRow { - Section { label: SharedString, count: usize }, - Room { room: Entity }, - Community { community: Entity }, - Hint { text: SharedString }, + Section { + label: SharedString, + count: usize, + }, + Room { + room: Entity, + }, + Community { + community: Entity, + }, + Action { + label: SharedString, + tab: SidebarTab, + }, + Hint { + text: SharedString, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -24,6 +39,7 @@ pub enum TreeRowKind { Section, Room, Community, + Action, Hint, } @@ -109,6 +125,7 @@ impl RenderOnce for TreeRow { let is_section = self.kind == TreeRowKind::Section; let is_room = self.kind == TreeRowKind::Room; let is_community = self.kind == TreeRowKind::Community; + let is_action = self.kind == TreeRowKind::Action; let is_hint = self.kind == TreeRowKind::Hint; let is_selected = self.selected; @@ -146,6 +163,11 @@ impl RenderOnce for TreeRow { .font_semibold() }) .when(is_room || is_community, |this| this.text_sm()) + .when(is_action, |this| { + this.text_sm() + .font_medium() + .text_color(cx.theme().text_accent) + }) .when(is_hint, |this| { this.text_xs() .font_normal()