feat: Out-of-Box Experience (#12)

* refactor app view

* feat: onboarding

* add back buttons in onboarding
This commit is contained in:
reya
2025-03-25 12:34:39 +07:00
committed by GitHub
parent e15cbcc22c
commit 00cf7792e5
34 changed files with 1680 additions and 1920 deletions
-277
View File
@@ -1,277 +0,0 @@
use global::get_client;
use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
StyledImage, Window,
};
use serde::Deserialize;
use std::sync::Arc;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::{dock::DockPlacement, DockArea, DockItem},
popup_menu::PopupMenuExt,
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
};
use super::{chat, contacts, onboarding, profile, relays, settings, sidebar, welcome};
use crate::device::Device;
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind {
Room(u64),
Profile,
Contacts,
Settings,
}
#[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 }
}
}
// Dock actions
impl_internal_actions!(dock, [AddPanel]);
// Account actions
actions!(account, [Logout]);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
AppView::new(window, cx)
}
pub struct AppView {
dock: Entity<DockArea>,
}
impl AppView {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
// Initialize dock layout
let dock = cx.new(|cx| DockArea::new(window, cx));
let weak_dock = dock.downgrade();
// Initialize left dock
let left_panel = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Initial central dock
let center_panel = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(welcome::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
// Set default dock layout with left and central docks
_ = weak_dock.update(cx, |view, cx| {
view.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
view.set_center(center_panel, window, cx);
});
cx.new(|_| Self { dock })
}
fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("appearance")
.xsmall()
.ghost()
.map(|this| {
if cx.theme().appearance.is_dark() {
this.icon(IconName::Sun)
} else {
this.icon(IconName::Moon)
}
})
.on_click(cx.listener(|_, _, window, cx| {
if cx.theme().appearance.is_dark() {
Theme::change(Appearance::Light, Some(window), cx);
} else {
Theme::change(Appearance::Dark, Some(window), cx);
}
}))
}
fn render_account_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("account")
.ghost()
.xsmall()
.reverse()
.icon(Icon::new(IconName::ChevronDownSmall))
.when_some(Device::global(cx), |this, account| {
this.when_some(account.read(cx).profile(), |this, profile| {
this.child(
img(profile.avatar.clone())
.size_5()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
})
})
.popup_menu(move |this, _, _cx| {
this.menu(
"Profile",
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
)
.menu(
"Contacts",
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
)
.menu(
"Settings",
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
)
.separator()
.menu("Change account", Box::new(Logout))
})
}
fn render_relays_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("relays")
.xsmall()
.ghost()
.icon(IconName::Relays)
.on_click(cx.listener(|this, _, window, cx| {
this.render_edit_relays(window, cx);
}))
}
fn render_edit_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, window, cx| {
let is_loading = relays.read(cx).loading();
this.width(px(420.))
.title("Edit your Messaging Relays")
.child(relays.clone())
.footer(
div()
.p_2()
.border_t_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.child(
Button::new("update_inbox_relays_btn")
.label("Update")
.primary()
.bold()
.rounded(ButtonRounded::Large)
.w_full()
.loading(is_loading)
.on_click(window.listener_for(&relays, |this, _, window, cx| {
this.update(window, cx);
})),
),
)
});
}
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
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),
}
}
PanelKind::Profile => {
let panel = profile::init(window, cx);
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
PanelKind::Contacts => {
let panel = Arc::new(contacts::init(window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
PanelKind::Settings => {
let panel = Arc::new(settings::init(window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
};
}
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
cx.background_spawn(async move {
// Reset nostr client
client.reset().await
})
.detach();
Root::update(window, cx, |this, window, cx| {
this.replace_view(onboarding::init(window, cx).into());
cx.notify();
});
}
}
impl Render for AppView {
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);
div()
.relative()
.size_full()
.child(
div()
.flex()
.flex_col()
.size_full()
// Title Bar
.child(
TitleBar::new()
// Left side
.child(div())
// Right side
.child(
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.px_2()
.child(self.render_mode_btn(cx))
.child(self.render_relays_btn(cx))
.child(self.render_account_btn(cx)),
),
)
// 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_logout_action))
}
}
+4 -8
View File
@@ -1,6 +1,6 @@
use anyhow::anyhow;
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
use chats::{room::Room, ChatRegistry};
use common::{
last_seen::LastSeen,
profile::NostrProfile,
@@ -37,14 +37,10 @@ pub fn init(
window: &mut Window,
cx: &mut App,
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
if let Some(chats) = ChatRegistry::global(cx) {
if let Some(room) = chats.read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, room, window, cx)))
} else {
Err(anyhow!("Chat room is not exist"))
}
if let Some(room) = ChatRegistry::global(cx).read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, room, window, cx)))
} else {
Err(anyhow!("Chat Registry is not initialized"))
Err(anyhow!("Chat room is not exist"))
}
}
+433
View File
@@ -0,0 +1,433 @@
use std::time::Duration;
use account::Account;
use common::utils::create_qr;
use global::get_client_keys;
use gpui::{
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::onboarding;
const INPUT_INVALID: &str = "You must provide a valid Private Key or Bunker.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
pub struct Login {
// Inputs
key_input: Entity<TextInput>,
error_message: Entity<Option<SharedString>>,
is_logging_in: bool,
// Nostr Connect
connect_relay: Entity<TextInput>,
connect_client: Entity<Option<NostrConnectURI>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
}
impl Login {
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 mut subscriptions = smallvec![];
let error_message = cx.new(|_| None);
let connect_client = cx.new(|_: &mut Context<'_, Option<NostrConnectURI>>| None);
let key_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("nsec... or bunker://...")
});
let connect_relay = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
input.set_text("wss://relay.nsec.app", window, cx);
input
});
subscriptions.push(cx.subscribe_in(
&key_input,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.login(window, cx);
}
},
));
subscriptions.push(cx.subscribe_in(
&connect_relay,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.change_relay(window, cx);
}
},
));
subscriptions.push(
cx.observe_in(&connect_client, window, |_, this, window, cx| {
let keys = get_client_keys().to_owned();
let account = Account::global(cx);
if let Some(uri) = this.read(cx) {
match NostrConnect::new(uri.to_owned(), keys, Duration::from_secs(300), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
}
}
}),
);
cx.spawn(|this, cx| async move {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
cx.update(|cx| {
this.update(cx, |this, cx| {
let Ok(relay_url) =
RelayUrl::parse(this.connect_relay.read(cx).text().to_string().as_str())
else {
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
this.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
})
})
.ok();
})
.detach();
Self {
key_input,
connect_relay,
connect_client,
subscriptions,
error_message,
is_logging_in: false,
name: "Login".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_logging_in(true, cx);
let content = self.key_input.read(cx).text();
let account = Account::global(cx);
if content.starts_with("nsec1") {
match SecretKey::parse(content.as_ref()) {
Ok(secret) => {
let keys = Keys::new(secret);
account.update(cx, |this, cx| {
this.login(keys, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else if content.starts_with("bunker://") {
let keys = get_client_keys().to_owned();
let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else {
self.set_error_message("Bunker URL is not valid".to_owned(), cx);
self.set_logging_in(false, cx);
return;
};
match NostrConnect::new(uri, keys, Duration::from_secs(120), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else {
self.set_logging_in(false, cx);
window.push_notification(Notification::error(INPUT_INVALID), cx);
};
}
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(relay_url) =
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
else {
window.push_notification(Notification::error("Relay URL is not valid."), cx);
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
self.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
}
fn set_error_message(&mut self, message: String, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = Some(SharedString::new(message));
cx.notify();
});
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_logging_in = status;
cx.notify();
}
fn back(&self, window: &mut Window, cx: &mut Context<Self>) {
let panel = onboarding::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.child(
div()
.w_80()
.flex()
.flex_col()
.gap_8()
.child(
div()
.text_center()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Welcome Back!"),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Continue with Private Key or Bunker"),
),
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(self.key_input.clone())
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.is_logging_in)
.disabled(self.is_logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(
self.error_message.read(cx).clone(),
|this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error),
)
},
),
),
),
)
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.2))
.text_color(
cx.theme().base.step(cx, ColorScaleStep::TWELVE),
)
.child("Continue with Nostr Connect"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Use Nostr Connect apps to scan the code"),
),
)
.when_some(self.connect_client.read(cx).as_ref(), |this, uri| {
if let Ok(qr) = create_qr(uri.to_string().as_ref()) {
this.child(
div()
.mb_2()
.p_2()
.size_64()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().appearance.is_dark(), |this| {
this.shadow_none().border_1().border_color(
cx.theme().base.step(cx, ColorScaleStep::SIX),
)
})
.bg(cx.theme().background)
.child(img(qr).h_56()),
)
} else {
this
}
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(self.connect_relay.clone())
.child(
Button::new("change")
.label("Change")
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.change_relay(window, cx);
})),
),
),
),
)
.child(
div().absolute().left_2().top_10().w_16().child(
Button::new("back")
.label("Back")
.icon(Icon::new(IconName::ArrowLeft))
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.back(window, cx);
})),
),
)
}
}
+8 -9
View File
@@ -1,11 +1,10 @@
mod chat;
mod contacts;
mod profile;
mod settings;
mod sidebar;
mod welcome;
pub mod app;
pub mod chat;
pub mod contacts;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod profile;
pub mod relays;
pub mod startup;
pub mod settings;
pub mod sidebar;
pub mod welcome;
+363
View File
@@ -0,0 +1,363 @@
use account::Account;
use async_utility::task::spawn;
use common::utils::nip96_upload;
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{
div, img, prelude::FluentBuilder, px, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, Window,
};
use nostr_sdk::prelude::*;
use smol::fs;
use std::str::FromStr;
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::TextInput,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Disableable, Icon, IconName, Sizable, Size, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::onboarding;
const STEAM_ID_DESCRIPTION: &str =
"Steam ID is used to get your currently playing game and update your status.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
}
pub struct NewAccount {
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
bio_input: Entity<TextInput>,
steam_input: Entity<TextInput>,
is_uploading: bool,
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl NewAccount {
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 name_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("Alice")
});
let avatar_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.small()
.placeholder("https://example.com/avatar.jpg")
});
let steam_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("76561199810385277")
});
let bio_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line()
.placeholder("A short introduce about you.")
});
Self {
name_input,
avatar_input,
steam_input,
bio_input,
is_uploading: false,
is_submitting: false,
name: "New Account".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).text().to_string();
let name = self.name_input.read(cx).text().to_string();
let bio = self.bio_input.read(cx).text().to_string();
let steam = self.steam_input.read(cx).text().to_string();
let mut metadata = Metadata::new()
.display_name(name)
.about(bio)
.custom_field("steam", steam);
if let Ok(url) = Url::from_str(&avatar) {
metadata = metadata.picture(url);
};
Account::global(cx).update(cx, |this, cx| {
this.new_account(metadata, window, cx);
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
self.set_uploading(true, cx);
cx.spawn_in(window, |this, mut cx| async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let Some(path) = paths.pop() else {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
return;
};
if let Ok(file_data) = fs::read(path).await {
let client = get_client();
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_text(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
}
Ok(None) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
fn back(&self, window: &mut Window, cx: &mut Context<Self>) {
let panel = onboarding::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
}
impl Panel for NewAccount {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for NewAccount {}
impl Focusable for NewAccount {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_8()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Create New Account"),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_3()
.child(
div()
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
if self.avatar_input.read(cx).text().is_empty() {
this.child(
img("brand/avatar.jpg")
.size_10()
.rounded_full()
.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).text()
))
.size_10()
.rounded_full()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.label("Set Profile Picture")
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Name *:")
.child(self.name_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bio:")
.child(self.bio_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Steam ID:")
.child(self.steam_input.clone())
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(STEAM_ID_DESCRIPTION),
),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
Button::new("submit")
.label("Continue")
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
.child(
div().absolute().left_2().top_10().w_16().child(
Button::new("back")
.label("Back")
.icon(Icon::new(IconName::ArrowLeft))
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.back(window, cx);
})),
),
)
}
}
+104 -345
View File
@@ -1,399 +1,158 @@
use common::qr::create_qr;
use gpui::{
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Window,
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use std::{path::PathBuf, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput},
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Disableable, Size, StyledExt,
Icon, IconName, StyledExt,
};
use crate::device::Device;
use crate::chat_space::ChatSpace;
use super::{login, new_account};
const LOGO_URL: &str = "brand/coop.svg";
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "A Nostr client for secure communication.";
// TODO: Replace it with Persona Mobile App
const NSTART_URL: &str =
"https://start.njump.me?an=Coop&at=ios&ac=coop&afb=yes&asf=yes&aan=null&aac=null&arr=wss://relay.damus.io&awr=wss://relay.primal.net,wss://purplerelay.com,wss://offchain.pub";
const SUBTITLE: &str = "a Nostr client for secure communication.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
enum PageKind {
Bunker,
Connect,
Selection,
}
pub struct Onboarding {
bunker_input: Entity<TextInput>,
connect_url: Entity<Option<PathBuf>>,
error_message: Entity<Option<SharedString>>,
open: PageKind,
is_loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let connect_url = cx.new(|_| None);
let error_message = cx.new(|_| None);
let bunker_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("bunker://<pubkey>?relay=wss://relay.example.com")
});
cx.new(|cx| {
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&bunker_input,
window,
move |this: &mut Self, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.connect(window, cx);
}
},
));
Self {
bunker_input,
connect_url,
error_message,
subscriptions,
open: PageKind::Selection,
is_loading: false,
}
})
cx.new(|cx| Self::view(window, cx))
}
fn login(&self, signer: NostrConnect, _window: &mut Window, cx: &mut Context<Self>) {
let Some(device) = Device::global(cx) else {
return;
};
let entity = cx.weak_entity();
device.update(cx, |this, cx| {
let login = this.login(signer, cx);
cx.spawn(|_, cx| async move {
if let Err(e) = login.await {
cx.update(|cx| {
entity
.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
this.set_loading(false, cx);
})
.ok();
})
.ok();
}
})
.detach();
});
}
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = Device::global(cx) else {
return;
};
let text = self.bunker_input.read(cx).text().to_string();
let keys = Arc::unwrap_or_clone(model.read(cx).client_keys());
self.set_loading(true, cx);
let Ok(uri) = NostrConnectURI::parse(text) else {
self.set_loading(false, cx);
self.set_error("Bunker URL is invalid".to_owned(), cx);
return;
};
let Ok(signer) = NostrConnect::new(uri, keys, Duration::from_secs(300), None) else {
self.set_loading(false, cx);
self.set_error("Failed to establish connection".to_owned(), cx);
return;
};
self.login(signer, window, cx);
}
fn wait_for_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let app_keys = Keys::generate();
let url = NostrConnectURI::client(
app_keys.public_key(),
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
"Coop",
);
// Create QR code and save it to a app directory
let qr_path = create_qr(url.to_string().as_str()).ok();
// Update QR code path
self.connect_url.update(cx, |this, cx| {
*this = qr_path;
cx.notify();
});
// Open Connect page
self.open(PageKind::Connect, window, cx);
// Wait for connection
if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) {
self.login(signer, window, cx);
} else {
self.set_loading(false, cx);
self.set_error("Failed to establish connection".to_owned(), cx);
self.open(PageKind::Selection, window, cx);
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Onboarding".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
fn open_new_account(&self, window: &mut Window, cx: &mut Context<Self>) {
let new_account = new_account::init(window, cx);
ChatSpace::set_center_panel(new_account, window, cx);
}
fn set_error(&mut self, msg: String, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = Some(msg.into());
cx.notify();
});
fn open_login(&self, window: &mut Window, cx: &mut Context<Self>) {
let login = login::init(window, cx);
ChatSpace::set_center_panel(login, window, cx);
}
}
// Dismiss error message after 3 seconds
cx.spawn(|this, cx| async move {
cx.background_executor().timer(Duration::from_secs(3)).await;
_ = cx.update(|cx| {
this.update(cx, |this, cx| {
this.error_message.update(cx, |this, cx| {
*this = None;
cx.notify();
})
})
});
})
.detach();
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context<Self>) {
self.open = kind;
cx.notify();
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
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 {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.py_4()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_8()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_8()
.gap_4()
.child(
svg()
.path(LOGO_URL)
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_4()
.text_center()
.child(
svg()
.path(LOGO_URL)
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
div()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child(TITLE),
)
.child(
div()
.text_center()
.child(
div()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child(TITLE),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child(SUBTITLE),
),
.text_sm()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(SUBTITLE),
),
),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.primary()
.reverse()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_new_account(window, cx);
})),
)
.child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| {
match self.open {
PageKind::Connect => this
.when_some(self.connect_url.read(cx).as_ref(), |this, path| {
this.child(
div()
.mb_2()
.p_2()
.size_72()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_lg()
.shadow_md()
.when(cx.theme().appearance.is_dark(), |this| {
this.shadow_none().border_1().border_color(
cx.theme().base.step(cx, ColorScaleStep::SIX),
)
})
.bg(cx.theme().background)
.child(img(path.as_path()).h_64()),
)
})
.child(
div()
.text_xs()
.text_center()
.font_semibold()
.line_height(relative(1.2))
.child("Scan this QR to connect"),
)
.child(
Button::new("wait_for_connection")
.label("Waiting for connection")
.primary()
.w_full()
.loading(true)
.disabled(true),
)
.child(
Button::new("use_url")
.label("Use Bunker URL")
.custom(
ButtonCustomVariant::new(window, cx)
.color(
cx.theme().base.step(cx, ColorScaleStep::THREE),
)
.border(
cx.theme().base.step(cx, ColorScaleStep::THREE),
)
.hover(
cx.theme().base.step(cx, ColorScaleStep::FOUR),
)
.active(
cx.theme().base.step(cx, ColorScaleStep::FIVE),
)
.foreground(
cx.theme()
.base
.step(cx, ColorScaleStep::TWELVE),
),
)
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.open(PageKind::Bunker, window, cx);
})),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.open(PageKind::Selection, window, cx);
})),
),
PageKind::Bunker => this
.child(
div()
.mb_2()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bunker URL:")
.child(self.bunker_input.clone())
.when_some(
self.error_message.read(cx).as_ref(),
|this, error| {
this.child(
div()
.my_1()
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error.clone()),
)
},
),
)
.child(
Button::new("login")
.label("Login")
.primary()
.w_full()
.loading(self.is_loading)
.disabled(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.connect(window, cx);
})),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.open(PageKind::Selection, window, cx);
})),
),
PageKind::Selection => this
.child(
Button::new("login_connect_btn")
.label("Login with Nostr Connect")
.primary()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.wait_for_connection(window, cx);
})),
)
.child(
Button::new("join_btn")
.label("Are you new? Join here!")
.ghost()
.w_full()
.on_click(|_, _, cx| {
cx.open_url(NSTART_URL);
}),
),
}
})),
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.ghost()
.underline()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_login(window, cx);
})),
),
)
}
}
+4 -20
View File
@@ -14,8 +14,6 @@ use ui::{
ContextModal, IconName, Sizable,
};
use crate::device::Device;
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
const HELP_TEXT: &str = "Please add some relays.";
@@ -113,12 +111,11 @@ impl Relays {
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let relays = self.relays.read(cx).clone();
let window_handle = window.window_handle();
// Show loading spinner
self.set_loading(true, cx);
let relays = self.relays.read(cx).clone();
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await?;
@@ -173,27 +170,14 @@ impl Relays {
Ok(output.val)
});
cx.spawn(|this, mut cx| async move {
cx.spawn_in(window, |this, mut cx| async move {
if task.await.is_ok() {
cx.update_window(window_handle, |_, window, cx| {
cx.update(|window, cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
cx.notify();
});
if let Some(device) = Device::global(cx) {
let relays = this
.read_with(cx, |this, cx| this.relays.read(cx).clone())
.unwrap();
device.update(cx, |this, cx| {
if let Some(profile) = this.profile() {
let new_profile = profile.clone().relays(Some(relays.into()));
this.set_profile(new_profile, cx);
}
})
}
window.close_modal(cx);
})
.ok();
+5 -6
View File
@@ -1,4 +1,4 @@
use chats::{registry::ChatRegistry, room::Room};
use chats::{room::Room, ChatRegistry};
use common::{profile::NostrProfile, utils::random_name};
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client};
use gpui::{
@@ -166,13 +166,12 @@ impl Compose {
if let Ok(event) = event.await {
_ = cx.update_window(window_handle, |_, window, cx| {
// Stop loading spinner
_ = this.update(cx, |this, cx| {
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
});
})
.ok();
let Some(chats) = ChatRegistry::global(cx) else {
return;
};
let chats = ChatRegistry::global(cx);
let room = Room::new(&event, cx);
chats.update(cx, |state, cx| {
+60 -63
View File
@@ -1,4 +1,4 @@
use chats::{registry::ChatRegistry, room::Room};
use chats::{room::Room, ChatRegistry};
use compose::Compose;
use gpui::{
div, img, percentage, prelude::FluentBuilder, px, relative, uniform_list, AnyElement, App,
@@ -15,7 +15,7 @@ use ui::{
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
};
use super::app::AddPanel;
use crate::chat_space::{AddPanel, PanelKind};
mod compose;
@@ -159,7 +159,7 @@ impl Sidebar {
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
super::app::PanelKind::Room(id),
PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
@@ -278,69 +278,66 @@ impl Render for Sidebar {
})),
)
.when(!self.is_collapsed, |this| {
this.flex_1()
.w_full()
.when_some(ChatRegistry::global(cx), |this, state| {
let is_loading = state.read(cx).is_loading();
let len = state.read(cx).rooms().len();
this.flex_1().w_full().map(|this| {
let state = ChatRegistry::global(cx);
let is_loading = state.read(cx).is_loading();
let len = state.read(cx).rooms().len();
if is_loading {
this.children(self.render_skeleton(5))
} else if state.read(cx).rooms().is_empty() {
this.child(
div()
.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.rounded(px(cx.theme().radius))
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No chats"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme()
.base
.step(cx, ColorScaleStep::ELEVEN),
)
.child("Recent chats will appear here."),
),
)
} else {
this.child(
uniform_list(
entity,
"rooms",
len,
move |this, range, _, cx| {
let mut items = vec![];
for ix in range {
if let Some(room) =
state.read(cx).rooms().get(ix)
{
items.push(this.render_room(ix, room, cx));
}
}
items
},
if is_loading {
this.children(self.render_skeleton(5))
} else if state.read(cx).rooms().is_empty() {
this.child(
div()
.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.rounded(px(cx.theme().radius))
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No chats"),
)
.size_full(),
.child(
div()
.text_xs()
.text_color(
cx.theme()
.base
.step(cx, ColorScaleStep::ELEVEN),
)
.child("Recent chats will appear here."),
),
)
} else {
this.child(
uniform_list(
entity,
"rooms",
len,
move |this, range, _, cx| {
let mut items = vec![];
for ix in range {
if let Some(room) = state.read(cx).rooms().get(ix) {
items.push(this.render_room(ix, room, cx));
}
}
items
},
)
}
})
.size_full(),
)
}
})
}),
)
}
-32
View File
@@ -1,32 +0,0 @@
use gpui::{
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
};
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
Startup::new(window, cx)
}
pub struct Startup {}
impl Startup {
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {})
}
}
impl Render for Startup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ impl Welcome {
impl Panel for Welcome {
fn panel_id(&self) -> SharedString {
"WelcomePanel".into()
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {