diff --git a/Cargo.lock b/Cargo.lock index 12915fe..696093e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -971,7 +971,6 @@ dependencies = [ "oneshot", "smallvec", "smol", - "ui", ] [[package]] @@ -1243,7 +1242,6 @@ dependencies = [ "oneshot", "reqwest_client", "rust-embed", - "rustls", "serde", "serde_json", "smallvec", @@ -2277,6 +2275,7 @@ version = "0.1.5" dependencies = [ "dirs 5.0.1", "nostr-sdk", + "rustls", "smol", "whoami", ] diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index a60a71a..260e097 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -8,7 +8,6 @@ publish.workspace = true account = { path = "../account" } common = { path = "../common" } global = { path = "../global" } -ui = { path = "../ui" } gpui.workspace = true nostr.workspace = true diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index acb3bba..55dd6f5 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -1,6 +1,6 @@ use std::{ cmp::Reverse, - collections::{BTreeMap, HashMap, HashSet} + collections::{HashMap, HashSet}, }; use account::Account; @@ -8,19 +8,21 @@ use anyhow::Error; use common::room_hash; use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; use global::get_client; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; +use gpui::{ + App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, +}; use itertools::Itertools; use nostr_sdk::prelude::*; use room::RoomKind; use smallvec::{smallvec, SmallVec}; -use ui::ContextModal; use crate::room::Room; -mod constants; pub mod message; pub mod room; +mod constants; + pub fn init(cx: &mut App) { ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); } @@ -29,6 +31,9 @@ struct GlobalChatRegistry(Entity); impl Global for GlobalChatRegistry {} +#[derive(Debug)] +pub struct NewRoom(pub WeakEntity); + /// Main registry for managing chat rooms and user profiles /// /// The ChatRegistry is responsible for: @@ -37,8 +42,6 @@ impl Global for GlobalChatRegistry {} /// - Loading room data from the lmdb /// - Handling messages and room creation pub struct ChatRegistry { - /// Map of user public keys to their profile metadata - profiles: Entity>>, /// Collection of all chat rooms pub rooms: Vec>, /// Indicates if rooms are currently being loaded @@ -50,6 +53,8 @@ pub struct ChatRegistry { subscriptions: SmallVec<[Subscription; 2]>, } +impl EventEmitter for ChatRegistry {} + impl ChatRegistry { /// Retrieve the Global ChatRegistry instance pub fn global(cx: &App) -> Entity { @@ -68,7 +73,6 @@ impl ChatRegistry { /// Create a new ChatRegistry instance fn new(cx: &mut Context) -> Self { - let profiles = cx.new(|_| BTreeMap::new()); let mut subscriptions = smallvec![]; // When the ChatRegistry is created, load all rooms from the local database @@ -79,30 +83,13 @@ impl ChatRegistry { })); // When any Room is created, load metadata for all members - subscriptions.push(cx.observe_new::(|this, window, cx| { - if let Some(window) = window { - let task = this.load_metadata(cx); - - cx.spawn_in(window, async move |_, cx| { - if let Ok(data) = task.await { - cx.update(|_, cx| { - for (public_key, metadata) in data.into_iter() { - Self::global(cx).update(cx, |this, cx| { - this.add_profile(public_key, metadata, cx); - }) - } - }) - .ok(); - } - }) - .detach(); - } + subscriptions.push(cx.observe_new::(|this, _window, cx| { + this.load_metadata(cx).detach(); })); Self { rooms: vec![], wait_for_eose: true, - profiles, subscriptions, } } @@ -115,11 +102,31 @@ impl ChatRegistry { .cloned() } - /// Get rooms by its kind. - pub fn rooms_by_kind(&self, kind: RoomKind, cx: &App) -> Vec> { + /// Get room by its position. + pub fn room_by_ix(&self, ix: usize, _cx: &App) -> Option<&Entity> { + self.rooms.get(ix) + } + + /// Get all ongoing rooms. + pub fn ongoing_rooms(&self, cx: &App) -> Vec> { self.rooms .iter() - .filter(|room| room.read(cx).kind == kind) + .filter(|room| room.read(cx).kind == RoomKind::Ongoing) + .cloned() + .collect() + } + + /// Get all request rooms. + pub fn request_rooms(&self, trusted_only: bool, cx: &App) -> Vec> { + self.rooms + .iter() + .filter(|room| { + if trusted_only { + room.read(cx).kind == RoomKind::Trusted + } else { + room.read(cx).kind != RoomKind::Ongoing + } + }) .cloned() .collect() } @@ -189,10 +196,9 @@ impl ChatRegistry { .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) { let hash = room_hash(&event); - let mut is_trust = trusted_keys.contains(&event.pubkey); - if is_trust == false { + if !is_trust { // Check if room's author is seen in any contact list let filter = Filter::new().kind(Kind::ContactList).pubkey(event.pubkey); // If room's author is seen at least once, mark as trusted @@ -256,71 +262,25 @@ impl ChatRegistry { .detach(); } - /// Add a user profile to the registry - /// - /// Only adds the profile if it doesn't already exist or is currently none - pub fn add_profile( - &mut self, - public_key: PublicKey, - metadata: Option, - cx: &mut Context, - ) { - self.profiles.update(cx, |this, _cx| { - this.entry(public_key) - .and_modify(|entry| { - if entry.is_none() { - *entry = metadata.clone(); - } - }) - .or_insert_with(|| metadata); - }); - } - - /// Get a user profile by public key - pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile { - let metadata = if let Some(profile) = self.profiles.read(cx).get(public_key) { - profile.clone().unwrap_or_default() + /// Push a new Room to the global registry + pub fn push_room(&mut self, room: Entity, cx: &mut Context) { + let weak_room = if let Some(room) = self + .rooms + .iter() + .find(|this| this.read(cx).id == room.read(cx).id) + { + room.downgrade() } else { - Metadata::default() - }; + let weak_room = room.downgrade(); - Profile::new(*public_key, metadata) - } - - /// Push a Room Entity to the global registry - /// - /// Returns the ID of the room - pub fn push_room(&mut self, room: Entity, cx: &mut Context) -> u64 { - let id = room.read(cx).id; - - if !self.rooms.iter().any(|this| this.read(cx) == room.read(cx)) { + // Add this room to the global registry self.rooms.insert(0, room); cx.notify(); - } - id - } + weak_room + }; - /// Parse a Nostr event into a Coop Room and push it to the global registry - /// - /// Returns the ID of the new room - pub fn event_to_room( - &mut self, - event: &Event, - window: &mut Window, - cx: &mut Context, - ) -> u64 { - let room = Room::new(event).kind(RoomKind::Ongoing); - let id = room.id; - - if !self.rooms.iter().any(|this| this.read(cx) == &room) { - self.rooms.insert(0, cx.new(|_| room)); - cx.notify(); - } else { - window.push_notification("Room already exists", cx); - } - - id + cx.emit(NewRoom(weak_room)); } /// Parse a Nostr event into a Coop Message and push it to the belonging room diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index 7750f0f..d7d1a65 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -3,8 +3,8 @@ use std::{cmp::Ordering, sync::Arc}; use account::Account; use anyhow::{anyhow, Error}; use chrono::{Local, TimeZone}; -use common::{compare, profile::SharedProfile, room_hash}; -use global::get_client; +use common::{compare, profile::RenderProfile, room_hash}; +use global::{async_cache_profile, get_cache_profile, get_client, profiles}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -12,7 +12,6 @@ use nostr_sdk::prelude::*; use crate::{ constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE}, message::Message, - ChatRegistry, }; #[derive(Debug, Clone)] @@ -157,20 +156,6 @@ impl Room { .into() } - /// Gets the profile for a specific public key - /// - /// # Arguments - /// - /// * `public_key` - The public key to get the profile for - /// * `cx` - The App context - /// - /// # Returns - /// - /// The Profile associated with the given public key - pub fn profile_by_pubkey(&self, public_key: &PublicKey, cx: &App) -> Profile { - ChatRegistry::global(cx).read(cx).profile(public_key, cx) - } - /// Gets the first member in the room that isn't the current user /// /// # Arguments @@ -183,7 +168,7 @@ impl Room { pub fn first_member(&self, cx: &App) -> Profile { let account = Account::global(cx).read(cx); let Some(profile) = account.profile.clone() else { - return self.profile_by_pubkey(&self.members[0], cx); + return get_cache_profile(&self.members[0]); }; if let Some(public_key) = self @@ -193,7 +178,7 @@ impl Room { .collect::>() .first() { - self.profile_by_pubkey(public_key, cx) + get_cache_profile(public_key) } else { profile } @@ -215,13 +200,13 @@ impl Room { let profiles = self .members .iter() - .map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx)) + .map(get_cache_profile) .collect::>(); let mut name = profiles .iter() .take(2) - .map(|profile| profile.shared_name()) + .map(|profile| profile.render_name()) .collect::>() .join(", "); @@ -231,7 +216,7 @@ impl Room { name.into() } else { - self.first_member(cx).shared_name() + self.first_member(cx).render_name() } } @@ -269,7 +254,7 @@ impl Room { if let Some(picture) = self.picture.as_ref() { picture.clone() } else if !self.is_group() { - self.first_member(cx).shared_avatar() + self.first_member(cx).render_avatar() } else { "brand/group.png".into() } @@ -327,22 +312,27 @@ impl Room { /// /// A Task that resolves to Result)>, Error> #[allow(clippy::type_complexity)] - pub fn load_metadata( - &self, - cx: &mut Context, - ) -> Task)>, Error>> { + pub fn load_metadata(&self, cx: &mut Context) -> Task> { let client = get_client(); let public_keys = Arc::clone(&self.members); cx.background_spawn(async move { - let mut output = vec![]; - for public_key in public_keys.iter() { let metadata = client.database().metadata(*public_key).await?; - output.push((*public_key, metadata)); + + profiles() + .write() + .await + .entry(*public_key) + .and_modify(|entry| { + if entry.is_none() { + *entry = metadata.clone(); + } + }) + .or_insert_with(|| metadata); } - Ok(output) + Ok(()) }) } @@ -391,10 +381,6 @@ impl Room { pub fn load_messages(&self, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = Arc::clone(&self.members); - let profiles: Vec = pubkeys - .iter() - .map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx)) - .collect(); let filter = Filter::new() .kind(Kind::PrivateDirectMessage) @@ -442,12 +428,6 @@ impl Room { } } - let author = profiles - .iter() - .find(|profile| profile.public_key() == event.pubkey) - .cloned() - .unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default())); - let pubkey_tokens = tokens .filter_map(|token| match token { Token::Nostr(nip21) => match nip21 { @@ -459,16 +439,12 @@ impl Room { }) .collect::>(); - for pubkey in pubkey_tokens { - mentions.push( - profiles - .iter() - .find(|profile| profile.public_key() == pubkey) - .cloned() - .unwrap_or_else(|| Profile::new(pubkey, Metadata::default())), - ); + for pubkey in pubkey_tokens.iter() { + mentions.push(async_cache_profile(pubkey).await); } + let author = async_cache_profile(&event.pubkey).await; + if let Ok(message) = Message::builder() .id(event.id) .content(content) @@ -498,10 +474,10 @@ impl Room { /// /// Processes the event and emits an Incoming to the UI when complete pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context) { - let author = ChatRegistry::get_global(cx).profile(&event.pubkey, cx); + let author = get_cache_profile(&event.pubkey); // Extract all mentions from content - let mentions = extract_mentions(&event.content, cx); + let mentions = extract_mentions(&event.content); // Extract reply_to if present let mut replies_to = vec![]; @@ -583,7 +559,7 @@ impl Room { event.ensure_id(); // Extract all mentions from content - let mentions = extract_mentions(&event.content, cx); + let mentions = extract_mentions(&event.content); // Extract reply_to if present let mut replies_to = vec![]; @@ -727,13 +703,11 @@ impl Room { } } -pub fn extract_mentions(content: &str, cx: &App) -> Vec { +pub fn extract_mentions(content: &str) -> Vec { let parser = NostrParser::new(); let tokens = parser.parse(content); let mut mentions = vec![]; - let profiles = ChatRegistry::get_global(cx).profiles.read(cx); - let pubkey_tokens = tokens .filter_map(|token| match token { Token::Nostr(nip21) => match nip21 { @@ -746,9 +720,7 @@ pub fn extract_mentions(content: &str, cx: &App) -> Vec { .collect::>(); for pubkey in pubkey_tokens.into_iter() { - if let Some(metadata) = profiles.get(&pubkey).cloned() { - mentions.push(Profile::new(pubkey, metadata.unwrap_or_default())); - } + mentions.push(get_cache_profile(&pubkey)); } mentions diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index 32c9d9a..fbf7f10 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -1,29 +1,29 @@ -use global::constants::IMAGE_SERVICE; +use global::constants::IMAGE_RESIZE_SERVICE; use gpui::SharedString; use nostr_sdk::prelude::*; -pub trait SharedProfile { - fn shared_avatar(&self) -> SharedString; - fn shared_name(&self) -> SharedString; +pub trait RenderProfile { + fn render_avatar(&self) -> SharedString; + fn render_name(&self) -> SharedString; } -impl SharedProfile for Profile { - fn shared_avatar(&self) -> SharedString { +impl RenderProfile for Profile { + fn render_avatar(&self) -> SharedString { self.metadata() .picture .as_ref() .filter(|picture| !picture.is_empty()) .map(|picture| { format!( - "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1&default=npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png", - IMAGE_SERVICE, picture + "{}/?url={}&w=100&h=100&fit=cover&mask=circle&default=https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png&n=-1", + IMAGE_RESIZE_SERVICE, picture ) .into() }) .unwrap_or_else(|| "brand/avatar.png".into()) } - fn shared_name(&self) -> SharedString { + fn render_name(&self) -> SharedString { if let Some(display_name) = self.metadata().display_name.as_ref() { if !display_name.is_empty() { return display_name.into(); diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index add2abe..6e0919b 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -36,5 +36,4 @@ futures.workspace = true oneshot.workspace = true webbrowser = "1.0.4" -rustls = "0.23.23" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 4310d47..fc19896 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use account::Account; use anyhow::Error; +use chats::ChatRegistry; use global::{ - constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH, IMAGE_CACHE_LIMIT}, + constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH}, get_client, }; use gpui::{ - div, image_cache, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, - Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, - Task, Window, + div, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, Context, Entity, + InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, Window, }; use nostr_sdk::prelude::*; use serde::Deserialize; @@ -21,14 +21,13 @@ use ui::{ ContextModal, IconName, Root, Sizable, TitleBar, }; -use crate::{ - lru_cache::cache_provider, - views::{ - chat::{self, Chat}, - compose, login, new_account, onboarding, profile, relays, sidebar, welcome, - }, +use crate::views::{ + chat::{self, Chat}, + compose, login, new_account, onboarding, profile, relays, sidebar, welcome, }; +impl_internal_actions!(dock, [ToggleModal]); + pub fn init(window: &mut Window, cx: &mut App) -> Entity { ChatSpace::new(window, cx) } @@ -63,25 +62,11 @@ pub struct ToggleModal { pub modal: ModalKind, } -impl_internal_actions!(dock, [AddPanel, ToggleModal]); - -#[derive(Clone, PartialEq, Eq, Deserialize)] -pub struct AddPanel { - panel: PanelKind, - position: DockPlacement, -} - -impl AddPanel { - pub fn new(panel: PanelKind, position: DockPlacement) -> Self { - Self { panel, position } - } -} - pub struct ChatSpace { titlebar: bool, dock: Entity, #[allow(unused)] - subscriptions: SmallVec<[Subscription; 2]>, + subscriptions: SmallVec<[Subscription; 3]>, } impl ChatSpace { @@ -97,6 +82,7 @@ impl ChatSpace { cx.new(|cx| { let account = Account::global(cx); + let chats = ChatRegistry::global(cx); let mut subscriptions = smallvec![]; subscriptions.push(cx.observe_in( @@ -111,6 +97,21 @@ impl ChatSpace { }, )); + subscriptions.push(cx.subscribe_in( + &chats, + window, + |this, _state, event, window, cx| { + if let Some(room) = event.0.upgrade() { + this.dock.update(cx, |this, cx| { + let panel = chat::init(room, window, cx); + this.add_panel(panel, DockPlacement::Center, window, cx); + }); + } else { + window.push_notification("Failed to open room. Please retry later.", cx); + } + }, + )); + subscriptions.push(cx.observe_new::(|this, window, cx| { if let Some(window) = window { this.load_messages(window, cx); @@ -204,22 +205,6 @@ impl ChatSpace { }) } - fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context) { - match &action.panel { - PanelKind::Room(id) => { - // User must be logged in to open a room - match chat::init(id, window, cx) { - Ok(panel) => { - self.dock.update(cx, |dock_area, cx| { - dock_area.add_panel(panel, action.position, window, cx); - }); - } - Err(e) => window.push_notification(e.to_string(), cx), - } - } - }; - } - fn on_modal_action( &mut self, action: &ToggleModal, @@ -294,69 +279,62 @@ impl Render for ChatSpace { .relative() .size_full() .child( - image_cache(cache_provider("image-cache", IMAGE_CACHE_LIMIT)) + div() + .flex() + .flex_col() .size_full() - .child( - div() - .flex() - .flex_col() - .size_full() - // Title Bar - .when(self.titlebar, |this| { - this.child( - TitleBar::new() - // Left side - .child(div()) - // Right side + // Title Bar + .when(self.titlebar, |this| { + this.child( + TitleBar::new() + // Left side + .child(div()) + // Right side + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_2() + .px_2() .child( - div() - .flex() - .items_center() - .justify_end() - .gap_2() - .px_2() - .child( - Button::new("appearance") - .xsmall() - .ghost() - .map(|this| { - if cx.theme().mode.is_dark() { - this.icon(IconName::Sun) - } else { - this.icon(IconName::Moon) - } - }) - .on_click(cx.listener( - |_, _, window, cx| { - if cx.theme().mode.is_dark() { - Theme::change( - ThemeMode::Light, - Some(window), - cx, - ); - } else { - Theme::change( - ThemeMode::Dark, - Some(window), - cx, - ); - } - }, - )), - ), + Button::new("appearance") + .xsmall() + .ghost() + .map(|this| { + if cx.theme().mode.is_dark() { + this.icon(IconName::Sun) + } else { + this.icon(IconName::Moon) + } + }) + .on_click(cx.listener(|_, _, window, cx| { + if cx.theme().mode.is_dark() { + Theme::change( + ThemeMode::Light, + Some(window), + cx, + ); + } else { + Theme::change( + ThemeMode::Dark, + Some(window), + cx, + ); + } + })), ), - ) - }) - // Dock - .child(self.dock.clone()), - ), + ), + ) + }) + // Dock + .child(self.dock.clone()), ) // Notifications .child(div().absolute().top_8().children(notification_layer)) // Modals .children(modal_layer) // Actions - .on_action(cx.listener(Self::on_panel_action)) .on_action(cx.listener(Self::on_modal_action)) } } diff --git a/crates/coop/src/lru_cache.rs b/crates/coop/src/lru_cache.rs deleted file mode 100644 index 8de0238..0000000 --- a/crates/coop/src/lru_cache.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use futures::FutureExt; -use gpui::{ - hash, AnyImageCache, App, AppContext, Asset, AssetLogger, Context, ElementId, Entity, - ImageAssetLoader, ImageCache, ImageCacheProvider, Window, -}; - -pub fn cache_provider(id: impl Into, max_items: usize) -> LruCacheProvider { - LruCacheProvider { - id: id.into(), - max_items, - } -} - -pub struct LruCacheProvider { - id: ElementId, - max_items: usize, -} - -impl ImageCacheProvider for LruCacheProvider { - fn provide(&mut self, window: &mut Window, cx: &mut App) -> AnyImageCache { - window - .with_global_id(self.id.clone(), |global_id, window| { - window.with_element_state::, _>(global_id, |lru_cache, _window| { - let mut lru_cache = - lru_cache.unwrap_or_else(|| cx.new(|cx| LruCache::new(self.max_items, cx))); - if lru_cache.read(cx).max_items != self.max_items { - lru_cache = cx.new(|cx| LruCache::new(self.max_items, cx)); - } - (lru_cache.clone(), lru_cache) - }) - }) - .into() - } -} - -struct LruCache { - max_items: usize, - usages: Vec, - cache: HashMap, -} - -impl LruCache { - fn new(max_items: usize, cx: &mut Context) -> Self { - cx.on_release(|simple_cache, cx| { - for (_, mut item) in std::mem::take(&mut simple_cache.cache) { - if let Some(Ok(image)) = item.get() { - cx.drop_image(image, None); - } - } - }) - .detach(); - - Self { - max_items, - usages: Vec::with_capacity(max_items), - cache: HashMap::with_capacity(max_items), - } - } -} - -impl ImageCache for LruCache { - fn load( - &mut self, - resource: &gpui::Resource, - window: &mut Window, - cx: &mut App, - ) -> Option, gpui::ImageCacheError>> { - assert_eq!(self.usages.len(), self.cache.len()); - assert!(self.cache.len() <= self.max_items); - - let hash = hash(resource); - - if let Some(item) = self.cache.get_mut(&hash) { - let current_ix = self - .usages - .iter() - .position(|item| *item == hash) - .expect("cache and usages must stay in sync"); - self.usages.remove(current_ix); - self.usages.insert(0, hash); - - return item.get(); - } - - let fut = AssetLogger::::load(resource.clone(), cx); - let task = cx.background_executor().spawn(fut).shared(); - if self.usages.len() == self.max_items { - let oldest = self.usages.pop().unwrap(); - let mut image = self - .cache - .remove(&oldest) - .expect("cache and usages must be in sync"); - if let Some(Ok(image)) = image.get() { - cx.drop_image(image, Some(window)); - } - } - self.cache - .insert(hash, gpui::ImageCacheItem::Loading(task.clone())); - self.usages.insert(0, hash); - - let entity = window.current_view(); - window - .spawn(cx, { - async move |cx| { - _ = task.await; - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); - } - }) - .detach(); - - None - } -} diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index f72d1e5..3c297e1 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -10,7 +10,7 @@ use global::{ ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS, }, - get_client, + get_client, init_global_state, profiles, }; use gpui::{ actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, @@ -21,9 +21,9 @@ use gpui::{point, SharedString, TitlebarOptions}; #[cfg(target_os = "linux")] use gpui::{WindowBackgroundAppearance, WindowDecorations}; use nostr_sdk::{ - nips::nip01::Coordinate, pool::prelude::ReqExitPolicy, Client, Event, EventBuilder, EventId, - Filter, JsonUtil, Keys, Kind, Metadata, PublicKey, RelayMessage, RelayPoolNotification, - SubscribeAutoCloseOptions, SubscriptionId, Tag, + async_utility::task::spawn, nips::nip01::Coordinate, pool::prelude::ReqExitPolicy, Client, + Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Metadata, PublicKey, RelayMessage, + RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, Tag, }; use smol::Timer; use std::{collections::HashSet, mem, sync::Arc, time::Duration}; @@ -32,7 +32,6 @@ use ui::Root; pub(crate) mod asset; pub(crate) mod chatspace; -pub(crate) mod lru_cache; pub(crate) mod views; actions!(coop, [Quit]); @@ -41,8 +40,6 @@ actions!(coop, [Quit]); enum Signal { /// Receive event Event(Event), - /// Receive metadata - Metadata(Box<(PublicKey, Option)>), /// Receive eose Eose, /// Receive app updates @@ -50,205 +47,207 @@ enum Signal { } fn main() { - // Enable logging + // Initialize logging tracing_subscriber::fmt::init(); - // Fix crash on startup - _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + // Initialize global state + init_global_state(); let (event_tx, event_rx) = smol::channel::bounded::(2048); let (batch_tx, batch_rx) = smol::channel::bounded::>(500); - // Initialize nostr client let client = get_client(); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + // Spawn a task to establish relay connections + // NOTE: Use `async_utility` instead of `smol-rs` + spawn(async move { + for relay in BOOTSTRAP_RELAYS.into_iter() { + if let Err(e) = client.add_relay(relay).await { + log::error!("Failed to add relay {}: {}", relay, e); + } + } + + for relay in SEARCH_RELAYS.into_iter() { + if let Err(e) = client.add_relay(relay).await { + log::error!("Failed to add relay {}: {}", relay, e); + } + } + + // Establish connection to bootstrap relays + client.connect().await; + + log::info!("Connected to bootstrap relays"); + log::info!("Subscribing to app updates..."); + + let coordinate = Coordinate { + kind: Kind::Custom(32267), + public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"), + identifier: APP_ID.into(), + }; + + let filter = Filter::new() + .kind(Kind::ReleaseArtifactSet) + .coordinate(&coordinate) + .limit(1); + + if let Err(e) = client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await + { + log::error!("Failed to subscribe for app updates: {}", e); + } + }); + + // Spawn a task to handle metadata batching + // NOTE: Use `async_utility` instead of `smol-rs` + spawn(async move { + let mut batch: HashSet = HashSet::new(); + + loop { + let mut timeout = + Box::pin(Timer::after(Duration::from_millis(METADATA_BATCH_TIMEOUT)).fuse()); + + select! { + pubkeys = batch_rx.recv().fuse() => { + match pubkeys { + Ok(keys) => { + batch.extend(keys); + if batch.len() >= METADATA_BATCH_LIMIT { + sync_metadata(mem::take(&mut batch), client, opts).await; + } + } + Err(_) => break, + } + } + _ = timeout => { + if !batch.is_empty() { + sync_metadata(mem::take(&mut batch), client, opts).await; + } + } + } + } + }); + + // Spawn a task to handle relay pool notification + // NOTE: Use `async_utility` instead of `smol-rs` + spawn(async move { + let keys = Keys::generate(); + let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); + let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); + let mut notifications = client.notifications(); + + let mut processed_events: HashSet = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + if let RelayPoolNotification::Message { message, .. } = notification { + match message { + RelayMessage::Event { + event, + subscription_id, + } => { + if processed_events.contains(&event.id) { + continue; + } + processed_events.insert(event.id); + + match event.kind { + Kind::GiftWrap => { + let event = match get_unwrapped(event.id).await { + Ok(event) => event, + Err(_) => match client.unwrap_gift_wrap(&event).await { + Ok(unwrap) => match unwrap.rumor.sign_with_keys(&keys) { + Ok(unwrapped) => { + set_unwrapped(event.id, &unwrapped, &keys) + .await + .ok(); + unwrapped + } + Err(_) => continue, + }, + Err(_) => continue, + }, + }; + + let mut pubkeys = vec![]; + pubkeys.extend(event.tags.public_keys()); + pubkeys.push(event.pubkey); + + // Send all pubkeys to the batch to sync metadata + batch_tx.send(pubkeys).await.ok(); + + // Save the event to the database, use for query directly. + client.database().save_event(&event).await.ok(); + + // Send this event to the GPUI + if new_id == *subscription_id { + event_tx.send(Signal::Event(event)).await.ok(); + } + } + Kind::Metadata => { + let metadata = Metadata::from_json(&event.content).ok(); + + profiles() + .write() + .await + .entry(event.pubkey) + .and_modify(|entry| { + if entry.is_none() { + *entry = metadata.clone(); + } + }) + .or_insert_with(|| metadata); + } + Kind::ContactList => { + if let Ok(signer) = client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + if public_key == event.pubkey { + let pubkeys = event + .tags + .public_keys() + .copied() + .collect::>(); + + batch_tx.send(pubkeys).await.ok(); + } + } + } + } + Kind::ReleaseArtifactSet => { + let filter = Filter::new() + .ids(event.tags.event_ids().copied()) + .kind(Kind::FileMetadata); + + if let Err(e) = client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await + { + log::error!("Failed to subscribe for file metadata: {}", e); + } else { + event_tx + .send(Signal::AppUpdates(event.into_owned())) + .await + .ok(); + } + } + _ => {} + } + } + RelayMessage::EndOfStoredEvents(subscription_id) => { + if all_id == *subscription_id { + event_tx.send(Signal::Eose).await.ok(); + } + } + _ => {} + } + } + } + }); + // Initialize application let app = Application::new() .with_assets(Assets) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())); - // Connect to default relays - app.background_executor() - .spawn(async move { - for relay in BOOTSTRAP_RELAYS.into_iter() { - if let Err(e) = client.add_relay(relay).await { - log::error!("Failed to add relay {}: {}", relay, e); - } - } - - for relay in SEARCH_RELAYS.into_iter() { - if let Err(e) = client.add_relay(relay).await { - log::error!("Failed to add relay {}: {}", relay, e); - } - } - - // Establish connection to bootstrap relays - client.connect().await; - - log::info!("Connected to bootstrap relays"); - log::info!("Subscribing to app updates..."); - - let coordinate = Coordinate { - kind: Kind::Custom(32267), - public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"), - identifier: APP_ID.into(), - }; - - let filter = Filter::new() - .kind(Kind::ReleaseArtifactSet) - .coordinate(&coordinate) - .limit(1); - - if let Err(e) = client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await - { - log::error!("Failed to subscribe for app updates: {}", e); - } - }) - .detach(); - - // Handle batch metadata - app.background_executor() - .spawn(async move { - let mut batch: HashSet = HashSet::new(); - - loop { - let mut timeout = - Box::pin(Timer::after(Duration::from_millis(METADATA_BATCH_TIMEOUT)).fuse()); - - select! { - pubkeys = batch_rx.recv().fuse() => { - match pubkeys { - Ok(keys) => { - batch.extend(keys); - if batch.len() >= METADATA_BATCH_LIMIT { - sync_metadata(mem::take(&mut batch), client, opts).await; - } - } - Err(_) => break, - } - } - _ = timeout => { - if !batch.is_empty() { - sync_metadata(mem::take(&mut batch), client, opts).await; - } - } - } - } - }) - .detach(); - - // Handle notifications - app.background_executor() - .spawn(async move { - let keys = Keys::generate(); - let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); - let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); - let mut notifications = client.notifications(); - - let mut processed_events: HashSet = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Message { message, .. } = notification { - match message { - RelayMessage::Event { - event, - subscription_id, - } => { - if processed_events.contains(&event.id) { continue } - processed_events.insert(event.id); - - match event.kind { - Kind::GiftWrap => { - let event = match get_unwrapped(event.id).await { - Ok(event) => event, - Err(_) => match client.unwrap_gift_wrap(&event).await { - Ok(unwrap) => { - match unwrap.rumor.sign_with_keys(&keys) { - Ok(unwrapped) => { - set_unwrapped(event.id, &unwrapped, &keys) - .await - .ok(); - unwrapped - } - Err(_) => continue, - } - } - Err(_) => continue, - }, - }; - - let mut pubkeys = vec![]; - pubkeys.extend(event.tags.public_keys()); - pubkeys.push(event.pubkey); - - // Send all pubkeys to the batch to sync metadata - batch_tx.send(pubkeys).await.ok(); - - // Save the event to the database, use for query directly. - client.database().save_event(&event).await.ok(); - - // Send this event to the GPUI - if new_id == *subscription_id { - event_tx.send(Signal::Event(event)).await.ok(); - } - } - Kind::Metadata => { - let metadata = Metadata::from_json(&event.content).ok(); - - event_tx - .send(Signal::Metadata(Box::new((event.pubkey, metadata)))) - .await - .ok(); - } - Kind::ContactList => { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - if public_key == event.pubkey { - let pubkeys = event - .tags - .public_keys() - .copied() - .collect::>(); - - batch_tx.send(pubkeys).await.ok(); - } - } - } - } - Kind::ReleaseArtifactSet => { - let filter = Filter::new() - .ids(event.tags.event_ids().copied()) - .kind(Kind::FileMetadata); - - if let Err(e) = client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await - { - log::error!("Failed to subscribe for file metadata: {}", e); - } else { - event_tx - .send(Signal::AppUpdates(event.into_owned())) - .await - .ok(); - } - } - _ => {} - } - } - RelayMessage::EndOfStoredEvents(subscription_id) => { - if all_id == *subscription_id { - event_tx.send(Signal::Eose).await.ok(); - } - } - _ => {} - } - } - } - }) - .detach(); - app.run(move |cx| { // Bring the app to the foreground cx.activate(true); @@ -328,11 +327,6 @@ fn main() { this.event_to_message(event, window, cx); }); } - Signal::Metadata(data) => { - chats.update(cx, |this, cx| { - this.add_profile(data.0, data.1, cx); - }); - } Signal::AppUpdates(event) => { auto_updater.update(cx, |this, cx| { this.update(event, cx); diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index 98f174c..f0873e4 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -1,20 +1,18 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; -use anyhow::{anyhow, Error}; use async_utility::task::spawn; use chats::{ message::Message, room::{Room, SendError}, - ChatRegistry, }; -use common::{nip96_upload, profile::SharedProfile}; -use global::{constants::IMAGE_SERVICE, get_client}; +use common::{nip96_upload, profile::RenderProfile}; +use global::get_client; use gpui::{ - div, img, impl_internal_actions, list, prelude::FluentBuilder, px, red, relative, svg, white, - AnyElement, App, AppContext, Context, Div, Element, Empty, Entity, EventEmitter, Flatten, - FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, - ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, - StyledImage, Subscription, Window, + div, img, impl_internal_actions, list, prelude::FluentBuilder, px, red, relative, rems, svg, + white, AnyElement, App, AppContext, Context, Div, Element, Empty, Entity, EventEmitter, + Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, + ObjectFit, ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, + StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -23,6 +21,7 @@ use smallvec::{smallvec, SmallVec}; use smol::fs; use theme::ActiveTheme; use ui::{ + avatar::Avatar, button::{Button, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, emoji_picker::EmojiPicker, @@ -40,12 +39,8 @@ pub struct ChangeSubject(pub String); impl_internal_actions!(chat, [ChangeSubject]); -pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result>, Error> { - if let Some(room) = ChatRegistry::global(cx).read(cx).room(id, cx) { - Ok(Arc::new(Chat::new(id, room, window, cx))) - } else { - Err(anyhow!("Chat Room not found.")) - } +pub fn init(room: Entity, window: &mut Window, cx: &mut App) -> Arc> { + Arc::new(Chat::new(room, window, cx)) } pub struct Chat { @@ -63,12 +58,13 @@ pub struct Chat { // Media Attachment attaches: Entity>>, uploading: bool, + image_cache: Entity, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 2]>, } impl Chat { - pub fn new(id: &u64, room: Entity, window: &mut Window, cx: &mut App) -> Entity { + pub fn new(room: Entity, window: &mut Window, cx: &mut App) -> Entity { let attaches = cx.new(|_| None); let replies_to = cx.new(|_| None); @@ -144,9 +140,10 @@ impl Chat { }); Self { + image_cache: RetainAllImageCache::new(cx), focus_handle: cx.focus_handle(), uploading: false, - id: id.to_string().into(), + id: room.read(cx).id.to_string().into(), text_data: HashMap::new(), room, messages, @@ -428,18 +425,15 @@ impl Chat { let path: SharedString = url.to_string().into(); div() - .id(path.clone()) + .id("") .relative() .w_16() .child( - img(format!( - "{}/?url={}&w=128&h=128&fit=cover&n=-1", - IMAGE_SERVICE, path - )) - .size_16() - .shadow_lg() - .rounded(cx.theme().radius) - .object_fit(ObjectFit::ScaleDown), + img(path.clone()) + .size_16() + .shadow_lg() + .rounded(cx.theme().radius) + .object_fit(ObjectFit::ScaleDown), ) .child( div() @@ -481,7 +475,7 @@ impl Chat { .child( div() .text_color(cx.theme().text_accent) - .child(message.author.as_ref().unwrap().shared_name()), + .child(message.author.as_ref().unwrap().render_name()), ), ) .child( @@ -565,7 +559,7 @@ impl Chat { div() .flex() .gap_3() - .child(img(author.shared_avatar()).size_8().flex_shrink_0()) + .child(Avatar::new(author.render_avatar()).size(rems(2.))) .child( div() .flex_1() @@ -583,7 +577,7 @@ impl Chat { div() .font_semibold() .text_color(cx.theme().text) - .child(author.shared_name()), + .child(author.render_name()), ) .child( div() @@ -621,7 +615,7 @@ impl Chat { .author .as_ref() .unwrap() - .shared_name(), + .render_name(), ), ) .child( @@ -707,7 +701,7 @@ impl Panel for Chat { .flex() .items_center() .gap_1p5() - .child(img(url).size_5().flex_shrink_0()) + .child(Avatar::new(url).size(rems(1.25))) .child(label) .into_any() }) @@ -753,6 +747,7 @@ impl Focusable for Chat { impl Render for Chat { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() + .image_cache(self.image_cache.clone()) .size_full() .child(list(self.list_state.clone()).flex_1()) .child( @@ -845,7 +840,7 @@ fn message_errors(errors: Vec, cx: &App) -> Div { .gap_1() .text_color(cx.theme().text_muted) .child("Send to:") - .child(error.profile.shared_name()), + .child(error.profile.render_name()), ) .child(error.message) })) diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 1d09e7d..2e64630 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -4,8 +4,8 @@ use std::{ }; use anyhow::Error; -use chats::ChatRegistry; -use common::profile::SharedProfile; +use chats::{room::Room, ChatRegistry}; +use common::profile::RenderProfile; use global::get_client; use gpui::{ div, img, impl_internal_actions, prelude::FluentBuilder, px, red, relative, uniform_list, App, @@ -20,13 +20,10 @@ use smol::Timer; use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, - dock_area::dock::DockPlacement, input::{InputEvent, InputState, TextInput}, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, }; -use crate::chatspace::{AddPanel, PanelKind}; - pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Compose::new(window, cx)) } @@ -153,14 +150,13 @@ impl Compose { cx.spawn_in(window, async move |this, cx| match event.await { Ok(event) => { cx.update(|window, cx| { - ChatRegistry::global(cx).update(cx, |chats, cx| { - let id = chats.event_to_room(&event, window, cx); - window.close_modal(cx); - window.dispatch_action( - Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)), - cx, - ); + let room = cx.new(|_| Room::new(&event).kind(chats::room::RoomKind::Ongoing)); + + ChatRegistry::global(cx).update(cx, |this, cx| { + this.push_room(room, cx); }); + + window.close_modal(cx); }) .ok(); } @@ -338,7 +334,7 @@ impl Render for Compose { .flex() .items_center() .gap_1() - .child(div().pb_0p5().text_sm().font_semibold().child("Subject:")) + .child(div().text_sm().font_semibold().child("Subject:")) .child(TextInput::new(&self.title_input).small().appearance(false)), ), ) @@ -415,11 +411,11 @@ impl Render for Compose { .gap_3() .text_sm() .child( - img(item.shared_avatar()) + img(item.render_avatar()) .size_7() .flex_shrink_0(), ) - .child(item.shared_name()), + .child(item.render_name()), ) .when(is_select, |this| { this.child( diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index f575078..fc353fa 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use account::Account; use async_utility::task::spawn; use common::nip96_upload; -use global::{constants::IMAGE_SERVICE, get_client}; +use global::get_client; use gpui::{ div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, @@ -231,16 +231,18 @@ impl Render for NewAccount { .gap_2() .map(|this| { if self.avatar_input.read(cx).value().is_empty() { - this.child(img("brand/avatar.png").size_10().flex_shrink_0()) + this.child( + img("brand/avatar.png") + .rounded_full() + .size_10() + .flex_shrink_0(), + ) } else { this.child( - img(format!( - "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", - IMAGE_SERVICE, - self.avatar_input.read(cx).value() - )) - .size_10() - .flex_shrink_0(), + img(self.avatar_input.read(cx).value().clone()) + .rounded_full() + .size_10() + .flex_shrink_0(), ) } }) diff --git a/crates/coop/src/views/profile.rs b/crates/coop/src/views/profile.rs index 29bbedb..71d28cd 100644 --- a/crates/coop/src/views/profile.rs +++ b/crates/coop/src/views/profile.rs @@ -1,6 +1,6 @@ use async_utility::task::spawn; use common::nip96_upload; -use global::{constants::IMAGE_SERVICE, get_client}; +use global::get_client; use gpui::{ div, img, prelude::FluentBuilder, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement, PathPromptOptions, Render, Styled, Task, Window, @@ -243,15 +243,18 @@ impl Render for Profile { .map(|this| { let picture = self.avatar_input.read(cx).value(); if picture.is_empty() { - this.child(img("brand/avatar.png").size_10().flex_shrink_0()) + this.child( + img("brand/avatar.png") + .rounded_full() + .size_10() + .flex_shrink_0(), + ) } else { this.child( - img(format!( - "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", - IMAGE_SERVICE, picture - )) - .size_10() - .flex_shrink_0(), + img(picture.clone()) + .rounded_full() + .size_10() + .flex_shrink_0(), ) } }) diff --git a/crates/coop/src/views/sidebar/element.rs b/crates/coop/src/views/sidebar/element.rs new file mode 100644 index 0000000..8cda3dd --- /dev/null +++ b/crates/coop/src/views/sidebar/element.rs @@ -0,0 +1,116 @@ +use std::rc::Rc; + +use gpui::{ + div, img, prelude::FluentBuilder, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, + ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, +}; +use theme::ActiveTheme; +use ui::{avatar::Avatar, StyledExt}; + +#[derive(IntoElement)] +pub struct DisplayRoom { + ix: usize, + base: Div, + img: Option, + label: Option, + description: Option, + #[allow(clippy::type_complexity)] + handler: Rc, +} + +impl DisplayRoom { + pub fn new(ix: usize) -> Self { + Self { + ix, + base: div().h_9().w_full().px_1p5(), + img: None, + label: None, + description: None, + handler: Rc::new(|_, _, _| {}), + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn img(mut self, img: impl Into) -> Self { + self.img = Some(img.into()); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.handler = Rc::new(handler); + self + } +} + +impl RenderOnce for DisplayRoom { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let handler = self.handler.clone(); + + self.base + .id(self.ix) + .flex() + .items_center() + .gap_2() + .text_sm() + .rounded(cx.theme().radius) + .child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .map(|this| { + if let Some(path) = self.img { + this.child(Avatar::new(path).size(rems(1.5))) + } else { + this.child( + img("brand/avatar.png") + .rounded_full() + .size_6() + .into_any_element(), + ) + } + }), + ) + .child( + div() + .flex_1() + .flex() + .items_center() + .justify_between() + .when_some(self.label, |this, label| { + this.child( + div() + .flex_1() + .line_clamp(1) + .text_ellipsis() + .font_medium() + .child(label), + ) + }) + .when_some(self.description, |this, description| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(description), + ) + }), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(move |ev, window, cx| handler(ev, window, cx)) + } +} diff --git a/crates/coop/src/views/sidebar/folder.rs b/crates/coop/src/views/sidebar/folder.rs deleted file mode 100644 index 5b41de2..0000000 --- a/crates/coop/src/views/sidebar/folder.rs +++ /dev/null @@ -1,329 +0,0 @@ -use std::rc::Rc; - -use gpui::{ - div, percentage, prelude::FluentBuilder, App, ClickEvent, Div, Img, InteractiveElement, - IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled, - Window, -}; -use theme::ActiveTheme; -use ui::{tooltip::Tooltip, Collapsible, Icon, IconName, Sizable, StyledExt}; - -type Handler = Rc; - -#[derive(IntoElement)] -pub struct Parent { - base: Div, - icon: Option, - tooltip: Option, - label: SharedString, - items: Vec, - collapsed: bool, - handler: Handler, -} - -impl Parent { - pub fn new(label: impl Into) -> Self { - Self { - base: div().flex().flex_col().gap_2(), - label: label.into(), - icon: None, - tooltip: None, - items: Vec::new(), - collapsed: false, - handler: Rc::new(|_, _, _| {}), - } - } - - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - pub fn tooltip(mut self, tooltip: impl Into) -> Self { - self.tooltip = Some(tooltip.into()); - self - } - - pub fn collapsed(mut self, collapsed: bool) -> Self { - self.collapsed = collapsed; - self - } - - pub fn child(mut self, child: impl Into) -> Self { - self.items.push(child.into()); - self - } - - #[allow(dead_code)] - pub fn children(mut self, children: impl IntoIterator>) -> Self { - self.items = children.into_iter().map(Into::into).collect(); - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.handler = Rc::new(handler); - self - } -} - -impl Collapsible for Parent { - fn is_collapsed(&self) -> bool { - self.collapsed - } - - fn collapsed(mut self, collapsed: bool) -> Self { - self.collapsed = collapsed; - self - } -} - -impl RenderOnce for Parent { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let handler = self.handler.clone(); - - self.base - .child( - div() - .id(self.label.clone()) - .flex() - .items_center() - .gap_2() - .px_2() - .h_8() - .rounded(cx.theme().radius) - .text_sm() - .text_color(cx.theme().text_muted) - .font_medium() - .child( - Icon::new(IconName::CaretDown) - .xsmall() - .when(self.collapsed, |this| this.rotate(percentage(270. / 360.))), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .when_some(self.icon, |this, icon| this.child(icon.small())) - .child(self.label.clone()), - ) - .when_some(self.tooltip.clone(), |this, tooltip| { - this.tooltip(move |window, cx| { - Tooltip::new(tooltip.clone(), window, cx).into() - }) - }) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .on_click(move |ev, window, cx| handler(ev, window, cx)), - ) - .when(!self.collapsed, |this| { - this.child(div().flex().flex_col().gap_2().pl_3().children(self.items)) - }) - } -} - -#[derive(IntoElement)] -pub struct Folder { - base: Div, - icon: Option, - tooltip: Option, - label: SharedString, - items: Vec, - collapsed: bool, - handler: Handler, -} - -impl Folder { - pub fn new(label: impl Into) -> Self { - Self { - base: div().flex().flex_col().gap_2(), - label: label.into(), - icon: None, - tooltip: None, - items: Vec::new(), - collapsed: false, - handler: Rc::new(|_, _, _| {}), - } - } - - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - pub fn tooltip(mut self, tooltip: impl Into) -> Self { - self.tooltip = Some(tooltip.into()); - self - } - - pub fn collapsed(mut self, collapsed: bool) -> Self { - self.collapsed = collapsed; - self - } - - pub fn children(mut self, children: impl IntoIterator>) -> Self { - self.items = children.into_iter().map(Into::into).collect(); - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.handler = Rc::new(handler); - self - } -} - -impl Collapsible for Folder { - fn is_collapsed(&self) -> bool { - self.collapsed - } - - fn collapsed(mut self, collapsed: bool) -> Self { - self.collapsed = collapsed; - self - } -} - -impl RenderOnce for Folder { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let handler = self.handler.clone(); - - self.base - .child( - div() - .id(self.label.clone()) - .flex() - .items_center() - .gap_2() - .px_2() - .h_8() - .rounded(cx.theme().radius) - .text_sm() - .text_color(cx.theme().text_muted) - .font_medium() - .child( - Icon::new(IconName::CaretDown) - .xsmall() - .when(self.collapsed, |this| this.rotate(percentage(270. / 360.))), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .when_some(self.icon, |this, icon| this.child(icon.small())) - .child(self.label.clone()), - ) - .when_some(self.tooltip.clone(), |this, tooltip| { - this.tooltip(move |window, cx| { - Tooltip::new(tooltip.clone(), window, cx).into() - }) - }) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .on_click(move |ev, window, cx| handler(ev, window, cx)), - ) - .when(!self.collapsed, |this| { - this.child(div().flex().flex_col().gap_1().pl_6().children(self.items)) - }) - } -} - -#[derive(IntoElement)] -pub struct FolderItem { - ix: usize, - base: Div, - img: Option, - label: Option, - description: Option, - handler: Handler, -} - -impl FolderItem { - pub fn new(ix: usize) -> Self { - Self { - ix, - base: div().h_8().w_full().px_2(), - img: None, - label: None, - description: None, - handler: Rc::new(|_, _, _| {}), - } - } - - pub fn label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } - - pub fn description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - pub fn img(mut self, img: Img) -> Self { - self.img = Some(img); - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.handler = Rc::new(handler); - self - } -} - -impl RenderOnce for FolderItem { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let handler = self.handler.clone(); - - self.base - .id(self.ix) - .flex() - .items_center() - .gap_2() - .text_sm() - .rounded(cx.theme().radius) - .child(div().size_6().flex_none().map(|this| { - if let Some(img) = self.img { - this.child(img.size_6().flex_none()) - } else { - this.child( - div() - .size_6() - .flex_none() - .flex() - .justify_center() - .items_center() - .rounded_full() - .bg(cx.theme().element_background), - ) - } - })) - .child( - div() - .flex_1() - .flex() - .items_center() - .justify_between() - .when_some(self.label, |this, label| { - this.child(div().truncate().text_ellipsis().font_medium().child(label)) - }) - .when_some(self.description, |this, description| { - this.child( - div() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(description), - ) - }), - ) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .on_click(move |ev, window, cx| handler(ev, window, cx)) - } -} diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 0f16ec9..edeec6c 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{BTreeSet, HashSet}, - time::Duration, -}; +use std::{collections::BTreeSet, ops::Range, time::Duration}; use account::Account; use async_utility::task::spawn; @@ -10,33 +7,30 @@ use chats::{ ChatRegistry, }; -use common::{debounced_delay::DebouncedDelay, profile::SharedProfile}; -use folder::{Folder, FolderItem, Parent}; +use common::{debounced_delay::DebouncedDelay, profile::RenderProfile}; +use element::DisplayRoom; use global::{constants::SEARCH_RELAYS, get_client}; use gpui::{ - div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, ScrollHandle, - SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, + div, prelude::FluentBuilder, rems, uniform_list, AnyElement, App, AppContext, Context, Entity, + EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, + SharedString, Styled, Subscription, Task, Window, }; use itertools::Itertools; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use theme::ActiveTheme; use ui::{ - button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants}, - dock_area::{ - dock::DockPlacement, - panel::{Panel, PanelEvent}, - }, + avatar::Avatar, + button::{Button, ButtonRounded, ButtonVariants}, + dock_area::panel::{Panel, PanelEvent}, input::{InputEvent, InputState, TextInput}, popup_menu::{PopupMenu, PopupMenuExt}, skeleton::Skeleton, - IconName, Sizable, StyledExt, + ContextModal, IconName, Selectable, Sizable, StyledExt, }; -use crate::chatspace::{AddPanel, ModalKind, PanelKind, ToggleModal}; +use crate::chatspace::{ModalKind, ToggleModal}; -mod folder; +mod element; const FIND_DELAY: u64 = 600; const FIND_LIMIT: usize = 10; @@ -45,18 +39,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { Sidebar::new(window, cx) } -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub enum Item { - Ongoing, - Incoming, -} - -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub enum SubItem { - Trusted, - Unknown, -} - pub struct Sidebar { name: SharedString, // Search @@ -65,13 +47,12 @@ pub struct Sidebar { finding: bool, local_result: Entity>>>, global_result: Entity>>>, - // Layout - folders: bool, - active_items: HashSet, - active_subitems: HashSet, + // Rooms + active_filter: Entity, + trusted_only: bool, // GPUI focus_handle: FocusHandle, - scroll_handle: ScrollHandle, + image_cache: Entity, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 1]>, } @@ -82,16 +63,7 @@ impl Sidebar { } fn view(window: &mut Window, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - let scroll_handle = ScrollHandle::default(); - - let mut active_items = HashSet::with_capacity(2); - active_items.insert(Item::Ongoing); - - let mut active_subitems = HashSet::with_capacity(2); - active_subitems.insert(SubItem::Trusted); - active_subitems.insert(SubItem::Unknown); - + let active_filter = cx.new(|_| RoomKind::Ongoing); let local_result = cx.new(|_| None); let global_result = cx.new(|_| None); @@ -100,8 +72,10 @@ impl Sidebar { let mut subscriptions = smallvec![]; - subscriptions.push( - cx.subscribe_in(&find_input, window, |this, _, event, _, cx| { + subscriptions.push(cx.subscribe_in( + &find_input, + window, + |this, _state, event, _window, cx| { match event { InputEvent::PressEnter { .. } => this.search(cx), InputEvent::Change(text) => { @@ -119,44 +93,24 @@ impl Sidebar { } _ => {} } - }), - ); + }, + )); Self { name: "Chat Sidebar".into(), - folders: false, + focus_handle: cx.focus_handle(), + image_cache: RetainAllImageCache::new(cx), find_debouncer: DebouncedDelay::new(), finding: false, + trusted_only: false, + active_filter, find_input, local_result, global_result, - active_items, - active_subitems, - focus_handle, - scroll_handle, subscriptions, } } - fn toggle_item(&mut self, item: Item, cx: &mut Context) { - if !self.active_items.remove(&item) { - self.active_items.insert(item); - } - cx.notify(); - } - - fn toggle_subitem(&mut self, subitem: SubItem, cx: &mut Context) { - if !self.active_subitems.remove(&subitem) { - self.active_subitems.insert(subitem); - } - cx.notify(); - } - - fn toggle_folder(&mut self, cx: &mut Context) { - self.folders = !self.folders; - cx.notify(); - } - fn debounced_search(&self, cx: &mut Context) -> Task<()> { cx.spawn(async move |this, cx| { this.update(cx, |this, cx| { @@ -189,7 +143,7 @@ impl Sidebar { spawn(async move { let client = get_client(); - let signer = client.signer().await.unwrap(); + let signer = client.signer().await.expect("signer is required"); for event in events.into_iter() { let metadata = Metadata::from_json(event.content).unwrap_or_default(); @@ -227,6 +181,14 @@ impl Sidebar { return; } + if query.starts_with("nevent1") + || query.starts_with("naddr") + || query.starts_with("nsec1") + || query.starts_with("note1") + { + return; + } + // Return if search is in progress if self.finding { return; @@ -299,83 +261,151 @@ impl Sidebar { }); } - fn push_room(&mut self, id: u64, window: &mut Window, cx: &mut Context) { - if let Some(result) = self.global_result.read(cx).as_ref() { - if let Some(room) = result.iter().find(|this| this.read(cx).id == id).cloned() { - ChatRegistry::global(cx).update(cx, |this, cx| { - this.push_room(room, cx); - }); - window.dispatch_action( - Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)), - cx, - ); - self.clear_search_results(cx); - } - } + fn filter(&self, kind: &RoomKind, cx: &Context) -> bool { + self.active_filter.read(cx) == kind + } + + fn set_filter(&mut self, kind: RoomKind, cx: &mut Context) { + self.active_filter.update(cx, |this, cx| { + *this = kind; + cx.notify(); + }) + } + + fn set_trusted_only(&mut self, cx: &mut Context) { + self.trusted_only = !self.trusted_only; + cx.notify(); + } + + fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context) { + let room = if let Some(room) = ChatRegistry::get_global(cx).room(&id, cx) { + room + } else { + self.clear_search_results(cx); + + let Some(result) = self.global_result.read(cx).as_ref() else { + window.push_notification("Failed to open room. Please try again later.", cx); + return; + }; + + let Some(room) = result.iter().find(|this| this.read(cx).id == id).cloned() else { + window.push_notification("Failed to open room. Please try again later.", cx); + return; + }; + + room + }; + + ChatRegistry::global(cx).update(cx, |this, cx| { + this.push_room(room, cx); + }); + } + + fn render_account(&self, profile: &Profile, cx: &Context) -> impl IntoElement { + div() + .px_3() + .h_8() + .flex_none() + .flex() + .justify_between() + .items_center() + .child( + div() + .flex() + .items_center() + .gap_2() + .text_sm() + .font_semibold() + .child(Avatar::new(profile.render_avatar()).size(rems(1.75))) + .child(profile.render_name()), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("user") + .icon(IconName::Ellipsis) + .small() + .ghost() + .rounded(ButtonRounded::Full) + .popup_menu(|this, _window, _cx| { + this.menu( + "Profile", + Box::new(ToggleModal { + modal: ModalKind::Profile, + }), + ) + .menu( + "Relays", + Box::new(ToggleModal { + modal: ModalKind::Relay, + }), + ) + }), + ) + .child( + Button::new("compose") + .icon(IconName::PlusFill) + .tooltip("Create DM or Group DM") + .small() + .primary() + .rounded(ButtonRounded::Full) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(ToggleModal { + modal: ModalKind::Compose, + }), + cx, + ); + })), + ), + ) } fn render_skeleton(&self, total: i32) -> impl IntoIterator { (0..total).map(|_| { div() - .h_8() + .h_9() .w_full() - .px_2() + .px_1p5() .flex() .items_center() .gap_2() .child(Skeleton::new().flex_shrink_0().size_6().rounded_full()) - .child(Skeleton::new().w_20().h_3().rounded_sm()) + .child(Skeleton::new().w_40().h_4().rounded_sm()) }) } - fn render_global_items(rooms: &[Entity], cx: &Context) -> Vec { - let mut items = Vec::with_capacity(rooms.len()); + fn render_uniform_item( + &self, + rooms: &[Entity], + range: Range, + cx: &Context, + ) -> Vec { + let mut items = Vec::with_capacity(range.end - range.start); - for room in rooms.iter() { - let this = room.read(cx); - let id = this.id; - let label = this.display_name(cx); - let img = this.display_image(cx).map(img); + for ix in range { + if let Some(room) = rooms.get(ix) { + let this = room.read(cx); + let id = this.id; + let ago = this.ago(); + let label = this.display_name(cx); + let img = this.display_image(cx); - let item = FolderItem::new(id as usize) - .label(label) - .img(img) - .on_click({ - cx.listener(move |this, _, window, cx| { - this.push_room(id, window, cx); - }) + let handler = cx.listener(move |this, _, window, cx| { + this.open_room(id, window, cx); }); - items.push(item); - } - - items - } - - fn render_items(rooms: &[Entity], cx: &Context) -> Vec { - let mut items = Vec::with_capacity(rooms.len()); - - for room in rooms.iter() { - let room = room.read(cx); - let id = room.id; - let ago = room.ago(); - let label = room.display_name(cx); - let img = room.display_image(cx).map(img); - - let item = FolderItem::new(id as usize) - .label(label) - .description(ago) - .img(img) - .on_click({ - cx.listener(move |_, _, window, cx| { - window.dispatch_action( - Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)), - cx, - ); - }) - }); - - items.push(item); + items.push( + DisplayRoom::new(ix) + .img(img) + .label(label) + .description(ago) + .on_click(handler), + ) + } } items @@ -409,220 +439,145 @@ impl Focusable for Sidebar { } impl Render for Sidebar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let account = Account::get_global(cx).profile_ref(); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let chats = ChatRegistry::get_global(cx); - // Get search result - let local_result = self.local_result.read(cx); - let global_result = self.global_result.read(cx); + let rooms = if let Some(results) = self.local_result.read(cx) { + results.to_owned() + } else { + #[allow(clippy::collapsible_else_if)] + if self.active_filter.read(cx) == &RoomKind::Ongoing { + chats.ongoing_rooms(cx) + } else { + chats.request_rooms(self.trusted_only, cx) + } + }; div() - .id("sidebar") - .track_focus(&self.focus_handle) - .track_scroll(&self.scroll_handle) - .overflow_y_scroll() + .image_cache(self.image_cache.clone()) .size_full() .flex() .flex_col() .gap_3() - .py_1() - .when_some(account, |this, profile| { - this.child( - div() - .px_3() - .h_7() - .flex_none() - .flex() - .justify_between() - .items_center() - .child( - div() - .flex() - .items_center() - .gap_2() - .text_sm() - .font_semibold() - .child(img(profile.shared_avatar()).size_7()) - .child(profile.shared_name()), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - Button::new("user") - .icon(IconName::Ellipsis) - .small() - .ghost() - .rounded(ButtonRounded::Full) - .popup_menu(|this, _window, _cx| { - this.menu( - "Profile", - Box::new(ToggleModal { - modal: ModalKind::Profile, - }), - ) - .menu( - "Relays", - Box::new(ToggleModal { - modal: ModalKind::Relay, - }), - ) - }), - ) - .child( - Button::new("compose") - .icon(IconName::PlusFill) - .tooltip("Create DM or Group DM") - .small() - .primary() - .rounded(ButtonRounded::Full) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(ToggleModal { - modal: ModalKind::Compose, - }), - cx, - ); - })), - ), - ), - ) + // Account + .when_some(Account::get_global(cx).profile_ref(), |this, profile| { + this.child(self.render_account(profile, cx)) }) + // Search Input .child( - div().px_3().h_7().flex_none().child( + div().px_3().w_full().h_7().flex_none().child( TextInput::new(&self.find_input).small().suffix( Button::new("find") .icon(IconName::Search) .tooltip("Press Enter to search") - .small() - .custom( - ButtonCustomVariant::new(window, cx) - .active(gpui::transparent_black()) - .color(gpui::transparent_black()) - .hover(gpui::transparent_black()) - .foreground(cx.theme().text_placeholder), - ), + .transparent() + .small(), ), ), ) - .when_some(global_result.as_ref(), |this, rooms| { + // Global Search Results + .when_some(self.global_result.read(cx).clone(), |this, rooms| { this.child( - div() - .px_1() - .flex() - .flex_col() - .gap_1() - .children(Self::render_global_items(rooms, cx)), + div().px_1().w_full().flex_1().overflow_y_hidden().child( + uniform_list( + cx.entity(), + "results", + rooms.len(), + move |this, range, _window, cx| { + this.render_uniform_item(&rooms, range, cx) + }, + ) + .h_full(), + ), ) }) .child( div() - .px_1() + .px_2() .w_full() + .flex_1() + .overflow_y_hidden() .flex() .flex_col() .gap_1() .child( div() - .mb_1() - .px_2() + .flex_none() + .px_1() .w_full() + .h_9() .flex() - .justify_between() .items_center() - .text_sm() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .child("Messages") + .justify_between() .child( - Button::new("menu") - .tooltip("Toggle chat folders") - .map(|this| { - if self.folders { - this.icon(IconName::FilterFill) - } else { - this.icon(IconName::Filter) - } - }) - .small() - .custom( - ButtonCustomVariant::new(window, cx) - .foreground(cx.theme().text_placeholder) - .color(cx.theme().ghost_element_background) - .hover(cx.theme().ghost_element_background) - .active(cx.theme().ghost_element_background), - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_folder(cx); - })), - ), - ) - .when(chats.wait_for_eose, |this| { - this.children(self.render_skeleton(6)) - }) - .map(|this| { - if let Some(rooms) = local_result { - this.children(Self::render_items(rooms, cx)) - } else if !self.folders { - this.children(Self::render_items(&chats.rooms, cx)) - } else { - this.child( - Folder::new("Ongoing") - .icon(IconName::Folder) - .tooltip("All ongoing conversations") - .collapsed(!self.active_items.contains(&Item::Ongoing)) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_item(Item::Ongoing, cx); - })) - .children(Self::render_items( - &chats.rooms_by_kind(RoomKind::Ongoing, cx), - cx, - )), - ) - .child( - Parent::new("Incoming") - .icon(IconName::Folder) - .tooltip("Incoming messages") - .collapsed(!self.active_items.contains(&Item::Incoming)) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_item(Item::Incoming, cx); - })) + div() + .flex() + .items_center() + .gap_2() .child( - Folder::new("Trusted") - .icon(IconName::Folder) - .tooltip("Incoming messages from trusted contacts") - .collapsed( - !self.active_subitems.contains(&SubItem::Trusted), - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_subitem(SubItem::Trusted, cx); - })) - .children(Self::render_items( - &chats.rooms_by_kind(RoomKind::Trusted, cx), - cx, - )), + Button::new("all") + .label("All") + .small() + .bold() + .secondary() + .rounded(ButtonRounded::Full) + .selected(self.filter(&RoomKind::Ongoing, cx)) + .on_click(cx.listener(|this, _, _, cx| { + this.set_filter(RoomKind::Ongoing, cx); + })), ) .child( - Folder::new("Unknown") - .icon(IconName::Folder) - .tooltip("Incoming messages from unknowns") - .collapsed( - !self.active_subitems.contains(&SubItem::Unknown), - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_subitem(SubItem::Unknown, cx); - })) - .children(Self::render_items( - &chats.rooms_by_kind(RoomKind::Unknown, cx), - cx, - )), + Button::new("requests") + .label("Requests") + .small() + .bold() + .secondary() + .rounded(ButtonRounded::Full) + .selected(!self.filter(&RoomKind::Ongoing, cx)) + .on_click(cx.listener(|this, _, _, cx| { + this.set_filter(RoomKind::Unknown, cx); + })), ), ) - } - }), + .when(!self.filter(&RoomKind::Ongoing, cx), |this| { + this.child( + Button::new("trusted") + .tooltip("Only show rooms from trusted contacts") + .map(|this| { + if self.trusted_only { + this.icon(IconName::FilterFill) + } else { + this.icon(IconName::Filter) + } + }) + .small() + .transparent() + .on_click(cx.listener(|this, _, _, cx| { + this.set_trusted_only(cx); + })), + ) + }), + ) + .when(chats.wait_for_eose, |this| { + this.child( + div() + .flex() + .flex_col() + .gap_1() + .children(self.render_skeleton(10)), + ) + }) + .child( + uniform_list( + cx.entity(), + "rooms", + rooms.len(), + move |this, range, _window, cx| { + this.render_uniform_item(&rooms, range, cx) + }, + ) + .h_full(), + ), ) } } diff --git a/crates/global/Cargo.toml b/crates/global/Cargo.toml index 02801ed..4f46a73 100644 --- a/crates/global/Cargo.toml +++ b/crates/global/Cargo.toml @@ -10,3 +10,4 @@ dirs.workspace = true smol.workspace = true whoami = "1.5.2" +rustls = "0.23.23" diff --git a/crates/global/src/constants.rs b/crates/global/src/constants.rs index 2f0461e..9164875 100644 --- a/crates/global/src/constants.rs +++ b/crates/global/src/constants.rs @@ -27,12 +27,8 @@ pub const DEFAULT_MODAL_WIDTH: f32 = 420.; /// Default width of the sidebar. pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.; -/// Total remote images will be cached -pub const IMAGE_CACHE_LIMIT: usize = 50; - -/// Image Resizer Service. -/// Use for resize all remote images (ex: avatar, banner,...) on-the-fly. -pub const IMAGE_SERVICE: &str = "https://wsrv.nl"; +/// Image Resize Service +pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl"; /// NIP96 Media Server. pub const NIP96_SERVER: &str = "https://nostrmedia.com"; diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs index 0c027e9..c2d7c4c 100644 --- a/crates/global/src/lib.rs +++ b/crates/global/src/lib.rs @@ -1,37 +1,110 @@ use nostr_sdk::prelude::*; use paths::nostr_file; +use smol::lock::RwLock; -use std::{sync::OnceLock, time::Duration}; +use std::{collections::BTreeMap, sync::OnceLock, time::Duration}; pub mod constants; pub mod paths; -static CLIENT: OnceLock = OnceLock::new(); -static CLIENT_KEYS: OnceLock = OnceLock::new(); +/// Represents the global state of the Nostr client, including: +/// - The Nostr client instance +/// - Client keys +/// - A cache of user profiles (metadata) +pub struct NostrState { + keys: Keys, + client: Client, + cache_profiles: RwLock>>, +} -/// Nostr Client instance +/// Global singleton instance of NostrState +static GLOBAL_STATE: OnceLock = OnceLock::new(); + +/// Initializes and returns a new NostrState instance with: +/// - LMDB database backend +/// - Default client options (gossip enabled, 800ms max avg latency) +/// - Newly generated keys +/// - Empty profile cache +pub fn init_global_state() -> NostrState { + // rustls uses the `aws_lc_rs` provider by default + // This only errors if the default provider has already + // been installed. We can ignore this `Result`. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + + // Setup database + let db_path = nostr_file(); + let lmdb = NostrLMDB::open(db_path).expect("Database is NOT initialized"); + + // Client options + let opts = Options::new() + .gossip(true) + .max_avg_latency(Duration::from_millis(800)); + + NostrState { + client: ClientBuilder::default().database(lmdb).opts(opts).build(), + keys: Keys::generate(), + cache_profiles: RwLock::new(BTreeMap::new()), + } +} + +/// Returns a reference to the global Nostr client instance. +/// +/// Initializes the global state if it hasn't been initialized yet. pub fn get_client() -> &'static Client { - CLIENT.get_or_init(|| { - // Setup database - let db_path = nostr_file(); - let lmdb = NostrLMDB::open(db_path).expect("Database is NOT initialized"); - - // Client options - let opts = Options::new() - // NIP-65 - // Coop is don't really need to enable this option, - // but this will help the client discover user's messaging relays efficiently. - .gossip(true) - // Skip all very slow relays - // Note: max delay is 800ms - .max_avg_latency(Duration::from_millis(800)); - - // Setup Nostr Client - ClientBuilder::default().database(lmdb).opts(opts).build() - }) + &GLOBAL_STATE.get_or_init(init_global_state).client } -/// Client Keys +/// Returns a reference to the client's cryptographic keys. +/// +/// Initializes the global state if it hasn't been initialized yet. pub fn get_client_keys() -> &'static Keys { - CLIENT_KEYS.get_or_init(Keys::generate) + &GLOBAL_STATE.get_or_init(init_global_state).keys +} + +/// Returns a reference to the global profile cache (thread-safe). +/// +/// Initializes the global state if it hasn't been initialized yet. +pub fn profiles() -> &'static RwLock>> { + &GLOBAL_STATE.get_or_init(init_global_state).cache_profiles +} + +/// Synchronously gets a profile from the cache by public key. +/// +/// Returns default metadata if the profile is not cached. +pub fn get_cache_profile(key: &PublicKey) -> Profile { + let metadata = if let Some(metadata) = profiles().read_blocking().get(key) { + metadata.clone().unwrap_or_default() + } else { + Metadata::default() + }; + + Profile::new(*key, metadata) +} + +/// Asynchronously gets a profile from the cache by public key. +/// +/// Returns default metadata if the profile isn't cached. +pub async fn async_cache_profile(key: &PublicKey) -> Profile { + let metadata = if let Some(metadata) = profiles().read().await.get(key) { + metadata.clone().unwrap_or_default() + } else { + Metadata::default() + }; + + Profile::new(*key, metadata) +} + +/// Synchronously inserts or updates a profile in the cache. +pub fn insert_cache_profile(key: PublicKey, metadata: Option) { + profiles() + .write_blocking() + .entry(key) + .and_modify(|entry| { + if entry.is_none() { + *entry = metadata.clone(); + } + }) + .or_insert_with(|| metadata); } diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 22ce7d6..5ff8bdb 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -56,6 +56,32 @@ pub struct ThemeColor { /// /// Disabled states are shown when a user cannot interact with an element, like a disabled button or input. pub element_disabled: Hsla, + /// Text color. Used for the foreground of an secondary element. + pub secondary_foreground: Hsla, + /// Background color. Used for the background of an secondary element that should have a different background than the surface it's on. + /// + /// Secondary elements might include: Buttons, Inputs, Checkboxes, Radio Buttons... + /// + /// For an element that should have the same background as the surface it's on, use `ghost_element_background`. + pub secondary_background: Hsla, + /// Background color. Used for the hover state of an secondary element that should have a different background than the surface it's on. + /// + /// Hover states are triggered by the mouse entering an element, or a finger touching an element on a touch screen. + pub secondary_hover: Hsla, + /// Background color. Used for the active state of an secondary element that should have a different background than the surface it's on. + /// + /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressed. + pub secondary_active: Hsla, + /// Background color. Used for the selected state of an secondary element that should have a different background than the surface it's on. + /// + /// Selected states are triggered by the element being selected (or "activated") by the user. + /// + /// This could include a selected checkbox, a toggleable button that is toggled on, etc. + pub secondary_selected: Hsla, + /// Background Color. Used for the disabled state of an secondary element that should have a different background than the surface it's on. + /// + /// Disabled states are shown when a user cannot interact with an element, like a disabled button or input. + pub secondary_disabled: Hsla, /// Background color. Used for the area that shows where a dragged element will be dropped. pub drop_target_background: Hsla, /// Used for the background of a ghost element that should have the same background as the surface it's on. @@ -160,6 +186,12 @@ impl ThemeColor { element_active: brand().light().step_10(), element_selected: brand().light().step_11(), element_disabled: brand().light_alpha().step_3(), + secondary_foreground: brand().light().step_12(), + secondary_background: brand().light().step_3(), + secondary_hover: brand().light_alpha().step_4(), + secondary_active: brand().light().step_5(), + secondary_selected: brand().light().step_5(), + secondary_disabled: brand().light_alpha().step_3(), drop_target_background: brand().light_alpha().step_2(), ghost_element_background: gpui::transparent_black(), ghost_element_hover: neutral().light_alpha().step_3(), @@ -211,6 +243,12 @@ impl ThemeColor { element_active: brand().dark().step_10(), element_selected: brand().dark().step_11(), element_disabled: brand().dark_alpha().step_3(), + secondary_foreground: brand().dark().step_12(), + secondary_background: brand().dark().step_3(), + secondary_hover: brand().dark_alpha().step_4(), + secondary_active: brand().dark().step_5(), + secondary_selected: brand().dark().step_5(), + secondary_disabled: brand().dark_alpha().step_3(), drop_target_background: brand().dark_alpha().step_2(), ghost_element_background: gpui::transparent_black(), ghost_element_hover: neutral().dark_alpha().step_3(), diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs new file mode 100644 index 0000000..a2cec74 --- /dev/null +++ b/crates/ui/src/avatar.rs @@ -0,0 +1,99 @@ +use gpui::{ + div, img, prelude::FluentBuilder, px, rems, AbsoluteLength, App, Hsla, ImageSource, Img, + IntoElement, ParentElement, RenderOnce, Styled, StyledImage, Window, +}; +use theme::ActiveTheme; + +/// An element that renders a user avatar with customizable appearance options. +/// +/// # Examples +/// +/// ``` +/// use ui::{Avatar}; +/// +/// Avatar::new("path/to/image.png") +/// .grayscale(true) +/// .border_color(gpui::red()); +/// ``` +#[derive(IntoElement)] +pub struct Avatar { + image: Img, + size: Option, + border_color: Option, +} + +impl Avatar { + /// Creates a new avatar element with the specified image source. + pub fn new(src: impl Into) -> Self { + Avatar { + image: img(src), + size: None, + border_color: None, + } + } + + /// Applies a grayscale filter to the avatar image. + /// + /// # Examples + /// + /// ``` + /// use ui::{Avatar, AvatarShape}; + /// + /// let avatar = Avatar::new("path/to/image.png").grayscale(true); + /// ``` + pub fn grayscale(mut self, grayscale: bool) -> Self { + self.image = self.image.grayscale(grayscale); + self + } + + /// Sets the border color of the avatar. + /// + /// This might be used to match the border to the background color of + /// the parent element to create the illusion of cropping another + /// shape underneath (for example in face piles.) + pub fn border_color(mut self, color: impl Into) -> Self { + self.border_color = Some(color.into()); + self + } + + /// Size overrides the avatar size. By default they are 1rem. + pub fn size>(mut self, size: impl Into>) -> Self { + self.size = size.into().map(Into::into); + self + } +} + +impl RenderOnce for Avatar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let border_width = if self.border_color.is_some() { + px(2.) + } else { + px(0.) + }; + + let image_size = self.size.unwrap_or_else(|| rems(1.).into()); + let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; + + div() + .flex_shrink_0() + .size(container_size) + .rounded_full() + .overflow_hidden() + .when_some(self.border_color, |this, color| { + this.border(border_width).border_color(color) + }) + .child( + self.image + .size(image_size) + .rounded_full() + .object_fit(gpui::ObjectFit::Fill) + .bg(cx.theme().ghost_element_background) + .with_fallback(move || { + img("brand/avatar.png") + .size(image_size) + .rounded_full() + .into_any_element() + }), + ) + } +} diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index c729326..db349e4 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -30,19 +30,19 @@ pub trait ButtonVariants: Sized { self.with_variant(ButtonVariant::Primary) } + /// With the secondary style for the Button. + fn secondary(self) -> Self { + self.with_variant(ButtonVariant::Secondary) + } + /// With the ghost style for the Button. fn ghost(self) -> Self { self.with_variant(ButtonVariant::Ghost) } - /// With the link style for the Button. - fn link(self) -> Self { - self.with_variant(ButtonVariant::Link) - } - - /// With the text style for the Button, it will no padding look like a normal text. - fn text(self) -> Self { - self.with_variant(ButtonVariant::Text) + /// With the transparent style for the Button. + fn transparent(self) -> Self { + self.with_variant(ButtonVariant::Transparent) } /// With the custom style for the Button. @@ -86,9 +86,9 @@ impl ButtonCustomVariant { #[derive(Clone, Copy, PartialEq, Eq)] pub enum ButtonVariant { Primary, + Secondary, Ghost, - Link, - Text, + Transparent, Custom(ButtonCustomVariant), } @@ -98,20 +98,6 @@ impl Default for ButtonVariant { } } -impl ButtonVariant { - fn is_link(&self) -> bool { - matches!(self, Self::Link) - } - - fn is_text(&self) -> bool { - matches!(self, Self::Text) - } - - fn no_padding(&self) -> bool { - self.is_link() || self.is_text() - } -} - type OnClick = Option>; /// A Button element. @@ -295,7 +281,7 @@ impl RenderOnce for Button { ButtonRounded::Normal => this.rounded(cx.theme().radius), ButtonRounded::Full => this.rounded_full(), }) - .when(!style.no_padding(), |this| { + .map(|this| { if self.label.is_none() && self.children.is_empty() { // Icon Button match self.size { @@ -321,13 +307,13 @@ impl RenderOnce for Button { } } }) + .text_color(normal_style.fg) .when(self.selected, |this| { let selected_style = style.selected(window, cx); this.bg(selected_style.bg).text_color(selected_style.fg) }) .when(!self.disabled && !self.selected, |this| { this.bg(normal_style.bg) - .when(normal_style.underline, |this| this.text_decoration_1()) .hover(|this| { let hover_style = style.hovered(window, cx); this.bg(hover_style.bg) @@ -359,7 +345,6 @@ impl RenderOnce for Button { .text_color(disabled_style.fg) .shadow_none() }) - .text_color(normal_style.fg) .child({ div() .flex() @@ -405,13 +390,14 @@ impl RenderOnce for Button { struct ButtonVariantStyle { bg: Hsla, fg: Hsla, - underline: bool, } impl ButtonVariant { fn bg_color(&self, _window: &Window, cx: &App) -> Hsla { match self { ButtonVariant::Primary => cx.theme().element_background, + ButtonVariant::Secondary => cx.theme().elevated_surface_background, + ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.color, _ => cx.theme().ghost_element_background, } @@ -420,90 +406,87 @@ impl ButtonVariant { fn text_color(&self, _window: &Window, cx: &App) -> Hsla { match self { ButtonVariant::Primary => cx.theme().element_foreground, - ButtonVariant::Link => cx.theme().text_accent, + ButtonVariant::Secondary => cx.theme().text_muted, + ButtonVariant::Transparent => cx.theme().text_placeholder, ButtonVariant::Ghost => cx.theme().text_muted, ButtonVariant::Custom(colors) => colors.foreground, - _ => cx.theme().text, } } - fn underline(&self, _window: &Window, _cx: &App) -> bool { - matches!(self, ButtonVariant::Link) - } - fn normal(&self, window: &Window, cx: &App) -> ButtonVariantStyle { let bg = self.bg_color(window, cx); let fg = self.text_color(window, cx); - let underline = self.underline(window, cx); - ButtonVariantStyle { bg, fg, underline } + ButtonVariantStyle { bg, fg } } fn hovered(&self, window: &Window, cx: &App) -> ButtonVariantStyle { let bg = match self { ButtonVariant::Primary => cx.theme().element_hover, + ButtonVariant::Secondary => cx.theme().secondary_hover, ButtonVariant::Ghost => cx.theme().ghost_element_hover, - ButtonVariant::Link => cx.theme().ghost_element_background, - ButtonVariant::Text => cx.theme().ghost_element_background, + ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.hover, }; + let fg = match self { + ButtonVariant::Secondary => cx.theme().secondary_foreground, ButtonVariant::Ghost => cx.theme().text, - ButtonVariant::Link => cx.theme().text_accent, + ButtonVariant::Transparent => cx.theme().text_placeholder, _ => self.text_color(window, cx), }; - let underline = self.underline(window, cx); - ButtonVariantStyle { bg, fg, underline } + ButtonVariantStyle { bg, fg } } fn active(&self, window: &Window, cx: &App) -> ButtonVariantStyle { let bg = match self { ButtonVariant::Primary => cx.theme().element_active, + ButtonVariant::Secondary => cx.theme().secondary_active, ButtonVariant::Ghost => cx.theme().ghost_element_active, + ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.active, - _ => cx.theme().ghost_element_background, }; + let fg = match self { - ButtonVariant::Link => cx.theme().text_accent, - ButtonVariant::Text => cx.theme().text, + ButtonVariant::Secondary => cx.theme().secondary_foreground, + ButtonVariant::Transparent => cx.theme().text_placeholder, _ => self.text_color(window, cx), }; - let underline = self.underline(window, cx); - ButtonVariantStyle { bg, fg, underline } + ButtonVariantStyle { bg, fg } } fn selected(&self, window: &Window, cx: &App) -> ButtonVariantStyle { let bg = match self { ButtonVariant::Primary => cx.theme().element_selected, + ButtonVariant::Secondary => cx.theme().secondary_selected, ButtonVariant::Ghost => cx.theme().ghost_element_selected, + ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.active, - _ => cx.theme().ghost_element_background, }; + let fg = match self { - ButtonVariant::Link => cx.theme().text_accent, - ButtonVariant::Text => cx.theme().text, + ButtonVariant::Secondary => cx.theme().secondary_foreground, + ButtonVariant::Transparent => cx.theme().text_placeholder, _ => self.text_color(window, cx), }; - let underline = self.underline(window, cx); - ButtonVariantStyle { bg, fg, underline } + ButtonVariantStyle { bg, fg } } - fn disabled(&self, window: &Window, cx: &App) -> ButtonVariantStyle { + fn disabled(&self, _window: &Window, cx: &App) -> ButtonVariantStyle { let bg = match self { - ButtonVariant::Link | ButtonVariant::Ghost | ButtonVariant::Text => { - cx.theme().ghost_element_disabled - } + ButtonVariant::Ghost => cx.theme().ghost_element_disabled, + ButtonVariant::Secondary => cx.theme().secondary_disabled, _ => cx.theme().element_disabled, }; + let fg = match self { ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color? _ => cx.theme().text_muted, }; - let underline = self.underline(window, cx); - ButtonVariantStyle { bg, fg, underline } + ButtonVariantStyle { bg, fg } } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 34f02ea..a059c58 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -8,16 +8,9 @@ pub use window_border::{window_border, WindowBorder}; pub use crate::Disableable; -mod event; -mod focusable; -mod icon; -mod root; -mod styled; -mod title_bar; -mod window_border; - pub(crate) mod actions; pub mod animation; +pub mod avatar; pub mod button; pub mod checkbox; pub mod context_menu; @@ -41,6 +34,14 @@ pub mod tab; pub mod text; pub mod tooltip; +mod event; +mod focusable; +mod icon; +mod root; +mod styled; +mod title_bar; +mod window_border; + /// Initialize the UI module. /// /// This must be called before using any of the UI components. diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index f33fac8..2a28cde 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -198,7 +198,8 @@ impl StyleSized for T { fn input_h(self, size: Size) -> Self { match size { - Size::Small => self.h_7(), + Size::XSmall => self.h_7(), + Size::Small => self.h_8(), Size::Medium => self.h_9(), Size::Large => self.h_12(), _ => self.h(px(24.)), diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index 7528ad5..451e711 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -1,4 +1,4 @@ -use common::profile::SharedProfile; +use common::profile::RenderProfile; use gpui::{ AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement, SharedString, StyledText, UnderlineStyle, Window, @@ -273,7 +273,7 @@ pub fn render_plain_text_mut( if let Some(profile) = profile_match { // Profile found - create a mention - let display_name = format!("@{}", profile.shared_name()); + let display_name = format!("@{}", profile.render_name()); // Replace mention with profile name text.replace_range(range.clone(), &display_name);