From d6504a8170966c26429fb8b4eb9327e50112aaa7 Mon Sep 17 00:00:00 2001 From: reya Date: Thu, 18 Dec 2025 09:00:29 +0700 Subject: [PATCH] wip: onboarding --- Cargo.lock | 26 +++--- Cargo.toml | 2 +- assets/icons/check.svg | 1 + assets/icons/chevron-right.svg | 1 + crates/account/src/lib.rs | 86 +++++-------------- crates/common/src/constants.rs | 10 +-- crates/lume/Cargo.toml | 4 +- crates/lume/src/main.rs | 7 +- crates/lume/src/menus.rs | 3 + crates/lume/src/panels/mod.rs | 1 + crates/lume/src/panels/onboarding.rs | 45 ++++++++++ crates/lume/src/sidebar/mod.rs | 4 +- crates/lume/src/workspace.rs | 122 +++------------------------ crates/note/Cargo.toml | 17 ++++ crates/note/src/lib.rs | 92 ++++++++++++++++++++ crates/person/Cargo.toml | 4 +- crates/person/src/lib.rs | 51 ++++++++++- 17 files changed, 262 insertions(+), 214 deletions(-) create mode 100644 assets/icons/check.svg create mode 100644 assets/icons/chevron-right.svg create mode 100644 crates/lume/src/panels/onboarding.rs create mode 100644 crates/note/Cargo.toml create mode 100644 crates/note/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 12bbd401..be5cfa25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3512,7 +3512,6 @@ dependencies = [ "anyhow", "assets", "common", - "dirs 5.0.1", "flume 0.12.0", "futures", "gpui", @@ -3522,7 +3521,6 @@ dependencies = [ "log", "nostr-connect", "nostr-sdk", - "oneshot", "person", "reqwest_client", "serde", @@ -4015,6 +4013,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "note" +version = "0.0.1" +dependencies = [ + "anyhow", + "common", + "flume 0.12.0", + "gpui", + "log", + "nostr-sdk", + "smallvec", + "state", +] + [[package]] name = "notify" version = "7.0.0" @@ -4315,12 +4327,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "oneshot" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" - [[package]] name = "oo7" version = "0.5.0" @@ -4552,13 +4558,11 @@ version = "0.0.1" dependencies = [ "anyhow", "common", + "flume 0.12.0", "gpui", "log", "nostr-sdk", - "serde", - "serde_json", "smallvec", - "smol", "state", ] diff --git a/Cargo.toml b/Cargo.toml index fb6815c5..8e98502c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,8 +28,8 @@ dirs = "5.0" futures = "0.3" itertools = "0.13.0" log = "0.4" -oneshot = "0.1.10" reqwest = { version = "0.12", features = ["multipart", "stream", "json"] } +flume = { version = "0.12", default-features = false, features = ["async", "select"] } rust-embed = "8.5.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 00000000..3861bd27 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/chevron-right.svg b/assets/icons/chevron-right.svg new file mode 100644 index 00000000..fab95dc6 --- /dev/null +++ b/assets/icons/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/account/src/lib.rs b/crates/account/src/lib.rs index 3e9628fb..8cddf4f8 100644 --- a/crates/account/src/lib.rs +++ b/crates/account/src/lib.rs @@ -1,6 +1,3 @@ -use std::collections::HashSet; -use std::env; - use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; @@ -19,9 +16,6 @@ pub struct Account { /// Public Key of the account public_key: Option, - /// Contact List of the account - pub contacts: Entity>, - /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, @@ -52,39 +46,7 @@ impl Account { /// Create a new account instance fn new(cx: &mut Context) -> Self { - let contacts = cx.new(|_| HashSet::default()); - - // Collect command line arguments - let args: Vec = env::args().collect(); - let account = args.get(1).and_then(|s| Keys::parse(s).ok()); - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - if let Some(keys) = account { - let public_key = keys.public_key(); - - tasks.push( - // Set signer in background - cx.spawn(async move |this, cx| { - // Set the signer - cx.background_executor() - .await_on_background(async move { - let client = client(); - client.set_signer(keys).await; - - log::info!("Signer is set"); - }) - .await; - - // Update state - this.update(cx, |this, cx| { - this.public_key = Some(public_key); - cx.notify(); - }) - }), - ); - } subscriptions.push( // Listen for public key set @@ -97,9 +59,8 @@ impl Account { Self { public_key: None, - contacts, _subscriptions: subscriptions, - _tasks: tasks, + _tasks: smallvec![], } } @@ -126,7 +87,24 @@ impl Account { // Subscribe to the user's contact list client.subscribe(filter, Some(opts)).await?; - log::info!("Subscribed to user metadata and contact list"); + // Construct a filter to get the user's other metadata + let filter = Filter::new() + .kinds(vec![ + Kind::MuteList, + Kind::Bookmarks, + Kind::BookmarkSet, + Kind::SearchRelays, + Kind::BlockedRelays, + Kind::RelaySet, + Kind::Custom(10012), + ]) + .author(public_key) + .limit(24); + + // Subscribe to the user's other metadata + client.subscribe(filter, Some(opts)).await?; + + log::info!("Subscribed to user metadata"); Ok(()) }); @@ -144,30 +122,4 @@ impl Account { // This method is only called when user is logged in, so unwrap safely self.public_key.unwrap() } - - /// Load the contacts of the account from the database - pub fn load_contacts(&mut self, cx: &mut Context) { - let task: Task, Error>> = cx.background_spawn(async move { - let client = client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let contacts = client.database().contacts_public_keys(public_key).await?; - - Ok(contacts) - }); - - self._tasks.push(cx.spawn(async move |this, cx| { - if let Ok(contacts) = task.await { - this.update(cx, |this, cx| { - this.contacts.update(cx, |this, cx| { - this.extend(contacts); - cx.notify(); - }); - }) - .ok(); - } - - Ok(()) - })); - } } diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index 6fa9260c..68c87b76 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -1,4 +1,7 @@ +/// Client (or application) name. pub const CLIENT_NAME: &str = "Lume"; + +/// Application ID. pub const APP_ID: &str = "su.reya.lume"; /// Bootstrap Relays. @@ -10,13 +13,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 5] = [ "wss://purplepag.es", ]; -/// Search Relays. -pub const SEARCH_RELAYS: [&str; 3] = [ - "wss://relay.nostr.band", - "wss://search.nos.today", - "wss://relay.noswhere.com", -]; - /// Default relay for Nostr Connect pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; diff --git a/crates/lume/Cargo.toml b/crates/lume/Cargo.toml index f8cd87e3..2c03b368 100644 --- a/crates/lume/Cargo.toml +++ b/crates/lume/Cargo.toml @@ -27,12 +27,10 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true itertools.workspace = true -dirs.workspace = true log.workspace = true smallvec.workspace = true smol.workspace = true futures.workspace = true -oneshot.workspace = true +flume.workspace = true tracing-subscriber = { version = "0.3.18", features = ["fmt"] } -flume = { version = "0.12", default-features = false, features = ["async", "select"] } diff --git a/crates/lume/src/main.rs b/crates/lume/src/main.rs index 4fb54078..8dc4ff08 100644 --- a/crates/lume/src/main.rs +++ b/crates/lume/src/main.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use assets::Assets; -use common::{APP_ID, BOOTSTRAP_RELAYS, CLIENT_NAME, SEARCH_RELAYS}; +use common::{APP_ID, BOOTSTRAP_RELAYS, CLIENT_NAME}; use gpui::{ point, px, size, AppContext, Application, Bounds, SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, @@ -40,11 +40,6 @@ fn main() { client.add_relay(url).await.ok(); } - // Add search relays to the relay pool - for url in SEARCH_RELAYS { - client.add_relay(url).await.ok(); - } - // Connect to all added relays client.connect().await; }) diff --git a/crates/lume/src/menus.rs b/crates/lume/src/menus.rs index 5287575c..43c6e849 100644 --- a/crates/lume/src/menus.rs +++ b/crates/lume/src/menus.rs @@ -15,6 +15,7 @@ pub fn init(title: impl Into, cx: &mut App) -> Entity cx.observe_global::({ let title = title.clone(); let app_menu_bar = app_menu_bar.clone(); + move |cx| { update_app_menu(title.clone(), app_menu_bar.clone(), cx); } @@ -26,6 +27,7 @@ pub fn init(title: impl Into, cx: &mut App) -> Entity fn update_app_menu(title: impl Into, app_menu_bar: Entity, cx: &mut App) { let mode = cx.theme().mode; + cx.set_menus(vec![ Menu { name: title.into(), @@ -87,6 +89,7 @@ fn update_app_menu(title: impl Into, app_menu_bar: Entity MenuItem { let themes = ThemeRegistry::global(cx).sorted_themes(); let current_name = cx.theme().theme_name(); + MenuItem::Submenu(Menu { name: "Theme".into(), items: themes diff --git a/crates/lume/src/panels/mod.rs b/crates/lume/src/panels/mod.rs index 9e5237a6..cfb1bafa 100644 --- a/crates/lume/src/panels/mod.rs +++ b/crates/lume/src/panels/mod.rs @@ -1,2 +1,3 @@ pub mod feed; +pub mod onboarding; pub mod startup; diff --git a/crates/lume/src/panels/onboarding.rs b/crates/lume/src/panels/onboarding.rs new file mode 100644 index 00000000..7188123b --- /dev/null +++ b/crates/lume/src/panels/onboarding.rs @@ -0,0 +1,45 @@ +use gpui::{ + div, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Window, +}; +use gpui_component::dock::{Panel, PanelEvent}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Onboarding::new(window, cx)) +} + +pub struct Onboarding { + focus_handle: FocusHandle, +} + +impl Onboarding { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for Onboarding { + fn panel_name(&self) -> &'static str { + "Onboarding" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + SharedString::from("Onboarding") + } +} + +impl EventEmitter for Onboarding {} + +impl Focusable for Onboarding { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Onboarding { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child("Onboarding") + } +} diff --git a/crates/lume/src/sidebar/mod.rs b/crates/lume/src/sidebar/mod.rs index 20eaff20..a4f6c42f 100644 --- a/crates/lume/src/sidebar/mod.rs +++ b/crates/lume/src/sidebar/mod.rs @@ -1,4 +1,3 @@ -use account::Account; use common::BOOTSTRAP_RELAYS; use gpui::{ div, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, @@ -47,8 +46,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let person = PersonRegistry::global(cx); - let account = Account::global(cx); - let contacts = account.read(cx).contacts.read(cx); + let contacts = Vec::new(); v_flex() .id("sidebar-wrapper") diff --git a/crates/lume/src/workspace.rs b/crates/lume/src/workspace.rs index 0cd914ea..671d3a3a 100644 --- a/crates/lume/src/workspace.rs +++ b/crates/lume/src/workspace.rs @@ -1,22 +1,18 @@ -use std::collections::HashSet; use std::sync::Arc; use account::Account; -use anyhow::Error; -use common::{BOOTSTRAP_RELAYS, CLIENT_NAME, DEFAULT_SIDEBAR_WIDTH}; +use common::{CLIENT_NAME, DEFAULT_SIDEBAR_WIDTH}; use gpui::{ div, px, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, - Render, Styled, Subscription, Task, Window, + Render, Styled, Subscription, Window, }; use gpui_component::dock::{DockArea, DockItem, DockPlacement, PanelStyle}; use gpui_component::{v_flex, Root, Theme}; use nostr_sdk::prelude::*; -use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; -use state::{client, StateEvent}; use crate::panels::feed::Feed; -use crate::panels::startup; +use crate::panels::{onboarding, startup}; use crate::sidebar; use crate::title_bar::AppTitleBar; @@ -36,32 +32,23 @@ pub struct Workspace { /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, - - /// Background tasks - _tasks: SmallVec<[Task>; 2]>, } impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let account = Account::global(cx); - // App's title bar let title_bar = cx.new(|cx| AppTitleBar::new(CLIENT_NAME, window, cx)); // Dock area for the workspace. - let dock = - cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(PanelStyle::TabBar)); - - // Channel for communication between Nostr and GPUI - let (tx, rx) = flume::bounded::(2048); + let dock = cx.new(|cx| { + let startup = Arc::new(onboarding::init(window, cx)); + let mut this = DockArea::new("dock", None, window, cx).panel_style(PanelStyle::TabBar); + this.set_center(DockItem::panel(startup), window, cx); + this + }); + let account = Account::global(cx); let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - // Automatically sync theme with system appearance - subscriptions.push(window.observe_window_appearance(|window, cx| { - Theme::sync_system_appearance(Some(window), cx); - })); // Observe account entity changes subscriptions.push( @@ -72,98 +59,15 @@ impl Workspace { }), ); - // Handle nostr notifications - tasks.push(cx.background_spawn(async move { - let client = client(); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let mut notifications = client.notifications(); - let mut processed_events: HashSet = HashSet::default(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, .. } = notification else { - continue; - }; - - match message { - RelayMessage::Event { event, .. } => { - // Skip if already processed - if !processed_events.insert(event.id) { - continue; - } - - match event.kind { - Kind::ContactList => { - // Get all public keys from the event - let public_keys: Vec = - event.tags.public_keys().copied().collect(); - - // Construct a filter to get metadata for each public key - let filter = Filter::new() - .kind(Kind::Metadata) - .limit(public_keys.len()) - .authors(public_keys); - - // Subscribe to metadata events in the bootstrap relays - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - // Notify GPUI of received contact list - tx.send_async(StateEvent::ReceivedContactList).await.ok(); - } - Kind::Metadata => { - // Parse metadata from event, default if invalid - let metadata = - Metadata::from_json(&event.content).unwrap_or_default(); - - // Construct nostr profile with metadata and public key - let profile = Box::new(Profile::new(event.pubkey, metadata)); - - // Notify GPUI of received profile - tx.send_async(StateEvent::ReceivedProfile(profile)) - .await - .ok(); - } - _ => {} - } - } - RelayMessage::EndOfStoredEvents(_) => { - // TODO - } - _ => {} - } - } - Ok(()) - })); - - // Handle state events - tasks.push(cx.spawn_in(window, async move |_this, cx| { - while let Ok(event) = rx.recv_async().await { - cx.update(|_window, cx| match event { - StateEvent::ReceivedContactList => { - let account = Account::global(cx); - account.update(cx, |this, cx| { - this.load_contacts(cx); - }); - } - StateEvent::ReceivedProfile(profile) => { - let person = PersonRegistry::global(cx); - person.update(cx, |this, cx| { - this.insert_or_update(&profile, cx); - }); - } - }) - // Entity has been released, ignore any errors - .ok(); - } - Ok(()) + // Automatically sync theme with system appearance + subscriptions.push(window.observe_window_appearance(|window, cx| { + Theme::sync_system_appearance(Some(window), cx); })); Self { dock, title_bar, _subscriptions: subscriptions, - _tasks: tasks, } } diff --git a/crates/note/Cargo.toml b/crates/note/Cargo.toml new file mode 100644 index 00000000..049a90b4 --- /dev/null +++ b/crates/note/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "note" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +state = { path = "../state" } + +gpui.workspace = true +nostr-sdk.workspace = true + +anyhow.workspace = true +smallvec.workspace = true +flume.workspace = true +log.workspace = true diff --git a/crates/note/src/lib.rs b/crates/note/src/lib.rs new file mode 100644 index 00000000..94d68291 --- /dev/null +++ b/crates/note/src/lib.rs @@ -0,0 +1,92 @@ +use std::collections::{HashMap, HashSet}; + +use gpui::{App, AppContext, Context, Entity, Global, Task}; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::client; + +pub fn init(cx: &mut App) { + NoteRegistry::set_global(cx.new(NoteRegistry::new), cx); +} + +struct GlobalNoteRegistry(Entity); + +impl Global for GlobalNoteRegistry {} + +/// Note Registry +#[derive(Debug)] +pub struct NoteRegistry { + /// Collection of all notes + pub notes: HashMap, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 2]>, +} + +impl NoteRegistry { + /// Retrieve the global note registry state + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Set the global note registry instance + pub(crate) fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalNoteRegistry(state)); + } + + /// Create a new note registry instance + pub(crate) fn new(cx: &mut Context) -> Self { + let mut tasks = smallvec![]; + + // Channel for communication between Nostr and GPUI + let (tx, rx) = flume::bounded::(2048); + + tasks.push( + // Handle nostr notifications + cx.background_spawn(async move { + let client = client(); + let mut notifications = client.notifications(); + let mut processed_events: HashSet = HashSet::default(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + continue; + }; + + if let RelayMessage::Event { event, .. } = message { + // Skip if already processed + if !processed_events.insert(event.id) { + continue; + } + + if event.kind == Kind::TextNote || event.kind == Kind::Repost { + tx.send_async(event.into_owned()).await.ok(); + } + } + } + }), + ); + + tasks.push( + // Update GPUI state + cx.spawn(async move |this, cx| { + while let Ok(event) = rx.recv_async().await { + this.update(cx, |this, cx| { + this.notes.insert(event.id, event); + cx.notify(); + }) + .ok(); + } + }), + ); + + Self { + notes: HashMap::new(), + _tasks: tasks, + } + } + + pub fn get(&self, id: &EventId) -> Option<&Event> { + self.notes.get(id) + } +} diff --git a/crates/person/Cargo.toml b/crates/person/Cargo.toml index cd71d410..153b844c 100644 --- a/crates/person/Cargo.toml +++ b/crates/person/Cargo.toml @@ -13,7 +13,5 @@ nostr-sdk.workspace = true anyhow.workspace = true smallvec.workspace = true -smol.workspace = true +flume.workspace = true log.workspace = true -serde.workspace = true -serde_json.workspace = true diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 5aa13223..be261af2 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task}; use nostr_sdk::prelude::*; @@ -20,7 +20,7 @@ pub struct PersonRegistry { pub persons: HashMap>, /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 2]>, + _tasks: SmallVec<[Task<()>; 3]>, } impl PersonRegistry { @@ -38,6 +38,9 @@ impl PersonRegistry { pub(crate) fn new(cx: &mut Context) -> Self { let mut tasks = smallvec![]; + // Channel for communication between Nostr and GPUI + let (tx, rx) = flume::bounded::(1024); + tasks.push( // Load all user profiles from the database cx.spawn(async move |this, cx| { @@ -57,6 +60,47 @@ impl PersonRegistry { }), ); + tasks.push( + // Handle nostr notifications + cx.background_spawn(async move { + let client = client(); + let mut notifications = client.notifications(); + let mut processed_events: HashSet = HashSet::default(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + continue; + }; + + if let RelayMessage::Event { event, .. } = message { + // Skip if already processed + if !processed_events.insert(event.id) { + continue; + } + + if event.kind == Kind::Metadata { + let metadata = Metadata::from_json(&event.content).unwrap_or_default(); + let profile = Profile::new(event.pubkey, metadata); + + tx.send_async(profile).await.ok(); + } + } + } + }), + ); + + tasks.push( + // Update GPUI state + cx.spawn(async move |this, cx| { + while let Ok(profile) = rx.recv_async().await { + this.update(cx, |this, cx| { + this.insert_or_update(&profile, cx); + }) + .ok(); + } + }), + ); + Self { persons: HashMap::new(), _tasks: tasks, @@ -110,8 +154,7 @@ impl PersonRegistry { pub fn get(&self, public_key: &PublicKey, cx: &App) -> Profile { self.persons .get(public_key) - .map(|e| e.read(cx)) - .cloned() + .map(|e| e.read(cx).clone()) .unwrap_or(Profile::new(public_key.to_owned(), Metadata::default())) } }