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

This commit is contained in:
2026-02-04 08:42:03 +07:00
parent cc7efd2864
commit 07c5d58f8e
9 changed files with 199 additions and 563 deletions

View File

@@ -104,7 +104,7 @@ impl ChatRegistry {
let device_signer = device.read(cx).device_signer.clone();
// A flag to indicate if the registry is loading
let tracking_flag = Arc::new(AtomicBool::new(true));
let tracking_flag = Arc::new(AtomicBool::new(false));
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<NostrEvent>(2048);
@@ -166,7 +166,7 @@ impl ChatRegistry {
Self {
rooms: vec![],
loading: true,
loading: false,
sender: tx.clone(),
tracking_flag,
tracking: None,
@@ -253,37 +253,18 @@ impl ChatRegistry {
/// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let status = self.tracking_flag.clone();
let tx = self.sender.clone();
self.tracking = Some(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(12);
let mut total_loops = 0;
loop {
if client.has_signer().await {
total_loops += 1;
if status.load(Ordering::Acquire) {
// Reset gift wrap processing flag
_ = status.compare_exchange(
true,
false,
Ordering::Release,
Ordering::Relaxed,
);
tx.send_async(NostrEvent::Unwrapping(true)).await.ok();
} else {
// Wait at least 2 loops to prevent exiting early while events are still being processed
if total_loops >= 2 {
tx.send_async(NostrEvent::Unwrapping(false)).await.ok();
// Reset the counter
total_loops = 0;
}
}
if status.load(Ordering::Acquire) {
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
tx.send_async(NostrEvent::Unwrapping(true)).await.ok();
} else {
tx.send_async(NostrEvent::Unwrapping(false)).await.ok();
}
smol::Timer::after(loop_duration).await;
}

View File

@@ -12,6 +12,7 @@ const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
pub trait RenderedProfile {
fn avatar(&self) -> SharedString;
@@ -24,7 +25,12 @@ impl RenderedProfile for Profile {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.map(|picture| {
let url = format!(
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.unwrap_or_else(|| "brand/avatar.png".into())
}

View File

@@ -1,510 +0,0 @@
use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use chat::{ChatRegistry, Room};
use common::{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::{NostrAddress, 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, Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension};
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();
let http_client = cx.http_client();
// 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 let Ok(addr) = Nip05Address::parse(&content) {
let task = Tokio::spawn(cx, async move {
if let Ok(profile) = addr.profile(&http_client).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::CheckCircle)
.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::PlusCircle)
.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.)),
)
}
}),
)
}
}

View File

@@ -114,7 +114,6 @@ impl Render for GreeterPanel {
v_flex()
.gap_2()
.w_full()
.items_start()
.child(
h_flex()
.gap_1()
@@ -127,17 +126,16 @@ impl Render for GreeterPanel {
)
.child(
v_flex()
.w_full()
.items_start()
.justify_start()
.gap_2()
.w_full()
.when(relay_list_state == RelayState::NotSet, |this| {
this.child(
Button::new("relaylist")
.icon(Icon::new(IconName::Door))
.icon(Icon::new(IconName::Relay))
.label("Set up relay list")
.ghost()
.small()
.no_center()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
relay_list::init(window, cx),
@@ -153,10 +151,11 @@ impl Render for GreeterPanel {
|this| {
this.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.icon(Icon::new(IconName::Relay))
.label("Set up messaging relays")
.ghost()
.small()
.no_center()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
messaging_relays::init(window, cx),
@@ -176,7 +175,6 @@ impl Render for GreeterPanel {
v_flex()
.gap_2()
.w_full()
.items_start()
.child(
h_flex()
.gap_1()
@@ -189,16 +187,15 @@ impl Render for GreeterPanel {
)
.child(
v_flex()
.w_full()
.items_start()
.justify_start()
.gap_2()
.w_full()
.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.no_center()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
@@ -214,6 +211,7 @@ impl Render for GreeterPanel {
.label("Import a secret key or bunker")
.ghost()
.small()
.no_center()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
@@ -230,7 +228,6 @@ impl Render for GreeterPanel {
v_flex()
.gap_2()
.w_full()
.items_start()
.child(
h_flex()
.gap_1()
@@ -243,16 +240,23 @@ impl Render for GreeterPanel {
)
.child(
v_flex()
.w_full()
.items_start()
.justify_start()
.gap_2()
.w_full()
.child(
Button::new("backup")
.icon(Icon::new(IconName::Shield))
.label("Backup account")
.ghost()
.small()
.no_center(),
)
.child(
Button::new("profile")
.icon(Icon::new(IconName::Profile))
.label("Update profile")
.ghost()
.small()
.no_center()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
profile::init(window, cx),
@@ -262,19 +266,13 @@ impl Render for GreeterPanel {
);
}),
)
.child(
Button::new("changelog")
.icon(Icon::new(IconName::Ship))
.label("Keep up to date")
.ghost()
.small(),
)
.child(
Button::new("invite")
.icon(Icon::new(IconName::Invite))
.label("Invite people")
.label("Invite friends")
.ghost()
.small(),
.small()
.no_center(),
),
),
),

View File

@@ -23,3 +23,4 @@ serde.workspace = true
serde_json.workspace = true
rustls = "0.23"
petname = "2.0.2"

View File

@@ -37,6 +37,8 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
/// Default avatar for new users
pub const DEFAULT_AVATAR: &str = "https://image.nostr.build/93bb6084457a42620849b6827f3f34f111ae5a4ac728638a989d4ed4b4bb3ac8.png";
/// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
@@ -726,7 +728,12 @@ impl NostrRegistry {
/// Create a new identity
fn create_identity(&mut self, cx: &mut Context<Self>) {
let client = self.client();
// Generate new keys
let keys = Keys::generate();
// Get write credential task
let write_credential = cx.write_credentials(
CLIENT_NAME,
&keys.public_key().to_hex(),
@@ -736,10 +743,23 @@ impl NostrRegistry {
// Update the signer
self.set_signer(keys, false, cx);
// TODO: set metadata
// Spawn a task to write the credentials
// Spawn a task to set metadata and write the credentials
cx.background_spawn(async move {
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
let avatar = Url::parse(DEFAULT_AVATAR).unwrap();
// Construct metadata for the identity
let metadata = Metadata::new()
.display_name(&name)
.name(&name)
.picture(avatar);
// Set metadata for the identity
if let Err(e) = client.set_metadata(&metadata).await {
log::error!("Failed to set metadata: {}", e);
}
// Write the credentials
if let Err(e) = write_credential.await {
log::error!("Failed to write credentials: {}", e);
}

View File

@@ -42,6 +42,7 @@ pub enum IconName {
Settings,
Sun,
Ship,
Shield,
Upload,
Usb,
PanelLeft,
@@ -96,6 +97,7 @@ impl IconName {
Self::Settings => "icons/settings.svg",
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::PanelLeft => "icons/panel-left.svg",