diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ca558bf Binary files /dev/null and b/.DS_Store differ diff --git a/Cargo.toml b/Cargo.toml index d53056a..b154767 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ coop = { path = "crates/*" } # UI gpui = { git = "https://github.com/huacnlee/zed.git", branch = "export-platform-window" } components = { package = "ui", git = "https://github.com/longbridgeapp/gpui-component" } +reqwest_client = { git = "https://github.com/huacnlee/zed.git", branch = "export-platform-window" } # Nostr nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index a71470f..7418574 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -15,6 +15,10 @@ tokio.workspace = true nostr-sdk.workspace = true keyring-search.workspace = true keyring.workspace = true +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +reqwest_client.workspace = true client = { version = "0.1.0", path = "../client" } rust-embed = "8.5.0" diff --git a/crates/ui/src/asset.rs b/crates/ui/src/asset.rs new file mode 100644 index 0000000..b31d392 --- /dev/null +++ b/crates/ui/src/asset.rs @@ -0,0 +1,27 @@ +use anyhow::anyhow; +use gpui::*; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "../../assets"] +pub struct Assets; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + Self::get(path) + .map(|f| Some(f.data)) + .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path)) + } + + fn list(&self, path: &str) -> Result> { + Ok(Self::iter() + .filter_map(|p| { + if p.starts_with(path) { + Some(p.into()) + } else { + None + } + }) + .collect()) + } +} diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs index 7943f29..32d13b6 100644 --- a/crates/ui/src/main.rs +++ b/crates/ui/src/main.rs @@ -1,37 +1,17 @@ +use asset::Assets; use client::NostrClient; use components::theme::{Theme, ThemeColor, ThemeMode}; use gpui::*; -use http_client::anyhow; use state::AppState; +use std::sync::Arc; use views::app::AppView; +pub mod asset; pub mod state; pub mod utils; pub mod views; -#[derive(rust_embed::RustEmbed)] -#[folder = "../../assets"] -struct Assets; - -impl AssetSource for Assets { - fn load(&self, path: &str) -> Result>> { - Self::get(path) - .map(|f| Some(f.data)) - .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path)) - } - - fn list(&self, path: &str) -> Result> { - Ok(Self::iter() - .filter_map(|p| { - if p.starts_with(path) { - Some(p.into()) - } else { - None - } - }) - .collect()) - } -} +actions!(main_menu, [Quit]); #[tokio::main] async fn main() { @@ -40,42 +20,50 @@ async fn main() { // Initializ app state let app_state = AppState::new(); - App::new().with_assets(Assets).run(move |cx| { - // Initialize components - components::init(cx); + App::new() + .with_assets(Assets) + .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) + .run(move |cx| { + // Initialize components + components::init(cx); - // Set custom theme - let mut theme = Theme::from(ThemeColor::dark()); - // TODO: support light mode - theme.mode = ThemeMode::Dark; - // TODO: adjust color set + // Set custom theme + let mut theme = Theme::from(ThemeColor::dark()); + // TODO: support light mode + theme.mode = ThemeMode::Dark; + // TODO: adjust color set - // Set global theme - cx.set_global(theme); + // Set app state + cx.set_global(theme); + cx.set_global(nostr); + cx.set_global(app_state); - // Set nostr client as global state - cx.set_global(nostr); - cx.set_global(app_state); + // Set quit action + cx.on_action(quit); - // Rerender - cx.refresh(); + // Rerender + cx.refresh(); - // Set window size - let bounds = Bounds::centered(None, size(px(860.0), px(650.0)), cx); + // Set window size + let bounds = Bounds::centered(None, size(px(860.0), px(650.0)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_decorations: Some(WindowDecorations::Client), - titlebar: Some(TitlebarOptions { - title: Some(SharedString::new_static("coop")), - appears_transparent: true, + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_decorations: Some(WindowDecorations::Client), + titlebar: Some(TitlebarOptions { + title: Some(SharedString::new_static("coop")), + appears_transparent: true, + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }, - |cx| cx.new_view(AppView::new), - ) - .unwrap(); - }); + }, + |cx| cx.new_view(AppView::new), + ) + .unwrap(); + }); +} + +fn quit(_: &Quit, cx: &mut AppContext) { + cx.quit(); } diff --git a/crates/ui/src/views/app.rs b/crates/ui/src/views/app.rs index c5a913e..0422688 100644 --- a/crates/ui/src/views/app.rs +++ b/crates/ui/src/views/app.rs @@ -1,34 +1,23 @@ use components::theme::ActiveTheme; use gpui::*; +use super::{chat_space::ChatSpace, onboarding::Onboarding}; use crate::state::AppState; -use super::{chatspace::ChatSpaceView, setup::SetupView}; - pub struct AppView { - onboarding: Model>, // TODO: create onboarding view - setup: View, - chat_space: View, + onboarding: View, + chat_space: View, } impl AppView { pub fn new(cx: &mut ViewContext<'_, Self>) -> AppView { // Onboarding model - let onboarding = cx.new_model(|_| None); - // Setup view - let setup = cx.new_view(SetupView::new); + let onboarding = cx.new_view(Onboarding::new); // Chat Space view - let chat_space = cx.new_view(ChatSpaceView::new); - - cx.foreground_executor() - .spawn(async move { - // TODO: create onboarding view for the first time open app - }) - .detach(); + let chat_space = cx.new_view(ChatSpace::new); AppView { onboarding, - setup, chat_space, } } @@ -39,18 +28,14 @@ impl Render for AppView { let mut content = div().size_full().flex().items_center().justify_center(); if cx.global::().accounts.is_empty() { - content = content.child(self.setup.clone()) + content = content.child(self.onboarding.clone()) } else { content = content.child(self.chat_space.clone()) } - if let Some(onboarding) = self.onboarding.read(cx).as_ref() { - content = content.child(onboarding.clone()) - } - div() .bg(cx.theme().background) - .text_color(rgb(0xFFFFFF)) + .text_color(cx.theme().foreground) .size_full() .child(content) } diff --git a/crates/ui/src/views/chat_space/bottom_bar.rs b/crates/ui/src/views/chat_space/bottom_bar.rs new file mode 100644 index 0000000..1a54da6 --- /dev/null +++ b/crates/ui/src/views/chat_space/bottom_bar.rs @@ -0,0 +1,113 @@ +use client::NostrClient; +use components::theme::ActiveTheme; +use gpui::*; +use nostr_sdk::prelude::*; +use prelude::FluentBuilder; +use std::time::Duration; + +use crate::state::AppState; + +#[derive(Clone, IntoElement)] +struct Account { + #[allow(dead_code)] // TODO: remove this + public_key: PublicKey, + metadata: Model>, +} + +impl Account { + pub fn new(public_key: PublicKey, cx: &mut WindowContext) -> Self { + let client = cx.global::().client; + + let metadata = cx.new_model(|_| None); + let async_metadata = metadata.clone(); + + let mut async_cx = cx.to_async(); + + cx.foreground_executor() + .spawn(async move { + match client + .fetch_metadata(public_key, Some(Duration::from_secs(2))) + .await + { + Ok(metadata) => { + async_metadata + .update(&mut async_cx, |a, b| { + *a = Some(metadata); + b.notify() + }) + .unwrap(); + } + Err(_) => todo!(), + } + }) + .detach(); + + Self { + public_key, + metadata, + } + } +} + +impl RenderOnce for Account { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + match self.metadata.read(cx) { + Some(metadata) => div() + .w_8() + .h_12() + .px_1() + .flex() + .items_center() + .justify_center() + .border_b_2() + .border_color(cx.theme().primary_active) + .when_some(metadata.picture.clone(), |parent, picture| { + parent.child( + img(picture) + .size_6() + .rounded_full() + .object_fit(ObjectFit::Cover), + ) + }), + None => div(), // TODO: add fallback image + } + } +} + +pub struct BottomBar { + accounts: Vec, +} + +impl BottomBar { + pub fn new(cx: &mut ViewContext<'_, Self>) -> BottomBar { + let state: Vec = cx + .global::() + .accounts + .clone() + .into_iter() + .collect(); + + let win_cx = cx.window_context(); + + let accounts = state + .into_iter() + .map(|pk| Account::new(pk, win_cx)) + .collect::>(); + + BottomBar { accounts } + } +} + +impl Render for BottomBar { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + div() + .h_12() + .px_3() + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .gap_1() + .children(self.accounts.clone()) + } +} diff --git a/crates/ui/src/views/chat_space/mod.rs b/crates/ui/src/views/chat_space/mod.rs new file mode 100644 index 0000000..2592517 --- /dev/null +++ b/crates/ui/src/views/chat_space/mod.rs @@ -0,0 +1,56 @@ +use bottom_bar::BottomBar; +use components::{ + resizable::{h_resizable, resizable_panel, ResizablePanelGroup}, + theme::ActiveTheme, +}; +use gpui::*; + +pub mod bottom_bar; + +pub struct ChatSpace { + layout: View, +} + +impl ChatSpace { + pub fn new(cx: &mut ViewContext<'_, Self>) -> Self { + let bottom_bar = cx.new_view(BottomBar::new); + // TODO: add chat list view + + let layout = cx.new_view(|cx| { + h_resizable(cx) + .child( + resizable_panel().size(px(260.)).content(move |cx| { + div() + .size_full() + .bg(cx.theme().secondary) + .flex() + .flex_col() + .child( + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .w_full() + .child("Chat List"), + ) + .child(bottom_bar.clone()) + .into_any_element() + }), + cx, + ) + .child( + resizable_panel().content(|_| div().child("Content").into_any_element()), + cx, + ) + }); + + Self { layout } + } +} + +impl Render for ChatSpace { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + div().relative().size_full().child(self.layout.clone()) + } +} diff --git a/crates/ui/src/views/chatspace.rs b/crates/ui/src/views/chatspace.rs deleted file mode 100644 index 12905c6..0000000 --- a/crates/ui/src/views/chatspace.rs +++ /dev/null @@ -1,24 +0,0 @@ -use gpui::*; - -pub struct ChatSpaceView { - pub text: SharedString, -} - -impl ChatSpaceView { - pub fn new(_cx: &mut ViewContext<'_, Self>) -> ChatSpaceView { - ChatSpaceView { - text: "chat".into(), - } - } -} - -impl Render for ChatSpaceView { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - div() - .flex() - .size_full() - .justify_center() - .items_center() - .child(format!("Hello, {}!", &self.text)) - } -} diff --git a/crates/ui/src/views/mod.rs b/crates/ui/src/views/mod.rs index bab6c43..3a4035d 100644 --- a/crates/ui/src/views/mod.rs +++ b/crates/ui/src/views/mod.rs @@ -1,3 +1,3 @@ pub mod app; -pub mod chatspace; -pub mod setup; +pub mod chat_space; +pub mod onboarding; diff --git a/crates/ui/src/views/setup.rs b/crates/ui/src/views/onboarding.rs similarity index 88% rename from crates/ui/src/views/setup.rs rename to crates/ui/src/views/onboarding.rs index e5a3b77..3828cc9 100644 --- a/crates/ui/src/views/setup.rs +++ b/crates/ui/src/views/onboarding.rs @@ -8,12 +8,12 @@ use nostr_sdk::prelude::*; use crate::state::AppState; -pub struct SetupView { +pub struct Onboarding { input: View, } -impl SetupView { - pub fn new(cx: &mut ViewContext<'_, Self>) -> SetupView { +impl Onboarding { + pub fn new(cx: &mut ViewContext<'_, Self>) -> Self { let input = cx.new_view(|cx| { let mut input = TextInput::new(cx); input.set_size(components::Size::Medium, cx); @@ -36,11 +36,11 @@ impl SetupView { }) .detach(); - SetupView { input } + Self { input } } } -impl Render for SetupView { +impl Render for Onboarding { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { div() .size_1_3()