.
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m30s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m31s

This commit is contained in:
2026-01-26 15:44:29 +07:00
parent 2e91145276
commit 4a748ca3d1
10 changed files with 514 additions and 1507 deletions

View File

@@ -1,425 +0,0 @@
use std::time::Duration;
use anyhow::anyhow;
use common::BUNKER_TIMEOUT;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, 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::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
cx.new(|cx| Login::new(window, cx))
}
#[derive(Debug)]
pub struct Login {
key_input: Entity<InputState>,
pass_input: Entity<InputState>,
error: Entity<Option<SharedString>>,
countdown: Entity<Option<u64>>,
require_password: bool,
logging_in: bool,
/// Panel
name: SharedString,
focus_handle: FocusHandle,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Login {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => {
this.login(window, cx);
}
InputEvent::Change => {
if input.read(cx).value().starts_with("ncryptsec1") {
this.require_password = true;
cx.notify();
}
}
_ => {}
};
}),
);
Self {
key_input,
pass_input,
error,
countdown,
name: "Welcome Back".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
require_password: false,
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.login_with_bunker(&value, window, cx);
} else if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, cx);
} else if value.starts_with("nsec1") {
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
self.login_with_keys(keys, cx);
} else {
self.set_error("Invalid", cx);
}
} else {
self.set_error("Invalid", cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let app_keys = Keys::generate();
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
// signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=BUNKER_TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
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(&app_keys, &uri, window, cx);
this.connect(signer, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
fn save_connection(
&mut self,
keys: &Keys,
uri: &NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = 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);
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
if pwd.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
let password = pwd.to_owned();
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
match result {
Ok(keys) => {
this.login_with_keys(keys, cx);
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_hex().into_bytes();
cx.spawn(async move |this, cx| {
let bunker_url = KeyItem::User.to_string();
// Write the app keys for further connection
if let Err(e) = keystore
.write_credentials(&bunker_url, &username, &secret, cx)
.await
{
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})
.ok();
}
this.update(cx, |_this, cx| {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(keys, cx);
});
})
.ok();
})
.detach();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
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 {
v_flex()
.relative()
.size_full()
.items_center()
.justify_center()
.child(
v_flex()
.w_96()
.gap_10()
.child(
div()
.text_center()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Continue with Private Key or Bunker")),
)
.child(
v_flex()
.gap_3()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(self.require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
})
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
),
)
}
}

View File

@@ -1,217 +0,0 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::home_dir;
use gpui::{
div, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt};
pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity<Backup> {
cx.new(|cx| Backup::new(keys, window, cx))
}
#[derive(Debug)]
pub struct Backup {
pubkey_input: Entity<InputState>,
secret_input: Entity<InputState>,
error: Option<SharedString>,
copied: bool,
// Async operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Backup {
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<Self>) -> Self {
let Ok(npub) = keys.public_key.to_bech32();
let Ok(nsec) = keys.secret_key().to_bech32();
let pubkey_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(npub)
});
let secret_input = cx.new(|cx| {
InputState::new(window, cx)
.disabled(true)
.default_value(nsec)
});
Self {
pubkey_input,
secret_input,
error: None,
copied: false,
_tasks: smallvec![],
}
}
pub fn backup(&self, window: &Window, cx: &Context<Self>) -> Task<Result<(), Error>> {
let dir = home_dir();
let path = cx.prompt_for_new_path(dir, Some("My Nostr Account"));
let nsec = self.secret_input.read(cx).value().to_string();
cx.spawn_in(window, async move |this, cx| {
match path.await {
Ok(Ok(Some(path))) => {
if let Err(e) = smol::fs::write(&path, nsec).await {
this.update_in(cx, |this, window, cx| {
this.set_error(e.to_string(), window, cx);
})
.expect("Entity has been released");
} else {
return Ok(());
}
}
_ => {
log::error!("Failed to save backup keys");
}
};
Err(anyhow!("Failed to backup keys"))
})
}
fn copy(&mut self, value: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value.into());
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// Reset the copied state after a delay
if status {
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update_in(cx, |this, window, cx| {
this.set_copied(false, window, cx);
})
.ok();
}));
}
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
// Clear the error message after a delay
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
}));
}
}
impl Render for Backup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you.";
const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account.";
const PK: &str = "Public Key is the address that others will use to find you.";
const SK: &str = "Secret Key provides access to your account.";
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(DESCRIPTION))
.child(
v_flex()
.gap_1()
.child(
div()
.font_semibold()
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.pubkey_input).small())
.child(
Button::new("copy-pubkey")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.ghost_alt()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy(this.pubkey_input.read(cx).value(), window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(PK)),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(
div()
.font_semibold()
.child(SharedString::from("Secret Key:")),
)
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.secret_input).small())
.child(
Button::new("copy-secret")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.ghost_alt()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy(this.secret_input.read(cx).value(), window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(SK)),
),
)
.child(divider(cx))
.child(
div()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(SharedString::from(WARN)),
)
}
}

View File

@@ -1,350 +0,0 @@
use anyhow::{anyhow, Error};
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
use dock::panel::{Panel, PanelEvent};
use gpui::{
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use key_store::{KeyItem, KeyStore};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use state::NostrRegistry;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{divider, v_flex, Disableable, IconName, Sizable, WindowExtension};
mod backup;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
cx.new(|cx| NewAccount::new(window, cx))
}
#[derive(Debug)]
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
temp_keys: Entity<Keys>,
uploading: bool,
submitting: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
}
impl NewAccount {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let temp_keys = cx.new(|_| Keys::generate());
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input = cx.new(|cx| InputState::new(window, cx));
Self {
name_input,
avatar_input,
temp_keys,
uploading: false,
submitting: false,
name: "Create a new identity".into(),
focus_handle: cx.focus_handle(),
}
}
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.submitting(true, cx);
let keys = self.temp_keys.read(cx).clone();
let view = backup::init(&keys, window, cx);
let weak_view = view.downgrade();
let current_view = cx.entity().downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
let current_view = current_view.clone();
modal
.alert()
.title(SharedString::from(
"Backup to avoid losing access to your account",
))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text("Download"))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let view = current_view.clone();
let task = this.backup(window, cx);
cx.spawn_in(window, async move |_this, cx| {
let result = task.await;
match result {
Ok(_) => {
view.update_in(cx, |this, window, cx| {
this.set_signer(window, cx);
})
.expect("Entity has been released");
}
Err(e) => {
log::error!("Failed to backup: {e}");
}
}
})
.detach();
})
.ok();
// true to close the modal
false
})
})
}
pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let keystore = KeyStore::global(cx).read(cx).backend();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let keys = self.temp_keys.read(cx).clone();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_hex().into_bytes();
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
// Close all modals if available
window.close_all_modals(cx);
// Set the client's signer with the current keys
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = keys.clone();
let nip65_relays = default_nip65_relays();
let nip17_relays = default_nip17_relays();
// Construct a NIP-65 event
let event = EventBuilder::new(Kind::RelayList, "")
.tags(
nip65_relays
.iter()
.cloned()
.map(|(url, metadata)| Tag::relay_metadata(url, metadata)),
)
.sign(&signer)
.await?;
// Set NIP-65 relays
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
// Extract only write relays
let write_relays: Vec<RelayUrl> = nip65_relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None
}
})
.collect();
// Ensure relays are connected
for url in write_relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Construct a NIP-17 event
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(nip17_relays.iter().cloned().map(Tag::relay))
.sign(&signer)
.await?;
// Set NIP-17 relays
client.send_event_to(&write_relays, &event).await?;
// Construct a metadata event
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Send metadata event to both write relays and bootstrap relays
client.send_event_to(&write_relays, &event).await?;
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
// Update the client's signer with the current keys
client.set_signer(keys).await;
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
let url = KeyItem::User.to_string();
// Write the app keys for further connection
keystore
.write_credentials(&url, &username, &secret, cx)
.await
.ok();
if let Err(e) = task.await {
this.update_in(cx, |this, window, cx| {
this.submitting(false, cx);
window.push_notification(e.to_string(), cx);
})
.expect("Entity has been released");
}
})
.detach();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.uploading(true, cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_file_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
match paths.await {
Ok(Ok(Some(mut paths))) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(&client, &nip96_server, file).await?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
_ => Err(anyhow!("Error")),
}
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
}
};
this.uploading(false, cx);
})
.expect("Entity has been released");
})
.detach();
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.submitting = status;
cx.notify();
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
}
impl Panel for NewAccount {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
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 Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = self.avatar_input.read(cx).value();
v_flex()
.size_full()
.relative()
.items_center()
.justify_center()
.child(
v_flex()
.w_96()
.gap_2()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).size(rems(4.25)))
.child(
Button::new("upload")
.icon(IconName::PlusCircle)
.label("Add an avatar")
.xsmall()
.ghost()
.rounded()
.disabled(self.uploading)
//.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("What should people call you?"))
.child(
TextInput::new(&self.name_input)
.disabled(self.submitting)
.small(),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label("Continue")
.primary()
.loading(self.submitting)
.disabled(self.submitting || self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.create(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,123 @@
use std::sync::Arc;
use common::TextUtils;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
Window,
};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::notification::Notification;
use ui::{v_flex, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
cx.new(|cx| ConnectPanel::new(window, cx))
}
pub struct ConnectPanel {
name: SharedString,
focus_handle: FocusHandle,
/// QR Code
qr_code: Option<Arc<Image>>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl ConnectPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let (signer, uri) = nostr.read(cx).client_connect(None);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
let mut tasks = smallvec![];
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
name: "Nostr Connect".into(),
focus_handle: cx.focus_handle(),
qr_code,
_tasks: tasks,
}
}
}
impl Panel for ConnectPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ConnectPanel {}
impl Focusable for ConnectPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ConnectPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_3()
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.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"),
)),
)
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
}
}

View File

@@ -9,25 +9,28 @@ use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Greeter> {
cx.new(|cx| Greeter::new(window, cx))
use crate::panels::{connect, import};
use crate::workspace::Workspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
cx.new(|cx| GreeterPanel::new(window, cx))
}
pub struct Greeter {
pub struct GreeterPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl Greeter {
impl GreeterPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Greeter".into(),
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Greeter {
impl Panel for GreeterPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
@@ -43,7 +46,7 @@ impl Panel for Greeter {
)
.child(
div()
.text_sm()
.text_xs()
.text_color(cx.theme().text_muted)
.child(self.name.clone()),
)
@@ -51,15 +54,15 @@ impl Panel for Greeter {
}
}
impl EventEmitter<PanelEvent> for Greeter {}
impl EventEmitter<PanelEvent> for GreeterPanel {}
impl Focusable for Greeter {
impl Focusable for GreeterPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Greeter {
impl Render for GreeterPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const TITLE: &str = "Welcome to Coop!";
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
@@ -126,14 +129,28 @@ impl Render for Greeter {
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small(),
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
window,
cx,
);
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small(),
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
window,
cx,
);
}),
)
})
.child(

View File

@@ -0,0 +1,344 @@
use std::time::Duration;
use anyhow::anyhow;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
div, 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 state::{CoopAuthUrlHandler, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
cx.new(|cx| ImportPanel::new(window, cx))
}
#[derive(Debug)]
pub struct ImportPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently logging in
logging_in: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl ImportPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
Self {
key_input,
pass_input,
error,
countdown,
name: "Import".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.login_with_bunker(&value, window, cx);
return;
}
if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
} else {
self.set_error("Invalid", cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let app_keys = nostr.read(cx).app_keys();
let timeout = Duration::from_secs(30);
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
if pwd.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
let password = pwd.to_owned();
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
match result {
Ok(keys) => {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for ImportPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ImportPanel {}
impl Focusable for ImportPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ImportPanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_3()
.child(
div()
.text_center()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Import a Secret Key or Bunker")),
)
.child(
v_flex()
.gap_2()
.w_96()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
},
)
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
}
}

View File

@@ -1 +1,3 @@
pub mod connect;
pub mod greeter;
pub mod import;

View File

@@ -1,319 +0,0 @@
use std::time::Duration;
use common::BUNKER_TIMEOUT;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
Window,
};
use key_store::{Credential, KeyItem, KeyStore};
use nostr_connect::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Sizable, StyledExt, WindowExtension};
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))
}
/// Startup
#[derive(Debug)]
pub struct Startup {
name: SharedString,
focus_handle: FocusHandle,
/// Local user credentials
credential: Credential,
/// Whether the loadng is in progress
loading: bool,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Startup {
fn new(credential: Credential, window: &mut Window, cx: &mut Context<Self>) -> Self {
let tasks = smallvec![];
let mut subscriptions = smallvec![];
subscriptions.push(
// Clear the local state when user closes the account panel
cx.on_release_in(window, move |this, window, cx| {
this.image_cache.update(cx, |this, cx| {
this.clear(window, cx);
});
}),
);
Self {
credential,
loading: false,
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
_subscriptions: subscriptions,
_tasks: tasks,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_loading(true, cx);
let secret = self.credential.secret();
// Try to login with bunker
if secret.starts_with("bunker://") {
match NostrConnectUri::parse(secret) {
Ok(uri) => {
self.login_with_bunker(uri, window, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
self.set_loading(false, cx);
}
}
return;
};
// Fall back to login with keys
match SecretKey::parse(secret) {
Ok(secret) => {
self.login_with_keys(secret, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
self.set_loading(false, cx);
}
}
}
fn login_with_bunker(
&mut self,
uri: NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let keystore = KeyStore::global(cx).read(cx).backend();
// Handle connection in the background
cx.spawn_in(window, async move |this, cx| {
let result = keystore
.read_credentials(&KeyItem::Bunker.to_string(), cx)
.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some((_, content))) => {
let secret = SecretKey::from_slice(&content).unwrap();
let keys = Keys::new(secret);
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap();
// Handle auth url with the default browser
// signer.auth_url_handler(CoopAuthUrlHandler);
// Connect to the remote signer
this._tasks.push(
// Handle connection in the background
cx.spawn_in(window, async move |this, cx| {
match signer.bunker_uri().await {
Ok(_) => {
client.set_signer(signer).await;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
window.push_notification(e.to_string(), cx);
this.set_loading(false, cx);
})
.ok();
}
}
}),
)
}
Ok(None) => {
window.push_notification(
"You must allow Coop access to the keyring to continue.",
cx,
);
this.set_loading(false, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
this.set_loading(false, cx);
}
};
})
.ok();
})
.detach();
}
fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context<Self>) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.set_signer(keys, cx);
})
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
}
impl Panel for Startup {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for Startup {}
impl Focusable for Startup {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Startup {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let bunker = self.credential.secret().starts_with("bunker://");
let profile = persons.read(cx).get(&self.credential.public_key(), cx);
v_flex()
.image_cache(self.image_cache.clone())
.relative()
.size_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()
.gap_2()
.child(
div()
.id("account")
.h_10()
.w_72()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius_lg)
.text_sm()
.when(self.loading, |this| {
this.child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(Indicator::new().small()),
)
})
.when(!self.loading, |this| {
let avatar = profile.avatar();
let name = profile.name();
this.child(
h_flex()
.h_full()
.justify_center()
.gap_2()
.child(
h_flex()
.gap_1()
.child(Avatar::new(avatar).size(rems(1.5)))
.child(div().pb_px().font_semibold().child(name)),
)
.child(div().when(bunker, |this| {
let label = SharedString::from("Nostr Connect");
this.child(
div()
.py_0p5()
.px_2()
.text_xs()
.bg(cx.theme().secondary_active)
.text_color(cx.theme().secondary_foreground)
.rounded_full()
.child(label),
)
})),
)
})
.text_color(cx.theme().text)
.active(|this| {
this.text_color(cx.theme().element_foreground)
.bg(cx.theme().element_active)
})
.hover(|this| {
this.text_color(cx.theme().element_foreground)
.bg(cx.theme().element_hover)
})
.on_click(cx.listener(move |this, _e, window, cx| {
this.login(window, cx);
})),
)
.child(Button::new("logout").label("Sign out").ghost().on_click(
|_, _window, cx| {
reset(cx);
},
)),
)
}
}

View File

@@ -1,101 +0,0 @@
use dock::panel::{Panel, PanelEvent};
use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
};
use theme::ActiveTheme;
use ui::{h_flex, v_flex, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
cx.new(|cx| Welcome::new(window, cx))
}
pub struct Welcome {
name: SharedString,
focus_handle: FocusHandle,
}
impl Welcome {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Welcome".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Welcome {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
svg()
.path("brand/coop.svg")
.size_4()
.text_color(cx.theme().text_muted),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone()),
)
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for Welcome {}
impl Focusable for Welcome {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Welcome {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
v_flex()
.gap_2()
.items_center()
.justify_center()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().elevated_surface_background),
)
.child(
v_flex()
.items_center()
.justify_center()
.text_center()
.child(
div()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("coop on nostr")),
)
.child(
div()
.id("version")
.text_color(cx.theme().text_placeholder)
.text_xs()
.on_click(|_, _window, cx| {
cx.open_url("https://github.com/lumehq/coop/releases");
}),
),
),
)
}
}

View File

@@ -4,6 +4,7 @@ use chat::{ChatEvent, ChatRegistry};
use chat_ui::{CopyPublicKey, OpenPublicKey};
use common::DEFAULT_SIDEBAR_WIDTH;
use dock::dock::DockPlacement;
use dock::panel::PanelView;
use dock::{ClosePanel, DockArea, DockItem};
use gpui::{
div, px, relative, App, AppContext, Axis, ClipboardItem, Context, Entity, InteractiveElement,
@@ -108,6 +109,21 @@ impl Workspace {
}
}
pub fn add_panel<P>(panel: P, window: &mut Window, cx: &mut App)
where
P: PanelView,
{
if let Some(root) = window.root::<Root>().flatten() {
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
workspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.add_panel(Arc::new(panel), DockPlacement::Center, window, cx);
});
});
}
}
}
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
@@ -340,89 +356,6 @@ impl Workspace {
Some(ids)
}
/*
fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let chat = ChatRegistry::global(cx);
let status = chat.read(cx).loading();
let auto_update = AutoUpdater::global(cx);
let relay_auth = RelayAuth::global(cx);
let pending_requests = relay_auth.read(cx).pending_requests(cx);
h_flex()
.gap_2()
.map(|this| match auto_update.read(cx).status.as_ref() {
AutoUpdateStatus::Checking => this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Checking for Coop updates...")),
),
AutoUpdateStatus::Installing => this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Installing updates...")),
),
AutoUpdateStatus::Errored { msg } => this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(msg.as_ref())),
),
AutoUpdateStatus::Updated => this.child(
div()
.id("restart")
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Updated. Click to restart"))
.on_click(|_ev, _window, cx| {
cx.restart();
}),
),
_ => this.child(div()),
})
.when(pending_requests > 0, |this| {
this.child(
h_flex()
.id("requests")
.h_6()
.px_2()
.items_center()
.justify_center()
.text_xs()
.rounded_full()
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.hover(|this| this.bg(cx.theme().warning_hover))
.active(|this| this.bg(cx.theme().warning_active))
.child(SharedString::from(format!(
"You have {} pending authentication requests",
pending_requests
)))
.on_click(move |_ev, window, cx| {
relay_auth.update(cx, |this, cx| {
this.re_ask(window, cx);
});
}),
)
})
.when(status, |this| {
this.child(deferred(
h_flex()
.px_2()
.h_6()
.gap_1()
.text_xs()
.rounded_full()
.bg(cx.theme().surface_background)
.child(SharedString::from(
"Getting messages. This may take a while...",
)),
))
})
}*/
}
impl Render for Workspace {