chore: Refine the UI (#102)

* update deps

* update window options

* linux title bar

* fix build

* .

* fix build

* rounded corners on linux

* .

* .

* fix i18n key

* fix change subject modal

* .

* update new account

* .

* update relay modal

* .

* fix i18n keys

---------

Co-authored-by: reya <reya@macbook.local>
This commit is contained in:
reya
2025-08-02 11:37:15 +07:00
committed by GitHub
parent 3cf9dde882
commit c188f12993
43 changed files with 2552 additions and 1790 deletions

View File

@@ -11,6 +11,7 @@ path = "src/main.rs"
[dependencies]
assets = { path = "../assets" }
ui = { path = "../ui" }
title_bar = { path = "../title_bar" }
identity = { path = "../identity" }
theme = { path = "../theme" }
common = { path = "../common" }

View File

@@ -1,33 +1,39 @@
use std::sync::Arc;
use anyhow::Error;
use client_keys::ClientKeys;
use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::nostr_client;
use common::display::DisplayProfile;
use global::constants::DEFAULT_SIDEBAR_WIDTH;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
actions, div, px, rems, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::t;
use identity::Identity;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use registry::{Registry, RoomEmitter};
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, Theme, ThemeMode};
use title_bar::TitleBar;
use ui::actions::OpenProfile;
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::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, ContextModal, IconName, Root, Sizable, StyledExt};
use crate::views::compose::compose_button;
use crate::views::screening::Screening;
use crate::views::user_profile::UserProfile;
use crate::views::{
chat, login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome,
backup_keys, chat, login, messaging_relays, new_account, onboarding, preferences, sidebar,
startup, user_profile, welcome,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
@@ -44,6 +50,8 @@ pub fn new_account(window: &mut Window, cx: &mut App) {
ChatSpace::set_center_panel(panel, window, cx);
}
actions!(user, [DarkMode, Settings, Logout]);
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind {
Room(u64),
@@ -70,14 +78,15 @@ pub struct ToggleModal {
}
pub struct ChatSpace {
title_bar: Entity<TitleBar>,
dock: Entity<DockArea>,
toolbar: bool,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 5]>,
}
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let title_bar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| {
let panel = Arc::new(startup::init(window, cx));
let center = DockItem::panel(panel);
@@ -99,14 +108,17 @@ impl ChatSpace {
window,
|_this: &mut Self, state, window, cx| {
if !state.read(cx).has_keys() {
window.open_modal(cx, |this, _window, cx| {
let title = SharedString::new(t!("startup.client_keys_warning"));
let desc = SharedString::new(t!("startup.client_keys_desc"));
window.open_modal(cx, move |this, _window, cx| {
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.button_props(
ModalButtonProps::default()
.cancel_text(t!("chatspace.create_new_keys"))
.cancel_text(t!("startup.create_new_keys"))
.ok_text(t!("common.allow")),
)
.child(
@@ -124,13 +136,9 @@ impl ChatSpace {
div()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chatspace.warning"))),
.child(title.clone()),
)
.child(div().line_height(relative(1.4)).child(
SharedString::new(t!(
"chatspace.allow_keychain_access"
)),
)),
.child(desc.clone()),
)
.on_cancel(|_, _window, cx| {
ClientKeys::global(cx).update(cx, |this, cx| {
@@ -157,21 +165,21 @@ impl ChatSpace {
window,
|this: &mut Self, state, window, cx| {
if !state.read(cx).has_signer() {
this.open_onboarding(window, cx);
this.set_onboarding_panels(window, cx);
} else {
this.open_chats(window, cx);
this.set_chat_panels(window, cx);
}
},
));
// Automatically run on_load function when UserProfile is created
// Automatically run load function when UserProfile is created
subscriptions.push(cx.observe_new::<UserProfile>(|this, window, cx| {
if let Some(window) = window {
this.on_load(window, cx);
this.load(window, cx);
}
}));
// Automatically run on_load function when Screening is created
// Automatically run load function when Screening is created
subscriptions.push(cx.observe_new::<Screening>(|this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
@@ -196,7 +204,7 @@ impl ChatSpace {
this.add_panel(panel, DockPlacement::Center, window, cx);
});
} else {
window.push_notification(t!("chatspace.failed_to_open_room"), cx);
window.push_notification(t!("common.room_error"), cx);
}
}
RoomEmitter::Close(..) => {
@@ -216,16 +224,13 @@ impl ChatSpace {
Self {
dock,
title_bar,
subscriptions,
toolbar: false,
}
})
}
pub fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// No active user, disable user's toolbar
self.toolbar(false, cx);
pub fn set_onboarding_panels(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let panel = Arc::new(onboarding::init(window, cx));
let center = DockItem::panel(panel);
@@ -235,17 +240,14 @@ impl ChatSpace {
});
}
pub fn open_chats(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Enable the toolbar for logged in users
self.toolbar(true, cx);
// Load all chat rooms from database
Registry::global(cx).update(cx, |this, cx| {
this.load_rooms(window, cx);
});
pub fn set_chat_panels(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = Registry::global(cx);
let weak_dock = self.dock.downgrade();
// The left panel will render sidebar
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// The center panel will render chat rooms (as tabs)
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
@@ -261,66 +263,31 @@ impl ChatSpace {
cx,
);
// Update dock
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);
});
cx.defer_in(window, |this, window, cx| {
let verify_messaging_relays = this.verify_messaging_relays(cx);
cx.spawn_in(window, async move |_, cx| {
if let Ok(status) = verify_messaging_relays.await {
if !status {
cx.update(|window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::SetupRelay,
}),
cx,
);
})
.ok();
}
}
})
.detach();
// Load all chat rooms from the database
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
});
}
pub fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let settings = preferences::init(window, cx);
let title = SharedString::new(t!("chatspace.preferences_title"));
pub fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context<Self>) {
let view = preferences::init(window, cx);
let title = SharedString::new(t!("common.preferences"));
window.open_modal(cx, move |modal, _, _| {
modal
.title(title.clone())
.width(px(DEFAULT_MODAL_WIDTH))
.child(settings.clone())
.width(px(480.))
.child(view.clone())
});
}
fn toolbar(&mut self, status: bool, cx: &mut Context<Self>) {
self.toolbar = status;
cx.notify();
}
fn verify_messaging_relays(&self, cx: &App) -> Task<Result<bool, Error>> {
cx.background_spawn(async move {
let client = nostr_client();
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);
let is_exist = client.database().query(filter).await?.first().is_some();
Ok(is_exist)
})
}
fn toggle_appearance(&self, window: &mut Window, cx: &mut App) {
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 {
@@ -328,23 +295,80 @@ impl ChatSpace {
}
}
fn logout(&self, window: &mut Window, cx: &mut App) {
Identity::global(cx).update(cx, |this, cx| {
fn on_sign_out(&mut self, _ev: &Logout, window: &mut Window, cx: &mut Context<Self>) {
let identity = Identity::global(cx);
// TODO: save current session?
identity.update(cx, |this, cx| {
this.unload(window, cx);
});
}
fn on_open_profile(&mut self, a: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = a.0;
fn on_open_profile(&mut self, ev: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = ev.0;
let profile = user_profile::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
// user_profile::init(public_key, window, cx)
this.child(profile.clone())
this.alert()
.show_close(true)
.overlay_closable(true)
.child(profile.clone())
.button_props(ModalButtonProps::default().ok_text(t!("profile.njump")))
.on_ok(move |_, _window, cx| {
let Ok(bech32) = public_key.to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
false
})
});
}
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
fn render_titlebar_left_side(
&mut self,
_window: &mut Window,
_cx: &Context<Self>,
) -> impl IntoElement {
let compose_button = compose_button().into_any_element();
h_flex().gap_1().child(compose_button)
}
fn render_titlebar_right_side(
&mut self,
profile: &Profile,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let need_backup = Identity::read_global(cx).need_backup();
let relay_ready = Identity::read_global(cx).relay_ready();
h_flex()
.gap_1()
.when_some(relay_ready, |this, status| {
this.when(!status, |this| this.child(messaging_relays::relay_button()))
})
.when_some(need_backup, |this, keys| {
this.child(backup_keys::backup_button(keys.to_owned()))
})
.child(
Button::new("user")
.small()
.reverse()
.transparent()
.icon(IconName::CaretDown)
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.49)))
.popup_menu(|this, _window, _cx| {
this.menu(t!("user.dark_mode"), Box::new(DarkMode))
.menu(t!("user.settings"), Box::new(Settings))
.separator()
.menu(t!("user.sign_out"), Box::new(Logout))
}),
)
}
pub(crate) 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);
@@ -365,7 +389,27 @@ impl Render for ChatSpace {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
// Only render titlebar element if user is logged in
if let Some(identity) = Identity::read_global(cx).public_key() {
let profile = Registry::read_global(cx).get_person(&identity, cx);
let left_side = self
.render_titlebar_left_side(window, cx)
.into_any_element();
let right_side = self
.render_titlebar_right_side(&profile, window, cx)
.into_any_element();
self.title_bar.update(cx, |this, _cx| {
this.set_children(vec![left_side, right_side]);
})
}
div()
.on_action(cx.listener(Self::on_settings))
.on_action(cx.listener(Self::on_dark_mode))
.on_action(cx.listener(Self::on_sign_out))
.on_action(cx.listener(Self::on_open_profile))
.relative()
.size_full()
@@ -375,58 +419,7 @@ impl Render for ChatSpace {
.flex_col()
.size_full()
// Title Bar
.child(
TitleBar::new()
// Left side
.child(div())
// Right side
.when(self.toolbar, |this| {
this.child(
div()
.flex()
.items_center()
.justify_end()
.gap_1p5()
.px_2()
.child(
Button::new("appearance")
.tooltip(t!("chatspace.appearance_tooltip"))
.small()
.ghost()
.map(|this| {
if cx.theme().mode.is_dark() {
this.icon(IconName::Sun)
} else {
this.icon(IconName::Moon)
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_appearance(window, cx);
})),
)
.child(
Button::new("preferences")
.tooltip(t!("chatspace.preferences_tooltip"))
.small()
.ghost()
.icon(IconName::Settings)
.on_click(cx.listener(|this, _, window, cx| {
this.open_settings(window, cx);
})),
)
.child(
Button::new("logout")
.tooltip(t!("common.logout"))
.small()
.ghost()
.icon(IconName::Logout)
.on_click(cx.listener(|this, _, window, cx| {
this.logout(window, cx);
})),
),
)
}),
)
.child(self.title_bar.clone())
// Dock
.child(self.dock.clone()),
)

View File

@@ -5,21 +5,16 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use assets::Assets;
use auto_update::AutoUpdater;
#[cfg(not(target_os = "linux"))]
use global::constants::APP_NAME;
use global::constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
};
use global::{nostr_client, NostrSignal};
use gpui::{
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
WindowBounds, WindowKind, WindowOptions,
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
WindowKind, WindowOptions,
};
#[cfg(not(target_os = "linux"))]
use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
@@ -138,7 +133,7 @@ fn main() {
continue;
}
let duration = smol::Timer::after(Duration::from_secs(75));
let duration = smol::Timer::after(Duration::from_secs(30));
let recv = || async {
// prevent inline format
@@ -202,25 +197,22 @@ fn main() {
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 options
let opts = WindowOptions {
#[cfg(not(target_os = "linux"))]
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(size(px(800.0), px(600.0))),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(920.0), px(700.0)),
cx,
))),
#[cfg(target_os = "linux")]
window_background: WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
..Default::default()
};

View File

@@ -0,0 +1,258 @@
use std::fs;
use std::time::Duration;
use dirs::document_dir;
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
SharedString, Styled, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{divider, h_flex, v_flex, ContextModal, Disableable, IconName, Sizable};
pub fn backup_button(keys: Keys) -> impl IntoElement {
div().child(
Button::new("backup")
.icon(IconName::Info)
.label(t!("new_account.backup_label"))
.danger()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("new_account.backup_label"));
let keys = keys.clone();
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(
ModalButtonProps::default()
.cancel_text(t!("new_account.backup_skip"))
.ok_text(t!("new_account.backup_download")),
)
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.download(window, cx);
})
.ok();
// true to close the modal
false
})
})
}),
)
}
pub struct BackupKeys {
password: Entity<InputState>,
pubkey_input: Entity<InputState>,
secret_input: Entity<InputState>,
error: Option<SharedString>,
copied: bool,
}
impl BackupKeys {
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 password = cx.new(|cx| InputState::new(window, cx).masked(true));
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 {
password,
pubkey_input,
secret_input,
error: None,
copied: false,
}
}
fn copy_secret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string());
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 {
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();
})
.detach();
}
}
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;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
fn download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let document_dir = document_dir().expect("Failed to get document directory");
let password = self.password.read(cx).value().to_string();
if password.is_empty() {
self.set_error(t!("login.password_is_required"), window, cx);
return;
};
let path = cx.prompt_for_new_path(&document_dir);
let nsec = self.secret_input.read(cx).value().to_string();
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(path.await.map_err(|e| e.into())) {
Ok(Ok(Some(path))) => {
cx.update(|window, cx| {
match fs::write(&path, nsec) {
Ok(_) => {
Identity::global(cx).update(cx, |this, cx| {
this.clear_need_backup(password, cx);
});
// Close the current modal
window.close_modal(cx);
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
};
})
.ok();
}
_ => {
log::error!("Failed to save backup keys");
}
};
})
.detach();
}
}
impl Render for BackupKeys {
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(shared_t!("new_account.backup_description")),
)
.child(
v_flex()
.gap_1()
.child(shared_t!("common.pubkey"))
.child(TextInput::new(&self.pubkey_input).small())
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_pubkey_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("common.secret"))
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.secret_input).small())
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_secret(window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_secret_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("login.set_password"))
.child(TextInput::new(&self.password).small())
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
}
}

View File

@@ -33,6 +33,7 @@ use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::emoji_picker::EmojiPicker;
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::text::RichText;
@@ -813,7 +814,7 @@ impl Panel for Chat {
}
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
let id = self.room.read(cx).id;
let room = self.room.downgrade();
let subject = self
.room
.read(cx)
@@ -825,11 +826,31 @@ impl Panel for Chat {
.icon(IconName::EditFill)
.tooltip(t!("chat.change_subject_button"))
.on_click(move |_, window, cx| {
let subject = subject::init(id, subject.clone(), window, cx);
let view = subject::init(subject.clone(), window, cx);
let room = room.clone();
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
this.title(SharedString::new(t!("chat.change_subject_modal_title")))
.child(subject.clone())
let room = room.clone();
let weak_view = weak_view.clone();
this.confirm()
.title(SharedString::new(t!("chat.change_subject_modal_title")))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.change")))
.on_ok(move |_, _window, cx| {
if let Ok(subject) =
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
{
room.update(cx, |this, cx| {
this.subject = Some(subject);
cx.notify();
})
.ok();
}
// true to close the modal
true
})
});
});
@@ -891,8 +912,9 @@ impl Render for Chat {
.text_color(cx.theme().text_muted)
.child(
Button::new("upload")
.icon(Icon::new(IconName::Upload))
.icon(IconName::Upload)
.ghost()
.large()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(
@@ -903,7 +925,8 @@ impl Render for Chat {
)
.child(
EmojiPicker::new(self.input.downgrade())
.icon(IconName::EmojiFill),
.icon(IconName::EmojiFill)
.large(),
),
)
.child(TextInput::new(&self.input)),

View File

@@ -3,92 +3,53 @@ use gpui::{
Styled, Window,
};
use i18n::t;
use registry::Registry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{ContextModal, Sizable};
use ui::{v_flex, Sizable};
pub fn init(
id: u64,
subject: Option<String>,
window: &mut Window,
cx: &mut App,
) -> Entity<Subject> {
Subject::new(id, subject, window, cx)
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
Subject::new(subject, window, cx)
}
pub struct Subject {
id: u64,
input: Entity<InputState>,
}
impl Subject {
pub fn new(
id: u64,
subject: Option<String>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let input = cx.new(|cx| {
let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
if let Some(text) = subject.clone() {
if let Some(text) = subject.as_ref() {
this.set_value(text, window, cx);
}
this
});
cx.new(|_| Self { id, input })
cx.new(|_| Self { input })
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = Registry::global(cx).read(cx);
let subject = self.input.read(cx).value().clone();
if let Some(room) = registry.room(&self.id, cx) {
room.update(cx, |this, cx| {
this.subject = Some(subject);
cx.notify();
});
window.close_modal(cx);
} else {
window.push_notification(SharedString::new(t!("subject.room_not_found")), cx);
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value().clone()
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_3()
v_flex()
.gap_1()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("subject.title"))),
)
.child(TextInput::new(&self.input).small())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::new(t!("subject.help_text"))),
),
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("subject.title"))),
)
.child(TextInput::new(&self.input).small())
.child(
Button::new("submit")
.label(t!("common.change"))
.primary()
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::new(t!("subject.help_text"))),
)
}
}

View File

@@ -8,9 +8,9 @@ use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, red, relative, uniform_list, App, AppContext, Context, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window,
div, px, relative, rems, uniform_list, AppContext, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, Window,
};
use i18n::t;
use itertools::Itertools;
@@ -21,13 +21,29 @@ use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::Timer;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
cx.new(|cx| Compose::new(window, cx))
pub fn compose_button() -> impl IntoElement {
div().child(
Button::new("compose")
.icon(IconName::Plus)
.ghost_alt()
.cta()
.small()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let compose = cx.new(|cx| Compose::new(window, cx));
let title = SharedString::new(t!("sidebar.direct_messages"));
window.open_modal(cx, move |modal, _window, _cx| {
modal.title(title.clone()).child(compose.clone())
})
}),
)
}
#[derive(Debug)]
@@ -147,13 +163,13 @@ impl Compose {
Ok(())
}
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let public_keys: Vec<PublicKey> = self.selected(cx);
if public_keys.is_empty() {
self.set_error(Some(t!("compose.receiver_required").into()), cx);
return;
}
};
// Show loading spinner
self.set_submitting(true, cx);
@@ -169,7 +185,7 @@ impl Compose {
));
}
let event: Task<Result<Room, anyhow::Error>> = cx.background_spawn(async move {
let event: Task<Result<Room, Error>> = cx.background_spawn(async move {
let signer = nostr_client().signer().await?;
let public_key = signer.get_public_key().await?;
@@ -187,15 +203,17 @@ impl Compose {
match event.await {
Ok(room) => {
cx.update(|window, cx| {
let registry = Registry::global(cx);
// Reset local state
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
Registry::global(cx).update(cx, |this, cx| {
// Create and insert the new room into the registry
registry.update(cx, |this, cx| {
this.push_room(cx.new(|_| room), cx);
});
// Close the current modal
window.close_modal(cx);
})
.ok();
@@ -371,22 +389,20 @@ impl Compose {
let selected = entity.read(cx).select;
items.push(
div()
h_flex()
.id(ix)
.px_1()
.h_9()
.w_full()
.h_11()
.py_1()
.px_3()
.flex()
.items_center()
.justify_between()
.rounded(cx.theme().radius)
.child(
div()
.flex()
.items_center()
.gap_3()
.gap_1p5()
.text_sm()
.child(img(profile.avatar_url(proxy)).size_7().flex_shrink_0())
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
.child(profile.display_name()),
)
.when(selected, |this| {
@@ -414,51 +430,52 @@ impl Render for Compose {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let label = if self.submitting {
t!("compose.creating_dm_button")
} else if self.contacts.len() > 1 {
} else if self.selected(cx).len() > 1 {
t!("compose.create_group_dm_button")
} else {
t!("compose.create_dm_button")
};
let error = self.error_message.read(cx).as_ref();
v_flex()
.gap_1()
.mb_4()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("compose.description"))),
)
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child(div().text_xs().text_color(red()).child(msg.clone()))
.when_some(error, |this, msg| {
this.child(
div()
.italic()
.text_sm()
.text_color(cx.theme().danger_foreground)
.child(msg.clone()),
)
})
.child(
div().flex().flex_col().child(
div()
.h_10()
.border_b_1()
.border_color(cx.theme().border)
.flex()
.items_center()
.gap_1()
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.subject_label"))),
)
.child(TextInput::new(&self.title_input).small().appearance(false)),
),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.mt_1()
h_flex()
.gap_1()
.h_10()
.border_b_1()
.border_color(cx.theme().border)
.child(
div()
.flex()
.flex_col()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.subject_label"))),
)
.child(TextInput::new(&self.title_input).small().appearance(false)),
)
.child(
v_flex()
.my_1()
.gap_2()
.child(
v_flex()
.gap_2()
.child(
div()
@@ -467,9 +484,7 @@ impl Render for Compose {
.child(SharedString::new(t!("compose.to_label"))),
)
.child(
div()
.flex()
.items_center()
h_flex()
.gap_1()
.child(
TextInput::new(&self.user_input)
@@ -479,8 +494,8 @@ impl Render for Compose {
.child(
Button::new("add")
.icon(IconName::PlusCircleFill)
.small()
.ghost()
.loading(self.adding)
.disabled(self.adding)
.on_click(cx.listener(move |this, _, window, cx| {
this.add_and_select_contact(window, cx);
@@ -491,14 +506,12 @@ impl Render for Compose {
.map(|this| {
if self.contacts.is_empty() {
this.child(
div()
.w_full()
v_flex()
.h_24()
.flex()
.flex_col()
.w_full()
.items_center()
.justify_center()
.text_align(TextAlign::Center)
.text_center()
.child(
div()
.text_xs()
@@ -525,7 +538,7 @@ impl Render for Compose {
this.list_items(range, cx)
}),
)
.min_h(px(280.)),
.min_h(px(300.)),
)
}
}),
@@ -534,11 +547,12 @@ impl Render for Compose {
Button::new("create_dm_btn")
.label(label)
.primary()
.small()
.w_full()
.loading(self.submitting)
.disabled(self.submitting || self.adding)
.on_click(cx.listener(move |this, _event, window, cx| {
this.compose(window, cx);
this.submit(window, cx);
})),
)
}

View File

@@ -15,7 +15,7 @@ use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable};
use ui::{v_flex, Disableable, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
EditProfile::new(window, cx)
@@ -166,10 +166,7 @@ impl EditProfile {
.detach();
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Show loading spinner
self.set_submitting(true, cx);
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Event>, 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();
@@ -191,52 +188,25 @@ impl EditProfile {
new_metadata = new_metadata.website(url);
}
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
nostr_client().set_metadata(&new_metadata).await?;
Ok(())
});
cx.background_spawn(async move {
let client = nostr_client();
let output = client.set_metadata(&new_metadata).await?;
let event = client.database().event_by_id(&output.val).await?;
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.push_notification(t!("profile.updated_successfully"), cx);
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
Ok(event)
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
}
impl Render for EditProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
v_flex()
.gap_3()
.px_3()
.child(
div()
.w_full()
@@ -306,17 +276,5 @@ impl Render for EditProfile {
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
div().py_3().child(
Button::new("submit")
.label(SharedString::new(t!("common.update")))
.primary()
.disabled(self.is_loading || self.is_submitting)
.loading(self.is_submitting)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
}
}

View File

@@ -11,7 +11,7 @@ use gpui::{
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use i18n::t;
use i18n::{shared_t, t};
use identity::Identity;
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
@@ -53,8 +53,11 @@ impl Login {
let key_input =
cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
let relay_input =
cx.new(|cx| InputState::new(window, cx).default_value(NOSTR_CONNECT_RELAY));
let relay_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(NOSTR_CONNECT_RELAY)
.placeholder(NOSTR_CONNECT_RELAY)
});
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
@@ -556,12 +559,12 @@ impl Render for Login {
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("login.title"))),
.child(shared_t!("login.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("login.key_description"))),
.child(shared_t!("login.key_description")),
),
)
.child(
@@ -581,13 +584,12 @@ impl Render for Login {
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
let msg = t!("login.approve_message", i = i);
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::new(msg)),
.child(shared_t!("login.approve_message", i = i)),
)
})
.when_some(self.error.read(cx).clone(), |this, error| {
@@ -603,89 +605,90 @@ impl Render for Login {
),
)
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child(SharedString::new(t!("login.nostr_connect"))),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("login.scan_qr"))),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
this.child(
div().flex_1().p_1().child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.id("")
.mb_2()
.p_2()
.size_72()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child(shared_t!("login.nostr_connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.scan_qr")),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
this.child(
div()
.id("")
.mb_2()
.p_2()
.size_72()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().mode.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().border)
})
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(t!("common.copied"), cx);
})),
)
})
.child(
div()
.w_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().mode.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().border)
})
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(
t!("common.copied").to_string(),
cx,
);
})),
)
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
this.change_relay(window, cx);
})),
),
),
),
.gap_1()
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
this.change_relay(window, cx);
},
)),
),
),
),
),
)
}
}

View File

@@ -0,0 +1,396 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use global::constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP17_RELAYS};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
TextAlign, UniformList, Window,
};
use i18n::{shared_t, t};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelays> {
cx.new(|cx| MessagingRelays::new(window, cx))
}
pub fn relay_button() -> impl IntoElement {
div().child(
Button::new("dm-relays")
.icon(IconName::Info)
.label(t!("relays.button_label"))
.warning()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("relays.modal_title"));
let view = cx.new(|cx| MessagingRelays::new(window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
})
}),
)
}
pub struct MessagingRelays {
input: Entity<InputState>,
relays: Vec<RelayUrl>,
error: Option<SharedString>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl MessagingRelays {
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 mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(move |this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
));
Self {
input,
subscriptions,
relays: vec![],
error: None,
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
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() {
let relays = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
Ok(relays)
} else {
Err(anyhow!("Not found."))
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = task.await {
this.update(cx, |this, cx| {
this.relays = relays;
cx.notify();
})
.ok();
}
})
.detach();
}
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") {
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
if !self.relays.contains(&url) {
self.relays.push(url);
}
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.remove(ix);
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;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.relays.is_empty() {
self.set_error(t!("relays.empty"), window, cx);
return;
};
let relays = self.relays.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect_vec(),
);
// Set messaging relays
client.send_event_builder(builder).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
}
let all_msg_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_msg_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let new_messages = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0);
// Close old subscriptions
client.unsubscribe(&all_msg_id).await;
client.unsubscribe(&new_msg_id).await;
// Subscribe to all messages
client
.subscribe_with_id(all_msg_id, all_messages, None)
.await?;
// Subscribe to new messages
client
.subscribe_with_id(new_msg_id, new_messages, None)
.await?;
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.close_modal(cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
})
.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 |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
items.push(
div().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(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click(cx.listener(move |this, _, window, cx| {
this.remove(ix, window, 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::new(t!("relays.add_some_relays")))
}
}
impl Render for MessagingRelays {
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(shared_t!("relays.description")),
)
.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(t!("common.add"))
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(shared_t!("relays.recommended")),
)
.child(h_flex().gap_1().children({
NIP17_RELAYS.iter().map(|&relay| {
div()
.id(relay)
.group("")
.py_0p5()
.px_1p5()
.text_xs()
.text_center()
.bg(cx.theme().secondary_background)
.hover(|this| this.bg(cx.theme().secondary_hover))
.active(|this| this.bg(cx.theme().secondary_active))
.rounded_full()
.child(relay)
.on_click(cx.listener(move |this, _, window, cx| {
this.input.update(cx, |this, cx| {
this.set_value(relay, 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))
}
})
}
}

View File

@@ -1,11 +1,12 @@
pub mod backup_keys;
pub mod chat;
pub mod compose;
pub mod edit_profile;
pub mod login;
pub mod messaging_relays;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod relays;
pub mod screening;
pub mod sidebar;
pub mod startup;

View File

@@ -1,22 +1,24 @@
use anyhow::anyhow;
use common::nip96::nip96_upload;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Window,
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, WeakEntity, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
@@ -25,7 +27,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
bio_input: Entity<InputState>,
is_uploading: bool,
is_submitting: bool,
// Panel
@@ -46,19 +47,12 @@ impl NewAccount {
.placeholder(SharedString::new(t!("profile.placeholder_name")))
});
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.placeholder(SharedString::new(t!("profile.placeholder_bio")))
});
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.png"));
Self {
name_input,
avatar_input,
bio_input,
is_uploading: false,
is_submitting: false,
name: "New Account".into(),
@@ -69,140 +63,97 @@ impl NewAccount {
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
self.submitting(true, cx);
let identity = Identity::global(cx);
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 mut metadata = Metadata::new().display_name(name).about(bio);
// Build metadata
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
let current_view = cx.entity().downgrade();
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let metadata = metadata.clone();
let weak_input = weak_input.clone();
let view_cancel = current_view.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |_this, cx| {
window.push_notification(t!("new_account.password_invalid"), cx)
})
.ok();
true
})
.on_ok(move |_, window, cx| {
let metadata = metadata.clone();
let value = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
if let Some(password) = value {
Identity::global(cx).update(cx, |this, cx| {
this.new_identity(password.to_string(), metadata, window, cx);
});
}
true
})
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(SharedString::new(t!("new_account.set_password_prompt")))
.child(TextInput::new(&pwd_input).small()),
)
identity.update(cx, |this, cx| {
this.new_identity(metadata, window, cx);
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_media_server(cx);
let avatar_input = self.avatar_input.downgrade();
self.uploading(true, cx);
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_media_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
self.set_uploading(true, cx);
cx.spawn_in(window, async move |this, cx| {
let task = Tokio::spawn(cx, async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let Some(path) = paths.pop() else {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
})
.ok();
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
return;
};
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
nostr_sdk::async_utility::task::spawn(async move {
if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
Ok(None) => {
cx.update(|_, cx| {
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
})
.ok();
})
.ok();
}
Err(_) => {}
Ok(Err(e)) => {
Self::notify_error(cx, this, e.to_string());
}
Err(e) => {
Self::notify_error(cx, this, e.to_string());
}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity<NewAccount>, e: String) {
cx.update(|window, cx| {
entity
.update(cx, |this, cx| {
window.push_notification(e, cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
@@ -243,93 +194,72 @@ impl Focusable for NewAccount {
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_10()
.child(
div()
.text_center()
.text_lg()
.text_center()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::new(t!("new_account.title"))),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_3()
v_flex()
.w_96()
.gap_4()
.child(
div()
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
if self.avatar_input.read(cx).value().is_empty() {
this.child(
img("brand/avatar.png")
.rounded_full()
.size_10()
.flex_shrink_0(),
)
} else {
this.child(
img(self.avatar_input.read(cx).value().clone())
.rounded_full()
.size_10()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.label(t!("profile.set_profile_picture"))
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_name")))
.child(SharedString::new(t!("new_account.name")))
.child(TextInput::new(&self.name_input).small()),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_1()
.text_sm()
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().elevated_surface_background),
.child(
div()
.text_sm()
.child(SharedString::new(t!("new_account.avatar"))),
)
.child(
v_flex()
.p_1()
.h_32()
.w_full()
.items_center()
.justify_center()
.gap_2()
.rounded(cx.theme().radius)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.child(
Avatar::new(self.avatar_input.read(cx).value().to_string())
.size(rems(2.25)),
)
.child(
Button::new("upload")
.icon(IconName::Plus)
.label(t!("common.upload"))
.ghost()
.small()
.rounded(ButtonRounded::Full)
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label(SharedString::new(t!("common.continue")))

View File

@@ -15,7 +15,7 @@ use nostr_sdk::prelude::*;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::checkbox::Checkbox;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
@@ -244,12 +244,13 @@ impl Render for Onboarding {
}),
)
.child(
div().w_24().absolute().bottom_4().right_4().child(
Button::new("unload")
div().w_24().absolute().bottom_2().right_2().child(
Button::new("logout")
.icon(IconName::Logout)
.label(SharedString::new(t!("common.logout")))
.ghost()
.small()
.label(SharedString::new(t!("user.sign_out")))
.danger()
.xsmall()
.rounded(ButtonRounded::Full)
.disabled(self.loading)
.on_click(|_, window, cx| {
Identity::global(cx).update(cx, |this, cx| {

View File

@@ -1,5 +1,4 @@
use common::display::DisplayProfile;
use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER};
use gpui::http_client::Url;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -14,10 +13,11 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::switch::Switch;
use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, relays};
use crate::views::{edit_profile, messaging_relays};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
@@ -33,8 +33,8 @@ impl Preferences {
let media_server = AppSettings::get_media_server(cx).to_string();
let media_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(media_server)
.placeholder(NIP96_SERVER)
.default_value(media_server.clone())
.placeholder(media_server)
});
Self { media_input }
@@ -42,25 +42,73 @@ impl Preferences {
}
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let edit_profile = edit_profile::init(window, cx);
let view = edit_profile::init(window, cx);
let weak_view = view.downgrade();
let title = SharedString::new(t!("profile.title"));
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(title.clone())
.width(px(DEFAULT_MODAL_WIDTH))
.child(edit_profile.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let set_metadata = this.set_metadata(cx);
cx.spawn_in(window, async move |_, cx| {
match set_metadata.await {
Ok(event) => {
if let Some(event) = event {
cx.update(|_, cx| {
Registry::global(cx).update(cx, |this, cx| {
this.insert_or_update_person(event, cx);
});
})
.ok();
}
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
})
.detach();
})
.ok();
// true to close the modal
true
})
});
}
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
let title = SharedString::new(t!("preferences.modal_relays_title"));
let title = SharedString::new(t!("relays.modal_title"));
let view = messaging_relays::init(window, cx);
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(DEFAULT_MODAL_WIDTH))
let weak_view = weak_view.clone();
this.confirm()
.title(title.clone())
.child(relays.clone())
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
});
}
}
@@ -80,10 +128,8 @@ impl Render for Preferences {
v_flex()
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.child(
div()
@@ -135,7 +181,7 @@ impl Render for Preferences {
)
.child(
Button::new("relays")
.label("DM Relays")
.label("Messaging Relays")
.ghost()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
@@ -146,11 +192,8 @@ impl Render for Preferences {
}),
)
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
@@ -162,6 +205,7 @@ impl Render for Preferences {
)
.child(
div()
.my_1()
.flex()
.items_start()
.gap_1()
@@ -193,10 +237,8 @@ impl Render for Preferences {
),
)
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
@@ -208,9 +250,7 @@ impl Render for Preferences {
.child(SharedString::new(t!("preferences.messages_header"))),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_2()
.child(
Switch::new("screening")
@@ -242,10 +282,8 @@ impl Render for Preferences {
),
)
.child(
div()
v_flex()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
@@ -257,9 +295,7 @@ impl Render for Preferences {
.child(SharedString::new(t!("preferences.display_header"))),
)
.child(
div()
.flex()
.flex_col()
v_flex()
.gap_2()
.child(
Switch::new("hide_user_avatars")

View File

@@ -1,347 +0,0 @@
use anyhow::Error;
use global::constants::NEW_MESSAGE_SUB_ID;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign,
UniformList, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable};
const MIN_HEIGHT: f32 = 200.0;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
Relays::new(window, cx)
}
pub struct Relays {
relays: Entity<Vec<RelayUrl>>,
input: Entity<InputState>,
focus_handle: FocusHandle,
is_loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Relays {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let relays = cx.new(|cx| {
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let client = nostr_client();
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 relays = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
Ok(relays)
} else {
let relays = vec![
RelayUrl::parse("wss://auth.nostr1.com")?,
RelayUrl::parse("wss://relay.0xchat.com")?,
];
Ok(relays)
}
});
cx.spawn(async move |this, cx| {
if let Ok(relays) = task.await {
cx.update(|cx| {
this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
*this = relays;
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
vec![]
});
cx.new(|cx| {
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Relays, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
));
Self {
relays,
input,
subscriptions,
is_loading: false,
focus_handle: cx.focus_handle(),
}
})
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_loading(true, cx);
let relays = self.relays.read(cx).clone();
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// If user didn't have any NIP-65 relays, add default ones
if client.database().relay_list(public_key).await?.is_empty() {
let builder = EventBuilder::relay_list(vec![
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
]);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {e}");
}
}
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let output = client.send_event_builder(builder).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
}
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
// Close old subscription
client.unsubscribe(&sub_id).await;
// Subscribe to new messages
if let Err(e) = client
.subscribe_with_id(
sub_id,
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0),
None,
)
.await
{
log::error!("Failed to subscribe to new messages: {e}");
}
Ok(output.val)
});
cx.spawn_in(window, async move |this, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_loading(false, cx);
cx.notify();
})
.ok();
window.close_modal(cx);
})
.ok();
}
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
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") {
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
self.relays.update(cx, |this, cx| {
if !this.contains(&url) {
this.push(url);
cx.notify();
}
});
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.update(cx, |this, cx| {
this.remove(ix);
cx.notify();
});
}
fn render_list(
&mut self,
relays: Vec<RelayUrl>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> UniformList {
let total = relays.len();
uniform_list(
"relays",
total,
cx.processor(move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
items.push(
div().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(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click(cx.listener(move |this, _, window, cx| {
this.remove(ix, window, cx)
})),
),
),
)
}
items
}),
)
.w_full()
.min_h(px(MIN_HEIGHT))
}
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.h_20()
.mb_2()
.flex()
.items_center()
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::new(t!("relays.add_some_relays")))
}
}
impl Render for Relays {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.size_full()
.px_3()
.pb_3()
.flex()
.flex_col()
.justify_between()
.child(
div()
.flex_1()
.flex()
.flex_col()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("relays.description"))),
)
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.items_center()
.w_full()
.gap_2()
.child(TextInput::new(&self.input).small())
.child(
Button::new("add_relay_btn")
.icon(IconName::Plus)
.label(t!("common.add"))
.small()
.ghost()
.rounded_md()
.on_click(cx.listener(|this, _, window, cx| {
this.add(window, cx)
})),
),
)
.map(|this| {
let relays = self.relays.read(cx).clone();
if !relays.is_empty() {
this.child(self.render_list(relays, window, cx))
} else {
this.child(self.render_empty(window, cx))
}
}),
),
)
.child(
Button::new("submti")
.label(t!("common.update"))
.primary()
.w_full()
.loading(self.is_loading)
.disabled(self.is_loading)
.on_click(cx.listener(|this, _, window, cx| {
this.update(window, cx);
})),
)
}
}

View File

@@ -4,15 +4,14 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use common::debounced_delay::DebouncedDelay;
use common::display::{DisplayProfile, TextUtils};
use global::constants::{BOOTSTRAP_RELAYS, DEFAULT_MODAL_WIDTH, SEARCH_RELAYS};
use common::display::TextUtils;
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, uniform_list, AnyElement, App, AppContext, ClipboardItem, Context,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription,
Task, Window,
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
Styled, Subscription, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
@@ -25,7 +24,6 @@ use registry::{Registry, RoomEmitter};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
@@ -34,8 +32,6 @@ use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton;
use ui::{v_flex, ContextModal, IconName, Selectable, Sizable, StyledExt};
use crate::views::compose;
mod list_item;
const FIND_DELAY: u64 = 600;
@@ -551,18 +547,6 @@ impl Sidebar {
});
}
fn open_compose(&self, window: &mut Window, cx: &mut Context<Self>) {
let compose = compose::init(window, cx);
let title = SharedString::new(t!("sidebar.direct_messages"));
window.open_modal(cx, move |modal, _window, _cx| {
modal
.title(title.clone())
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
});
}
fn open_loading_modal(&self, window: &mut Window, cx: &mut Context<Self>) {
let title = SharedString::new(t!("sidebar.loading_modal_title"));
let text_1 = SharedString::new(t!("sidebar.loading_modal_body_1"));
@@ -596,49 +580,6 @@ impl Sidebar {
});
}
fn account(&self, profile: &Profile, cx: &Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
div()
.px_3()
.h_8()
.flex_none()
.flex()
.justify_between()
.items_center()
.child(
div()
.id("current-user")
.flex()
.items_center()
.gap_2()
.text_sm()
.font_semibold()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
.child(profile.display_name())
.on_click(cx.listener({
let Ok(public_key) = profile.public_key().to_bech32();
let item = ClipboardItem::new_string(public_key);
move |_, _, window, cx| {
cx.write_to_clipboard(item.clone());
window.push_notification(t!("common.copied"), cx);
}
})),
)
.child(
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip(t!("sidebar.dm_tooltip"))
.small()
.primary()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(|this, _, window, cx| {
this.open_compose(window, cx);
})),
)
}
fn skeletons(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
@@ -727,9 +668,6 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let profile = Identity::read_global(cx)
.public_key()
.map(|pk| registry.get_person(&pk, cx));
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {
@@ -745,22 +683,17 @@ impl Render for Sidebar {
}
};
div()
v_flex()
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.flex()
.flex_col()
.gap_3()
// Account
.when_some(profile, |this, profile| {
this.child(self.account(&profile, cx))
})
// Search Input
.child(
div()
.relative()
.px_3()
.mt_3()
.px_2p5()
.w_full()
.h_7()
.flex_none()
@@ -781,14 +714,12 @@ impl Render for Sidebar {
)
// Chat Rooms
.child(
div()
.px_2()
.w_full()
.flex_1()
.overflow_y_hidden()
.flex()
.flex_col()
v_flex()
.gap_1()
.flex_1()
.px_1p5()
.w_full()
.overflow_y_hidden()
.child(
div()
.flex_none()
@@ -879,8 +810,11 @@ impl Render for Sidebar {
),
)
.when(registry.loading, |this| {
let title = SharedString::new(t!("sidebar.retrieving_messages"));
let desc = SharedString::new(t!("sidebar.retrieving_messages_description"));
this.child(
div().absolute().bottom_4().px_4().w_full().child(
div().absolute().bottom_3().px_3().w_full().child(
div()
.p_1()
.w_full()
@@ -890,35 +824,28 @@ impl Render for Sidebar {
.justify_between()
.bg(cx.theme().panel_background)
.shadow_sm()
// Empty div
.child(div().size_6().flex_shrink_0())
// Loading indicator
// Loading
.child(div().flex_shrink_0().pl_1().child(Indicator::new().small()))
// Title
.child(
div()
v_flex()
.flex_1()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_xs()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.flex()
.items_center()
.gap_1()
.line_height(relative(1.2))
.child(Indicator::new().xsmall())
.child(SharedString::new(t!(
"sidebar.retrieving_messages"
))),
.child(title.clone()),
)
.child(div().text_color(cx.theme().text_muted).child(
SharedString::new(t!(
"sidebar.retrieving_messages_description"
)),
)),
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(desc.clone()),
),
)
// Info button
.child(

View File

@@ -40,7 +40,7 @@ impl UserProfile {
})
}
pub fn on_load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if user isn't logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
@@ -100,11 +100,6 @@ impl UserProfile {
self.profile(cx).metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.public_key.to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let item = ClipboardItem::new_string(bech32);
@@ -221,7 +216,7 @@ impl Render for UserProfile {
.child(shared_bech32),
)
.child(
Button::new("copy-pubkey")
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
@@ -259,14 +254,5 @@ impl Render for UserProfile {
),
),
)
.child(
Button::new("open-njump")
.label(t!("profile.njump"))
.primary()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
}
}