update send message and chat panel ui
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m20s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Rust / build (macos-latest, stable) (pull_request) Has been cancelled
Rust / build (windows-latest, stable) (pull_request) Has been cancelled

This commit is contained in:
2026-02-22 14:34:41 +07:00
parent e3141aba19
commit 67ccfcb132
23 changed files with 639 additions and 1627 deletions

View File

@@ -588,6 +588,7 @@ impl ChatRegistry {
room.update(cx, |this, cx| {
this.push_message(message, cx);
});
self.sort(cx);
}
None => {
// Push the new room to the front of the list

View File

@@ -1,8 +1,9 @@
use std::cmp::Ordering;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use anyhow::{anyhow, Error};
use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
@@ -11,7 +12,10 @@ use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT};
use crate::{ChatRegistry, NewMessage};
use crate::NewMessage;
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
#[derive(Debug, Clone)]
pub struct SendReport {
@@ -222,6 +226,17 @@ impl Room {
cx.notify();
}
/// Updates the signer kind config for the room
pub fn set_signer_kind(&mut self, kind: &SignerKind, cx: &mut Context<Self>) {
self.config.set_signer_kind(kind);
cx.notify();
}
/// Returns the config of the room
pub fn config(&self) -> &RoomConfig {
&self.config
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
@@ -296,10 +311,6 @@ impl Room {
if new_message {
self.set_created_at(created_at, cx);
// Sort chats after emitting a new message
ChatRegistry::global(cx).update(cx, |this, cx| {
this.sort(cx);
});
}
}
@@ -308,49 +319,88 @@ impl Room {
cx.emit(RoomEvent::Reload);
}
#[allow(clippy::type_complexity)]
/// Get gossip relays for each member
pub fn early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
pub fn connect(&self, cx: &App) -> HashMap<PublicKey, Task<Result<(bool, bool), Error>>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let members = self.members();
let subscription_id = SubscriptionId::new(format!("room-{}", self.id));
let mut tasks = HashMap::new();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
for member in members.into_iter() {
if member == public_key {
continue;
};
// Construct a filter for messaging relays
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
// Construct a filter for announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to get member's gossip relays
client
.subscribe(vec![inbox, announcement])
.with_id(subscription_id.clone())
.close_on(
SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE),
)
.await?;
for member in members.into_iter() {
// Skip if member is the current user
if member == public_key {
continue;
}
Ok(())
})
let client = nostr.read(cx).client();
let write_relays = nostr.read(cx).write_relays(&member, cx);
tasks.insert(
member,
cx.background_spawn(async move {
let urls = write_relays.await;
// Return if no relays are available
if urls.is_empty() {
return Err(anyhow!(
"User has not set up any relays. You cannot send messages to them."
));
}
// Construct filters for inbox and announcement
let inbox_filter = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
let announcement_filter = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Create subscription targets
let target = urls
.into_iter()
.map(|relay| {
(
relay,
vec![inbox_filter.clone(), announcement_filter.clone()],
)
})
.collect::<HashMap<_, _>>();
// Stream events from user's write relays
let mut stream = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await?;
let mut has_inbox = false;
let mut has_announcement = false;
while let Some((_url, res)) = stream.next().await {
let event = res?;
match event.kind {
Kind::InboxRelays => has_inbox = true,
Kind::Custom(10044) => has_announcement = true,
_ => {}
}
// Early exit if both flags are found
if has_inbox && has_announcement {
break;
}
}
Ok((has_inbox, has_announcement))
}),
);
}
tasks
}
/// Get all messages belonging to the room
@@ -418,11 +468,6 @@ impl Room {
// Add all receiver tags
for member in members.into_iter() {
// Skip current user
if member.public_key() == sender {
continue;
}
tags.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
public_key: member.public_key(),
@@ -445,61 +490,59 @@ impl Room {
/// Send rumor event to all members's messaging relays
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
let config = self.config.clone();
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get room's config
let config = self.config.clone();
// Get current user's public key
let sender = nostr.read(cx).signer().public_key()?;
let public_key = nostr.read(cx).signer().public_key()?;
let sender = persons.read(cx).get(&public_key, cx);
// Get all members (excluding sender)
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.filter(|public_key| public_key != &&sender.public_key())
.map(|member| persons.read(cx).get(member, cx))
.collect();
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
let backup = config.backup();
let user_signer = signer.get().await;
let encryption_signer = signer.get_encryption_signer().await;
let mut sents = 0;
let mut reports = Vec::new();
// Process each member
for member in members {
let relays = member.messaging_relays();
let announcement = member.announcement();
let public_key = member.public_key();
// Skip if member has no messaging relays
if relays.is_empty() {
reports.push(SendReport::new(member.public_key()).error("No messaging relays"));
reports.push(SendReport::new(public_key).error("No messaging relays"));
continue;
}
// Ensure relay connections
for url in relays.iter() {
client
.add_relay(url)
.and_connect()
.capabilities(RelayCapabilities::GOSSIP)
.await
.ok();
// Handle encryption signer requirements
if signer_kind.encryption() {
if announcement.is_none() {
reports.push(SendReport::new(public_key).error(NO_DEKEY));
continue;
}
if encryption_signer.is_none() {
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
continue;
}
}
// When forced to use encryption signer, skip if receiver has no announcement
if signer_kind.encryption() && announcement.is_none() {
reports
.push(SendReport::new(member.public_key()).error("Encryption not found"));
continue;
}
// Determine receiver and signer based on signer kind
let (receiver, signer_to_use) = match signer_kind {
// Determine receiver and signer
let (receiver, signer) = match signer_kind {
SignerKind::Auto => {
if let Some(announcement) = announcement {
if let Some(enc_signer) = encryption_signer.as_ref() {
@@ -512,272 +555,77 @@ impl Room {
}
}
SignerKind::Encryption => {
let Some(encryption_signer) = encryption_signer.as_ref() else {
reports.push(
SendReport::new(member.public_key()).error("Encryption not found"),
);
continue;
};
let Some(announcement) = announcement else {
reports.push(
SendReport::new(member.public_key())
.error("Announcement not found"),
);
continue;
};
(announcement.public_key(), encryption_signer.clone())
// Safe to unwrap due to earlier checks
(
announcement.unwrap().public_key(),
encryption_signer.as_ref().unwrap().clone(),
)
}
SignerKind::User => (member.public_key(), user_signer.clone()),
};
// Create and send gift-wrapped event
match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await {
Ok(event) => {
match client
.send_event(&event)
.to(relays)
.ack_policy(AckPolicy::none())
.await
{
Ok(output) => {
reports.push(
SendReport::new(member.public_key())
.gift_wrap_id(event.id)
.output(output),
);
}
Err(e) => {
reports.push(
SendReport::new(member.public_key()).error(e.to_string()),
);
}
}
}
Err(e) => {
reports.push(SendReport::new(member.public_key()).error(e.to_string()));
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await
{
Ok((report, _)) => {
reports.push(report);
sents += 1;
}
Err(report) => reports.push(report),
}
}
// Send backup to current user if needed
if backup && sents >= 1 {
let relays = sender.messaging_relays();
let public_key = sender.public_key();
let signer = encryption_signer.as_ref().unwrap_or(&user_signer);
match send_gift_wrap(&client, signer, &public_key, &rumor, relays, public_key).await
{
Ok((report, _)) => reports.push(report),
Err(report) => reports.push(report),
}
}
reports
}))
}
/*
* /// Create a new unsigned message event
pub fn create_message(
&self,
content: &str,
replies: Vec<EventId>,
cx: &App,
) -> Task<Result<UnsignedEvent, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let subject = self.subject.clone();
let content = content.to_string();
let mut member_and_relay_hints = HashMap::new();
// Populate the hashmap with member and relay hint tasks
for member in self.members.iter() {
let hint = nostr.read(cx).relay_hint(member, cx);
member_and_relay_hints.insert(member.to_owned(), hint);
}
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// List of event tags for each receiver
let mut tags = vec![];
for (member, task) in member_and_relay_hints.into_iter() {
// Skip current user
if member == public_key {
continue;
}
// Get relay hint if available
let relay_url = task.await;
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member,
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
}
// Add subject tag if present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add all reply tags
for id in replies {
tags.push(Tag::event(id))
}
// Construct a direct message event
//
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tags(tags)
.build(public_key);
// Ensure the event ID has been generated
event.ensure_id();
Ok(event)
})
}
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: &UnsignedEvent,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut members = self.members();
let rumor = rumor.to_owned();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let current_user = signer.get_public_key().await?;
// Remove the current user's public key from the list of receivers
// the current user will be handled separately
members.retain(|this| this != &current_user);
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for receiver in members.into_iter() {
// Construct the gift wrap event
let event =
EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
// Send the gift wrap event to the messaging relays
match client.send_event(&event).to_nip17().await {
Ok(output) => {
let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
let tracker = tracker().read().await;
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
// Check if event was successfully resent
if tracker.is_sent_by_coop(&id) {
let output = Output::new(id);
let report = SendReport::new(receiver).status(output);
reports.push(report);
break;
}
// Check if retry limit exceeded
if attempt == SEND_RETRY {
reports.push(report);
break;
}
smol::Timer::after(Duration::from_millis(1200)).await;
}
} else {
reports.push(report);
}
}
Err(e) => {
reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
// Construct the gift-wrapped event
let event =
EventBuilder::gift_wrap(signer, &current_user, rumor.clone(), vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) {
// Send the event to the messaging relays
match client.send_event(&event).to_nip17().await {
Ok(output) => {
reports.push(SendReport::new(current_user).status(output));
}
Err(e) => {
reports.push(SendReport::new(current_user).error(e.to_string()));
}
}
} else {
reports.push(SendReport::new(current_user).on_hold(event));
}
Ok(reports)
})
}
/// Create a task to resend a failed message
pub fn resend_message(
&self,
reports: Vec<SendReport>,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let mut resend_reports = vec![];
for report in reports.into_iter() {
let receiver = report.receiver;
// Process failed events
if let Some(output) = report.status {
let id = output.id();
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
if let Some(event) = client.database().event_by_id(id).await? {
for url in urls.into_iter() {
let relay = client.relay(url).await?.context("Relay not found")?;
let id = relay.send_event(&event).await?;
let resent: Output<EventId> = Output {
val: id,
success: HashSet::from([url.to_owned()]),
failed: HashMap::new(),
};
resend_reports.push(SendReport::new(receiver).status(resent));
}
}
}
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
// Send the event to the messaging relays
match client.send_event(&event).await {
Ok(output) => {
resend_reports.push(SendReport::new(receiver).status(output));
}
Err(e) => {
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
}
Ok(resend_reports)
})
}
*/
}
// Helper function to send a gift-wrapped event
async fn send_gift_wrap<T>(
client: &Client,
signer: &T,
receiver: &PublicKey,
rumor: &UnsignedEvent,
relays: &[RelayUrl],
public_key: PublicKey,
) -> Result<(SendReport, bool), SendReport>
where
T: NostrSigner + 'static,
{
// Ensure relay connections
for url in relays {
client.add_relay(url).and_connect().await.ok();
}
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
Ok(event) => {
match client
.send_event(&event)
.to(relays)
.ack_policy(AckPolicy::none())
.await
{
Ok(output) => Ok((
SendReport::new(public_key)
.gift_wrap_id(event.id)
.output(output),
true,
)),
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
}
}
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
}
}

View File

@@ -1,12 +1,14 @@
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::SignerKind;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub enum Command {
Insert(&'static str),
ChangeSubject(&'static str),
ChangeSigner(SignerKind),
}
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]

View File

@@ -17,7 +17,7 @@ use gpui_tokio::Tokio;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::AppSettings;
use settings::{AppSettings, SignerKind};
use smallvec::{smallvec, SmallVec};
use smol::fs;
use smol::lock::RwLock;
@@ -41,6 +41,11 @@ use crate::text::RenderedText;
mod actions;
mod text;
const NO_INBOX: &str = "has not set up messaging relays. \
They will not receive your messages.";
const NO_ANNOUNCEMENT: &str = "has not set up an encryption key. \
You cannot send messages encrypted with an encryption key to them yet.";
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
}
@@ -225,12 +230,43 @@ impl ChatPanel {
}
/// Get all necessary data for each member
fn connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(connect) = self.room.read_with(cx, |this, cx| this.early_connect(cx)) else {
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(tasks) = self.room.read_with(cx, |this, cx| this.connect(cx)) else {
return;
};
self.tasks.push(cx.background_spawn(connect));
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
for (member, task) in tasks.into_iter() {
match task.await {
Ok((has_inbox, has_announcement)) => {
this.update(cx, |this, cx| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&member, cx);
if !has_inbox {
let content = format!("{} {}", profile.name(), NO_INBOX);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
if !has_announcement {
let content = format!("{} {}", profile.name(), NO_ANNOUNCEMENT);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.insert_message(Message::warning(e.to_string()), true, cx);
})?;
}
};
}
Ok(())
}));
}
/// Load all messages belonging to this room
@@ -339,6 +375,7 @@ impl ChatPanel {
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Send and get reports
let outputs = task.await;
// Add sent IDs to the list
@@ -559,6 +596,36 @@ impl ChatPanel {
persons.read(cx).get(public_key, cx)
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
self.send_message(content, window, cx);
}
Command::ChangeSubject(subject) => {
if self
.room
.update(cx, |this, cx| {
this.set_subject(*subject, cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to change subject"), cx);
}
}
Command::ChangeSigner(kind) => {
if self
.room
.update(cx, |this, cx| {
this.set_signer_kind(kind, cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to change signer"), cx);
}
}
}
}
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
const MSG: &str =
"This conversation is private. Only members can see each other's messages.";
@@ -1133,23 +1200,60 @@ impl ChatPanel {
items
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
self.send_message(content, window, cx);
}
Command::ChangeSubject(subject) => {
if self
.room
.update(cx, |this, cx| {
this.set_subject(*subject, cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to change subject"), cx);
}
}
}
fn render_encryption_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let signer_kind = self
.room
.read_with(cx, |this, _cx| this.config().signer_kind().clone())
.ok()
.unwrap_or_default();
Button::new("encryption")
.icon(IconName::UserKey)
.ghost()
.large()
.dropdown_menu(move |this, _window, _cx| {
let auto = matches!(signer_kind, SignerKind::Auto);
let encryption = matches!(signer_kind, SignerKind::Encryption);
let user = matches!(signer_kind, SignerKind::User);
this.check_side(ui::Side::Right)
.menu_with_check_and_disabled(
"Auto",
auto,
Box::new(Command::ChangeSigner(SignerKind::Auto)),
auto,
)
.menu_with_check_and_disabled(
"Decoupled Encryption Key",
encryption,
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
encryption,
)
.menu_with_check_and_disabled(
"User Identity",
user,
Box::new(Command::ChangeSigner(SignerKind::User)),
user,
)
})
}
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| {
this.horizontal()
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
.menu("🎉", Box::new(Command::Insert("🎉")))
.menu("😕", Box::new(Command::Insert("😕")))
.menu("❤️", Box::new(Command::Insert("❤️")))
.menu("🚀", Box::new(Command::Insert("🚀")))
.menu("👀", Box::new(Command::Insert("👀")))
})
}
}
@@ -1235,26 +1339,8 @@ impl Render for ChatPanel {
h_flex()
.pl_1()
.gap_1()
.child(
Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(
gpui::Corner::BottomLeft,
move |this, _window, _cx| {
this.horizontal()
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
.menu("🎉", Box::new(Command::Insert("🎉")))
.menu("😕", Box::new(Command::Insert("😕")))
.menu("❤️", Box::new(Command::Insert("❤️")))
.menu("🚀", Box::new(Command::Insert("🚀")))
.menu("👀", Box::new(Command::Insert("👀")))
},
),
)
.child(self.render_encryption_menu(window, cx))
.child(self.render_emoji_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)

View File

@@ -16,7 +16,7 @@ use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
@@ -497,7 +497,7 @@ impl Render for Sidebar {
.gap_2()
.child(
h_flex()
.h(TITLEBAR_HEIGHT)
.h(TABBAR_HEIGHT)
.border_b_1()
.border_color(cx.theme().border_variant)
.bg(cx.theme().elevated_surface_background)

View File

@@ -80,13 +80,25 @@ pub struct RoomConfig {
}
impl RoomConfig {
/// Get backup config
pub fn backup(&self) -> bool {
self.backup
}
/// Get signer kind config
pub fn signer_kind(&self) -> &SignerKind {
&self.signer_kind
}
/// Set backup config
pub fn set_backup(&mut self, backup: bool) {
self.backup = backup;
}
/// Set signer kind config
pub fn set_signer_kind(&mut self, kind: &SignerKind) {
self.signer_kind = kind.to_owned();
}
}
/// Settings

View File

@@ -42,7 +42,7 @@ pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
"wss://user.kindpag.es",
];

View File

@@ -176,11 +176,7 @@ impl NostrRegistry {
while let Some(notification) = notifications.next().await {
if let ClientNotification::Message {
message:
RelayMessage::Event {
event,
subscription_id,
},
message: RelayMessage::Event { event, .. },
..
} = notification
{
@@ -191,11 +187,6 @@ impl NostrRegistry {
match event.kind {
Kind::RelayList => {
// Automatically get messaging relays for each member when the user opens a room
if subscription_id.as_str().starts_with("room-") {
get_adv_events_by(&client, event.as_ref()).await?;
}
tx.send_async(event.into_owned()).await?;
}
Kind::InboxRelays => {
@@ -773,53 +764,6 @@ impl NostrRegistry {
}
}
/// Automatically get messaging relays and encryption announcement from a received relay list
async fn get_adv_events_by(client: &Client, event: &Event) -> Result<(), Error> {
// Subscription options
let opts = SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE);
// Extract write relays from event
let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(event)
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url)
} else {
None
}
})
.collect();
// Ensure relay connections
for relay in write_relays.iter() {
client.add_relay(*relay).await?;
client.connect_relay(*relay).await?;
}
// Construct filter for inbox relays
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(event.pubkey)
.limit(1);
// Construct filter for encryption announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(event.pubkey)
.limit(1);
// Construct target for subscription
let target = write_relays
.into_iter()
.map(|relay| (relay, vec![inbox.clone(), announcement.clone()]))
.collect::<HashMap<_, _>>();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
/// Get or create a new app keys
fn get_or_init_app_keys() -> Result<Keys, Error> {
let dir = config_dir().join(".app_keys");
@@ -857,15 +801,15 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.primal.net/").unwrap(),
RelayUrl::parse("wss://relay.primal.net").unwrap(),
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.damus.io/").unwrap(),
RelayUrl::parse("wss://relay.damus.io").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nos.lol/").unwrap(),
RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Read),
),
]
@@ -873,8 +817,8 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
fn default_messaging_relays() -> Vec<RelayUrl> {
vec![
//RelayUrl::parse("wss://auth.nostr1.com/").unwrap(),
RelayUrl::parse("wss://nip17.com/").unwrap(),
RelayUrl::parse("wss://nos.lol").unwrap(),
RelayUrl::parse("wss://nip17.com").unwrap(),
]
}

View File

@@ -29,6 +29,9 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Defines window titlebar height
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(28.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);

View File

@@ -1,49 +1,109 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement as _, Styled as _, Window,
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
use crate::{h_flex, v_flex, Disableable, IconName, Selectable};
type OnClick = Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>;
use crate::icon::IconNamed;
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
/// A Checkbox element.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct Checkbox {
id: ElementId,
base: Div,
style: StyleRefinement,
label: Option<SharedString>,
children: Vec<AnyElement>,
checked: bool,
disabled: bool,
on_click: OnClick,
size: Size,
tab_stop: bool,
tab_index: isize,
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
}
impl Checkbox {
/// Create a new Checkbox with the given id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
style: StyleRefinement::default(),
label: None,
children: Vec::new(),
checked: false,
disabled: false,
size: Size::default(),
on_click: None,
tab_stop: true,
tab_index: 0,
}
}
/// Set the label for the checkbox.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the checked state for the checkbox.
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Set the click handler for the checkbox.
///
/// The `&bool` parameter indicates the new checked state after the click.
pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self.on_click = Some(Rc::new(handler));
self
}
/// Set the tab stop for the checkbox, default is true.
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
/// Set the tab index for the checkbox, default is 0.
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
#[allow(clippy::type_complexity)]
fn handle_click(
on_click: &Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
checked: bool,
window: &mut Window,
cx: &mut App,
) {
let new_checked = !checked;
if let Some(f) = on_click {
(f)(&new_checked, window, cx);
}
}
}
impl InteractiveElement for Checkbox {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Checkbox {}
impl Styled for Checkbox {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl Disableable for Checkbox {
@@ -63,64 +123,190 @@ impl Selectable for Checkbox {
}
}
impl RenderOnce for Checkbox {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let icon_color = if self.disabled {
cx.theme().icon_muted
} else {
cx.theme().icon_accent
};
h_flex()
.id(self.id)
.gap_2()
.items_center()
.child(
v_flex()
.flex_shrink_0()
.relative()
.rounded_sm()
.size_5()
.bg(cx.theme().elevated_surface_background)
.child(
svg()
.absolute()
.top_0p5()
.left_0p5()
.size_4()
.text_color(icon_color)
.map(|this| match self.checked {
true => this.path(IconName::Check.path()),
_ => this,
}),
),
)
.map(|this| {
if let Some(label) = self.label {
this.text_color(cx.theme().text_muted).child(
div()
.w_full()
.overflow_x_hidden()
.text_ellipsis()
.text_sm()
.child(label),
)
} else {
this
}
})
.when(self.disabled, |this| {
this.cursor_not_allowed()
.text_color(cx.theme().text_placeholder)
})
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {
this.on_click(move |_, window, cx| {
let checked = !self.checked;
on_click(&checked, window, cx);
})
},
)
impl ParentElement for Checkbox {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Sizable for Checkbox {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
pub(crate) fn checkbox_check_icon(
id: ElementId,
size: Size,
checked: bool,
disabled: bool,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let toggle_state = window.use_keyed_state(id, cx, |_, _| checked);
let color = if disabled {
cx.theme().text.opacity(0.5)
} else {
cx.theme().text
};
svg()
.absolute()
.top_px()
.left_px()
.map(|this| match size {
Size::XSmall => this.size_2(),
Size::Small => this.size_2p5(),
Size::Medium => this.size_3(),
Size::Large => this.size_3p5(),
_ => this.size_3(),
})
.text_color(color)
.map(|this| match checked {
true => this.path(IconName::Check.path()),
_ => this,
})
.map(|this| {
if !disabled && checked != *toggle_state.read(cx) {
let duration = Duration::from_secs_f64(0.25);
cx.spawn({
let toggle_state = toggle_state.clone();
async move |cx| {
cx.background_executor().timer(duration).await;
toggle_state.update(cx, |this, _| *this = checked);
}
})
.detach();
this.with_animation(
ElementId::NamedInteger("toggle".into(), checked as u64),
Animation::new(Duration::from_secs_f64(0.25)),
move |this, delta| {
this.opacity(if checked { 1.0 * delta } else { 1.0 - delta })
},
)
.into_any_element()
} else {
this.into_any_element()
}
})
}
impl RenderOnce for Checkbox {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
.read(cx)
.clone();
let checked = self.checked;
let radius = cx.theme().radius.min(px(4.));
let border_color = if checked {
cx.theme().border_focused
} else {
cx.theme().border
};
let color = if self.disabled {
border_color.opacity(0.5)
} else {
border_color
};
div().child(
self.base
.id(self.id.clone())
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_stop(self.tab_stop)
.tab_index(self.tab_index),
)
})
.h_flex()
.gap_2()
.items_start()
.line_height(relative(1.))
.text_color(cx.theme().text)
.map(|this| match self.size {
Size::XSmall => this.text_xs(),
Size::Small => this.text_sm(),
Size::Medium => this.text_base(),
Size::Large => this.text_lg(),
_ => this,
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.rounded(cx.theme().radius * 0.5)
.refine_style(&self.style)
.child(
div()
.relative()
.map(|this| match self.size {
Size::XSmall => this.size_3(),
Size::Small => this.size_3p5(),
Size::Medium => this.size_4(),
Size::Large => this.size(rems(1.125)),
_ => this.size_4(),
})
.flex_shrink_0()
.border_1()
.border_color(color)
.rounded(radius)
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
.map(|this| match checked {
false => this.bg(cx.theme().background),
_ => this.bg(color),
})
.child(checkbox_check_icon(
self.id,
self.size,
checked,
self.disabled,
window,
cx,
)),
)
.when(self.label.is_some() || !self.children.is_empty(), |this| {
this.child(
v_flex()
.w_full()
.line_height(relative(1.2))
.gap_1()
.map(|this| {
if let Some(label) = self.label {
this.child(
div()
.size_full()
.text_color(cx.theme().text)
.when(self.disabled, |this| {
this.text_color(cx.theme().text_muted)
})
.line_height(relative(1.))
.child(label),
)
} else {
this
}
})
.children(self.children),
)
})
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
// Avoid focus on mouse down.
window.prevent_default();
})
.when(!self.disabled, |this| {
this.on_click({
let on_click = self.on_click.clone();
move |_, window, cx| {
window.prevent_default();
Self::handle_click(&on_click, checked, window, cx);
}
})
}),
)
}
}

View File

@@ -7,7 +7,7 @@ use gpui::{
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
use crate::button::{Button, ButtonVariants as _};
use crate::dock_area::dock::DockPlacement;
@@ -645,7 +645,7 @@ impl TabPanel {
TabBar::new()
.track_scroll(&self.tab_bar_scroll_handle)
.h(TITLEBAR_HEIGHT)
.h(TABBAR_HEIGHT)
.when(has_extend_dock_button, |this| {
this.prefix(
h_flex()

View File

@@ -1,12 +1,23 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
svg, AnyElement, App, AppContext, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
svg, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render,
RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
};
use theme::ActiveTheme;
use crate::{Sizable, Size};
pub trait IconNamed {
/// Returns the embedded path of the icon.
fn path(self) -> SharedString;
}
impl<T: IconNamed> From<T> for Icon {
fn from(value: T) -> Self {
Icon::build(value)
}
}
#[derive(IntoElement, Clone)]
pub enum IconName {
ArrowLeft,
@@ -43,6 +54,7 @@ pub enum IconName {
Sun,
Ship,
Shield,
UserKey,
Upload,
Usb,
PanelLeft,
@@ -63,7 +75,14 @@ pub enum IconName {
}
impl IconName {
pub fn path(self) -> SharedString {
/// Return the icon as a Entity<Icon>
pub fn view(self, cx: &mut App) -> Entity<Icon> {
Icon::build(self).view(cx)
}
}
impl IconNamed for IconName {
fn path(self) -> SharedString {
match self {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
@@ -99,6 +118,7 @@ impl IconName {
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::PanelLeft => "icons/panel-left.svg",
@@ -119,17 +139,6 @@ impl IconName {
}
.into()
}
/// Return the icon as a Entity<Icon>
pub fn view(self, window: &mut Window, cx: &mut App) -> Entity<Icon> {
Icon::build(self).view(window, cx)
}
}
impl From<IconName> for Icon {
fn from(val: IconName) -> Self {
Icon::build(val)
}
}
impl From<IconName> for AnyElement {
@@ -139,7 +148,7 @@ impl From<IconName> for AnyElement {
}
impl RenderOnce for IconName {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
Icon::build(self)
}
}
@@ -147,6 +156,7 @@ impl RenderOnce for IconName {
#[derive(IntoElement)]
pub struct Icon {
base: Svg,
style: StyleRefinement,
path: SharedString,
text_color: Option<Hsla>,
size: Option<Size>,
@@ -157,6 +167,7 @@ impl Default for Icon {
fn default() -> Self {
Self {
base: svg().flex_none().size_4(),
style: StyleRefinement::default(),
path: "".into(),
text_color: None,
size: None,
@@ -168,23 +179,20 @@ impl Default for Icon {
impl Clone for Icon {
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone());
if let Some(size) = self.size {
this = this.with_size(size);
}
this.style = self.style.clone();
this.rotation = self.rotation;
this.size = self.size;
this.text_color = self.text_color;
this
}
}
pub trait IconNamed {
fn path(&self) -> SharedString;
}
impl Icon {
pub fn new(icon: impl Into<Icon>) -> Self {
icon.into()
}
fn build(name: IconName) -> Self {
fn build(name: impl IconNamed) -> Self {
Self::default().path(name.path())
}
@@ -197,7 +205,7 @@ impl Icon {
}
/// Create a new view for the icon
pub fn view(self, _window: &mut Window, cx: &mut App) -> Entity<Icon> {
pub fn view(self, cx: &mut App) -> Entity<Icon> {
cx.new(|_| self)
}
@@ -221,7 +229,7 @@ impl Icon {
impl Styled for Icon {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
&mut self.style
}
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
@@ -240,9 +248,15 @@ impl Sizable for Icon {
impl RenderOnce for Icon {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| window.text_style().color);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
self.base
let mut base = self.base;
*base.style() = self.style;
base.flex_shrink_0()
.text_color(text_color)
.when(!has_base_size, |this| this.size(text_size))
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
@@ -261,16 +275,17 @@ impl From<Icon> for AnyElement {
}
impl Render for Icon {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
svg()
.flex_none()
let mut base = svg().flex_none();
*base.style() = self.style.clone();
base.flex_shrink_0()
.text_color(text_color)
.when(!has_base_size, |this| this.size(text_size))
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
@@ -278,7 +293,7 @@ impl Render for Icon {
Size::Medium => this.size_5(),
Size::Large => this.size_6(),
})
.when(!self.path.is_empty(), |this| this.path(self.path.clone()))
.path(self.path.clone())
.when_some(self.rotation, |this, rotation| {
this.with_transformation(Transformation::rotate(rotation))
})

View File

@@ -1028,7 +1028,7 @@ impl PopupMenu {
Icon::empty()
};
Some(icon.xsmall())
Some(icon.small())
}
#[inline]

View File

@@ -3,7 +3,7 @@ use gpui::{
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, StatefulInteractiveElement, Styled, Window,
};
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use crate::{Selectable, Sizable, Size};
@@ -136,7 +136,7 @@ impl RenderOnce for Tab {
self.base
.id(self.ix)
.h(TITLEBAR_HEIGHT)
.h(TABBAR_HEIGHT)
.px_4()
.relative()
.flex()