Redesign for the v1 stable release #3

Merged
reya merged 30 commits from v1-redesign into master 2026-02-04 01:43:24 +00:00
10 changed files with 88 additions and 542 deletions
Showing only changes of commit ed403188f8 - Show all commits

3
Cargo.lock generated
View File

@@ -1318,7 +1318,6 @@ dependencies = [
"title_bar",
"tracing-subscriber",
"ui",
"webbrowser",
]
[[package]]
@@ -6093,10 +6092,12 @@ dependencies = [
"flume",
"gpui",
"log",
"nostr-connect",
"nostr-lmdb",
"nostr-sdk",
"rustls",
"smol",
"webbrowser",
]
[[package]]

View File

@@ -58,7 +58,6 @@ smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser.workspace = true
indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -1,6 +1,5 @@
use gpui::{actions, App};
use key_store::{KeyItem, KeyStore};
use nostr_connect::prelude::*;
use state::NostrRegistry;
// Sidebar actions
@@ -21,20 +20,6 @@ actions!(
]
);
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
log::info!("Received Auth URL: {auth_url}");
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}
pub fn reset(cx: &mut App) {
let backend = KeyStore::global(cx).read(cx).backend();
let client = NostrRegistry::global(cx).read(cx).client();

View File

@@ -10,7 +10,6 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Window,
};
use key_store::{Credential, KeyItem, KeyStore};
use nostr_connect::prelude::*;
use person::PersonRegistry;
use relay_auth::RelayAuth;
@@ -21,34 +20,23 @@ use title_bar::TitleBar;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView;
use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::modal::ModalButtonProps;
use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension};
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
use crate::actions::{
reset, DarkMode, KeyringPopup, Logout, Settings, Themes, ViewProfile, ViewRelays,
};
use crate::user::viewer;
use crate::views::compose::compose_button;
use crate::views::{onboarding, preferences, setup_relay, startup, welcome};
use crate::{login, new_identity, sidebar, user};
use crate::views::{preferences, setup_relay, welcome};
use crate::{login, sidebar, user};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
cx.new(|cx| ChatSpace::new(window, cx))
}
pub fn login(window: &mut Window, cx: &mut App) {
let panel = login::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
pub fn new_account(window: &mut Window, cx: &mut App) {
let panel = new_identity::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
#[derive(Debug)]
pub struct ChatSpace {
/// App's Title Bar
@@ -61,20 +49,15 @@ pub struct ChatSpace {
ready: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let keystore = KeyStore::global(cx);
let title_bar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx));
let identity = nostr.read(cx).identity();
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -84,50 +67,6 @@ impl ChatSpace {
}),
);
subscriptions.push(
// Observe account entity changes
cx.observe_in(&identity, window, move |this, state, window, cx| {
if !this.ready && state.read(cx).has_public_key() {
this.set_default_layout(window, cx);
// Load all chat room in the database if available
let chat = ChatRegistry::global(cx);
chat.update(cx, |this, cx| {
this.get_rooms(cx);
});
};
}),
);
subscriptions.push(
// Observe keystore entity changes
cx.observe_in(&keystore, window, move |_this, state, window, cx| {
if state.read(cx).initialized {
let backend = state.read(cx).backend();
cx.spawn_in(window, async move |this, cx| {
let result = backend
.read_credentials(&KeyItem::User.to_string(), cx)
.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some((user, secret))) => {
let credential = Credential::new(user, secret);
this.set_startup_layout(credential, window, cx);
}
_ => {
this.set_onboarding_layout(window, cx);
}
};
})
.ok();
})
.detach();
}
}),
);
subscriptions.push(
// Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
@@ -171,6 +110,11 @@ impl ChatSpace {
}),
);
// Set the default layout for app's dock
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
});
Self {
dock,
title_bar,
@@ -179,43 +123,29 @@ impl ChatSpace {
}
}
fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let panel = Arc::new(onboarding::init(window, cx));
let center = DockItem::panel(panel);
self.dock.update(cx, |this, cx| {
this.reset(window, cx);
this.set_center(center, window, cx);
});
}
fn set_startup_layout(&mut self, cre: Credential, window: &mut Window, cx: &mut Context<Self>) {
let panel = Arc::new(startup::init(cre, window, cx));
let center = DockItem::panel(panel);
self.dock.update(cx, |this, cx| {
this.reset(window, cx);
this.set_center(center, window, cx);
});
}
fn set_default_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
let sidebar = Arc::new(sidebar::init(window, cx));
let center = Arc::new(welcome::init(window, cx));
// Sidebar
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
let left = DockItem::panel(sidebar);
// Main workspace
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(vec![center], None, &weak_dock, window, cx)],
vec![DockItem::tabs(
vec![Arc::new(welcome::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
self.ready = true;
// Update the dock layout
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
this.set_center(center, window, cx);
@@ -426,24 +356,6 @@ impl ChatSpace {
Some(ids)
}
fn set_center_panel<P>(panel: P, window: &mut Window, cx: &mut App)
where
P: PanelView,
{
if let Some(Some(root)) = window.root::<Root>() {
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
let panel = Arc::new(panel);
let center = DockItem::panel(panel);
chatspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
});
}
}
}
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx);
@@ -569,62 +481,18 @@ impl ChatSpace {
)
})
}
fn titlebar_center(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity().downgrade();
let panel = self.dock.read(cx).items.view();
let title = panel.title(cx);
let id = panel.panel_id(cx);
if id == "Onboarding" {
return div();
};
h_flex()
.flex_1()
.w_full()
.justify_center()
.text_center()
.font_semibold()
.text_sm()
.child(
div().flex_1().child(
Button::new("back")
.icon(IconName::ArrowLeft)
.small()
.ghost_alt()
.rounded()
.on_click(move |_ev, window, cx| {
entity
.update(cx, |this, cx| {
this.set_onboarding_layout(window, cx);
})
.expect("Entity has been released");
}),
),
)
.child(div().flex_1().child(title))
.child(div().flex_1())
}
}
impl Render for ChatSpace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
let left = self.titlebar_left(window, cx).into_any_element();
let right = self.titlebar_right(window, cx).into_any_element();
let center = self.titlebar_center(cx).into_any_element();
let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty();
// Update title bar children
self.title_bar.update(cx, |this, _cx| {
if single_panel {
this.set_children(vec![center]);
} else {
this.set_children(vec![left, right]);
}
});
div()

View File

@@ -18,8 +18,6 @@ use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
use crate::actions::CoopAuthUrlHandler;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
cx.new(|cx| Login::new(window, cx))
}
@@ -120,7 +118,7 @@ impl Login {
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {

View File

@@ -1,5 +1,4 @@
pub mod compose;
pub mod onboarding;
pub mod preferences;
pub mod screening;
pub mod setup_relay;

View File

@@ -1,363 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, StatefulInteractiveElement, Styled, Task, Window,
};
use key_store::{KeyItem, KeyStore};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::notification::Notification;
use ui::{divider, h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
use crate::chatspace::{self};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
#[derive(Debug, Clone)]
pub enum NostrConnectApp {
Nsec(String),
Amber(String),
Aegis(String),
}
impl NostrConnectApp {
pub fn all() -> Vec<Self> {
vec![
NostrConnectApp::Nsec("https://nsec.app".to_string()),
NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()),
NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()),
]
}
pub fn url(&self) -> &str {
match self {
Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url,
}
}
pub fn as_str(&self) -> String {
match self {
NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(),
NostrConnectApp::Amber(_) => "Amber (Android)".into(),
NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(),
}
}
}
pub struct Onboarding {
app_keys: Keys,
qr_code: Option<Arc<Image>>,
/// Panel
name: SharedString,
focus_handle: FocusHandle,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let app_keys = Keys::generate();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let qr_code = uri.to_string().to_qr();
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
let mut tasks = smallvec![];
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |this, cx| {
let result = signer.bunker_uri().await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.save_connection(&uri, window, cx);
this.connect(signer, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
qr_code,
app_keys,
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
_tasks: tasks,
}
}
fn save_connection(
&mut self,
uri: &NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = KeyStore::global(cx).read(cx).backend();
let username = self.app_keys.public_key().to_hex();
let secret = self.app_keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();
// Clear the secret parameter in the URI if it exists
if let Some(s) = uri.secret() {
clean_uri = clean_uri.replace(s, "");
}
cx.spawn_in(window, async move |this, cx| {
let user_url = KeyItem::User.to_string();
let bunker_url = KeyItem::Bunker.to_string();
let user_password = clean_uri.into_bytes();
// Write bunker uri to keyring for further connection
if let Err(e) = keystore
.write_credentials(&user_url, "bunker", &user_password, cx)
.await
{
this.update_in(cx, |_, window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
// Write the app keys for further connection
if let Err(e) = keystore
.write_credentials(&bunker_url, &username, &secret, cx)
.await
{
this.update_in(cx, |_, window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
})
.detach();
}
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
client.set_signer(signer).await;
})
.detach();
}
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
let all_apps = NostrConnectApp::all();
let mut items = Vec::with_capacity(all_apps.len());
for (ix, item) in all_apps.into_iter().enumerate() {
items.push(self.render_app(ix, item.as_str(), item.url(), cx));
}
items
}
fn render_app<T>(&self, ix: usize, label: T, url: &str, cx: &Context<Self>) -> impl IntoElement
where
T: Into<SharedString>,
{
div()
.id(ix)
.flex_1()
.rounded(cx.theme().radius)
.py_0p5()
.px_2()
.bg(cx.theme().ghost_element_background_alt)
.child(label.into())
.on_click({
let url = url.to_owned();
move |_e, _window, cx| {
cx.open_url(&url);
}
})
}
}
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.child(
v_flex()
.flex_1()
.h_full()
.gap_10()
.items_center()
.justify_center()
.child(
v_flex()
.items_center()
.justify_center()
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Welcome to Coop")),
)
.child(div().text_color(cx.theme().text_muted).child(
SharedString::from("Chat Freely, Stay Private on Nostr."),
)),
),
)
.child(
v_flex()
.w_80()
.gap_3()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label(SharedString::from("Start Messaging on Nostr"))
.primary()
.large()
.bold()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::new_account(window, cx);
})),
)
.child(
h_flex()
.my_1()
.gap_1()
.child(divider(cx))
.child(div().text_sm().text_color(cx.theme().text_muted).child(
SharedString::from(
"Already have an account? Continue with",
),
))
.child(divider(cx)),
)
.child(
Button::new("key")
.label("Secret Key or Bunker")
.large()
.ghost_alt()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
),
),
)
.child(
div()
.relative()
.p_2()
.flex_1()
.h_full()
.rounded(cx.theme().radius_lg)
.child(
v_flex()
.size_full()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius_lg)
.child(
v_flex()
.gap_5()
.items_center()
.justify_center()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_lg())
.border_1()
.border_color(cx.theme().element_active),
)
})
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from(
"Continue with Nostr Connect",
)),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Use Nostr Connect apps to scan the code",
)),
)
.child(
h_flex()
.mt_2()
.gap_1()
.text_xs()
.justify_center()
.children(self.render_apps(cx)),
),
),
),
),
)
}
}

View File

@@ -20,7 +20,7 @@ use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Sizable, StyledExt, WindowExtension};
use crate::actions::{reset, CoopAuthUrlHandler};
use crate::actions::reset;
pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity<Startup> {
cx.new(|cx| Startup::new(cre, window, cx))
@@ -129,7 +129,7 @@ impl Startup {
let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// signer.auth_url_handler(CoopAuthUrlHandler);
// Connect to the remote signer
this._tasks.push(

View File

@@ -9,11 +9,13 @@ common = { path = "../common" }
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
rustls = "0.23"

View File

@@ -2,8 +2,9 @@ use std::collections::HashSet;
use std::time::Duration;
use anyhow::Error;
use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use common::{config_dir, BOOTSTRAP_RELAYS, CLIENT_NAME, SEARCH_RELAYS};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_connect::prelude::*;
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;
@@ -25,6 +26,10 @@ pub fn init(cx: &mut App) {
/// Default timeout for subscription
pub const TIMEOUT: u64 = 3;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default subscription id for gift wrap events
pub const GIFTWRAP_SUBSCRIPTION: &str = "giftwrap-events";
@@ -592,4 +597,56 @@ impl NostrRegistry {
Ok(())
}));
}
/// Store a connection for future uses
pub fn persit_connection(&mut self, uri: NostrConnectUri, cx: &mut App) {
let client = self.client();
let rng_keys = Keys::generate();
self.tasks.push(cx.background_spawn(async move {
// Construct the event for application-specific data
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string())
.tag(Tag::identifier("coop:account"))
.sign(&rng_keys)
.await?;
// Store the event in the database
client.database().save_event(&event).await?;
Ok(())
}));
}
/// Generate a direct nostr connection initiated by the client
pub fn client_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) {
let app_keys = self.app_keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
// Determine the relay will be used for Nostr Connect
let relay = match relay {
Some(relay) => relay,
None => RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(),
};
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
(signer, uri)
}
}
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}