diff --git a/Cargo.lock b/Cargo.lock index 9bb94d3..da7f635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6927,6 +6927,7 @@ dependencies = [ "gpui-native-kit", "log", "metadata", + "nostr", "paths", "settings", "signed_core", diff --git a/crates/signed_app/Cargo.toml b/crates/signed_app/Cargo.toml index 2407490..dbb638f 100644 --- a/crates/signed_app/Cargo.toml +++ b/crates/signed_app/Cargo.toml @@ -17,6 +17,7 @@ utils = { path = "../utils" } gpui.workspace = true gpui-native-kit.workspace = true +nostr.workspace = true anyhow.workspace = true futures.workspace = true log.workspace = true diff --git a/crates/signed_app/src/app.rs b/crates/signed_app/src/app.rs index 3eef175..0e8877a 100644 --- a/crates/signed_app/src/app.rs +++ b/crates/signed_app/src/app.rs @@ -1,32 +1,80 @@ use gpui::{ App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, - Window, div, + Subscription, Window, div, }; -use gpui_native_kit::empty_state::EmptyState; -use gpui_native_kit::layout::dock::DockState; +use gpui_native_kit::icons::IconRole; +use gpui_native_kit::layout::dock::{Dock, DockState}; use gpui_native_kit::layout::shell::{LayoutState, Shell}; use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override}; -use settings::{AppearanceMode, Settings}; +use gpui_native_kit::toast::Toast; +use settings::{AppearanceMode, Settings, SettingsStore}; +use signed_state::{Backend, BackendEvent}; -use crate::dialogs::DialogHost; +use crate::dialogs::{self, DialogHost}; +use crate::views::placeholder::PlaceholderView; +use crate::views::sidebar::SidebarView; pub struct AppRoot { layout: Entity, dock: Entity, + sidebar: Entity, dialogs: Entity, + _subscriptions: Vec, } impl AppRoot { - pub fn new(_window: &mut Window, cx: &mut App) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { let layout = LayoutState::new(cx); let dock = DockState::new(cx); + + dock.update(cx, |dock, cx| { + dock.register_panel("welcome", |_, cx| { + cx.new(|_| PlaceholderView::new("Welcome")) + }); + dock.register_panel("profile", |_, cx| { + cx.new(|_| PlaceholderView::new("Profile")) + }); + dock.register_panel("relays", |_, cx| cx.new(|_| PlaceholderView::new("Relays"))); + dock.open_panel("welcome", window, cx); + }); + + let sidebar = cx.new(|cx| SidebarView::new(dock.downgrade(), cx)); let dialogs = cx.new(DialogHost::new); DialogHost::set_global(dialogs.clone(), cx); + let mut subscriptions = Vec::new(); + let backend = Backend::global(cx); + subscriptions.push( + cx.subscribe(&backend, |this, _backend, event, cx| match event { + BackendEvent::PassphraseRequired => dialogs::passphrase::open(cx), + BackendEvent::Error(message) => { + this.layout.update(cx, |layout, cx| { + layout.push_toast(Toast::new(message.clone()).icon(IconRole::Warning), cx); + }); + } + _ => {} + }), + ); + + let settings = SettingsStore::global(cx); + subscriptions.push(cx.observe(&settings, |_this, store, cx| { + let settings = store.read(cx).settings().clone(); + apply_appearance(&settings, cx); + })); + + window.defer(cx, |_window, cx| { + let required = Backend::global(cx).read(cx).passphrase_required(); + if required { + dialogs::passphrase::open(cx); + } + }); + Self { layout, dock, + sidebar, dialogs, + _subscriptions: subscriptions, } } } @@ -40,7 +88,13 @@ impl Render for AppRoot { .text_color(theme.colors.foreground) .child( Shell::new("signed-shell", self.layout.clone()) - .center(EmptyState::new("Signed").hint("UI migration in progress")), + .left_sidebar(self.sidebar.read(cx).build(cx)) + .center( + Dock::new("signed-dock", self.dock.clone()) + .window_controls(true) + .traffic_light_pad(!self.layout.read(cx).is_left_sidebar_visible()) + .sidebar_toggles(self.layout.clone()), + ), ) .child(self.dialogs.clone()) } diff --git a/crates/signed_app/src/dialogs/exit.rs b/crates/signed_app/src/dialogs/exit.rs new file mode 100644 index 0000000..47f5d7d --- /dev/null +++ b/crates/signed_app/src/dialogs/exit.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, Render, + Styled as _, Window, div, +}; +use gpui_native_kit::button::{Button, ButtonVariant}; +use gpui_native_kit::common::v_flex; +use gpui_native_kit::dialog::Dialog; +use gpui_native_kit::theme::ActiveTheme as _; +use signed_state::Backend; + +use super::{close_global, open_global}; + +pub struct ExitDialog; + +impl Render for ExitDialog { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Dialog::new("exit-dialog") + .open(true) + .on_change(|open, _, cx| { + if !open { + close_global(cx); + } + }) + .title("Exit Signed?") + .content(|_, cx| { + v_flex() + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child( + "You will be signed out on this device and will need to sign in again on the next session.", + ), + ) + .into_any_element() + }) + .actions(|_, _| { + let cancel: AnyElement = Button::new("cancel-exit") + .label("Cancel") + .variant(ButtonVariant::Secondary) + .on_click(|_, _, cx| close_global(cx)) + .into_any_element(); + + let exit: AnyElement = Button::new("confirm-exit") + .label("Exit") + .variant(ButtonVariant::Destructive) + .on_click(|_, _, cx| { + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| backend.sign_out(cx)); + close_global(cx); + }) + .into_any_element(); + + vec![cancel, exit] + }) + } +} + +/// Opens the sign-out confirmation. +pub fn open(cx: &mut App) { + open_global(cx.new(|_| ExitDialog), cx); +} diff --git a/crates/signed_app/src/dialogs/import.rs b/crates/signed_app/src/dialogs/import.rs new file mode 100644 index 0000000..a058383 --- /dev/null +++ b/crates/signed_app/src/dialogs/import.rs @@ -0,0 +1,337 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + Styled as _, Subscription, Task, Window, div, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::v_flex; +use gpui_native_kit::dialog::Dialog; +use gpui_native_kit::field::Field; +use gpui_native_kit::input::{Input, InputState, InputStateEvent}; +use gpui_native_kit::theme::ActiveTheme as _; +use signed_state::Backend; + +use super::close_global; +use super::progress::{DialogProgress, error_row}; + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Stage { + #[default] + Credential, + /// An `nsec` was entered; ask for a passphrase to encrypt it with. + Encrypt, + /// An `ncryptsec` was entered; ask for its passphrase to decrypt it. + Decrypt, +} + +/// The credential entered on the first step. +enum Credential { + Nsec(String), + Ncryptsec(String), + Bunker(String), +} + +impl Credential { + fn parse(input: &str) -> Option { + let input = input.trim(); + + if input.starts_with("nsec1") { + Some(Self::Nsec(input.to_owned())) + } else if input.starts_with("ncryptsec1") { + Some(Self::Ncryptsec(input.to_owned())) + } else if input.starts_with("bunker://") { + Some(Self::Bunker(input.to_owned())) + } else { + None + } + } +} + +/// State of the import dialog, so the current step and async results can be rendered. +#[derive(Default)] +pub struct ImportState { + stage: Stage, + credential: String, + progress: DialogProgress, +} + +pub struct ImportDialog { + secret_input: Entity, + pass_input: Entity, + repass_input: Entity, + state: Entity, + _subscriptions: Vec, +} + +impl ImportDialog { + pub fn new(cx: &mut App) -> Entity { + let secret_input = InputState::new(cx); + let pass_input = InputState::new(cx); + pass_input.update(cx, |state, cx| state.set_password(true, cx)); + let repass_input = InputState::new(cx); + repass_input.update(cx, |state, cx| state.set_password(true, cx)); + let state = cx.new(|_| ImportState::default()); + + cx.new(|cx| { + let mut subscriptions = Vec::new(); + + for input in [&secret_input, &pass_input, &repass_input] { + let secret_input = secret_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + subscriptions.push(cx.subscribe(input, move |_this, _input, event, cx| { + if matches!(event, InputStateEvent::Submitted) { + submit(&secret_input, &pass_input, &repass_input, &state, cx); + } + })); + } + + Self { + secret_input, + pass_input, + repass_input, + state, + _subscriptions: subscriptions, + } + }) + } +} + +impl Render for ImportDialog { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let secret_input = self.secret_input.clone(); + let pass_input = self.pass_input.clone(); + let repass_input = self.repass_input.clone(); + let state = self.state.clone(); + let action_state = state.clone(); + let action_secret = secret_input.clone(); + let action_pass = pass_input.clone(); + let action_repass = repass_input.clone(); + + Dialog::new("import-dialog") + .open(true) + .on_change(|open, _, cx| { + if !open { + close_global(cx); + } + }) + .title("Import identity") + .content(move |_window, cx| { + let state = state.read(cx); + let stage = state.stage; + let description = match stage { + Stage::Credential => { + "Paste the secret key or bunker URI of an existing identity." + } + Stage::Encrypt => "Choose a passphrase to encrypt your key on this device.", + Stage::Decrypt => "Enter the passphrase used to encrypt this key.", + }; + + let form = match stage { + Stage::Credential => v_flex().gap_3().child( + Field::new("secret-field", "Secret key or bunker URI") + .child(Input::new("secret-input", secret_input.clone())), + ), + Stage::Encrypt => v_flex() + .gap_3() + .child( + Field::new("passphrase-field", "Passphrase") + .child(Input::new("passphrase-input", pass_input.clone())), + ) + .child( + Field::new("repeat-field", "Repeat passphrase") + .child(Input::new("repeat-input", repass_input.clone())), + ), + Stage::Decrypt => v_flex().gap_3().child( + Field::new("passphrase-field", "Passphrase") + .child(Input::new("passphrase-input", pass_input.clone())), + ), + }; + + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(description), + ) + .child(form) + .when_some(error_row(&state.progress.error, cx), |el, error| { + el.child(error) + }) + .into_any_element() + }) + .actions(move |_, cx| { + let state = action_state.read(cx); + let busy = state.progress.busy; + let label = match state.stage { + Stage::Credential => "Continue", + Stage::Encrypt => "Import identity", + Stage::Decrypt => "Unlock", + }; + let secret = action_secret.clone(); + let pass = action_pass.clone(); + let repass = action_repass.clone(); + let state = action_state.clone(); + + let import: AnyElement = Button::new("import-identity") + .label(label) + .loading(busy) + .disabled(busy) + .on_click(move |_, _, cx| { + submit(&secret, &pass, &repass, &state, cx); + }) + .into_any_element(); + + vec![import] + }) + } +} + +fn submit( + secret_input: &Entity, + pass_input: &Entity, + repass_input: &Entity, + state: &Entity, + cx: &mut App, +) { + if state.read(cx).progress.busy { + return; + } + + match state.read(cx).stage { + Stage::Credential => start_import(secret_input, state, cx), + Stage::Encrypt => import_nsec(pass_input, repass_input, state, cx), + Stage::Decrypt => import_ncryptsec(pass_input, state, cx), + } +} + +/// Parses the entered credential and either moves to the passphrase step or connects to a bunker. +fn start_import(secret_input: &Entity, state: &Entity, cx: &mut App) { + let credential = match Credential::parse(secret_input.read(cx).value()) { + Some(credential) => credential, + None => { + state.update(cx, |state, cx| { + state + .progress + .fail("Enter an nsec, ncryptsec or bunker:// URI"); + cx.notify(); + }); + return; + } + }; + + match credential { + Credential::Nsec(secret) => { + state.update(cx, |state, cx| { + state.credential = secret; + state.stage = Stage::Encrypt; + state.progress.error = None; + cx.notify(); + }); + } + Credential::Ncryptsec(secret) => { + state.update(cx, |state, cx| { + state.credential = secret; + state.stage = Stage::Decrypt; + state.progress.error = None; + cx.notify(); + }); + } + Credential::Bunker(uri) => { + state.update(cx, |state, cx| { + state.progress.begin(); + cx.notify(); + }); + + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx)); + finish_import(task, state, cx); + } + } +} + +/// Encrypts the entered `nsec` with the passphrase and signs in with it. +fn import_nsec( + pass_input: &Entity, + repass_input: &Entity, + state: &Entity, + cx: &mut App, +) { + let backend = Backend::global(cx); + let nsec = state.read(cx).credential.clone(); + let pass = pass_input.read(cx).value().to_string(); + let repass = repass_input.read(cx).value().to_string(); + + if pass.is_empty() { + state.update(cx, |state, cx| { + state.progress.fail("Passphrase must not be empty"); + cx.notify(); + }); + return; + } + + if pass != repass { + state.update(cx, |state, cx| { + state.progress.fail("Passphrases do not match"); + cx.notify(); + }); + return; + } + + state.update(cx, |state, cx| { + state.progress.begin(); + cx.notify(); + }); + + let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx)); + finish_import(task, state, cx); +} + +/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it. +fn import_ncryptsec(pass_input: &Entity, state: &Entity, cx: &mut App) { + let backend = Backend::global(cx); + let ncryptsec = state.read(cx).credential.clone(); + let pass = pass_input.read(cx).value().to_string(); + + if pass.is_empty() { + state.update(cx, |state, cx| { + state.progress.fail("Passphrase must not be empty"); + cx.notify(); + }); + return; + } + + state.update(cx, |state, cx| { + state.progress.begin(); + cx.notify(); + }); + + let task = backend.update(cx, |backend, cx| { + backend.import_ncryptsec(&ncryptsec, &pass, cx) + }); + finish_import(task, state, cx); +} + +/// Runs the import task to completion, closing the dialog on success. +fn finish_import( + task: Task>, + state: &Entity, + cx: &mut App, +) { + let state = state.clone(); + + cx.spawn(async move |cx| match task.await { + Ok(_) => cx.update(close_global), + Err(error) => { + state.update(cx, |state, cx| { + state.progress.fail(error.to_string()); + cx.notify(); + }); + } + }) + .detach(); +} diff --git a/crates/signed_app/src/dialogs/mod.rs b/crates/signed_app/src/dialogs/mod.rs index 342bf45..43f12b2 100644 --- a/crates/signed_app/src/dialogs/mod.rs +++ b/crates/signed_app/src/dialogs/mod.rs @@ -1,3 +1,9 @@ +pub mod exit; +pub mod import; +pub mod onboarding; +pub mod passphrase; +pub mod progress; + use gpui::prelude::FluentBuilder as _; use gpui::{ AnyView, App, Context, Entity, Global, IntoElement, ParentElement as _, Render, Styled as _, @@ -37,6 +43,28 @@ impl DialogHost { } } +/// Replaces the mounted modal with `view`. +pub fn open_global(view: impl Into, cx: &mut App) { + let host = DialogHost::global(cx); + host.update(cx, |host, cx| host.open(view.into(), cx)); +} + +/// Unmounts the current modal, if any. +pub fn close_global(cx: &mut App) { + let host = DialogHost::global(cx); + host.update(cx, |host, cx| host.close(cx)); +} + +/// Opens the onboarding form. +pub fn open_onboarding(cx: &mut App) { + open_global(onboarding::OnboardingDialog::new(cx), cx); +} + +/// Opens the identity import form. +pub fn open_import(cx: &mut App) { + open_global(import::ImportDialog::new(cx), cx); +} + impl Render for DialogHost { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() diff --git a/crates/signed_app/src/dialogs/onboarding.rs b/crates/signed_app/src/dialogs/onboarding.rs new file mode 100644 index 0000000..82a7df3 --- /dev/null +++ b/crates/signed_app/src/dialogs/onboarding.rs @@ -0,0 +1,139 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + Styled as _, Window, div, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::v_flex; +use gpui_native_kit::dialog::Dialog; +use gpui_native_kit::field::Field; +use gpui_native_kit::input::{Input, InputState}; +use gpui_native_kit::theme::ActiveTheme as _; +use signed_state::Backend; + +use super::close_global; +use super::progress::{DialogProgress, error_row}; + +pub struct OnboardingDialog { + name_input: Entity, + pass_input: Entity, + repass_input: Entity, + state: Entity, +} + +impl OnboardingDialog { + pub fn new(cx: &mut App) -> Entity { + let name_input = InputState::new(cx); + let pass_input = InputState::new(cx); + pass_input.update(cx, |state, cx| state.set_password(true, cx)); + let repass_input = InputState::new(cx); + repass_input.update(cx, |state, cx| state.set_password(true, cx)); + let state = cx.new(|_| DialogProgress::default()); + + cx.new(|_| Self { + name_input, + pass_input, + repass_input, + state, + }) + } + + fn create_identity(&mut self, cx: &mut Context) { + if self.state.read(cx).busy { + return; + } + + let name = self.name_input.read(cx).value().to_string(); + let pass = self.pass_input.read(cx).value().to_string(); + let repass = self.repass_input.read(cx).value().to_string(); + + if pass != repass { + self.state.update(cx, |state, cx| { + state.fail("Passphrases do not match"); + cx.notify(); + }); + return; + } + + self.state.update(cx, |state, cx| { + state.begin(); + cx.notify(); + }); + + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| backend.create_identity(&name, &pass, cx)); + let state = self.state.clone(); + + cx.spawn(async move |_this, cx| match task.await { + Ok(_) => cx.update(close_global), + Err(error) => { + state.update(cx, |state, cx| { + state.fail(error.to_string()); + cx.notify(); + }); + } + }) + .detach(); + } +} + +impl Render for OnboardingDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let name_input = self.name_input.clone(); + let pass_input = self.pass_input.clone(); + let repass_input = self.repass_input.clone(); + let state = self.state.clone(); + let action_state = state.clone(); + let this = cx.entity().downgrade(); + + Dialog::new("onboarding-dialog") + .open(true) + .on_change(|open, _, cx| { + if !open { + close_global(cx); + } + }) + .title("Create identity") + .content(move |_window, cx| { + let progress = state.read(cx); + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child("Set up your Signed identity to get started."), + ) + .child( + Field::new("name-field", "Name") + .description("Max 255 characters") + .child(Input::new("name-input", name_input.clone())), + ) + .child( + Field::new("passphrase-field", "Passphrase") + .child(Input::new("passphrase-input", pass_input.clone())), + ) + .child( + Field::new("repeat-field", "Repeat passphrase") + .child(Input::new("repeat-input", repass_input.clone())), + ) + .when_some(error_row(&progress.error, cx), |el, error| el.child(error)) + .into_any_element() + }) + .actions(move |_, cx| { + let busy = action_state.read(cx).busy; + let this = this.clone(); + + let create: AnyElement = Button::new("create-identity") + .label("Create new identity") + .loading(busy) + .disabled(busy) + .on_click(move |_, _, cx| { + let _ = this.update(cx, |this, cx| this.create_identity(cx)); + }) + .into_any_element(); + + vec![create] + }) + } +} diff --git a/crates/signed_app/src/dialogs/passphrase.rs b/crates/signed_app/src/dialogs/passphrase.rs new file mode 100644 index 0000000..4bb22aa --- /dev/null +++ b/crates/signed_app/src/dialogs/passphrase.rs @@ -0,0 +1,139 @@ +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + Styled as _, Subscription, Window, div, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::v_flex; +use gpui_native_kit::dialog::Dialog; +use gpui_native_kit::field::Field; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::input::{Input, InputState, InputStateEvent}; +use gpui_native_kit::theme::ActiveTheme as _; +use signed_state::Backend; + +use super::progress::{DialogProgress, error_row, push_steps_row}; +use super::{close_global, open_global}; + +pub struct PassphraseDialog { + pass_input: Entity, + state: Entity, + _enter_subscription: Subscription, +} + +impl PassphraseDialog { + pub fn new(cx: &mut App) -> Entity { + let pass_input = InputState::new(cx); + pass_input.update(cx, |state, cx| state.set_password(true, cx)); + let state = cx.new(|_| DialogProgress::default()); + + cx.new(|cx| { + let enter_subscription = + cx.subscribe(&pass_input, |this: &mut Self, _input, event, cx| { + if matches!(event, InputStateEvent::Submitted) { + this.unlock(cx); + } + }); + + Self { + pass_input, + state, + _enter_subscription: enter_subscription, + } + }) + } + + fn unlock(&mut self, cx: &mut Context) { + if self.state.read(cx).busy { + return; + } + + let pass = self.pass_input.read(cx).value().to_string(); + + if pass.is_empty() { + self.state.update(cx, |state, cx| { + state.fail("Passphrase must not be empty"); + cx.notify(); + }); + return; + } + + self.state.update(cx, |state, cx| { + state.begin(); + cx.notify(); + }); + + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); + let state = self.state.clone(); + + cx.spawn(async move |_this, cx| match task.await { + Ok(_) => { + cx.update(close_global); + } + Err(error) => { + state.update(cx, |state, cx| { + state.fail(error.to_string()); + cx.notify(); + }); + } + }) + .detach(); + } +} + +impl Render for PassphraseDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let pass_input = self.pass_input.clone(); + let state = self.state.clone(); + let action_state = state.clone(); + let this = cx.entity().downgrade(); + + Dialog::new("passphrase-dialog") + .open(true) + .on_change(|_open, _, _| {}) + .title("Unlock your identity") + .content(move |_window, cx| { + let progress = state.read(cx); + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child("Enter the passphrase used to encrypt this identity."), + ) + .child( + Field::new("passphrase-field", "Passphrase") + .child(Input::new("passphrase-input", pass_input.clone())), + ) + .when_some(error_row(&progress.error, cx), |el, error| el.child(error)) + .when_some(push_steps_row(&progress.push_steps, cx), |el, steps| { + el.child(steps) + }) + .into_any_element() + }) + .actions(move |_, cx| { + let busy = action_state.read(cx).busy; + let this = this.clone(); + + let unlock: AnyElement = Button::new("unlock") + .label("Unlock") + .icon(Icon::named(CustomIconName::Unlock)) + .loading(busy) + .disabled(busy) + .on_click(move |_, _, cx| { + let _ = this.update(cx, |this, cx| this.unlock(cx)); + }) + .into_any_element(); + + vec![unlock] + }) + } +} + +/// Opens the dialog; used by the backend passphrase event and the startup fallback. +pub fn open(cx: &mut App) { + open_global(PassphraseDialog::new(cx), cx); +} diff --git a/crates/signed_app/src/dialogs/progress.rs b/crates/signed_app/src/dialogs/progress.rs new file mode 100644 index 0000000..632f153 --- /dev/null +++ b/crates/signed_app/src/dialogs/progress.rs @@ -0,0 +1,128 @@ +use gpui::{ + AnyElement, App, Context, IntoElement as _, ParentElement as _, SharedString, Styled as _, + Subscription, div, px, +}; +use gpui_native_kit::common::{h_flex, v_flex}; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::spinner::Spinner; +use gpui_native_kit::theme::ActiveTheme as _; +use signed_core::RepoAddr; +use signed_state::{Backend, BackendEvent, PushStepStatus}; + +/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it. +#[derive(Debug, Default)] +pub struct DialogProgress { + pub busy: bool, + pub error: Option, + // The repository whose push checklist is shown; `None` outside a publish. + push_addr: Option, + pub push_steps: Vec, + _push_subscription: Option, +} + +impl DialogProgress { + /// Marks an action as started, disabling the form and clearing the previous error. + pub fn begin(&mut self) { + self.busy = true; + self.error = None; + } + + /// Starts a publish for the repository at `addr` and listens for its push steps. + pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context) { + self.begin(); + self.push_addr = Some(addr); + self.push_steps.clear(); + + if self._push_subscription.is_none() { + let backend = Backend::global(cx); + self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| { + let BackendEvent::PushProgress { addr, status } = event else { + return; + }; + + if this.push_addr.as_ref() != Some(addr) { + return; + } + + match this + .push_steps + .iter_mut() + .find(|step| step.step() == status.step()) + { + Some(step) => *step = status.clone(), + None => this.push_steps.push(status.clone()), + } + + cx.notify(); + })); + } + } + + /// Marks an action as failed, enabling the form and showing `message`. + pub fn fail(&mut self, message: impl Into) { + self.busy = false; + self.error = Some(message.into()); + } +} + +/// The shared error line under a dialog form, `None` when there is no error. +pub fn error_row(error: &Option, cx: &App) -> Option { + error.as_ref().map(|message| { + div() + .text_sm() + .text_color(cx.theme().ramps.destructive.base) + .child(message.clone()) + .into_any_element() + }) +} + +/// The push checklist of a running publish, `None` before any step is reported. +pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option { + if steps.is_empty() { + return None; + } + + let rows = steps + .iter() + .map(|status| { + let label = SharedString::from(status.step().label()); + + match status { + PushStepStatus::Running(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Spinner::new("push-step")) + .child(label) + .into_any_element(), + PushStepStatus::Done(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().colors.foreground) + .child( + Icon::named(IconRole::Check) + .size(px(14.)) + .color(cx.theme().colors.foreground), + ) + .child(label) + .into_any_element(), + PushStepStatus::Failed(_, reason) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().ramps.destructive.base) + .child( + Icon::named(IconRole::Close) + .size(px(14.)) + .color(cx.theme().ramps.destructive.base), + ) + .child(SharedString::from(format!( + "{}: {reason}", + status.step().label() + ))) + .into_any_element(), + } + }) + .collect::>(); + + Some(v_flex().gap_1().children(rows).into_any_element()) +} diff --git a/crates/signed_app/src/views/mod.rs b/crates/signed_app/src/views/mod.rs index 8b13789..4e24430 100644 --- a/crates/signed_app/src/views/mod.rs +++ b/crates/signed_app/src/views/mod.rs @@ -1 +1,2 @@ - +pub mod placeholder; +pub mod sidebar; diff --git a/crates/signed_app/src/views/placeholder.rs b/crates/signed_app/src/views/placeholder.rs new file mode 100644 index 0000000..d37130b --- /dev/null +++ b/crates/signed_app/src/views/placeholder.rs @@ -0,0 +1,30 @@ +use gpui::{ + App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, +}; +use gpui_native_kit::empty_state::EmptyState; +use gpui_native_kit::layout::dock::Panel; + +/// A tab that stands in for a screen that is not ported yet. +pub struct PlaceholderView { + title: SharedString, +} + +impl PlaceholderView { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + } + } +} + +impl Panel for PlaceholderView { + fn title(&self, _cx: &App) -> Option { + Some(self.title.clone()) + } +} + +impl Render for PlaceholderView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(EmptyState::new(self.title.clone())) + } +} diff --git a/crates/signed_app/src/views/sidebar.rs b/crates/signed_app/src/views/sidebar.rs new file mode 100644 index 0000000..5748d4b --- /dev/null +++ b/crates/signed_app/src/views/sidebar.rs @@ -0,0 +1,369 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use gpui::{ + App, Context, FontWeight, IntoElement, ObjectFit, ParentElement as _, SharedString, + Styled as _, StyledImage as _, Subscription, WeakEntity, Window, div, img, px, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::button::{Button, ButtonVariant}; +use gpui_native_kit::common::{h_flex, v_flex}; +use gpui_native_kit::data::sidebar::Sidebar; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::layout::dock::DockState; +use gpui_native_kit::menu::{Menu, MenuItem}; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::{Profile, ProfileDirectory}; +use nostr::prelude::PublicKey; +use signed_core::{Announcement, RepoAddr}; +use signed_state::{ + Backend, BackendEvent, CheckoutsStore, LocalReposStore, RepoListStore, ResolvedLocalRepo, + SyncStatusDirectory, +}; + +use crate::dialogs; + +pub struct SidebarView { + weak: WeakEntity, + dock: WeakEntity, + banner: SharedString, + scanning: bool, + /// User's announced repositories. + announcements: Arc>, + /// Local repositories found by the scan that are not announced yet. + local_repos: Arc>, + /// Unpushed commit counts per announced repository. + unpushed: HashMap, + /// Profiles rendered by the sidebar. + directory: gpui::Entity, + /// Sync statuses for the announced repositories. + sync_directory: gpui::Entity, + user_menu_open: bool, + _subscriptions: Vec, +} + +impl SidebarView { + pub fn new(dock: WeakEntity, cx: &mut Context) -> Self { + let backend = Backend::global(cx); + let repos = RepoListStore::global(cx); + let local = LocalReposStore::global(cx); + let checkouts = CheckoutsStore::global(cx); + + let mut subscriptions = Vec::new(); + + // Identity changes swap the whole sidebar between the sign-in screen and the signed-in content. + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { + let signer_changed = matches!(event, BackendEvent::SignerChanged); + let signer_required = matches!(event, BackendEvent::SignerRequired); + + if signer_required { + this.banner = pick_banner(); + cx.notify(); + } + + if this.refresh(cx) || signer_changed { + cx.notify(); + } + })); + + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { + if this.refresh(cx) { + cx.notify(); + } + })); + + subscriptions.push(cx.observe(&local, |this, _local, cx| { + if this.refresh(cx) { + cx.notify(); + } + })); + + // Push statuses are recomputed in the background, so only the badge counts change. + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_unpushed(cx) { + cx.notify(); + } + })); + + Self { + weak: cx.entity().downgrade(), + dock, + banner: pick_banner(), + announcements: Arc::new(Vec::new()), + local_repos: Arc::new(Vec::new()), + scanning: false, + unpushed: HashMap::new(), + directory: ProfileDirectory::new(cx), + sync_directory: SyncStatusDirectory::new(cx), + user_menu_open: false, + _subscriptions: subscriptions, + } + } + + fn refresh(&mut self, cx: &mut Context) -> bool { + let backend = Backend::global(cx); + let user = backend.read(cx).current_user(); + + let (announcements, local_repos, scanning) = { + let repo_list = RepoListStore::global(cx).read(cx); + + let announcements = user + .as_ref() + .map(|user| repo_list.announcements_of(user)) + .unwrap_or_default(); + + let local = LocalReposStore::global(cx).read(cx); + let local_repos = + LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements); + + (announcements, local_repos, local.scanning) + }; + + let announcements_changed = *self.announcements != announcements; + let local_changed = *self.local_repos != local_repos; + let scanning_changed = self.scanning != scanning; + + self.announcements = Arc::new(announcements); + self.local_repos = Arc::new(local_repos); + self.scanning = scanning; + + let mut profiles: Vec = user.into_iter().collect(); + let owners: Vec = self + .local_repos + .iter() + .filter_map(|entry| { + entry + .nip34 + .as_ref() + .and_then(|binding| binding.owner) + .filter(|owner| !profiles.contains(owner)) + }) + .collect(); + + profiles.extend(owners); + + self.directory.update(cx, |directory, cx| { + directory.track(profiles, cx); + }); + + let addrs: Vec = self + .announcements + .iter() + .map(|announcement| announcement.addr()) + .collect(); + + self.sync_directory.update(cx, |directory, cx| { + directory.track(addrs, cx); + }); + + if announcements_changed { + self.request_push_watches(cx); + self.unpushed.clear(); + } + + announcements_changed || local_changed || scanning_changed + } + + fn refresh_unpushed(&mut self, cx: &mut Context) -> bool { + let checkouts = CheckoutsStore::global(cx); + let mut unpushed = HashMap::with_capacity(self.announcements.len()); + + for announcement in self.announcements.iter() { + let addr = announcement.addr(); + let count = checkouts.read(cx).unpushed(&addr); + if count > 0 { + unpushed.insert(addr, count); + } + } + + if unpushed == self.unpushed { + return false; + } + + self.unpushed = unpushed; + true + } + + fn request_push_watches(&self, cx: &mut Context) { + let checkouts = CheckoutsStore::global(cx); + checkouts.update(cx, |checkouts, cx| { + for announcement in self.announcements.iter() { + checkouts.request_push_statuses(&announcement.addr(), cx); + } + }); + } + + /// Renders the sidebar for the current identity state. + pub fn build(&self, cx: &App) -> Sidebar { + let backend = Backend::global(cx); + + let profile = backend + .read(cx) + .current_user() + .map(|public_key| self.directory.read(cx).profile(&public_key, cx)); + + let Some(profile) = profile else { + return Sidebar::new("signed-sidebar").content(self.render_sign_in(cx)); + }; + + let weak = self.weak.clone(); + + Sidebar::new("signed-sidebar") + .transparent(true) + .header(self.render_user(&profile, cx)) + .entries(|_, _| Vec::new()) + .on_change(move |index, window, cx| { + let _ = weak.update(cx, |this, cx| this.activate(index, window, cx)); + }) + } + + fn activate(&mut self, _index: Option, _window: &mut Window, _cx: &mut Context) { + // Repository and navigation rows arrive with their screens. + } + + fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + if let Err(error) = self + .dock + .update(cx, |dock, cx| dock.open_panel(id, window, cx)) + { + log::warn!("failed to open panel {id}: {error}"); + } + } + + fn render_user(&self, profile: &Profile, cx: &App) -> impl IntoElement { + let name = profile.name(); + let picture = profile.picture(); + let public_key = profile.public_key(); + let weak = self.weak.clone(); + let weak_for_open = self.weak.clone(); + let muted = cx.theme().colors.muted_foreground; + + Menu::new("user-menu") + .open(self.user_menu_open) + .on_change({ + let weak = weak_for_open.clone(); + move |open, _, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.user_menu_open = open; + cx.notify(); + }) { + log::warn!("failed to update user menu: {error}"); + } + } + }) + .trigger({ + let name = name.clone(); + let picture = picture.clone(); + move |_, _| { + h_flex() + .gap_1() + .items_center() + .child( + Avatar::new(public_key) + .name(name.clone()) + .picture(picture.clone()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .child(name.clone()), + ) + .child( + Icon::named(IconRole::ChevronDown) + .size(px(12.)) + .color(muted), + ) + .into_any_element() + } + }) + .items(move |_, _| { + let profile_weak = weak.clone(); + let relays_weak = weak.clone(); + + vec![ + MenuItem::new("View Profile").handler(move |window, cx| { + if let Err(error) = profile_weak.update(cx, |this, cx| { + this.open_panel("profile", window, cx); + }) { + log::warn!("failed to open profile: {error}"); + } + }), + MenuItem::new("View Relays").handler(move |window, cx| { + if let Err(error) = relays_weak.update(cx, |this, cx| { + this.open_panel("relays", window, cx); + }) { + log::warn!("failed to open relays: {error}"); + } + }), + MenuItem::separator(), + MenuItem::new("Exit") + .destructive(true) + .handler(|_, cx| dialogs::exit::open(cx)), + ] + }) + } + + /// Shown while no identity is signed in. + fn render_sign_in(&self, _cx: &App) -> impl IntoElement { + let weak = self.weak.clone(); + let import_weak = self.weak.clone(); + + v_flex() + .size_full() + .relative() + .child( + div().absolute().inset_0().child( + img(self.banner.clone()) + .size_full() + .object_fit(ObjectFit::Cover), + ), + ) + .child( + v_flex() + .size_full() + .justify_end() + .p_4() + .gap_4() + .child(img("backgrounds/headline.png").max_w_48()) + .child( + v_flex() + .gap_1() + .w_full() + .child(Button::new("onboarding").label("Join now").on_click( + move |_, window, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.open_panel("welcome", window, cx); + crate::dialogs::open_onboarding(cx); + }) { + log::warn!("failed to open onboarding: {error}"); + } + }, + )) + .child( + Button::new("import") + .label("Import identity") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + if let Err(error) = import_weak + .update(cx, |_, cx| crate::dialogs::open_import(cx)) + { + log::warn!("failed to open import: {error}"); + } + }), + ), + ), + ) + } +} + +fn pick_banner() -> SharedString { + let num = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.subsec_nanos()) + .unwrap_or(1) + % 3 + + 1; + format!("backgrounds/banner{num}.jpg").into() +}