merged previous stuffs on master
This commit is contained in:
@@ -43,6 +43,7 @@ person = { path = "../person" }
|
||||
relay_auth = { path = "../relay_auth" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use auto_update::{AutoUpdateStatus, AutoUpdater};
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||
use common::DEFAULT_SIDEBAR_WIDTH;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
deferred, div, px, relative, rems, App, AppContext, Axis, ClipboardItem, Context, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
};
|
||||
use key_store::{Credential, KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use relay_auth::RelayAuth;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::{ActiveTheme, Theme, ThemeMode, ThemeRegistry};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::PanelView;
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::popup_menu::PopupMenuExt;
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::{
|
||||
reset, DarkMode, KeyringPopup, Logout, Settings, Themes, ViewProfile, ViewRelays,
|
||||
};
|
||||
use crate::user::viewer;
|
||||
use crate::views::compose::compose_button;
|
||||
use crate::views::{onboarding, preferences, setup_relay, startup, welcome};
|
||||
use crate::{login, new_identity, sidebar, user};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
|
||||
cx.new(|cx| ChatSpace::new(window, cx))
|
||||
}
|
||||
|
||||
pub fn login(window: &mut Window, cx: &mut App) {
|
||||
let panel = login::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
pub fn new_account(window: &mut Window, cx: &mut App) {
|
||||
let panel = new_identity::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ChatSpace {
|
||||
/// App's Title Bar
|
||||
title_bar: Entity<TitleBar>,
|
||||
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Determines if the chat space is ready to use
|
||||
ready: bool,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let keystore = KeyStore::global(cx);
|
||||
|
||||
let title_bar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Automatically sync theme with system appearance
|
||||
window.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe account entity changes
|
||||
cx.observe_in(&identity, window, move |this, state, window, cx| {
|
||||
if !this.ready && state.read(cx).has_public_key() {
|
||||
this.set_default_layout(window, cx);
|
||||
|
||||
// Load all chat room in the database if available
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe keystore entity changes
|
||||
cx.observe_in(&keystore, window, move |_this, state, window, cx| {
|
||||
if state.read(cx).initialized {
|
||||
let backend = state.read(cx).backend();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = backend
|
||||
.read_credentials(&KeyItem::User.to_string(), cx)
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Some((user, secret))) => {
|
||||
let credential = Credential::new(user, secret);
|
||||
this.set_startup_layout(credential, window, cx);
|
||||
}
|
||||
_ => {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(chat_ui::init(room, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
// Force focus to the tab panel
|
||||
this.focus_tab_panel(window, cx);
|
||||
// Dispatch the close panel action
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the chat registry
|
||||
cx.observe(&chat, move |this, chat, cx| {
|
||||
let ids = this.get_all_panels(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
dock,
|
||||
title_bar,
|
||||
ready: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.reset(window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_startup_layout(&mut self, cre: Credential, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(startup::init(cre, window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.reset(window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_default_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let weak_dock = self.dock.downgrade();
|
||||
|
||||
let sidebar = Arc::new(sidebar::init(window, cx));
|
||||
let center = Arc::new(welcome::init(window, cx));
|
||||
|
||||
let left = DockItem::panel(sidebar);
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(vec![center], None, &weak_dock, window, cx)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.ready = true;
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = preferences::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
modal
|
||||
.title(SharedString::from("Preferences"))
|
||||
.width(px(520.))
|
||||
.child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn on_profile(&mut self, _ev: &ViewProfile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = user::init(window, cx);
|
||||
let entity = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
|
||||
modal
|
||||
.title("Profile")
|
||||
.confirm()
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("Update"))
|
||||
.on_ok(move |_, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let set_metadata = this.set_metadata(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = set_metadata.await;
|
||||
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
match result {
|
||||
Ok(person) => {
|
||||
persons.update(cx, |this, cx| {
|
||||
this.insert(person, cx);
|
||||
// Close the edit profile modal
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_relays(&mut self, _ev: &ViewRelays, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = setup_relay::init(window, cx);
|
||||
let entity = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
|
||||
this.confirm()
|
||||
.title(SharedString::from("Set Up Messaging Relays"))
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("Update"))
|
||||
.on_ok(move |_, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if cx.theme().mode.is_dark() {
|
||||
Theme::change(ThemeMode::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(ThemeMode::Dark, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_themes(&mut self, _ev: &Themes, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let themes = registry.read(cx).themes();
|
||||
|
||||
this.title("Select theme")
|
||||
.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.child(v_flex().gap_2().pb_4().children({
|
||||
let mut items = Vec::with_capacity(themes.len());
|
||||
|
||||
for (name, theme) in themes.iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text)
|
||||
.line_height(relative(1.3))
|
||||
.child(theme.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(theme.author.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("change-{name}"))
|
||||
.label("Set")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let theme = theme.clone();
|
||||
move |_ev, window, cx| {
|
||||
Theme::apply_theme(theme.clone(), Some(window), cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
reset(cx);
|
||||
}
|
||||
|
||||
fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_key = ev.0;
|
||||
let view = viewer::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.alert()
|
||||
.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("View on njump.me"))
|
||||
.on_ok(move |_, _window, cx| {
|
||||
let bech32 = public_key.to_bech32().unwrap();
|
||||
let url = format!("https://njump.me/{bech32}");
|
||||
|
||||
// Open the URL in the default browser
|
||||
cx.open_url(&url);
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_copy_pubkey(&mut self, ev: &CopyPublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = ev.0.to_bech32();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(bech32));
|
||||
window.push_notification("Copied", cx);
|
||||
}
|
||||
|
||||
fn on_keyring(&mut self, _ev: &KeyringPopup, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.show_close(true)
|
||||
.title(SharedString::from("Keyring is disabled"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.pb_4()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Coop cannot access the Keyring Service on your system. By design, Coop uses Keyring to store your credentials."))
|
||||
.child(SharedString::from("Without access to Keyring, Coop will store your credentials as plain text."))
|
||||
.child(SharedString::from("If you want to store your credentials in the Keyring, please enable Keyring and allow Coop to access it.")),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn get_all_panels(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
fn set_center_panel<P>(panel: P, window: &mut Window, cx: &mut App)
|
||||
where
|
||||
P: PanelView,
|
||||
{
|
||||
if let Some(Some(root)) = window.root::<Root>() {
|
||||
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
|
||||
let panel = Arc::new(panel);
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
chatspace.update(cx, |this, cx| {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let status = chat.read(cx).loading();
|
||||
|
||||
if !nostr.read(cx).identity().read(cx).has_public_key() {
|
||||
return div();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.child(compose_button())
|
||||
.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...",
|
||||
)),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let auto_update = AutoUpdater::global(cx);
|
||||
|
||||
let relay_auth = RelayAuth::global(cx);
|
||||
let pending_requests = relay_auth.read(cx).pending_requests(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
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_some(identity.read(cx).public_key, |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
let keystore = KeyStore::global(cx);
|
||||
let is_using_file_keystore = keystore.read(cx).is_using_file_keystore();
|
||||
|
||||
let keyring_label = if is_using_file_keystore {
|
||||
SharedString::from("Disabled")
|
||||
} else {
|
||||
SharedString::from("Enabled")
|
||||
};
|
||||
|
||||
this.child(
|
||||
Button::new("user")
|
||||
.small()
|
||||
.reverse()
|
||||
.transparent()
|
||||
.icon(IconName::CaretDown)
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.45)))
|
||||
.popup_menu(move |this, _window, _cx| {
|
||||
this.label(profile.name())
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::EmojiFill,
|
||||
Box::new(ViewProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Messaging Relays",
|
||||
IconName::Server,
|
||||
Box::new(ViewRelays),
|
||||
)
|
||||
.separator()
|
||||
.label(SharedString::from("Keyring Service"))
|
||||
.menu_with_icon_and_disabled(
|
||||
keyring_label.clone(),
|
||||
IconName::Encryption,
|
||||
Box::new(KeyringPopup),
|
||||
!is_using_file_keystore,
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon("Dark Mode", IconName::Sun, Box::new(DarkMode))
|
||||
.menu_with_icon("Themes", IconName::Moon, Box::new(Themes))
|
||||
.menu_with_icon("Settings", IconName::Settings, Box::new(Settings))
|
||||
.menu_with_icon("Sign Out", IconName::Logout, Box::new(Logout))
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_center(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity().downgrade();
|
||||
let panel = self.dock.read(cx).items.view();
|
||||
let title = panel.title(cx);
|
||||
let id = panel.panel_id(cx);
|
||||
|
||||
if id == "Onboarding" {
|
||||
return div();
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().flex_1().child(
|
||||
Button::new("back")
|
||||
.icon(IconName::ArrowLeft)
|
||||
.small()
|
||||
.ghost_alt()
|
||||
.rounded()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().child(title))
|
||||
.child(div().flex_1())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ChatSpace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
let left = self.titlebar_left(window, cx).into_any_element();
|
||||
let right = self.titlebar_right(window, cx).into_any_element();
|
||||
let center = self.titlebar_center(cx).into_any_element();
|
||||
let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty();
|
||||
|
||||
// Update title bar children
|
||||
self.title_bar.update(cx, |this, _cx| {
|
||||
if single_panel {
|
||||
this.set_children(vec![center]);
|
||||
} else {
|
||||
this.set_children(vec![left, right]);
|
||||
}
|
||||
});
|
||||
|
||||
div()
|
||||
.id(SharedString::from("chatspace"))
|
||||
.on_action(cx.listener(Self::on_settings))
|
||||
.on_action(cx.listener(Self::on_profile))
|
||||
.on_action(cx.listener(Self::on_relays))
|
||||
.on_action(cx.listener(Self::on_dark_mode))
|
||||
.on_action(cx.listener(Self::on_themes))
|
||||
.on_action(cx.listener(Self::on_sign_out))
|
||||
.on_action(cx.listener(Self::on_open_pubkey))
|
||||
.on_action(cx.listener(Self::on_copy_pubkey))
|
||||
.on_action(cx.listener(Self::on_keyring))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(self.title_bar.clone())
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
}
|
||||
}
|
||||
1
crates/coop/src/dialogs/mod.rs
Normal file
1
crates/coop/src/dialogs/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod screening;
|
||||
@@ -1,454 +1,511 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{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, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
pub struct Screening {
|
||||
profile: Person,
|
||||
verified: bool,
|
||||
followed: bool,
|
||||
last_active: Option<Timestamp>,
|
||||
mutual_contacts: Vec<Profile>,
|
||||
_tasks: SmallVec<[Task<()>; 3]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> = cx.background_spawn({
|
||||
let client = nostr.read(cx).client();
|
||||
async move {
|
||||
let signer = client.signer().await?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
|
||||
// Check if user is in contact list
|
||||
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||
|
||||
// Check mutual contacts
|
||||
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
|
||||
if let Ok(events) = client.database().query(contact_list).await {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
|
||||
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
|
||||
mutual_contacts.push(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((followed, mutual_contacts))
|
||||
}
|
||||
});
|
||||
|
||||
let activity_check = cx.background_spawn(async move {
|
||||
let filter = Filter::new().author(public_key).limit(1);
|
||||
let mut activity: Option<Timestamp> = None;
|
||||
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2))
|
||||
.await
|
||||
{
|
||||
while let Some((_url, event)) = stream.next().await {
|
||||
if let Ok(event) = event {
|
||||
activity = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity
|
||||
});
|
||||
|
||||
let addr_check = if let Some(address) = profile.metadata().nip05 {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(public_key, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tasks.push(
|
||||
// Run the contact check in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok((followed, mutual_contacts)) = contact_check.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = followed;
|
||||
this.mutual_contacts = mutual_contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Run the activity check in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let active = activity_check.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.last_active = active;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Run the NIP-05 verification in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(task) = addr_check {
|
||||
if let Ok(verified) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = verified;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(&self, _cx: &Context<Self>) -> Option<String> {
|
||||
self.profile.metadata().nip05
|
||||
}
|
||||
|
||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
|
||||
let Ok(bech32) = self.profile.public_key().to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
|
||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.profile.public_key();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?;
|
||||
|
||||
// Send the report to the public relays
|
||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let contacts = self.mutual_contacts.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let contacts = contacts.clone();
|
||||
let total = contacts.len();
|
||||
|
||||
this.title(SharedString::from("Mutual contacts")).child(
|
||||
v_flex().gap_1().pb_4().child(
|
||||
uniform_list("contacts", total, move |range, _window, cx| {
|
||||
let mut items = Vec::with_capacity(total);
|
||||
|
||||
for ix in range {
|
||||
if let Some(contact) = contacts.get(ix) {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
})
|
||||
.child(Avatar::new(contact.avatar()).size(rems(1.75)))
|
||||
.child(contact.display_name()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.h(px(300.)),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Screening {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
|
||||
let total_mutuals = self.mutual_contacts.len();
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(self.profile.name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_1()
|
||||
.flex_1()
|
||||
.h_7()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
.line_height(relative(1.))
|
||||
.child(shorten_pubkey),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("njump")
|
||||
.label("View on njump.me")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Report)
|
||||
.danger()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(Some(self.followed), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Contact"))
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from("This person is one of your contacts.")
|
||||
} else {
|
||||
SharedString::from("This person is not one of your contacts.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(last_active, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Activity on Public Relays"))
|
||||
.child(
|
||||
Button::new("active")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| {
|
||||
if let Some(date) = self.last_active {
|
||||
this.child(SharedString::from(format!(
|
||||
"Last active: {}.",
|
||||
date.to_human_time()
|
||||
)))
|
||||
} else {
|
||||
this.child(SharedString::from("This person hasn't had any activity."))
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
SharedString::from(format!("{} validation", addr))
|
||||
} else {
|
||||
SharedString::from("Friendly Address (NIP-05) validation")
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from("The address matches the user's public key.")
|
||||
} else {
|
||||
SharedString::from("The address does not match the user's public key.")
|
||||
}
|
||||
} else {
|
||||
SharedString::from("This person has not set up their friendly address")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(total_mutuals > 0), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Mutual contacts"))
|
||||
.child(
|
||||
Button::new("mutuals")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if total_mutuals > 0 {
|
||||
SharedString::from(format!(
|
||||
"You have {} mutual contacts with this person.",
|
||||
total_mutuals
|
||||
))
|
||||
} else {
|
||||
SharedString::from("You don't have any mutual contacts with this person.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.size_6()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.map(|this| {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CheckCircleFill).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
this.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use common::{shorten_pubkey, RenderedTimestamp};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
/// Screening
|
||||
pub struct Screening {
|
||||
/// Public Key of the person being screened.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Whether the person's address is verified.
|
||||
verified: bool,
|
||||
|
||||
/// Whether the person is followed by current user.
|
||||
followed: bool,
|
||||
|
||||
/// Last time the person was active.
|
||||
last_active: Option<Timestamp>,
|
||||
|
||||
/// All mutual contacts of the person being screened.
|
||||
mutual_contacts: Vec<PublicKey>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<()>; 3]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
cx.defer_in(window, move |this, _window, cx| {
|
||||
this.check_contact(cx);
|
||||
this.check_wot(cx);
|
||||
this.check_last_activity(cx);
|
||||
this.verify_identifier(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn check_contact(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
|
||||
// Check if user is in contact list
|
||||
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||
|
||||
Ok(followed)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
|
||||
// Check mutual contacts
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
|
||||
if let Ok(events) = client.database().query(filter).await {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||
mutual_contacts.push(event.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mutual_contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch mutual contacts: {}", e);
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().author(public_key).limit(1);
|
||||
let mut activity: Option<Timestamp> = None;
|
||||
|
||||
// Construct target for subscription
|
||||
let target = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await
|
||||
{
|
||||
while let Some((_url, event)) = stream.next().await {
|
||||
if let Ok(event) = event {
|
||||
activity = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.last_active = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
|
||||
let http_client = cx.http_client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
// Skip if the user doesn't have a NIP-05 identifier
|
||||
let Some(address) = self.address(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<bool, Error>> =
|
||||
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &Context<Self>) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
|
||||
self.profile(cx)
|
||||
.metadata()
|
||||
.nip05
|
||||
.and_then(|addr| Nip05Address::parse(&addr).ok())
|
||||
}
|
||||
|
||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
|
||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||
let builder = EventBuilder::report(vec![tag], "");
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send the report to the public relays
|
||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let contacts = self.mutual_contacts.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let contacts = contacts.clone();
|
||||
let total = contacts.len();
|
||||
|
||||
this.title(SharedString::from("Mutual contacts")).child(
|
||||
v_flex().gap_1().pb_4().child(
|
||||
uniform_list("contacts", total, move |range, _window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(total);
|
||||
|
||||
for ix in range {
|
||||
let Some(contact) = contacts.get(ix) else {
|
||||
continue;
|
||||
};
|
||||
let profile = persons.read(cx).get(contact, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
|
||||
.child(profile.name()),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.h(px(300.)),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Screening {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let profile = self.profile(cx);
|
||||
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
||||
|
||||
let total_mutuals = self.mutual_contacts.len();
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(profile.avatar()).size(rems(4.)))
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(profile.name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_1()
|
||||
.flex_1()
|
||||
.h_7()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
.line_height(relative(1.))
|
||||
.child(shorten_pubkey),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("njump")
|
||||
.label("View on njump.me")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Boom)
|
||||
.danger()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(Some(self.followed), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Contact"))
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from("This person is one of your contacts.")
|
||||
} else {
|
||||
SharedString::from("This person is not one of your contacts.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(last_active, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Activity on Public Relays"))
|
||||
.child(
|
||||
Button::new("active")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| {
|
||||
if let Some(date) = self.last_active {
|
||||
this.child(SharedString::from(format!(
|
||||
"Last active: {}.",
|
||||
date.to_human_time()
|
||||
)))
|
||||
} else {
|
||||
this.child(SharedString::from("This person hasn't had any activity."))
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
SharedString::from(format!("{} validation", addr))
|
||||
} else {
|
||||
SharedString::from("Friendly Address (NIP-05) validation")
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from("The address matches the user's public key.")
|
||||
} else {
|
||||
SharedString::from("The address does not match the user's public key.")
|
||||
}
|
||||
} else {
|
||||
SharedString::from("This person has not set up their friendly address")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(total_mutuals > 0), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Mutual contacts"))
|
||||
.child(
|
||||
Button::new("mutuals")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if total_mutuals > 0 {
|
||||
SharedString::from(format!(
|
||||
"You have {} mutual contacts with this person.",
|
||||
total_mutuals
|
||||
))
|
||||
} else {
|
||||
SharedString::from("You don't have any mutual contacts with this person.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.size_6()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.map(|this| {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CheckCircle).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
this.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::BUNKER_TIMEOUT;
|
||||
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::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, ContextModal, Disableable, StyledExt};
|
||||
|
||||
use crate::actions::CoopAuthUrlHandler;
|
||||
|
||||
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()),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,143 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use assets::Assets;
|
||||
use common::{APP_ID, CLIENT_NAME};
|
||||
use gpui::{
|
||||
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
actions, point, px, size, App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
||||
WindowOptions,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
use ui::Root;
|
||||
|
||||
use crate::actions::{load_embedded_fonts, quit, Quit};
|
||||
|
||||
mod actions;
|
||||
mod chatspace;
|
||||
mod login;
|
||||
mod new_identity;
|
||||
mod dialogs;
|
||||
mod panels;
|
||||
mod sidebar;
|
||||
mod user;
|
||||
mod views;
|
||||
mod workspace;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Initialize the Application
|
||||
let app = Application::new()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||
|
||||
// Run application
|
||||
app.run(move |cx| {
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
application()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
|
||||
.run(move |cx| {
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q (macOS)
|
||||
#[cfg(target_os = "macos")]
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
// Register the `quit` function with CMD+Q (macOS)
|
||||
#[cfg(target_os = "macos")]
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Register the `quit` function with Super+Q (others)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
||||
// Register the `quit` function with Super+Q (others)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
window_background: WindowBackgroundAppearance::Opaque,
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
window_background: WindowBackgroundAppearance::Opaque,
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
// Initialize backend for keys storage
|
||||
key_store::init(cx);
|
||||
// Initialize backend for keys storage
|
||||
key_store::init(cx);
|
||||
|
||||
// Initialize the nostr client
|
||||
state::init(cx);
|
||||
// Initialize the nostr client
|
||||
state::init(window, cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(cx);
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(cx);
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
// Root Entity
|
||||
Root::new(chatspace::init(window, cx).into(), window, cx)
|
||||
// Root Entity
|
||||
Root::new(workspace::init(window, cx).into(), window, cx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
fn load_embedded_fonts(cx: &App) {
|
||||
let asset_source = cx.asset_source();
|
||||
let font_paths = asset_source.list("fonts").unwrap();
|
||||
let embedded_fonts = Mutex::new(vec![]);
|
||||
let executor = cx.background_executor();
|
||||
|
||||
cx.foreground_executor().block_on(executor.scoped(|scope| {
|
||||
for font_path in &font_paths {
|
||||
if !font_path.ends_with(".ttf") {
|
||||
continue;
|
||||
}
|
||||
|
||||
scope.spawn(async {
|
||||
let font_bytes = asset_source.load(font_path.as_str()).unwrap().unwrap();
|
||||
embedded_fonts.lock().unwrap().push(font_bytes);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
cx.text_system()
|
||||
.add_fonts(embedded_fonts.into_inner().unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn quit(_ev: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
|
||||
@@ -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::CheckCircleFill
|
||||
} 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::CheckCircleFill
|
||||
} 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
|
||||
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::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||
|
||||
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::PlusCircleFill)
|
||||
.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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
127
crates/coop/src/panels/connect.rs
Normal file
127
crates/coop/src/panels/connect.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::TextUtils;
|
||||
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::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::dock_area::ClosePanel;
|
||||
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);
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), 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()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
297
crates/coop/src/panels/greeter.rs
Normal file
297
crates/coop/src/panels/greeter.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use chat::ChatRegistry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
cx.new(|cx| GreeterPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct GreeterPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl GreeterPanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
Self {
|
||||
name: "Onboarding".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
if let Some(public_key) = signer.public_key() {
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
Workspace::add_panel(
|
||||
profile::init(public_key, window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for GreeterPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().text_muted),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for GreeterPanel {}
|
||||
|
||||
impl Focusable for GreeterPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip17_state = chat.read(cx).relay_state(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let nip65_state = nostr.read(cx).relay_list_state();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let owned = signer.owned();
|
||||
|
||||
let required_actions =
|
||||
nip65_state == RelayState::NotConfigured || nip17_state == RelayState::NotConfigured;
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w_112()
|
||||
.gap_6()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.mb_4()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(TITLE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(DESCRIPTION)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(required_actions, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Required Actions"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.when(nip65_state.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("relaylist")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up relay list")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
relay_list::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip17_state.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up messaging relays")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
messaging_relays::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!owned, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Use your own identity"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.icon(Icon::new(IconName::Door))
|
||||
.label("Connect account via Nostr Connect")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
connect::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Usb))
|
||||
.label("Import a secret key or bunker")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
import::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Get Started"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("backup")
|
||||
.icon(Icon::new(IconName::Shield))
|
||||
.label("Backup account")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start(),
|
||||
)
|
||||
.child(
|
||||
Button::new("profile")
|
||||
.icon(Icon::new(IconName::Profile))
|
||||
.label("Update profile")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.add_profile_panel(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("invite")
|
||||
.icon(Icon::new(IconName::Invite))
|
||||
.label("Invite friends")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
371
crates/coop/src/panels/import.rs
Normal file
371
crates/coop/src/panels/import.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
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::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::dock_area::ClosePanel;
|
||||
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).masked(true));
|
||||
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, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(secret) = SecretKey::parse(&value) {
|
||||
let keys = Keys::new(secret);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, true, cx);
|
||||
});
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), 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);
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), 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,
|
||||
window: &mut Window,
|
||||
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_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, true, cx);
|
||||
});
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), 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 {
|
||||
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
|
||||
It will be cleared when you close the app. \
|
||||
To persist your identity, please connect via Nostr Connect.";
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Import a Secret Key or Bunker")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.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()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(SECRET_WARN)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
348
crates/coop/src/panels/messaging_relays.rs
Normal file
348
crates/coop/src/panels/messaging_relays.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, TextAlign, UniformList, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelayPanel> {
|
||||
cx.new(|cx| MessagingRelayPanel::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MessagingRelayPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Relay URL input
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
|
||||
// All relays
|
||||
relays: HashSet<RelayUrl>,
|
||||
|
||||
// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl MessagingRelayPanel {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load user's relays in the local database
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::load(&client).await })
|
||||
.await;
|
||||
|
||||
if let Ok(relays) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to user's input events
|
||||
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Update Messaging Relays".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
input,
|
||||
relays: HashSet::new(),
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(nip17::extract_owned_relay_list(event).collect())
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.insert(url) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.relays.remove(url);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error("You need to add at least 1 relay", window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let tags: Vec<Tag> = self
|
||||
.relays
|
||||
.iter()
|
||||
.map(|relay| Tag::relay(relay.clone()))
|
||||
.collect();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Construct nip17 event builder
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Set messaging relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
// TODO
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_v, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let Some(url) = relays.iter().nth(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.py_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
div().text_sm().child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&url, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.h_full()
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.h_20()
|
||||
.justify_center()
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for MessagingRelayPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for MessagingRelayPanel {}
|
||||
|
||||
impl Focusable for MessagingRelayPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MessagingRelayPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Update Messaging Relays")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
6
crates/coop/src/panels/mod.rs
Normal file
6
crates/coop/src/panels/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod connect;
|
||||
pub mod greeter;
|
||||
pub mod import;
|
||||
pub mod messaging_relays;
|
||||
pub mod profile;
|
||||
pub mod relay_list;
|
||||
@@ -3,33 +3,36 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{nip96_upload, shorten_pubkey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
|
||||
Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Person;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
|
||||
use ui::notification::Notification;
|
||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
pub mod viewer;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
|
||||
cx.new(|cx| UserProfile::new(window, cx))
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
||||
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserProfile {
|
||||
/// User profile
|
||||
profile: Option<Profile>,
|
||||
pub struct ProfilePanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// User's public key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// User's name text input
|
||||
name_input: Entity<InputState>,
|
||||
@@ -48,17 +51,13 @@ pub struct UserProfile {
|
||||
|
||||
/// Copied states
|
||||
copied: bool,
|
||||
|
||||
/// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl UserProfile {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
impl ProfilePanel {
|
||||
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
||||
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||
// Use multi-line input for bio
|
||||
let bio_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -67,53 +66,29 @@ impl UserProfile {
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
let get_profile = Self::get_profile(cx);
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Get metadata in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(profile) = get_profile.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_profile(profile, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Get user's profile and update inputs
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
// Set all input's values with current profile
|
||||
this.set_profile(profile, window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
profile: None,
|
||||
name: "Update Profile".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
public_key,
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
uploading: false,
|
||||
copied: false,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile(cx: &App) -> Task<Result<Profile, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let metadata = client
|
||||
.database()
|
||||
.metadata(public_key)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let metadata = profile.metadata();
|
||||
fn set_profile(&mut self, person: Person, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let metadata = person.metadata();
|
||||
|
||||
self.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
@@ -138,9 +113,6 @@ impl UserProfile {
|
||||
this.set_value(website, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -155,19 +127,19 @@ impl UserProfile {
|
||||
cx.notify();
|
||||
|
||||
if status {
|
||||
self._tasks.push(
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Reset the copied state after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,149 +205,197 @@ impl UserProfile {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Person, Error>> {
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
let website = self.website_input.read(cx).value().to_string();
|
||||
/// Set the metadata for the current user
|
||||
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let metadata = metadata.clone();
|
||||
|
||||
// Get the current profile metadata
|
||||
let old_metadata = self
|
||||
.profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.metadata())
|
||||
.unwrap_or_default();
|
||||
cx.background_spawn(async move {
|
||||
// Build and sign the metadata event
|
||||
let builder = EventBuilder::metadata(&metadata);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send event to user's relays
|
||||
client.send_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let public_key = self.public_key;
|
||||
let old_metadata = persons.read(cx).get(&public_key, cx).metadata();
|
||||
|
||||
// Extract all new metadata fields
|
||||
let avatar = self.avatar_input.read(cx).value();
|
||||
let name = self.name_input.read(cx).value();
|
||||
let bio = self.bio_input.read(cx).value();
|
||||
let website = self.website_input.read(cx).value();
|
||||
|
||||
// Construct the new metadata
|
||||
let mut new_metadata = old_metadata.display_name(name).about(bio);
|
||||
let mut new_metadata = old_metadata
|
||||
.display_name(name.as_ref())
|
||||
.name(name.as_ref())
|
||||
.about(bio.as_ref());
|
||||
|
||||
// Verify the avatar URL before adding it
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the website URL before adding it
|
||||
if let Ok(url) = Url::from_str(&website) {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
// Set the metadata
|
||||
let task = self.publish(&new_metadata, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().await?;
|
||||
|
||||
// Sign the new metadata event
|
||||
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
|
||||
|
||||
// Send event to user's write relayss
|
||||
client.send_event_to(urls, &event).await?;
|
||||
|
||||
// Return the updated profile
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let profile = Person::new(event.pubkey, metadata);
|
||||
|
||||
Ok(profile)
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
persons.update(cx, |this, cx| {
|
||||
this.insert(Person::new(public_key, new_metadata), cx);
|
||||
});
|
||||
window.push_notification("Profile updated successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UserProfile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).value();
|
||||
let source = if picture.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
picture.as_str()
|
||||
};
|
||||
this.child(img(source).rounded_full().size_10().flex_shrink_0())
|
||||
})
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.label("Change")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Name:"))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Bio:"))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Website:"))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.when_some(self.profile.as_ref(), |this, profile| {
|
||||
let public_key = profile.public_key();
|
||||
let display = SharedString::from(shorten_pubkey(profile.public_key(), 8));
|
||||
impl Panel for ProfilePanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
this.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ProfilePanel {}
|
||||
|
||||
impl Focusable for ProfilePanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfilePanel {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||
|
||||
// Get the avatar
|
||||
let avatar_input = self.avatar_input.read(cx).value();
|
||||
let avatar = if avatar_input.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
avatar_input.as_str()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_112()
|
||||
.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()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("What should people call you?"))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("A short introduction about you:"))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Website:"))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.h_12()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(display)
|
||||
.child(shorten_pkey)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
IconName::CheckCircle
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.copy(
|
||||
public_key.to_bech32().unwrap(),
|
||||
this.public_key.to_bech32().unwrap(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -383,6 +403,16 @@ impl Render for UserProfile {
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.disabled(self.uploading)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
365
crates/coop/src/panels/relay_list.rs
Normal file
365
crates/coop/src/panels/relay_list.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, TextAlign, UniformList, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> {
|
||||
cx.new(|cx| RelayListPanel::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RelayListPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Relay URL input
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Relay metadata input
|
||||
metadata: Entity<Option<RelayMetadata>>,
|
||||
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
|
||||
// All relays
|
||||
relays: HashSet<(RelayUrl, Option<RelayMetadata>)>,
|
||||
|
||||
// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl RelayListPanel {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let metadata = cx.new(|_| None);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load user's relays in the local database
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::load(&client).await })
|
||||
.await;
|
||||
|
||||
if let Ok(relays) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to user's input events
|
||||
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Update Relay List".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
input,
|
||||
metadata,
|
||||
relays: HashSet::new(),
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(nip65::extract_owned_relay_list(event).collect())
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
let metadata = self.metadata.read(cx);
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.insert((url, metadata.to_owned())) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.relays.retain(|(relay, _)| relay != url);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error("You need to add at least 1 relay", window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let relays = self.relays.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let builder = EventBuilder::relay_list(relays);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Set relay list for current user
|
||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
// TODO
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_v, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let Some((url, metadata)) = relays.iter().nth(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.py_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
div().text_sm().child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.map(|this| {
|
||||
if let Some(metadata) = metadata {
|
||||
this.child(SharedString::from(
|
||||
metadata.to_string(),
|
||||
))
|
||||
} else {
|
||||
this.child(SharedString::from("Read+Write"))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
cx.listener(
|
||||
move |this, _ev, _window, cx| {
|
||||
this.remove(&url, cx);
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.h_full()
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.h_20()
|
||||
.justify_center()
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RelayListPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RelayListPanel {}
|
||||
|
||||
impl Focusable for RelayListPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RelayListPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Update Relay List")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
182
crates/coop/src/sidebar/entry.rs
Normal file
182
crates/coop/src/sidebar/entry.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::RoomKind;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::dock_area::ClosePanel;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
use crate::dialogs::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomEntry {
|
||||
ix: usize,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl RoomEntry {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
handler: None,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(mut self, public_key: PublicKey) -> Self {
|
||||
self.public_key = Some(public_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for RoomEntry {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomEntry {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
|
||||
let public_key = self.public_key;
|
||||
let is_selected = self.is_selected();
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.when_some(self.avatar, |this, avatar| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.child(Avatar::new(avatar).size(rems(1.5))),
|
||||
)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.when_some(self.name, |this, name| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.justify_between()
|
||||
.line_clamp(1)
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.child(name)
|
||||
.when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircle)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.when_some(self.created_at, |this, created_at| this.child(created_at)),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.when_some(self.handler, |this, handler| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
handler(event, window, cx);
|
||||
|
||||
if let Some(public_key) = public_key {
|
||||
if self.kind != Some(RoomKind::Ongoing) && screening {
|
||||
let screening = screening::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text("Ignore")
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
// Prevent closing the modal on click
|
||||
// modal will be automatically closed after closing panel
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::{ChatRegistry, RoomKind};
|
||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::context_menu::ContextMenuExt;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::skeleton::Skeleton;
|
||||
use ui::{h_flex, ContextModal, StyledExt};
|
||||
|
||||
use crate::views::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomListItem {
|
||||
ix: usize,
|
||||
room_id: Option<u64>,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl RoomListItem {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
room_id: None,
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room_id(mut self, room_id: u64) -> Self {
|
||||
self.room_id = Some(room_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn public_key(mut self, public_key: PublicKey) -> Self {
|
||||
self.public_key = Some(public_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomListItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
|
||||
let (
|
||||
Some(public_key),
|
||||
Some(room_id),
|
||||
Some(name),
|
||||
Some(avatar),
|
||||
Some(created_at),
|
||||
Some(kind),
|
||||
Some(handler),
|
||||
) = (
|
||||
self.public_key,
|
||||
self.room_id,
|
||||
self.name,
|
||||
self.avatar,
|
||||
self.created_at,
|
||||
self.kind,
|
||||
self.handler,
|
||||
)
|
||||
else {
|
||||
return h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(Skeleton::new().w_32().h_2p5().rounded(cx.theme().radius))
|
||||
.child(Skeleton::new().w_6().h_2p5().rounded(cx.theme().radius)),
|
||||
);
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.child(Avatar::new(avatar).size(rems(1.5))),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.line_clamp(1)
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(created_at),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.context_menu(move |this, _window, _cx| {
|
||||
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
|
||||
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
|
||||
})
|
||||
.on_click(move |event, window, cx| {
|
||||
handler(event, window, cx);
|
||||
|
||||
if kind != RoomKind::Ongoing && screening {
|
||||
let screening = screening::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text("Ignore")
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, _window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||
this.close_room(room_id, cx);
|
||||
});
|
||||
// false to prevent closing the modal
|
||||
// modal will be closed after closing panel
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{nip05_verify, shorten_pubkey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileViewer> {
|
||||
cx.new(|cx| ProfileViewer::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProfileViewer {
|
||||
profile: Person,
|
||||
|
||||
/// Follow status
|
||||
followed: bool,
|
||||
|
||||
/// Verification status
|
||||
verified: bool,
|
||||
|
||||
/// Copy status
|
||||
copied: bool,
|
||||
|
||||
/// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl ProfileViewer {
|
||||
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&target, cx);
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||
|
||||
Ok(contact_list.contains(&target))
|
||||
});
|
||||
|
||||
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(target, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tasks.push(
|
||||
// Load user profile data
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let followed = check_follow.await.unwrap_or(false);
|
||||
|
||||
// Update the followed status
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = followed;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Update the NIP05 verification status if user has NIP05 address
|
||||
if let Some(task) = verify_nip05 {
|
||||
if let Ok(verified) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = verified;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile,
|
||||
followed: false,
|
||||
verified: false,
|
||||
copied: false,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(&self, _cx: &Context<Self>) -> Option<String> {
|
||||
self.profile.metadata().nip05
|
||||
}
|
||||
|
||||
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.profile.public_key().to_bech32();
|
||||
let item = ClipboardItem::new_string(bech32);
|
||||
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();
|
||||
|
||||
if status {
|
||||
self._tasks.push(
|
||||
// Reset the copied state after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfileViewer {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let bech32 = shorten_pubkey(self.profile.public_key(), 16);
|
||||
let shared_bech32 = SharedString::from(bech32);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(self.profile.name()),
|
||||
)
|
||||
.when_some(self.address(cx), |this, address| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(address)
|
||||
.when(self.verified, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.relative()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.block(),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(!self.followed, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w_32()
|
||||
.p_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Unknown contact")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Bio:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.min_h_16()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
self.profile
|
||||
.metadata()
|
||||
.about
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("No bio.")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_12()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(shared_bech32)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy_pubkey(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chat::{ChatRegistry, Room};
|
||||
use common::{nip05_profile, TextUtils, BOOTSTRAP_RELAYS};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::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::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::notification::Notification;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn compose_button() -> impl IntoElement {
|
||||
div().child(
|
||||
Button::new("compose")
|
||||
.icon(IconName::Plus)
|
||||
.ghost_alt()
|
||||
.cta()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(move |_, window, cx| {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
let weak_view = compose.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
let label = if compose.read(cx).selected(cx).len() > 1 {
|
||||
SharedString::from("Create Group DM")
|
||||
} else {
|
||||
SharedString::from("Create DM")
|
||||
};
|
||||
|
||||
modal
|
||||
.alert()
|
||||
.overlay_closable(true)
|
||||
.keyboard(true)
|
||||
.show_close(true)
|
||||
.button_props(ModalButtonProps::default().ok_text(label))
|
||||
.title(SharedString::from("Direct Messages"))
|
||||
.child(compose.clone())
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.submit(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to prevent the modal from closing
|
||||
false
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Contact {
|
||||
public_key: PublicKey,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl AsRef<PublicKey> for Contact {
|
||||
fn as_ref(&self) -> &PublicKey {
|
||||
&self.public_key
|
||||
}
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
pub fn new(public_key: PublicKey) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self) -> Self {
|
||||
self.selected = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Compose {
|
||||
/// Input for the room's subject
|
||||
title_input: Entity<InputState>,
|
||||
|
||||
/// Input for the room's members
|
||||
user_input: Entity<InputState>,
|
||||
|
||||
/// User's contacts
|
||||
contacts: Entity<Vec<Contact>>,
|
||||
|
||||
/// Error message
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let contacts = cx.new(|_| vec![]);
|
||||
let error_message = cx.new(|_| None);
|
||||
|
||||
let user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile..."));
|
||||
|
||||
let title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
let contacts: Vec<Contact> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| Contact::new(profile.public_key()))
|
||||
.collect();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
tasks.push(
|
||||
// Load all contacts
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match get_contacts.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extend_contacts(contacts, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Clear the image cache when sidebar is closed
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Handle Enter event for user input
|
||||
cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add_and_select_contact(window, cx)
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Self {
|
||||
title_input,
|
||||
user_input,
|
||||
error_message,
|
||||
contacts,
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = Contact>,
|
||||
{
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let pk = contact.public_key;
|
||||
|
||||
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
|
||||
self._tasks.push(cx.background_spawn(async move {
|
||||
Self::request_metadata(&client, pk).await.ok();
|
||||
}));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, contact);
|
||||
cx.notify();
|
||||
});
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.set_error("Contact already added", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn select_contact(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
if let Some(contact) = this.iter_mut().find(|c| c.public_key == public_key) {
|
||||
contact.selected = true;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
|
||||
// Show loading indicator in the input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
});
|
||||
|
||||
if let Ok(public_key) = content.to_public_key() {
|
||||
let contact = Contact::new(public_key).selected();
|
||||
self.push_contact(contact, window, cx);
|
||||
} else if content.contains("@") {
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
if let Ok(profile) = nip05_profile(&content).await {
|
||||
let public_key = profile.public_key;
|
||||
let contact = Contact::new(public_key).selected();
|
||||
|
||||
Ok(contact)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Ok(contact)) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.push_contact(contact, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Tokio error: {e}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self, cx: &App) -> Vec<PublicKey> {
|
||||
self.contacts
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter_map(|contact| {
|
||||
if contact.selected {
|
||||
Some(contact.public_key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
|
||||
let receivers: Vec<PublicKey> = self.selected(cx);
|
||||
let subject_input = self.title_input.read(cx).value();
|
||||
let subject = (!subject_input.is_empty()).then(|| subject_input.to_string());
|
||||
|
||||
if !self.user_input.read(cx).value().is_empty() {
|
||||
self.add_and_select_contact(window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
let room = cx.new(|_| Room::new(subject, public_key, receivers));
|
||||
this.emit_room(room.downgrade(), cx);
|
||||
});
|
||||
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
// Unlock the user input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
|
||||
// Update error message
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = Some(error.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error_message.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
|
||||
|
||||
for ix in range {
|
||||
let Some(contact) = self.contacts.read(cx).get(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let public_key = contact.public_key;
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_2()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
|
||||
.child(profile.name()),
|
||||
)
|
||||
.when(contact.selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.text_color(cx.theme().text_accent),
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.select_contact(public_key, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let error = self.error_message.read(cx).as_ref();
|
||||
let loading = self.user_input.read(cx).loading;
|
||||
let contacts = self.contacts.read(cx);
|
||||
|
||||
v_flex()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).")),
|
||||
)
|
||||
.when_some(error, |this, msg| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(msg.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.h_10()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Subject:")),
|
||||
)
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("To:")),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(&self.user_input)
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.suffix(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.transparent()
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_and_select_contact(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
v_flex()
|
||||
.h_24()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::from("No contacts")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Your recently contacts will appear here.")),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.list_items(range, cx)
|
||||
}),
|
||||
)
|
||||
.h(px(300.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod compose;
|
||||
pub mod onboarding;
|
||||
pub mod preferences;
|
||||
pub mod screening;
|
||||
pub mod setup_relay;
|
||||
pub mod startup;
|
||||
pub mod welcome;
|
||||
@@ -1,363 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, Task, Window,
|
||||
};
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::notification::Notification;
|
||||
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::chatspace::{self};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NostrConnectApp {
|
||||
Nsec(String),
|
||||
Amber(String),
|
||||
Aegis(String),
|
||||
}
|
||||
|
||||
impl NostrConnectApp {
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![
|
||||
NostrConnectApp::Nsec("https://nsec.app".to_string()),
|
||||
NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()),
|
||||
NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> String {
|
||||
match self {
|
||||
NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(),
|
||||
NostrConnectApp::Amber(_) => "Amber (Android)".into(),
|
||||
NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
app_keys: Keys,
|
||||
qr_code: Option<Arc<Image>>,
|
||||
|
||||
/// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let app_keys = Keys::generate();
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
|
||||
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
||||
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||
let qr_code = uri.to_string().to_qr();
|
||||
|
||||
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
|
||||
//
|
||||
// Direct connection initiated by the client
|
||||
let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Wait for nostr connect
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.save_connection(&uri, window, cx);
|
||||
this.connect(signer, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
qr_code,
|
||||
app_keys,
|
||||
name: "Onboarding".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_connection(
|
||||
&mut self,
|
||||
uri: &NostrConnectUri,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
let username = self.app_keys.public_key().to_hex();
|
||||
let secret = self.app_keys.secret_key().to_secret_bytes();
|
||||
let mut clean_uri = uri.to_string();
|
||||
|
||||
// Clear the secret parameter in the URI if it exists
|
||||
if let Some(s) = uri.secret() {
|
||||
clean_uri = clean_uri.replace(s, "");
|
||||
}
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let user_url = KeyItem::User.to_string();
|
||||
let bunker_url = KeyItem::Bunker.to_string();
|
||||
let user_password = clean_uri.into_bytes();
|
||||
|
||||
// Write bunker uri to keyring for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&user_url, "bunker", &user_password, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Write the app keys for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
client.set_signer(signer).await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
let all_apps = NostrConnectApp::all();
|
||||
let mut items = Vec::with_capacity(all_apps.len());
|
||||
|
||||
for (ix, item) in all_apps.into_iter().enumerate() {
|
||||
items.push(self.render_app(ix, item.as_str(), item.url(), cx));
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn render_app<T>(&self, ix: usize, label: T, url: &str, cx: &Context<Self>) -> impl IntoElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
div()
|
||||
.id(ix)
|
||||
.flex_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.bg(cx.theme().ghost_element_background_alt)
|
||||
.child(label.into())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
move |_e, _window, cx| {
|
||||
cx.open_url(&url);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Onboarding {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Onboarding {}
|
||||
|
||||
impl Focusable for Onboarding {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.gap_10()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_16()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from("Welcome to Coop")),
|
||||
)
|
||||
.child(div().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Chat Freely, Stay Private on Nostr."),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_80()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("continue_btn")
|
||||
.icon(Icon::new(IconName::ArrowRight))
|
||||
.label(SharedString::from("Start Messaging on Nostr"))
|
||||
.primary()
|
||||
.large()
|
||||
.bold()
|
||||
.reverse()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::new_account(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.my_1()
|
||||
.gap_1()
|
||||
.child(divider(cx))
|
||||
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from(
|
||||
"Already have an account? Continue with",
|
||||
),
|
||||
))
|
||||
.child(divider(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("key")
|
||||
.label("Secret Key or Bunker")
|
||||
.large()
|
||||
.ghost_alt()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::login(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||
this.child(
|
||||
img(qr.clone())
|
||||
.size(px(256.))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||
.border_1()
|
||||
.border_color(cx.theme().element_active),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from(
|
||||
"Continue with Nostr Connect",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(
|
||||
"Use Nostr Connect apps to scan the code",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.justify_center()
|
||||
.children(self.render_apps(cx)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use gpui::{div, App, AppContext, Context, Entity, IntoElement, Render, Window};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||
cx.new(|cx| Preferences::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Preferences {
|
||||
//
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
pub fn new(_window: &mut Window, _cx: &mut App) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Preferences {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign, UniformList,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
|
||||
cx.new(|cx| SetupRelay::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetupRelay {
|
||||
input: Entity<InputState>,
|
||||
error: Option<SharedString>,
|
||||
|
||||
// All relays
|
||||
relays: HashSet<RelayUrl>,
|
||||
|
||||
// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl SetupRelay {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load user's relays in the local database
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::load(&client).await })
|
||||
.await;
|
||||
|
||||
if let Ok(relays) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to user's input events
|
||||
cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Self {
|
||||
input,
|
||||
relays: HashSet::new(),
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let urls = nip17::extract_owned_relay_list(event).collect();
|
||||
Ok(urls)
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.insert(url) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.relays.remove(url);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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
|
||||
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();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error(
|
||||
"You need to add at least 1 relay to receive messages from others.",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let relays = self.relays.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().await?;
|
||||
|
||||
let tags: Vec<Tag> = relays
|
||||
.iter()
|
||||
.map(|relay| Tag::relay(relay.clone()))
|
||||
.collect();
|
||||
|
||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.tags(tags)
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Set messaging relays
|
||||
client.send_event_to(urls, &event).await?;
|
||||
|
||||
// Connect to messaging relays
|
||||
for relay in relays.iter() {
|
||||
client.add_relay(relay).await.ok();
|
||||
client.connect_relay(relay).await.ok();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_v, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
if let Some(url) = relays.iter().nth(ix) {
|
||||
items.push(
|
||||
div()
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.py_0p5()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_xs()
|
||||
.child(SharedString::from(url.to_string()))
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&url, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.w_full()
|
||||
.min_h(px(200.))
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.h_20()
|
||||
.mb_2()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SetupRelay {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("In order to receive messages from others, you need to set up at least one Messaging Relay.")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusFill)
|
||||
.label("Add")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::BUNKER_TIMEOUT;
|
||||
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::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::{reset, CoopAuthUrlHandler};
|
||||
|
||||
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);
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{v_flex, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||
Welcome::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Welcome {
|
||||
name: SharedString,
|
||||
version: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Welcome {
|
||||
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 version = SharedString::from(format!("Version: {}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
Self {
|
||||
version,
|
||||
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 {
|
||||
div()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().element_background),
|
||||
)
|
||||
.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()
|
||||
.child(self.version.clone())
|
||||
.on_click(|_, _window, cx| {
|
||||
cx.open_url("https://github.com/lumehq/coop/releases");
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
289
crates/coop/src/workspace.rs
Normal file
289
crates/coop/src/workspace.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{PanelStyle, PanelView};
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
|
||||
|
||||
use crate::panels::greeter;
|
||||
use crate::sidebar;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
/// App's Title Bar
|
||||
titlebar: Entity<TitleBar>,
|
||||
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let titlebar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx).panel_style(PanelStyle::TabBar));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(chat_ui::init(room, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
// Force focus to the tab panel
|
||||
this.focus_tab_panel(window, cx);
|
||||
|
||||
// Dispatch the close panel action
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the chat registry
|
||||
cx.observe(&chat, move |this, chat, cx| {
|
||||
let ids = this.panel_ids(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Set the default layout for app's dock
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.set_layout(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
titlebar,
|
||||
dock,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add panel to the dock
|
||||
pub fn add_panel<P>(panel: P, placement: DockPlacement, 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), placement, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all panel ids
|
||||
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
/// Set the dock layout
|
||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let weak_dock = self.dock.downgrade();
|
||||
|
||||
// Sidebar
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// Main workspace
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(greeter::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Update the dock layout
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let current_user = signer.public_key();
|
||||
|
||||
h_flex()
|
||||
.h(TITLEBAR_HEIGHT)
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
|
||||
this.child(
|
||||
Button::new("current-user")
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
||||
.small()
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.label(profile.name())
|
||||
.separator()
|
||||
.menu("Profile", Box::new(ClosePanel))
|
||||
.menu("Backup", Box::new(ClosePanel))
|
||||
.menu("Themes", Box::new(ClosePanel))
|
||||
.menu("Settings", Box::new(ClosePanel))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nostr.read(cx).creating(), |this| {
|
||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Coop is creating a new identity for you..."),
|
||||
))
|
||||
})
|
||||
.when(!nostr.read(cx).connected(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Connecting...")),
|
||||
)
|
||||
})
|
||||
.map(|this| match nostr.read(cx).relay_list_state() {
|
||||
RelayState::Checking => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Fetching user's relay list...")),
|
||||
),
|
||||
RelayState::NotConfigured => this.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().warning_background)
|
||||
.rounded_sm()
|
||||
.child(SharedString::from("User hasn't configured a relay list")),
|
||||
),
|
||||
_ => this,
|
||||
})
|
||||
.map(|this| match chat.read(cx).relay_state(cx) {
|
||||
RelayState::Checking => {
|
||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Fetching user's messaging relay list..."),
|
||||
))
|
||||
}
|
||||
RelayState::NotConfigured => this.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().warning_background)
|
||||
.rounded_sm()
|
||||
.child(SharedString::from(
|
||||
"User hasn't configured a messaging relay list",
|
||||
)),
|
||||
),
|
||||
_ => this,
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
|
||||
h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
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);
|
||||
|
||||
// Titlebar elements
|
||||
let left = self.titlebar_left(window, cx).into_any_element();
|
||||
let right = self.titlebar_right(window, cx).into_any_element();
|
||||
|
||||
// Update title bar children
|
||||
self.titlebar.update(cx, |this, _cx| {
|
||||
this.set_children(vec![left, right]);
|
||||
});
|
||||
|
||||
div()
|
||||
.id(SharedString::from("workspace"))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(self.titlebar.clone())
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user