Continue redesign for the v1 stable release (#5)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m32s

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-12 08:32:17 +00:00
parent 32201554ec
commit ecd7f6aa9b
43 changed files with 2794 additions and 3409 deletions

610
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,11 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # Nostr
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"

View File

@@ -4,7 +4,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::BOOTSTRAP_RELAYS;
use gpui::http_client::{AsyncBody, HttpClient}; use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{ use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task, App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
@@ -243,12 +242,7 @@ impl AutoUpdater {
.author(app_pubkey) .author(app_pubkey)
.limit(1); .limit(1);
if let Err(e) = client // TODO
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe to updates: {e}");
};
}) })
} }
@@ -285,10 +279,7 @@ impl AutoUpdater {
.author(app_pubkey) .author(app_pubkey)
.ids(ids.clone()); .ids(ids.clone());
// Get all files for this release // TODO
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(ids) Ok(ids)
} else { } else {

View File

@@ -98,7 +98,7 @@ impl ChatRegistry {
/// Create a new chat registry instance /// Create a new chat registry instance
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity(); let nip17_state = nostr.read(cx).nip17_state();
let device = DeviceRegistry::global(cx); let device = DeviceRegistry::global(cx);
let device_signer = device.read(cx).device_signer.clone(); let device_signer = device.read(cx).device_signer.clone();
@@ -114,8 +114,8 @@ impl ChatRegistry {
subscriptions.push( subscriptions.push(
// Observe the identity // Observe the identity
cx.observe(&identity, |this, state, cx| { cx.observe(&nip17_state, |this, state, cx| {
if state.read(cx).messaging_relays_state() == RelayState::Set { if state.read(cx) == &RelayState::Configured {
// Handle nostr notifications // Handle nostr notifications
this.handle_notifications(cx); this.handle_notifications(cx);
// Track unwrapping progress // Track unwrapping progress
@@ -146,15 +146,15 @@ impl ChatRegistry {
}) })
.ok(); .ok();
} }
NostrEvent::Eose => { NostrEvent::Unwrapping(status) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_loading(status, cx);
this.get_rooms(cx); this.get_rooms(cx);
}) })
.ok(); .ok();
} }
NostrEvent::Unwrapping(status) => { NostrEvent::Eose => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_loading(status, cx);
this.get_rooms(cx); this.get_rooms(cx);
}) })
.ok(); .ok();
@@ -195,8 +195,8 @@ impl ChatRegistry {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await { while let Some(notification) = notifications.next().await {
let RelayPoolNotification::Message { message, .. } = notification else { let ClientNotification::Message { message, .. } = notification else {
// Skip non-message notifications // Skip non-message notifications
continue; continue;
}; };
@@ -310,27 +310,40 @@ impl ChatRegistry {
/// Add a new room to the start of list. /// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>) pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where where
I: Into<Room>, I: Into<Room> + 'static,
{ {
self.rooms.insert(0, cx.new(|_| room.into())); let nostr = NostrRegistry::global(cx);
cx.notify(); let client = nostr.read(cx).client();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Some(signer) = client.signer() {
if let Ok(public_key) = signer.get_public_key().await {
this.update(cx, |this, cx| {
this.rooms
.insert(0, cx.new(|_| room.into().organize(&public_key)));
cx.emit(ChatEvent::Ping);
cx.notify();
})
.ok();
}
}
}));
} }
/// Emit an open room event. /// Emit an open room event.
/// ///
/// If the room is new, add it to the registry. /// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) { pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
if let Some(room) = room.upgrade() { // Get the room's ID.
let id = room.read(cx).id; let id = room.read(cx).id;
// If the room is new, add it to the registry. // If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) { if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room); self.rooms.insert(0, room.to_owned());
}
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
} }
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
} }
/// Close a room. /// Close a room.
@@ -407,23 +420,20 @@ impl ChatRegistry {
pub fn get_rooms(&mut self, cx: &mut Context<Self>) { pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.get_rooms_from_database(cx); let task = self.get_rooms_from_database(cx);
self.tasks.push( self.tasks.push(cx.spawn(async move |this, cx| {
// Run and finished in the background match task.await {
cx.spawn(async move |this, cx| { Ok(rooms) => {
match task.await { this.update(cx, move |this, cx| {
Ok(rooms) => { this.extend_rooms(rooms, cx);
this.update(cx, move |this, cx| { this.sort(cx);
this.extend_rooms(rooms, cx); })
this.sort(cx); .ok();
}) }
.ok(); Err(e) => {
} log::error!("Failed to load rooms: {e}")
Err(e) => { }
log::error!("Failed to load rooms: {e}") };
} }));
};
}),
);
} }
/// Create a task to load rooms from the database /// Create a task to load rooms from the database
@@ -432,10 +442,13 @@ impl ChatRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
cx.background_spawn(async move { cx.background_spawn(async move {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
// Get contacts
let contacts = client.database().contacts_public_keys(public_key).await?; let contacts = client.database().contacts_public_keys(public_key).await?;
// Construct authored filter
let authored_filter = Filter::new() let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key); .custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
@@ -443,6 +456,7 @@ impl ChatRegistry {
// Get all authored events // Get all authored events
let authored = client.database().query(authored_filter).await?; let authored = client.database().query(authored_filter).await?;
// Construct addressed filter
let addressed_filter = Filter::new() let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key); .custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
@@ -453,6 +467,7 @@ impl ChatRegistry {
// Merge authored and addressed events // Merge authored and addressed events
let events = authored.merge(addressed); let events = authored.merge(addressed);
// Collect results
let mut rooms: HashSet<Room> = HashSet::new(); let mut rooms: HashSet<Room> = HashSet::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new(); let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
@@ -468,24 +483,21 @@ impl ChatRegistry {
for (_id, mut messages) in grouped.into_iter() { for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at)); messages.sort_by_key(|m| Reverse(m.created_at));
// Always use the latest message
let Some(latest) = messages.first() else { let Some(latest) = messages.first() else {
continue; continue;
}; };
let mut room = Room::from(latest); // Construct the room from the latest message.
//
if rooms.iter().any(|r| r.id == room.id) { // Call `.organize` to ensure the current user is at the end of the list.
continue; let mut room = Room::from(latest).organize(&public_key);
}
let mut public_keys = room.members();
public_keys.retain(|pk| pk != &public_key);
// Check if the user has responded to the room // Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key); let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts // Check if public keys are from the user's contacts
let is_contact = public_keys.iter().any(|k| contacts.contains(k)); let is_contact = room.members.iter().any(|k| contacts.contains(k));
// Set the room's kind based on status // Set the room's kind based on status
if user_sent || is_contact { if user_sent || is_contact {
@@ -499,6 +511,24 @@ impl ChatRegistry {
}) })
} }
/// Parse a nostr event into a message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
Some(room) => {
room.update(cx, |this, cx| {
this.push_message(message, cx);
});
}
None => {
// Push the new room to the front of the list
self.add_room(message.rumor, cx);
}
}
}
/// Trigger a refresh of the opened chat rooms by their IDs /// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) { pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
if let Some(ids) = ids { if let Some(ids) = ids {
@@ -512,53 +542,6 @@ impl ChatRegistry {
} }
} }
/// Parse a nostr event into a message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
// Get the unique id
let id = message.rumor.uniq_id();
// Get the author
let author = message.rumor.pubkey;
match self.rooms.iter().find(|room| room.read(cx).id == id) {
Some(room) => {
let new_message = message.rumor.created_at > room.read(cx).created_at;
let created_at = message.rumor.created_at;
// Update room
room.update(cx, |this, cx| {
// Update the last timestamp if the new message is newer
if new_message {
this.set_created_at(created_at, cx);
}
// Set this room is ongoing if the new message is from current user
if author == nostr.read(cx).identity().read(cx).public_key() {
this.set_ongoing(cx);
}
// Emit the new message to the room
this.emit_message(message, cx);
});
// Resort all rooms in the registry by their created at (after updated)
if new_message {
self.sort(cx);
}
}
None => {
// Push the new room to the front of the list
self.add_room(&message.rumor, cx);
// Notify the UI about the new room
cx.emit(ChatEvent::Ping);
}
}
}
/// Unwraps a gift-wrapped event and processes its contents. /// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor( async fn extract_rumor(
client: &Client, client: &Client,
@@ -597,8 +580,8 @@ impl ChatRegistry {
}; };
// Try with the user's signer // Try with the user's signer
let user_signer = client.signer().await?; let user_signer = client.signer().context("Signer not found")?;
let unwrapped = UnwrappedGift::from_gift_wrap(&user_signer, gift_wrap).await?; let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
Ok(unwrapped) Ok(unwrapped)
} }

View File

@@ -1,17 +1,25 @@
use std::hash::Hash; use std::hash::Hash;
use common::EventUtils;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
/// New message. /// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage { pub struct NewMessage {
pub gift_wrap: EventId, pub gift_wrap: EventId,
pub room: u64,
pub rumor: UnsignedEvent, pub rumor: UnsignedEvent,
} }
impl NewMessage { impl NewMessage {
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self { pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
Self { gift_wrap, rumor } let room = rumor.uniq_id();
Self {
gift_wrap,
room,
rumor,
}
} }
} }

View File

@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::{Context as AnyhowContext, Error};
use common::EventUtils; use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools; use itertools::Itertools;
@@ -11,7 +11,7 @@ use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry}; use person::{Person, PersonRegistry};
use state::{tracker, NostrRegistry}; use state::{tracker, NostrRegistry};
use crate::NewMessage; use crate::{ChatRegistry, NewMessage};
const SEND_RETRY: usize = 10; const SEND_RETRY: usize = 10;
@@ -99,16 +99,20 @@ pub enum RoomKind {
Ongoing, Ongoing,
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Room { pub struct Room {
/// Conversation ID /// Conversation ID
pub id: u64, pub id: u64,
/// The timestamp of the last message in the room /// The timestamp of the last message in the room
pub created_at: Timestamp, pub created_at: Timestamp,
/// Subject of the room /// Subject of the room
pub subject: Option<SharedString>, pub subject: Option<SharedString>,
/// All members of the room /// All members of the room
pub members: Vec<PublicKey>, pub(super) members: Vec<PublicKey>,
/// Kind /// Kind
pub kind: RoomKind, pub kind: RoomKind,
} }
@@ -145,11 +149,7 @@ impl From<&UnsignedEvent> for Room {
fn from(val: &UnsignedEvent) -> Self { fn from(val: &UnsignedEvent) -> Self {
let id = val.uniq_id(); let id = val.uniq_id();
let created_at = val.created_at; let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.extract_public_keys(); let members = val.extract_public_keys();
// Get subject from tags
let subject = val let subject = val
.tags .tags
.find(TagKind::Subject) .find(TagKind::Subject)
@@ -165,23 +165,45 @@ impl From<&UnsignedEvent> for Room {
} }
} }
impl From<UnsignedEvent> for Room {
fn from(val: UnsignedEvent) -> Self {
Room::from(&val)
}
}
impl Room { impl Room {
/// Constructs a new room with the given receiver and tags. /// Constructs a new room with the given receiver and tags.
pub fn new<T>(author: PublicKey, receivers: T) -> Self pub fn new<T>(author: PublicKey, receivers: T) -> Self
where where
T: IntoIterator<Item = PublicKey>, T: IntoIterator<Item = PublicKey>,
{ {
// Map receiver public keys to tags
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect()); let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
// Construct an unsigned event for a direct message
//
// WARNING: never sign this event
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "") let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags) .tags(tags)
.build(author); .build(author);
// Generate event ID // Ensure that the ID is set
event.ensure_id(); event.ensure_id();
Room::from(&event) Room::from(&event)
} }
/// Organizes the members of the room by moving the target member to the end.
///
/// Always call this function to ensure the current user is at the end of the list.
pub fn organize(mut self, target: &PublicKey) -> Self {
if let Some(index) = self.members.iter().position(|member| member == target) {
let member = self.members.remove(index);
self.members.push(member);
}
self
}
/// Sets the kind of the room and returns the modified room /// Sets the kind of the room and returns the modified room
pub fn kind(mut self, kind: RoomKind) -> Self { pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = kind; self.kind = kind;
@@ -216,28 +238,6 @@ impl Room {
self.members.clone() self.members.clone()
} }
/// Returns the members of the room with their messaging relays
pub fn members_with_relays(&self, cx: &App) -> Task<Vec<(PublicKey, Vec<RelayUrl>)>> {
let nostr = NostrRegistry::global(cx);
let mut tasks = vec![];
for member in self.members.iter() {
let task = nostr.read(cx).messaging_relays(member, cx);
tasks.push((*member, task));
}
cx.background_spawn(async move {
let mut results = vec![];
for (public_key, task) in tasks.into_iter() {
let urls = task.await;
results.push((public_key, urls));
}
results
})
}
/// Checks if the room has more than two members (group) /// Checks if the room has more than two members (group)
pub fn is_group(&self) -> bool { pub fn is_group(&self) -> bool {
self.members.len() > 2 self.members.len() > 2
@@ -266,17 +266,7 @@ impl Room {
/// Display member is always different from the current user. /// Display member is always different from the current user.
pub fn display_member(&self, cx: &App) -> Person { pub fn display_member(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx); persons.read(cx).get(&self.members[0], cx)
let public_key = nostr.read(cx).identity().read(cx).public_key();
let target_member = self
.members
.iter()
.find(|&member| member != &public_key)
.or_else(|| self.members.first())
.expect("Room should have at least one member");
persons.read(cx).get(target_member, cx)
} }
/// Merge the names of the first two members of the room. /// Merge the names of the first two members of the room.
@@ -297,7 +287,7 @@ impl Room {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
if profiles.len() > 2 { if profiles.len() > 3 {
name = format!("{}, +{}", name, profiles.len() - 2); name = format!("{}, +{}", name, profiles.len() - 2);
} }
@@ -307,9 +297,21 @@ impl Room {
} }
} }
/// Emits a new message signal to the current room /// Push a new message to the current room
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) { pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let created_at = message.rumor.created_at;
let new_message = created_at > self.created_at;
// Emit the incoming message event
cx.emit(RoomEvent::Incoming(message)); cx.emit(RoomEvent::Incoming(message));
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);
});
}
} }
/// Emits a signal to reload the current room's messages. /// Emits a signal to reload the current room's messages.
@@ -325,7 +327,7 @@ impl Room {
let id = SubscriptionId::new(format!("room-{}", self.id)); let id = SubscriptionId::new(format!("room-{}", self.id));
cx.background_spawn(async move { cx.background_spawn(async move {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
// Subscription options // Subscription options
@@ -343,7 +345,9 @@ impl Room {
// Subscribe to get member's gossip relays // Subscribe to get member's gossip relays
client client
.subscribe_with_id(id.clone(), filter, Some(opts)) .subscribe(filter)
.close_on(opts)
.with_id(id.clone())
.await?; .await?;
} }
@@ -375,68 +379,78 @@ impl Room {
}) })
} }
/// Create a new message event (unsigned) /// Create a new unsigned message event
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent { pub fn create_message(
&self,
content: &str,
replies: Vec<EventId>,
cx: &App,
) -> Task<Result<UnsignedEvent, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user
let public_key = nostr.read(cx).identity().read(cx).public_key();
// Get room's subject
let subject = self.subject.clone(); let subject = self.subject.clone();
let content = content.to_string();
let mut tags = vec![]; let mut member_and_relay_hints = HashMap::new();
// Add receivers // Populate the hashmap with member and relay hint tasks
//
// NOTE: current user will be removed from the list of receivers
for member in self.members.iter() { for member in self.members.iter() {
// Get relay hint if available let hint = nostr.read(cx).relay_hint(member, cx);
let relay_url = nostr.read(cx).relay_hint(member, cx); member_and_relay_hints.insert(member.to_owned(), hint);
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member.to_owned(),
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
} }
// Add subject tag if it's present cx.background_spawn(async move {
if let Some(value) = subject { let signer = client.signer().context("Signer not found")?;
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( let public_key = signer.get_public_key().await?;
value.to_string(),
)));
}
// Add reply/quote tag // List of event tags for each receiver
if replies.len() == 1 { let mut tags = vec![];
tags.push(Tag::event(replies[0]))
} else { for (member, task) in member_and_relay_hints.into_iter() {
for id in replies { // Skip current user
let tag = TagStandard::Quote { if member == public_key {
event_id: id.to_owned(), continue;
relay_url: None, }
public_key: None,
// 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))
tags.push(Tag::from_standardized_without_cell(tag));
} }
}
// Construct a direct message event // Add subject tag if present
// if let Some(value) = subject {
// WARNING: never sign and send this event to relays tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content) value.to_string(),
.tags(tags) )));
.build(public_key); }
// Ensure the event id has been generated // Add all reply tags
event.ensure_id(); for id in replies {
tags.push(Tag::event(id))
}
event // 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(Keys::generate().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 /// Create a task to send a message to all room members
@@ -448,46 +462,27 @@ impl Room {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
// Get current user's public key and relays let mut members = self.members();
let current_user = nostr.read(cx).identity().read(cx).public_key();
let current_user_relays = nostr.read(cx).messaging_relays(&current_user, cx);
let rumor = rumor.to_owned(); let rumor = rumor.to_owned();
// Get all members and their messaging relays
let task = self.members_with_relays(cx);
cx.background_spawn(async move { cx.background_spawn(async move {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let current_user_relays = current_user_relays.await; let current_user = signer.get_public_key().await?;
let mut members = task.await;
// Remove the current user's public key from the list of receivers // Remove the current user's public key from the list of receivers
// the current user will be handled separately // the current user will be handled separately
members.retain(|(this, _)| this != &current_user); members.retain(|this| this != &current_user);
// Collect the send reports // Collect the send reports
let mut reports: Vec<SendReport> = vec![]; let mut reports: Vec<SendReport> = vec![];
for (receiver, relays) in members.into_iter() { for receiver in members.into_iter() {
// Check if there are any relays to send the message to
if relays.is_empty() {
reports.push(SendReport::new(receiver).relays_not_found());
continue;
}
// Ensure relay connection
for url in relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Construct the gift wrap event // Construct the gift wrap event
let event = let event =
EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?; EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
// Send the gift wrap event to the messaging relays // Send the gift wrap event to the messaging relays
match client.send_event_to(relays, &event).await { match client.send_event(&event).to_nip17().await {
Ok(output) => { Ok(output) => {
let id = output.id().to_owned(); let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-")); let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
@@ -525,24 +520,12 @@ impl Room {
// Construct the gift-wrapped event // Construct the gift-wrapped event
let event = let event =
EventBuilder::gift_wrap(&signer, &current_user, rumor.clone(), vec![]).await?; EventBuilder::gift_wrap(signer, &current_user, rumor.clone(), vec![]).await?;
// Only send a backup message to current user if sent successfully to others // Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) { if reports.iter().all(|r| r.is_sent_success()) {
// Check if there are any relays to send the event to
if current_user_relays.is_empty() {
reports.push(SendReport::new(current_user).relays_not_found());
return Ok(reports);
}
// Ensure relay connection
for url in current_user_relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Send the event to the messaging relays // Send the event to the messaging relays
match client.send_event_to(current_user_relays, &event).await { match client.send_event(&event).to_nip17().await {
Ok(output) => { Ok(output) => {
reports.push(SendReport::new(current_user).status(output)); reports.push(SendReport::new(current_user).status(output));
} }
@@ -580,7 +563,7 @@ impl Room {
if let Some(event) = client.database().event_by_id(id).await? { if let Some(event) = client.database().event_by_id(id).await? {
for url in urls.into_iter() { for url in urls.into_iter() {
let relay = client.pool().relay(url).await?; let relay = client.relay(url).await?.context("Relay not found")?;
let id = relay.send_event(&event).await?; let id = relay.send_event(&event).await?;
let resent: Output<EventId> = Output { let resent: Output<EventId> = Output {

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration;
pub use actions::*; pub use actions::*;
use anyhow::Error;
use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport}; use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport};
use common::{nip96_upload, RenderedTimestamp}; use common::{nip96_upload, RenderedTimestamp};
use dock::panel::{Panel, PanelEvent}; use dock::panel::{Panel, PanelEvent};
@@ -244,27 +244,21 @@ impl ChatPanel {
return; return;
} }
// Get the current room entity
let Some(room) = self.room.upgrade().map(|this| this.read(cx)) else {
return;
};
// Get replies_to if it's present // Get replies_to if it's present
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect(); let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Create a temporary message for optimistic update // Get a task to create temporary message for optimistic update
let rumor = room.create_message(&content, replies.as_ref(), cx); let Ok(get_rumor) = self
let rumor_id = rumor.id.unwrap(); .room
.read_with(cx, |this, cx| this.create_message(&content, replies, cx))
// Create a task for sending the message in the background else {
let send_message = room.send_message(&rumor, cx); return;
};
// Optimistically update message list // Optimistically update message list
cx.spawn_in(window, async move |this, cx| { let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
// Wait for the delay let mut rumor = get_rumor.await?;
cx.background_executor() let rumor_id = rumor.id();
.timer(Duration::from_millis(100))
.await;
// Update the message list and reset the states // Update the message list and reset the states
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
@@ -280,43 +274,50 @@ impl ChatPanel {
// Update the message list // Update the message list
this.insert_message(&rumor, true, cx); this.insert_message(&rumor, true, cx);
})
.ok();
})
.detach();
self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Ok(task) = this
let result = send_message.await; .room
.read_with(cx, |this, cx| this.send_message(&rumor, cx))
{
this.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
match result { match result {
Ok(reports) => { Ok(reports) => {
// Update room's status // Update room's status
this.room this.room
.update(cx, |this, cx| { .update(cx, |this, cx| {
if this.kind != RoomKind::Ongoing { if this.kind != RoomKind::Ongoing {
// Update the room kind to ongoing, // Update the room kind to ongoing,
// but keep the room kind if send failed // but keep the room kind if send failed
if reports.iter().all(|r| !r.is_sent_success()) { if reports.iter().all(|r| !r.is_sent_success()) {
this.kind = RoomKind::Ongoing; this.kind = RoomKind::Ongoing;
cx.notify(); cx.notify();
} }
}
})
.ok();
// Insert the sent reports
this.reports_by_id.insert(rumor_id, reports);
cx.notify();
} }
}) Err(e) => {
.ok(); window.push_notification(e.to_string(), cx);
}
// Insert the sent reports }
this.reports_by_id.insert(rumor_id, reports); })
.ok();
cx.notify(); }))
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
} }
}) })?;
.ok();
})); Ok(())
});
task.detach();
} }
/// Insert a message into the chat panel /// Insert a message into the chat panel

View File

@@ -6,6 +6,7 @@ publish.workspace = true
[dependencies] [dependencies]
gpui.workspace = true gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
@@ -19,5 +20,3 @@ log.workspace = true
dirs = "5.0" dirs = "5.0"
qrcode = "0.14.1" qrcode = "0.14.1"
whoami = "1.6.1"
nostr = { git = "https://github.com/rust-nostr/nostr" }

View File

@@ -1,28 +0,0 @@
pub const CLIENT_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nos.social",
"wss://user.kindpag.es",
];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default retry count for fetching NIP-17 relays
pub const RELAY_RETRY: u64 = 2;
/// Default retry count for sending messages
pub const SEND_RETRY: u64 = 10;
/// Default timeout (in seconds) for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;

View File

@@ -12,44 +12,6 @@ const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60; const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24; const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30; const DAYS_IN_MONTH: i64 = 30;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
pub trait RenderedProfile {
fn avatar(&self) -> SharedString;
fn display_name(&self) -> SharedString;
}
impl RenderedProfile for Profile {
fn avatar(&self) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.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())
}
fn display_name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return SharedString::from(display_name);
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return SharedString::from(name);
}
}
SharedString::from(shorten_pubkey(self.public_key(), 4))
}
}
pub trait RenderedTimestamp { pub trait RenderedTimestamp {
fn to_human_time(&self) -> SharedString; fn to_human_time(&self) -> SharedString;

View File

@@ -1,66 +1,11 @@
use std::sync::OnceLock;
pub use constants::*;
pub use debounced_delay::*; pub use debounced_delay::*;
pub use display::*; pub use display::*;
pub use event::*; pub use event::*;
pub use nip96::*; pub use nip96::*;
use nostr_sdk::prelude::*;
pub use paths::*; pub use paths::*;
mod constants;
mod debounced_delay; mod debounced_delay;
mod display; mod display;
mod event; mod event;
mod nip96; mod nip96;
mod paths; mod paths;
static APP_NAME: OnceLock<String> = OnceLock::new();
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
/// Get the app name
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = whoami::devicename();
let platform = whoami::platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}
/// Default NIP-65 Relays. Used for new account
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
NIP65_RELAYS.get_or_init(|| {
vec![
(
RelayUrl::parse("wss://nostr.mom").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.snort.social").unwrap(),
Some(RelayMetadata::Write),
),
(RelayUrl::parse("wss://relay.primal.net").unwrap(), None),
(RelayUrl::parse("wss://relay.damus.io").unwrap(), None),
]
})
}
/// Default NIP-17 Relays. Used for new account
pub fn default_nip17_relays() -> &'static Vec<RelayUrl> {
NIP17_RELAYS.get_or_init(|| {
vec![
RelayUrl::parse("wss://nip17.com").unwrap(),
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
]
})
}

View File

@@ -72,11 +72,10 @@ pub async fn nip96_upload(
let json: Value = res.json().await?; let json: Value = res.json().await?;
let config = nip96::ServerConfig::from_json(json.to_string())?; let config = nip96::ServerConfig::from_json(json.to_string())?;
let signer = if client.has_signer().await { let signer = client
client.signer().await? .signer()
} else { .cloned()
Keys::generate().into_nostr_signer() .unwrap_or(Keys::generate().into_nostr_signer());
};
let url = upload(&signer, &config, file, None).await?; let url = upload(&signer, &config, file, None).await?;

View File

@@ -1,19 +0,0 @@
use gpui::actions;
// Sidebar actions
actions!(sidebar, [Reload, RelayStatus]);
// User actions
actions!(
coop,
[
KeyringPopup,
DarkMode,
ViewProfile,
ViewRelays,
Themes,
Settings,
Logout,
Quit
]
);

View File

@@ -1,580 +0,0 @@
use std::collections::HashSet;
use std::ops::Range;
use std::time::Duration;
use anyhow::Error;
use chat::{ChatRegistry, Room};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
anchored, deferred, div, point, px, rems, uniform_list, App, AppContext, Bounds, Context,
Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point,
Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription,
Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{h_flex, v_flex, window_paddings, Icon, IconName, Sizable, WindowExtension};
const WIDTH: Pixels = px(425.);
/// Command bar for searching conversations.
pub struct CommandBar {
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Vec<PublicKey>>,
/// Whether to show the contact list
show_contact_list: bool,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Image cache for avatars
image_cache: Entity<RetainAllImageCache>,
/// Async tasks
tasks: SmallVec<[Task<()>; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl CommandBar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let selected_pkeys = cx.new(|_| HashSet::new());
let contact_list = cx.new(|_| vec![]);
let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Find or start a conversation")
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear results when input is empty
this.reset(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
InputEvent::Focus => {
this.get_contact_list(window, cx);
}
_ => {}
};
}),
);
Self {
selected_pkeys,
contact_list,
show_contact_list: false,
find_debouncer: DebouncedDelay::new(),
finding: false,
find_input,
find_results,
find_task: None,
image_cache: RetainAllImageCache::new(cx),
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_contact_list(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.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();
}
};
}));
}
/// Extend the contact list with new contacts.
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
this.extend(contacts);
cx.notify();
});
}
/// Toggle the visibility of the contact list.
fn toggle_contact_list(&mut self, cx: &mut Context<Self>) {
self.show_contact_list = !self.show_contact_list;
cx.notify();
}
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity();
let query = self.find_input.read(cx).value();
// Return if the query is empty
if query.is_empty() {
return;
}
// Return if a search is already in progress
if self.finding {
if self.find_task.is_none() {
window.push_notification("There is another search in progress", cx);
return;
} else {
// Cancel the ongoing search request
self.find_task = None;
}
}
// Block the input until the search completes
self.set_finding(true, window, cx);
let find_users = if identity.read(cx).owned {
nostr.read(cx).wot_search(&query, cx)
} else {
nostr.read(cx).search(&query, cx)
};
// Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let rooms = find_users.await?;
// Update the UI with the search results
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
this.set_finding(false, window, cx);
})?;
Ok(())
}));
}
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
cx.notify();
});
}
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
// Set the search status
self.finding = status;
cx.notify();
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Clear all search results
self.find_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Reset the search status
self.set_finding(false, window, cx);
// Cancel the current search task
self.find_task = None;
cx.notify();
}
fn create(&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 = self.selected(cx);
chat.update(cx, |this, cx| {
let room = cx.new(|_| Room::new(public_key, receivers));
this.emit_room(room.downgrade(), cx);
});
window.close_modal(cx);
}
fn select(&mut self, pkey: PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(&pkey) {
this.remove(&pkey);
} else {
this.insert(pkey);
}
cx.notify();
});
}
fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(&pkey)
}
fn selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let Some(rooms) = self.find_results.read(cx) else {
return vec![];
};
rooms
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, item)| {
let profile = persons.read(cx).get(item, cx);
let pkey = item.to_owned();
let id = range.start + ix;
h_flex()
.id(id)
.h_8()
.w_full()
.px_1()
.gap_2()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
)
})
.child(
h_flex()
.flex_1()
.justify_between()
.line_clamp(1)
.text_ellipsis()
.truncate()
.text_sm()
.child(profile.name())
.when(self.is_selected(pkey, cx), |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.text_color(cx.theme().icon_accent),
)
}),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.select(pkey, cx);
}))
.into_any_element()
})
.collect()
}
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let contacts = self.contact_list.read(cx);
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, item)| {
let profile = persons.read(cx).get(item, cx);
let pkey = item.to_owned();
let id = range.start + ix;
h_flex()
.id(id)
.h_8()
.w_full()
.px_1()
.gap_2()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
)
})
.child(
h_flex()
.flex_1()
.justify_between()
.line_clamp(1)
.text_ellipsis()
.truncate()
.text_sm()
.child(profile.name())
.when(self.is_selected(pkey, cx), |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.text_color(cx.theme().icon_accent),
)
}),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.select(pkey, cx);
}))
.into_any_element()
})
.collect()
}
}
impl Render for CommandBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let window_paddings = window_paddings(window, cx);
let view_size = window.viewport_size()
- gpui::size(
window_paddings.left + window_paddings.right,
window_paddings.top + window_paddings.bottom,
);
let bounds = Bounds {
origin: Point::default(),
size: view_size,
};
let x = bounds.center().x - WIDTH / 2.;
let y = TITLEBAR_HEIGHT;
let input_focus_handle = self.find_input.read(cx).focus_handle(cx);
let input_focused = input_focus_handle.is_focused(window);
let results = self.find_results.read(cx).as_ref();
let total_results = results.map_or(0, |r| r.len());
let contacts = self.contact_list.read(cx);
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
div()
.image_cache(self.image_cache.clone())
.w_full()
.child(
TextInput::new(&self.find_input)
.appearance(true)
.bordered(false)
.xsmall()
.text_xs()
.when(!self.find_input.read(cx).loading, |this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
}),
)
.when(input_focused, |this| {
this.child(deferred(
anchored()
.position(point(window_paddings.left, window_paddings.top))
.snap_to_window()
.child(
div()
.occlude()
.w(view_size.width)
.h(view_size.height)
.on_mouse_down(MouseButton::Left, move |_ev, window, cx| {
window.focus_prev(cx);
})
.child(
v_flex()
.absolute()
.occlude()
.relative()
.left(x)
.top(y)
.w(WIDTH)
.min_h_24()
.overflow_y_hidden()
.p_1()
.gap_1()
.justify_between()
.border_1()
.border_color(cx.theme().border.alpha(0.4))
.bg(cx.theme().surface_background)
.shadow_md()
.rounded(cx.theme().radius_lg)
.map(|this| {
if self.show_contact_list {
this.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(|this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
.when(!contacts.is_empty(), |this| this.h_40()),
)
.when(contacts.is_empty(), |this| {
this.child(
h_flex()
.h_10()
.w_full()
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Your contact list is empty",
)),
)
})
} else {
this.child(
uniform_list(
"rooms",
total_results,
cx.processor(|this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.when(total_results > 0, |this| this.h_40()),
)
.when(total_results == 0, |this| {
this.child(
h_flex()
.h_10()
.w_full()
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Search results appear here",
)),
)
})
}
})
.child(
h_flex()
.pt_1()
.border_t_1()
.border_color(cx.theme().border_variant)
.justify_end()
.child(
Button::new("show-contacts")
.label({
if self.show_contact_list {
"Hide contact list"
} else {
"Show contact list"
}
})
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _ev, _window, cx| {
this.toggle_contact_list(cx);
},
)),
)
.when(
!self.selected_pkeys.read(cx).is_empty(),
|this| {
this.child(
Button::new("create")
.label(button_label)
.primary()
.xsmall()
.on_click(cx.listener(
move |this, _ev, window, cx| {
this.create(window, cx);
},
)),
)
},
),
),
),
),
))
})
}
}

View File

@@ -1,7 +1,8 @@
use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::{Context as AnyhowContext, Error};
use common::{shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS}; use common::{shorten_pubkey, RenderedTimestamp};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity, div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
@@ -10,7 +11,7 @@ use gpui::{
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry}; use person::{Person, PersonRegistry};
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{NostrAddress, NostrRegistry}; use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -21,61 +22,129 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<
cx.new(|cx| Screening::new(public_key, window, cx)) cx.new(|cx| Screening::new(public_key, window, cx))
} }
/// Screening
pub struct Screening { pub struct Screening {
profile: Person, /// Public Key of the person being screened.
public_key: PublicKey,
/// Whether the person's address is verified.
verified: bool, verified: bool,
/// Whether the person is followed by current user.
followed: bool, followed: bool,
/// Last time the person was active.
last_active: Option<Timestamp>, last_active: Option<Timestamp>,
mutual_contacts: Vec<Profile>,
_tasks: SmallVec<[Task<()>; 3]>, /// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
} }
impl Screening { impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let http_client = cx.http_client(); cx.defer_in(window, move |this, _window, cx| {
let nostr = NostrRegistry::global(cx); this.check_contact(cx);
let client = nostr.read(cx).client(); this.check_wot(cx);
this.check_last_activity(cx);
let persons = PersonRegistry::global(cx); this.verify_identifier(cx);
let profile = persons.read(cx).get(&public_key, cx);
let mut tasks = smallvec![];
// Check WOT
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> = cx.background_spawn({
let client = nostr.read(cx).client();
async move {
let signer = client.signer().await?;
let signer_pubkey = signer.get_public_key().await?;
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
// Check mutual contacts
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(contact_list).await {
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
mutual_contacts.push(profile);
}
}
}
Ok((followed, mutual_contacts))
}
}); });
// Check the last activity Self {
let activity_check = cx.background_spawn(async move { public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
}
}
fn check_contact(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
Ok(followed)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.followed = result;
cx.notify();
})
.ok();
}));
}
fn check_wot(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
mutual_contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
}));
}
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1); let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None; let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
if let Ok(mut stream) = client if let Ok(mut stream) = client
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2)) .stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await .await
{ {
while let Some((_url, event)) = stream.next().await { while let Some((_url, event)) = stream.next().await {
@@ -88,91 +157,74 @@ impl Screening {
activity activity
}); });
// Verify the NIP05 address if available self.tasks.push(cx.spawn(async move |this, cx| {
let addr_check = profile.metadata().nip05.and_then(|address| { let result = task.await;
Nip05Address::parse(&address).ok().map(|addr| {
cx.background_spawn(async move { addr.verify(&http_client, &public_key).await }) this.update(cx, |this, cx| {
this.last_active = result;
cx.notify();
}) })
}); .ok();
}));
tasks.push(
// Run the contact check in the background
cx.spawn_in(window, async move |this, cx| {
if let Ok((followed, mutual_contacts)) = contact_check.await {
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
cx.notify();
})
.ok();
}
}),
);
tasks.push(
// Run the activity check in the background
cx.spawn_in(window, async move |this, cx| {
let active = activity_check.await;
this.update(cx, |this, cx| {
this.last_active = active;
cx.notify();
})
.ok();
}),
);
tasks.push(
// Run the NIP-05 verification in the background
cx.spawn_in(window, async move |this, cx| {
if let Some(task) = addr_check {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}),
);
Self {
profile,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
_tasks: tasks,
}
} }
fn address(&self, _cx: &Context<Self>) -> Option<String> { fn verify_identifier(&mut self, cx: &mut Context<Self>) {
self.profile.metadata().nip05 let http_client = cx.http_client();
let public_key = self.public_key;
// Skip if the user doesn't have a NIP-05 identifier
let Some(address) = self.address(cx) else {
return;
};
let task: Task<Result<bool, Error>> =
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.verified = result;
cx.notify();
})
.ok();
}));
} }
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) { fn profile(&self, cx: &Context<Self>) -> Person {
let Ok(bech32) = self.profile.public_key().to_bech32(); let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}")); cx.open_url(&format!("https://njump.me/{bech32}"));
} }
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = self.profile.public_key(); let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let tag = Tag::public_key_report(public_key, Report::Impersonation); let tag = Tag::public_key_report(public_key, Report::Impersonation);
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?; let builder = EventBuilder::report(vec![tag], "");
let event = client.sign_event_builder(builder).await?;
// Send the report to the public relays // Send the report to the public relays
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(()) Ok(())
}); });
cx.spawn_in(window, async move |_, cx| { self.tasks.push(cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() { if task.await.is_ok() {
cx.update(|window, cx| { cx.update(|window, cx| {
window.close_modal(cx); window.close_modal(cx);
@@ -180,8 +232,7 @@ impl Screening {
}) })
.ok(); .ok();
} }
}) }));
.detach();
} }
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -194,25 +245,27 @@ impl Screening {
this.title(SharedString::from("Mutual contacts")).child( this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_4().child( v_flex().gap_1().pb_4().child(
uniform_list("contacts", total, move |range, _window, cx| { uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total); let mut items = Vec::with_capacity(total);
for ix in range { for ix in range {
if let Some(contact) = contacts.get(ix) { let Some(contact) = contacts.get(ix) else {
items.push( continue;
h_flex() };
.h_11() let profile = persons.read(cx).get(contact, cx);
.w_full()
.px_2() items.push(
.gap_1p5() h_flex()
.rounded(cx.theme().radius) .h_11()
.text_sm() .w_full()
.hover(|this| { .px_2()
this.bg(cx.theme().elevated_surface_background) .gap_1p5()
}) .rounded(cx.theme().radius)
.child(Avatar::new(contact.avatar()).size(rems(1.75))) .text_sm()
.child(contact.display_name()), .hover(|this| this.bg(cx.theme().elevated_surface_background))
); .child(Avatar::new(profile.avatar()).size(rems(1.75)))
} .child(profile.name()),
);
} }
items items
@@ -226,7 +279,9 @@ impl Screening {
impl Render for Screening { impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8); let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let total_mutuals = self.mutual_contacts.len(); let total_mutuals = self.mutual_contacts.len();
let last_active = self.last_active.map(|_| true); let last_active = self.last_active.map(|_| true);
@@ -238,12 +293,12 @@ impl Render for Screening {
.items_center() .items_center()
.justify_center() .justify_center()
.text_center() .text_center()
.child(Avatar::new(self.profile.avatar()).size(rems(4.))) .child(Avatar::new(profile.avatar()).size(rems(4.)))
.child( .child(
div() div()
.font_semibold() .font_semibold()
.line_height(relative(1.25)) .line_height(relative(1.25))
.child(self.profile.name()), .child(profile.name()),
), ),
) )
.child( .child(

View File

@@ -1,23 +1,21 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use assets::Assets; use assets::Assets;
use common::{APP_ID, CLIENT_NAME};
use gpui::{ use gpui::{
point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
WindowDecorations, WindowKind, WindowOptions, WindowDecorations, WindowKind, WindowOptions,
}; };
use state::{APP_ID, CLIENT_NAME};
use ui::Root; use ui::Root;
use crate::actions::Quit;
mod actions;
mod command_bar;
mod dialogs; mod dialogs;
mod panels; mod panels;
mod sidebar; mod sidebar;
mod workspace; mod workspace;
actions!(coop, [Quit]);
fn main() { fn main() {
// Initialize logging // Initialize logging
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();

View File

@@ -29,6 +29,26 @@ impl GreeterPanel {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
} }
} }
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
Workspace::add_panel(
profile::init(public_key, window, cx),
DockPlacement::Center,
window,
cx,
);
})
.ok();
})
.detach();
}
}
} }
impl Panel for GreeterPanel { impl Panel for GreeterPanel {
@@ -62,12 +82,13 @@ impl Render for GreeterPanel {
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr."; const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity(); let nip65_state = nostr.read(cx).nip65_state();
let nip17_state = nostr.read(cx).nip17_state();
let signer = nostr.read(cx).signer();
let owned = signer.owned();
let relay_list_state = identity.read(cx).relay_list_state(); let required_actions = nip65_state.read(cx) == &RelayState::NotConfigured
let messaging_relay_state = identity.read(cx).messaging_relays_state(); || nip17_state.read(cx) == &RelayState::NotConfigured;
let required_actions =
relay_list_state == RelayState::NotSet || messaging_relay_state == RelayState::NotSet;
h_flex() h_flex()
.size_full() .size_full()
@@ -128,14 +149,14 @@ impl Render for GreeterPanel {
v_flex() v_flex()
.gap_2() .gap_2()
.w_full() .w_full()
.when(relay_list_state == RelayState::NotSet, |this| { .when(nip65_state.read(cx).not_configured(), |this| {
this.child( this.child(
Button::new("relaylist") Button::new("relaylist")
.icon(Icon::new(IconName::Relay)) .icon(Icon::new(IconName::Relay))
.label("Set up relay list") .label("Set up relay list")
.ghost() .ghost()
.small() .small()
.no_center() .justify_start()
.on_click(move |_ev, window, cx| { .on_click(move |_ev, window, cx| {
Workspace::add_panel( Workspace::add_panel(
relay_list::init(window, cx), relay_list::init(window, cx),
@@ -146,31 +167,28 @@ impl Render for GreeterPanel {
}), }),
) )
}) })
.when( .when(nip17_state.read(cx).not_configured(), |this| {
messaging_relay_state == RelayState::NotSet, this.child(
|this| { Button::new("import")
this.child( .icon(Icon::new(IconName::Relay))
Button::new("import") .label("Set up messaging relays")
.icon(Icon::new(IconName::Relay)) .ghost()
.label("Set up messaging relays") .small()
.ghost() .justify_start()
.small() .on_click(move |_ev, window, cx| {
.no_center() Workspace::add_panel(
.on_click(move |_ev, window, cx| { messaging_relays::init(window, cx),
Workspace::add_panel( DockPlacement::Center,
messaging_relays::init(window, cx), window,
DockPlacement::Center, cx,
window, );
cx, }),
); )
}), }),
)
},
),
), ),
) )
}) })
.when(!identity.read(cx).owned, |this| { .when(!owned, |this| {
this.child( this.child(
v_flex() v_flex()
.gap_2() .gap_2()
@@ -195,7 +213,7 @@ impl Render for GreeterPanel {
.label("Connect account via Nostr Connect") .label("Connect account via Nostr Connect")
.ghost() .ghost()
.small() .small()
.no_center() .justify_start()
.on_click(move |_ev, window, cx| { .on_click(move |_ev, window, cx| {
Workspace::add_panel( Workspace::add_panel(
connect::init(window, cx), connect::init(window, cx),
@@ -211,7 +229,7 @@ impl Render for GreeterPanel {
.label("Import a secret key or bunker") .label("Import a secret key or bunker")
.ghost() .ghost()
.small() .small()
.no_center() .justify_start()
.on_click(move |_ev, window, cx| { .on_click(move |_ev, window, cx| {
Workspace::add_panel( Workspace::add_panel(
import::init(window, cx), import::init(window, cx),
@@ -248,7 +266,7 @@ impl Render for GreeterPanel {
.label("Backup account") .label("Backup account")
.ghost() .ghost()
.small() .small()
.no_center(), .justify_start(),
) )
.child( .child(
Button::new("profile") Button::new("profile")
@@ -256,15 +274,10 @@ impl Render for GreeterPanel {
.label("Update profile") .label("Update profile")
.ghost() .ghost()
.small() .small()
.no_center() .justify_start()
.on_click(move |_ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
Workspace::add_panel( this.add_profile_panel(window, cx)
profile::init(window, cx), })),
DockPlacement::Center,
window,
cx,
);
}),
) )
.child( .child(
Button::new("invite") Button::new("invite")
@@ -272,7 +285,7 @@ impl Render for GreeterPanel {
.label("Invite friends") .label("Invite friends")
.ghost() .ghost()
.small() .small()
.no_center(), .justify_start(),
), ),
), ),
), ),

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use dock::panel::{Panel, PanelEvent}; use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -89,7 +89,7 @@ impl MessagingRelayPanel {
} }
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> { async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let filter = Filter::new() let filter = Filter::new()
@@ -156,32 +156,20 @@ impl MessagingRelayPanel {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx); let tags: Vec<Tag> = self
let relays = self.relays.clone(); .relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect();
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await; // Construct nip17 event builder
let signer = client.signer().await?; let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let event = client.sign_event_builder(builder).await?;
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect();
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(tags)
.sign(&signer)
.await?;
// Set messaging relays // Set messaging relays
client.send_event_to(urls, &event).await?; client.send_event(&event).to_nip65().await?;
// Connect to messaging relays
for relay in relays.iter() {
client.add_relay(relay).await.ok();
client.connect_relay(relay).await.ok();
}
Ok(()) Ok(())
}); });

View File

@@ -22,8 +22,8 @@ use ui::input::{InputState, TextInput};
use ui::notification::Notification; use ui::notification::Notification;
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension}; use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> { pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
cx.new(|cx| ProfilePanel::new(window, cx)) cx.new(|cx| ProfilePanel::new(public_key, window, cx))
} }
#[derive(Debug)] #[derive(Debug)]
@@ -31,6 +31,9 @@ pub struct ProfilePanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// User's public key
public_key: PublicKey,
/// User's name text input /// User's name text input
name_input: Entity<InputState>, name_input: Entity<InputState>,
@@ -51,13 +54,10 @@ pub struct ProfilePanel {
} }
impl ProfilePanel { impl ProfilePanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me")); let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
// Hidden input for avatar url
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg")); let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
// Use multi-line input for bio // Use multi-line input for bio
let bio_input = cx.new(|cx| { let bio_input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
@@ -66,13 +66,10 @@ impl ProfilePanel {
.placeholder("A short introduce about you.") .placeholder("A short introduce about you.")
}); });
// Get user's profile and update inputs
cx.defer_in(window, move |this, window, cx| { cx.defer_in(window, move |this, window, cx| {
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx); let profile = persons.read(cx).get(&public_key, cx);
// Set all input's values with current profile // Set all input's values with current profile
this.set_profile(profile, window, cx); this.set_profile(profile, window, cx);
}); });
@@ -80,6 +77,7 @@ impl ProfilePanel {
Self { Self {
name: "Update Profile".into(), name: "Update Profile".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
public_key,
name_input, name_input,
avatar_input, avatar_input,
bio_input, bio_input,
@@ -209,7 +207,7 @@ impl ProfilePanel {
fn set_metadata(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn set_metadata(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key(); let public_key = self.public_key;
// Get the old metadata // Get the old metadata
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -289,9 +287,7 @@ impl Focusable for ProfilePanel {
impl Render for ProfilePanel { impl Render for ProfilePanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
let public_key = nostr.read(cx).identity().read(cx).public_key();
let shorten_pkey = SharedString::from(shorten_pubkey(public_key, 8));
// Get the avatar // Get the avatar
let avatar_input = self.avatar_input.read(cx).value(); let avatar_input = self.avatar_input.read(cx).value();
@@ -390,7 +386,7 @@ impl Render for ProfilePanel {
.ghost() .ghost()
.on_click(cx.listener(move |this, _ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.copy( this.copy(
public_key.to_bech32().unwrap(), this.public_key.to_bech32().unwrap(),
window, window,
cx, cx,
); );

View File

@@ -1,8 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::BOOTSTRAP_RELAYS;
use dock::panel::{Panel, PanelEvent}; use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -12,7 +11,7 @@ use gpui::{
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::NostrRegistry; use state::{NostrRegistry, BOOTSTRAP_RELAYS};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
@@ -96,7 +95,7 @@ impl RelayListPanel {
} }
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> { async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let filter = Filter::new() let filter = Filter::new()
@@ -167,11 +166,11 @@ impl RelayListPanel {
let relays = self.relays.clone(); let relays = self.relays.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?; let builder = EventBuilder::relay_list(relays);
let event = EventBuilder::relay_list(relays).sign(&signer).await?; let event = client.sign_event_builder(builder).await?;
// Set relay list for current user // Set relay list for current user
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(()) Ok(())
}); });

View File

@@ -14,23 +14,24 @@ use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::context_menu::ContextMenuExt; use ui::context_menu::ContextMenuExt;
use ui::modal::ModalButtonProps; use ui::modal::ModalButtonProps;
use ui::{h_flex, StyledExt, WindowExtension}; use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
use crate::dialogs::screening; use crate::dialogs::screening;
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct RoomListItem { pub struct RoomEntry {
ix: usize, ix: usize,
public_key: Option<PublicKey>, public_key: Option<PublicKey>,
name: Option<SharedString>, name: Option<SharedString>,
avatar: Option<SharedString>, avatar: Option<SharedString>,
created_at: Option<SharedString>, created_at: Option<SharedString>,
kind: Option<RoomKind>, kind: Option<RoomKind>,
selected: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
} }
impl RoomListItem { impl RoomEntry {
pub fn new(ix: usize) -> Self { pub fn new(ix: usize) -> Self {
Self { Self {
ix, ix,
@@ -40,6 +41,7 @@ impl RoomListItem {
created_at: None, created_at: None,
kind: None, kind: None,
handler: None, handler: None,
selected: false,
} }
} }
@@ -77,11 +79,25 @@ impl RoomListItem {
} }
} }
impl RenderOnce for RoomListItem { impl Selectable for RoomEntry {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl RenderOnce for RoomEntry {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
let screening = AppSettings::get_screening(cx); let screening = AppSettings::get_screening(cx);
let public_key = self.public_key;
let is_selected = self.is_selected();
h_flex() h_flex()
.id(self.ix) .id(self.ix)
.h_9() .h_9()
@@ -110,13 +126,21 @@ impl RenderOnce for RoomListItem {
.justify_between() .justify_between()
.when_some(self.name, |this, name| { .when_some(self.name, |this, name| {
this.child( this.child(
div() h_flex()
.flex_1() .flex_1()
.justify_between()
.line_clamp(1) .line_clamp(1)
.text_ellipsis() .text_ellipsis()
.truncate() .truncate()
.font_medium() .font_medium()
.child(name), .child(name)
.when(is_selected, |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.text_color(cx.theme().icon_accent),
)
}),
) )
}) })
.child( .child(
@@ -129,15 +153,17 @@ impl RenderOnce for RoomListItem {
), ),
) )
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.when_some(self.public_key, |this, public_key| { .when_some(public_key, |this, public_key| {
this.context_menu(move |this, _window, _cx| { this.context_menu(move |this, _window, _cx| {
this.menu("View Profile", Box::new(OpenPublicKey(public_key))) this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key))) .menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
}) })
.when_some(self.handler, |this, handler| { })
this.on_click(move |event, window, cx| { .when_some(self.handler, |this, handler| {
handler(event, window, cx); this.on_click(move |event, window, cx| {
handler(event, window, cx);
if let Some(public_key) = public_key {
if self.kind != Some(RoomKind::Ongoing) && screening { if self.kind != Some(RoomKind::Ongoing) && screening {
let screening = screening::init(public_key, window, cx); let screening = screening::init(public_key, window, cx);
@@ -152,12 +178,12 @@ impl RenderOnce for RoomListItem {
.on_cancel(move |_event, window, cx| { .on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx); window.dispatch_action(Box::new(ClosePanel), cx);
// Prevent closing the modal on click // Prevent closing the modal on click
// Modal will be automatically closed after closing panel // modal will be automatically closed after closing panel
false false
}) })
}); });
} }
}) }
}) })
}) })
} }

View File

@@ -1,22 +1,35 @@
use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::time::Duration;
use chat::{ChatEvent, ChatRegistry, RoomKind}; use anyhow::Error;
use common::RenderedTimestamp; use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, RenderedTimestamp};
use dock::panel::{Panel, PanelEvent}; use dock::panel::{Panel, PanelEvent};
use entry::RoomEntry;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
deferred, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
Subscription, Window, Task, Window,
}; };
use list_item::RoomListItem; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::divider::Divider;
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::{h_flex, v_flex, IconName, Selectable, Sizable, StyledExt}; use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{
h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
};
mod list_item; mod entry;
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
cx.new(|cx| Sidebar::new(window, cx)) cx.new(|cx| Sidebar::new(window, cx))
@@ -30,12 +43,42 @@ pub struct Sidebar {
/// Image cache /// Image cache
image_cache: Entity<RetainAllImageCache>, image_cache: Entity<RetainAllImageCache>,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Whether the find input is focused
find_focused: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Whether there are search results
has_search: bool,
/// Whether there are new chat requests /// Whether there are new chat requests
new_requests: bool, new_requests: bool,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// Chatroom filter /// Chatroom filter
filter: Entity<RoomKind>, filter: Entity<RoomKind>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
} }
@@ -44,9 +87,49 @@ impl Sidebar {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let filter = cx.new(|_| RoomKind::Ongoing); let filter = cx.new(|_| RoomKind::Ongoing);
let contact_list = cx.new(|_| None);
let selected_pkeys = cx.new(|_| HashSet::new());
let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(INPUT_PLACEHOLDER)
.clean_on_escape()
});
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear results when input is empty
this.reset(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
InputEvent::Focus => {
this.set_input_focus(window, cx);
this.get_contact_list(window, cx);
}
InputEvent::Blur => {
this.set_input_focus(window, cx);
}
};
}),
);
subscriptions.push( subscriptions.push(
// Subscribe for registry new events // Subscribe for registry new events
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
@@ -61,12 +144,206 @@ impl Sidebar {
name: "Sidebar".into(), name: "Sidebar".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx), image_cache: RetainAllImageCache::new(cx),
find_input,
find_debouncer: DebouncedDelay::new(),
find_results,
find_task: None,
find_focused: false,
finding: false,
has_search: false,
new_requests: false, new_requests: false,
contact_list,
selected_pkeys,
filter, filter,
tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
/// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_contact_list(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.set_contact_list(contacts, cx);
})?;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})?;
}
};
Ok(())
}));
}
/// Set the contact list with new contacts.
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
*this = Some(contacts.into_iter().collect());
cx.notify();
});
}
/// Trigger the debounced search
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
/// Search
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Return if a search is already in progress
if self.finding {
if self.find_task.is_none() {
window.push_notification("There is another search in progress", cx);
return;
} else {
// Cancel the ongoing search request
self.find_task = None;
}
}
// Get query
let query = self.find_input.read(cx).value();
// Return if the query is empty
if query.is_empty() {
return;
}
// Block the input until the search completes
self.set_finding(true, window, cx);
let nostr = NostrRegistry::global(cx);
let find_users = nostr.read(cx).search(&query, cx);
// Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let rooms = find_users.await?;
// Update the UI with the search results
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
this.set_finding(false, window, cx);
})?;
Ok(())
}));
}
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
cx.notify();
});
}
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
// Set the search status
self.finding = status;
cx.notify();
}
fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.find_focused = !self.find_focused;
cx.notify();
// Reset the find panel
if !self.find_focused {
self.reset(window, cx);
}
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Clear all search results
self.find_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all selected public keys
self.selected_pkeys.update(cx, |this, cx| {
this.clear();
cx.notify();
});
// Reset the search status
self.set_finding(false, window, cx);
// Cancel the current search task
self.find_task = None;
cx.notify();
}
/// Select a public key in the sidebar.
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(public_key) {
this.remove(public_key);
} else {
this.insert(public_key.to_owned());
}
cx.notify();
});
}
/// Check if a public key is selected in the sidebar.
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(public_key)
}
/// Get all selected public keys in the sidebar.
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let async_chat = chat.downgrade();
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_key = signer.get_public_key().await?;
// Create a new room and emit it
async_chat.update_in(cx, |this, _window, cx| {
let room = cx.new(|_| Room::new(public_key, receivers).kind(RoomKind::Ongoing));
this.emit_room(&room, cx);
})?;
// Reset the find panel
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})?;
Ok(())
}));
}
/// Get the active filter. /// Get the active filter.
fn current_filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool { fn current_filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
self.filter.read(cx) == kind self.filter.read(cx) == kind
@@ -92,15 +369,15 @@ impl Sidebar {
.enumerate() .enumerate()
.map(|(ix, item)| { .map(|(ix, item)| {
let room = item.read(cx); let room = item.read(cx);
let weak_room = item.downgrade(); let room_clone = item.clone();
let public_key = room.display_member(cx).public_key(); let public_key = room.display_member(cx).public_key();
let handler = cx.listener(move |_this, _ev, _window, cx| { let handler = cx.listener(move |_this, _ev, _window, cx| {
ChatRegistry::global(cx).update(cx, |s, cx| { ChatRegistry::global(cx).update(cx, |s, cx| {
s.emit_room(weak_room.clone(), cx); s.emit_room(&room_clone, cx);
}); });
}); });
RoomListItem::new(range.start + ix) RoomEntry::new(range.start + ix)
.name(room.display_name(cx)) .name(room.display_name(cx))
.avatar(room.display_image(cx)) .avatar(room.display_image(cx))
.public_key(public_key) .public_key(public_key)
@@ -111,6 +388,72 @@ impl Sidebar {
}) })
.collect() .collect()
} }
/// Render the contact list
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
// Get the contact list
let Some(results) = self.find_results.read(cx) else {
return vec![];
};
// Map the contact list to a list of elements
results
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
RoomEntry::new(range.start + ix)
.name(profile.name())
.avatar(profile.avatar())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
/// Render the contact list
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
// Get the contact list
let Some(contacts) = self.contact_list.read(cx) else {
return vec![];
};
// Map the contact list to a list of elements
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
RoomEntry::new(range.start + ix)
.name(profile.name())
.avatar(profile.avatar())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
} }
impl Panel for Sidebar { impl Panel for Sidebar {
@@ -133,89 +476,124 @@ impl Render for Sidebar {
let loading = chat.read(cx).loading(); let loading = chat.read(cx).loading();
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx); let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
// Whether the find panel should be shown
let show_find_panel = self.has_search || self.find_focused;
// Set button label based on total selected users
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
v_flex() v_flex()
.image_cache(self.image_cache.clone()) .image_cache(self.image_cache.clone())
.size_full() .size_full()
.relative() .relative()
.gap_2()
.child( .child(
h_flex() h_flex()
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.w_full()
.border_b_1() .border_b_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.child( .child(
h_flex() TextInput::new(&self.find_input)
.flex_1() .appearance(false)
.h_full() .bordered(false)
.gap_2() .small()
.p_2() .text_xs()
.justify_center() .when(!self.find_input.read(cx).loading, |this| {
.child( this.suffix(
Button::new("all") Button::new("find-icon")
.map(|this| { .icon(IconName::Search)
if self.current_filter(&RoomKind::Ongoing, cx) { .tooltip("Press Enter to search")
this.icon(IconName::InboxFill) .transparent()
} else { .small(),
this.icon(IconName::Inbox) )
} }),
})
.label("Inbox")
.tooltip("All ongoing conversations")
.xsmall()
.bold()
.ghost()
.flex_1()
.rounded_none()
.selected(self.current_filter(&RoomKind::Ongoing, cx))
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::Ongoing, cx);
})),
)
.child(
Button::new("requests")
.map(|this| {
if self.current_filter(&RoomKind::Request, cx) {
this.icon(IconName::FistbumpFill)
} else {
this.icon(IconName::Fistbump)
}
})
.label("Requests")
.tooltip("Incoming new conversations")
.xsmall()
.bold()
.ghost()
.flex_1()
.rounded_none()
.selected(!self.current_filter(&RoomKind::Ongoing, cx))
.when(self.new_requests, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
)
})
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::default(), cx);
})),
),
)
.child(
h_flex()
.h_full()
.px_2()
.border_l_1()
.border_color(cx.theme().border)
.child(
Button::new("option")
.icon(IconName::Ellipsis)
.small()
.ghost(),
),
), ),
) )
.when(!loading && total_rooms == 0, |this| { .child(
h_flex()
.h(TABBAR_HEIGHT)
.justify_center()
.border_b_1()
.border_color(cx.theme().border)
.when(show_find_panel, |this| {
this.child(
Button::new("search-results")
.icon(IconName::Search)
.label("Search")
.tooltip("All search results")
.small()
.underline()
.ghost()
.font_semibold()
.rounded_none()
.h_full()
.flex_1()
.selected(true),
)
})
.child(
Button::new("all")
.map(|this| {
if self.current_filter(&RoomKind::Ongoing, cx) {
this.icon(IconName::InboxFill)
} else {
this.icon(IconName::Inbox)
}
})
.when(!show_find_panel, |this| this.label("Inbox"))
.tooltip("All ongoing conversations")
.small()
.underline()
.ghost()
.font_semibold()
.rounded_none()
.h_full()
.flex_1()
.disabled(show_find_panel)
.selected(
!show_find_panel && self.current_filter(&RoomKind::Ongoing, cx),
)
.on_click(cx.listener(|this, _ev, _window, cx| {
this.set_filter(RoomKind::Ongoing, cx);
})),
)
.child(Divider::vertical())
.child(
Button::new("requests")
.map(|this| {
if self.current_filter(&RoomKind::Request, cx) {
this.icon(IconName::FistbumpFill)
} else {
this.icon(IconName::Fistbump)
}
})
.when(!show_find_panel, |this| this.label("Requests"))
.tooltip("Incoming new conversations")
.small()
.ghost()
.underline()
.font_semibold()
.rounded_none()
.h_full()
.flex_1()
.disabled(show_find_panel)
.selected(
!show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx),
)
.when(self.new_requests, |this| {
this.child(div().size_1().rounded_full().bg(cx.theme().cursor))
})
.on_click(cx.listener(|this, _ev, _window, cx| {
this.set_filter(RoomKind::default(), cx);
})),
),
)
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
this.child( this.child(
div().px_2p5().child(deferred( div().mt_2().px_2().child(
v_flex() v_flex()
.p_3() .p_3()
.h_24() .h_24()
@@ -238,47 +616,138 @@ impl Render for Sidebar {
"Start a conversation with someone to get started.", "Start a conversation with someone to get started.",
), ),
)), )),
)), ),
) )
}) })
.child( .child(
v_flex() v_flex()
.h_full()
.px_1p5() .px_1p5()
.w_full() .mt_2()
.flex_1() .flex_1()
.gap_1() .gap_1()
.overflow_y_hidden() .overflow_y_hidden()
.child( .when(show_find_panel, |this| {
uniform_list( this.gap_3()
"rooms", .when_some(self.find_results.read(cx).as_ref(), |this, results| {
total_rooms, this.child(
cx.processor(|this, range, _window, cx| { v_flex()
this.render_list_items(range, cx) .gap_1()
}), .flex_1()
) .border_b_1()
.h_full(), .border_color(cx.theme().border_variant)
) .child(
.when(loading, |this| { h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child(SharedString::from("Results")),
)
.child(
uniform_list(
"rooms",
results.len(),
cx.processor(|this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
})
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
this.child(
v_flex()
.gap_1()
.flex_1()
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child(SharedString::from("Suggestions")),
)
.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(move |this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
})
})
.when(!show_find_panel, |this| {
this.child( this.child(
div().absolute().top_2().left_0().w_full().px_8().child( uniform_list(
h_flex() "rooms",
.gap_2() total_rooms,
.w_full() cx.processor(|this, range, _window, cx| {
.h_9() this.render_list_items(range, cx)
.justify_center() }),
.bg(cx.theme().background.opacity(0.85)) )
.border_color(cx.theme().border_disabled) .flex_1()
.border_1() .h_full(),
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().icon_accent))
.child(SharedString::from("Getting messages...")),
),
) )
}), }),
) )
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
this.child(
div()
.absolute()
.bottom_0()
.left_0()
.h_9()
.w_full()
.px_2()
.child(
Button::new("create")
.label(button_label)
.primary()
.small()
.shadow_lg()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.create_room(window, cx);
})),
),
)
})
.when(loading, |this| {
this.child(
div()
.absolute()
.bottom_2()
.left_0()
.h_9()
.w_full()
.px_8()
.child(
h_flex()
.gap_2()
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.border_color(cx.theme().border_disabled)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().icon_accent))
.child(SharedString::from("Getting messages...")),
),
)
})
} }
} }

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use chat::{ChatEvent, ChatRegistry}; use chat::{ChatEvent, ChatRegistry};
use dock::dock::DockPlacement; use dock::dock::DockPlacement;
use dock::panel::PanelView; use dock::panel::{PanelStyle, PanelView};
use dock::{ClosePanel, DockArea, DockItem}; use dock::{ClosePanel, DockArea, DockItem};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -11,13 +11,14 @@ use gpui::{
}; };
use person::PersonRegistry; use person::PersonRegistry;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::NostrRegistry; use state::{NostrRegistry, RelayState};
use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use titlebar::TitleBar; use titlebar::TitleBar;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::{h_flex, v_flex, Icon, IconName, Root, Sizable, WindowExtension}; use ui::button::{Button, ButtonVariants};
use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
use crate::command_bar::CommandBar;
use crate::panels::greeter; use crate::panels::greeter;
use crate::sidebar; use crate::sidebar;
@@ -32,9 +33,6 @@ pub struct Workspace {
/// App's Dock Area /// App's Dock Area
dock: Entity<DockArea>, dock: Entity<DockArea>,
/// App's Command Bar
command_bar: Entity<CommandBar>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>, _subscriptions: SmallVec<[Subscription; 3]>,
} }
@@ -43,19 +41,10 @@ impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new()); let titlebar = cx.new(|_| TitleBar::new());
let command_bar = cx.new(|cx| CommandBar::new(window, cx)); let dock = cx.new(|cx| DockArea::new(window, cx).style(PanelStyle::TabBar));
let dock =
cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar));
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(
// Automatically sync theme with system appearance
window.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}),
);
subscriptions.push( subscriptions.push(
// Observe all events emitted by the chat registry // Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
@@ -108,7 +97,6 @@ impl Workspace {
Self { Self {
titlebar, titlebar,
dock, dock,
command_bar,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -175,36 +163,95 @@ impl Workspace {
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement { fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity(); let nip65 = nostr.read(cx).nip65_state();
let nip17 = nostr.read(cx).nip17_state();
let signer = nostr.read(cx).signer();
let current_user = signer.public_key();
h_flex() h_flex()
.h(TITLEBAR_HEIGHT) .h(TITLEBAR_HEIGHT)
.flex_1() .flex_shrink_0()
.justify_between() .justify_between()
.gap_2() .gap_2()
.when_some(identity.read(cx).public_key, |this, public_key| { .when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx); let profile = persons.read(cx).get(public_key, cx);
this.child( this.child(
h_flex() Button::new("current-user")
.gap_0p5()
.child(Avatar::new(profile.avatar()).size(rems(1.25))) .child(Avatar::new(profile.avatar()).size(rems(1.25)))
.child( .small()
Icon::new(IconName::ChevronDown) .caret()
.small() .compact()
.text_color(cx.theme().text_muted), .transparent()
), .popup_menu(move |this, _window, _cx| {
this.label(profile.name())
.separator()
.menu("Profile", Box::new(ClosePanel))
.menu("Backup", Box::new(ClosePanel))
.menu("Themes", Box::new(ClosePanel))
.menu("Settings", Box::new(ClosePanel))
}),
) )
}) })
.when(nostr.read(cx).creating_signer(), |this| {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Coop is creating a new identity for you..."),
))
})
.when(!nostr.read(cx).connected(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Connecting...")),
)
})
.map(|this| match nip65.read(cx) {
RelayState::Checking => this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Fetching user's relay list...")),
),
RelayState::NotConfigured => this.child(
h_flex()
.h_6()
.w_full()
.px_1()
.text_xs()
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_background)
.rounded_sm()
.child(SharedString::from("User hasn't configured a relay list")),
),
_ => this,
})
.map(|this| match nip17.read(cx) {
RelayState::Checking => {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Fetching user's messaging relay list..."),
))
}
RelayState::NotConfigured => this.child(
h_flex()
.h_6()
.w_full()
.px_1()
.text_xs()
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_background)
.rounded_sm()
.child(SharedString::from(
"User hasn't configured a messaging relay list",
)),
),
_ => this,
})
} }
fn titlebar_center(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
h_flex().flex_1().w_full().child(self.command_bar.clone())
}
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement { fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
h_flex().flex_1() h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
} }
} }
@@ -215,12 +262,11 @@ impl Render for Workspace {
// Titlebar elements // Titlebar elements
let left = self.titlebar_left(window, cx).into_any_element(); let left = self.titlebar_left(window, cx).into_any_element();
let center = self.titlebar_center(window, cx).into_any_element();
let right = self.titlebar_right(window, cx).into_any_element(); let right = self.titlebar_right(window, cx).into_any_element();
// Update title bar children // Update title bar children
self.titlebar.update(cx, |this, _cx| { self.titlebar.update(cx, |this, _cx| {
this.set_children(vec![left, center, right]); this.set_children(vec![left, right]);
}); });
div() div()

View File

@@ -9,6 +9,20 @@ pub enum DeviceState {
Set, Set,
} }
impl DeviceState {
pub fn initial(&self) -> bool {
matches!(self, DeviceState::Initial)
}
pub fn requesting(&self) -> bool {
matches!(self, DeviceState::Requesting)
}
pub fn set(&self) -> bool {
matches!(self, DeviceState::Set)
}
}
/// Announcement /// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement { pub struct Announcement {

View File

@@ -1,17 +1,17 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::app_name;
pub use device::*;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP}; use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
mod device; mod device;
pub use device::*;
const IDENTIFIER: &str = "coop:device"; const IDENTIFIER: &str = "coop:device";
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
@@ -30,17 +30,17 @@ pub struct DeviceRegistry {
/// Device signer /// Device signer
pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>, pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>,
/// Device state
pub state: DeviceState,
/// Device requests /// Device requests
requests: Entity<HashSet<Event>>, requests: Entity<HashSet<Event>>,
/// Device state
state: DeviceState,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions /// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
impl DeviceRegistry { impl DeviceRegistry {
@@ -58,7 +58,8 @@ impl DeviceRegistry {
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let identity = nostr.read(cx).identity(); let nip65_state = nostr.read(cx).nip65_state();
let nip17_state = nostr.read(cx).nip17_state();
let device_signer = cx.new(|_| None); let device_signer = cx.new(|_| None);
let requests = cx.new(|_| HashSet::default()); let requests = cx.new(|_| HashSet::default());
@@ -70,21 +71,26 @@ impl DeviceRegistry {
let mut tasks = vec![]; let mut tasks = vec![];
subscriptions.push( subscriptions.push(
// Observe the identity entity // Observe the NIP-65 state
cx.observe(&identity, |this, state, cx| { cx.observe(&nip65_state, |this, state, cx| {
match state.read(cx).relay_list_state() { match state.read(cx) {
RelayState::Initial => { RelayState::Idle => {
this.reset(cx); this.reset(cx);
} }
RelayState::Set => { RelayState::Configured => {
this.get_announcement(cx); this.get_announcement(cx);
if state.read(cx).messaging_relays_state() == RelayState::Set {
this.get_messages(cx);
}
} }
_ => {} _ => {}
} };
}),
);
subscriptions.push(
// Observe the NIP-17 state
cx.observe(&nip17_state, |this, state, cx| {
if state.read(cx) == &RelayState::Configured {
this.get_messages(cx);
};
}), }),
); );
@@ -130,8 +136,8 @@ impl DeviceRegistry {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await { while let Some(notification) = notifications.next().await {
if let RelayPoolNotification::Message { if let ClientNotification::Message {
message: RelayMessage::Event { event, .. }, message: RelayMessage::Event { event, .. },
.. ..
} = notification } = notification
@@ -162,7 +168,7 @@ impl DeviceRegistry {
/// Verify the author of an event /// Verify the author of an event
async fn verify_author(client: &Client, event: &Event) -> bool { async fn verify_author(client: &Client, event: &Event) -> bool {
if let Ok(signer) = client.signer().await { if let Some(signer) = client.signer() {
if let Ok(public_key) = signer.get_public_key().await { if let Ok(public_key) = signer.get_public_key().await {
return public_key == event.pubkey; return public_key == event.pubkey;
} }
@@ -172,7 +178,7 @@ impl DeviceRegistry {
/// Encrypt and store device keys in the local database. /// Encrypt and store device keys in the local database.
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> { async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
// Encrypt the value // Encrypt the value
@@ -193,7 +199,7 @@ impl DeviceRegistry {
/// Get device keys from the local database. /// Get device keys from the local database.
async fn get_keys(client: &Client) -> Result<Keys, Error> { async fn get_keys(client: &Client) -> Result<Keys, Error> {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let filter = Filter::new() let filter = Filter::new()
@@ -244,6 +250,8 @@ impl DeviceRegistry {
*this = Some(Arc::new(signer)); *this = Some(Arc::new(signer));
cx.notify(); cx.notify();
}); });
log::info!("Device Signer set");
} }
/// Set the device state /// Set the device state
@@ -265,40 +273,44 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let device_signer = self.device_signer.read(cx).clone(); let device_signer = self.device_signer.read(cx).clone();
let messaging_relays = nostr.read(cx).messaging_relays(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key(); let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
cx.background_spawn(async move {
let urls = messaging_relays.await; let urls = messaging_relays.await;
let user_signer = client.signer().context("Signer not found")?;
let public_key = user_signer.get_public_key().await?;
// Get messages with dekey // Get messages with dekey
if let Some(signer) = device_signer.as_ref() { if let Some(signer) = device_signer.as_ref() {
if let Ok(pkey) = signer.get_public_key().await { let device_pkey = signer.get_public_key().await?;
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(pkey); let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_pkey);
let id = SubscriptionId::new(DEVICE_GIFTWRAP); let id = SubscriptionId::new(DEVICE_GIFTWRAP);
if let Err(e) = client // Construct target for subscription
.subscribe_with_id_to(&urls, id, vec![filter], None) let target = urls
.await .iter()
{ .map(|relay| (relay, vec![filter.clone()]))
log::error!("Failed to subscribe to gift wrap events: {e}"); .collect::<HashMap<_, _>>();
}
} client.subscribe(target).with_id(id).await?;
} }
// Get messages with user key // Get messages with user key
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(USER_GIFTWRAP); let id = SubscriptionId::new(USER_GIFTWRAP);
if let Err(e) = client // Construct target for subscription
.subscribe_with_id_to(urls, id, vec![filter], None) let target = urls
.await .iter()
{ .map(|relay| (relay, vec![filter.clone()]))
log::error!("Failed to subscribe to gift wrap events: {e}"); .collect::<HashMap<_, _>>();
}
}) client.subscribe(target).with_id(id).await?;
.detach();
Ok(())
});
task.detach_and_log_err(cx);
} }
/// Get device announcement for current user /// Get device announcement for current user
@@ -306,11 +318,9 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<Event, Error>> = cx.background_spawn(async move { let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
let urls = write_relays.await; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Construct the filter for the device announcement event // Construct the filter for the device announcement event
let filter = Filter::new() let filter = Filter::new()
@@ -319,7 +329,8 @@ impl DeviceRegistry {
.limit(1); .limit(1);
let mut stream = client let mut stream = client
.stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT)) .stream_events(filter)
.timeout(Duration::from_secs(TIMEOUT))
.await?; .await?;
while let Some((_url, res)) = stream.next().await { while let Some((_url, res)) = stream.next().await {
@@ -360,28 +371,21 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key(); // Generate a new device keys
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let keys = Keys::generate(); let keys = Keys::generate();
let secret = keys.secret_key().to_secret_hex(); let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key(); let n = keys.public_key();
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let urls = write_relays.await;
// Construct an announcement event // Construct an announcement event
let event = EventBuilder::new(Kind::Custom(10044), "") let builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![
.tags(vec![ Tag::custom(TagKind::custom("n"), vec![n]),
Tag::custom(TagKind::custom("n"), vec![n]), Tag::client(app_name()),
Tag::client(app_name()), ]);
]) let event = client.sign_event_builder(builder).await?;
.sign(&signer)
.await?;
// Publish announcement // Publish announcement
client.send_event_to(&urls, &event).await?; client.send_event(&event).to_nip65().await?;
// Save device keys to the database // Save device keys to the database
Self::set_keys(&client, &secret).await?; Self::set_keys(&client, &secret).await?;
@@ -449,11 +453,9 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Construct a filter for device key requests // Construct a filter for device key requests
let filter = Filter::new() let filter = Filter::new()
@@ -462,7 +464,7 @@ impl DeviceRegistry {
.since(Timestamp::now()); .since(Timestamp::now());
// Subscribe to the device key requests on user's write relays // Subscribe to the device key requests on user's write relays
client.subscribe_to(&urls, vec![filter], None).await?; client.subscribe(filter).await?;
Ok(()) Ok(())
}); });
@@ -475,11 +477,9 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Construct a filter for device key requests // Construct a filter for device key requests
let filter = Filter::new() let filter = Filter::new()
@@ -488,7 +488,7 @@ impl DeviceRegistry {
.since(Timestamp::now()); .since(Timestamp::now());
// Subscribe to the device key requests on user's write relays // Subscribe to the device key requests on user's write relays
client.subscribe_to(&urls, vec![filter], None).await?; client.subscribe(filter).await?;
Ok(()) Ok(())
}); });
@@ -501,14 +501,11 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let app_keys = nostr.read(cx).app_keys().clone(); let app_keys = nostr.read(cx).app_keys().clone();
let app_pubkey = app_keys.public_key(); let app_pubkey = app_keys.public_key();
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move { let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let filter = Filter::new() let filter = Filter::new()
@@ -535,19 +532,15 @@ impl DeviceRegistry {
Ok(Some(keys)) Ok(Some(keys))
} }
None => { None => {
let urls = write_relays.await;
// Construct an event for device key request // Construct an event for device key request
let event = EventBuilder::new(Kind::Custom(4454), "") let builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![
.tags(vec![ Tag::client(app_name()),
Tag::client(app_name()), Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
Tag::custom(TagKind::custom("P"), vec![app_pubkey]), ]);
]) let event = client.sign_event_builder(builder).await?;
.sign(&signer)
.await?;
// Send the event to write relays // Send the event to write relays
client.send_event_to(&urls, &event).await?; client.send_event(&event).to_nip65().await?;
Ok(None) Ok(None)
} }
@@ -620,12 +613,8 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await; let signer = client.signer().context("Signer not found")?;
let signer = client.signer().await?;
// Get device keys // Get device keys
let keys = Self::get_keys(&client).await?; let keys = Self::get_keys(&client).await?;
@@ -646,16 +635,14 @@ impl DeviceRegistry {
// //
// P tag: the current device's public key // P tag: the current device's public key
// p tag: the requester's public key // p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload) let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
.tags(vec![ Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]), Tag::public_key(target),
Tag::public_key(target), ]);
]) let event = client.sign_event_builder(builder).await?;
.sign(&signer)
.await?;
// Send the response event to the user's relay list // Send the response event to the user's relay list
client.send_event_to(&urls, &event).await?; client.send_event(&event).to_nip65().await?;
Ok(()) Ok(())
}); });

View File

@@ -351,7 +351,7 @@ impl DockArea {
} }
/// Set the panel style of the dock area. /// Set the panel style of the dock area.
pub fn panel_style(mut self, style: PanelStyle) -> Self { pub fn style(mut self, style: PanelStyle) -> Self {
self.panel_style = style; self.panel_style = style;
self self
} }

View File

@@ -65,7 +65,7 @@ impl StackPanel {
} }
/// Return true if self or parent only have last panel. /// Return true if self or parent only have last panel.
pub(super) fn is_last_panel(&self, cx: &App) -> bool { pub fn is_last_panel(&self, cx: &App) -> bool {
if self.panels.len() > 1 { if self.panels.len() > 1 {
return false; return false;
} }
@@ -79,12 +79,12 @@ impl StackPanel {
true true
} }
pub(super) fn panels_len(&self) -> usize { pub fn panels_len(&self) -> usize {
self.panels.len() self.panels.len()
} }
/// Return the index of the panel. /// Return the index of the panel.
pub(crate) fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> { pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
self.panels.iter().position(|p| p == &panel) self.panels.iter().position(|p| p == &panel)
} }
@@ -253,11 +253,12 @@ impl StackPanel {
}); });
cx.emit(PanelEvent::LayoutChanged); cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(window, cx); self.remove_self_if_empty(window, cx);
} }
/// Replace the old panel with the new panel at same index. /// Replace the old panel with the new panel at same index.
pub(super) fn replace_panel( pub fn replace_panel(
&mut self, &mut self,
old_panel: Arc<dyn PanelView>, old_panel: Arc<dyn PanelView>,
new_panel: Entity<StackPanel>, new_panel: Entity<StackPanel>,
@@ -266,16 +267,15 @@ impl StackPanel {
) { ) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) { if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone()); self.panels[ix] = Arc::new(new_panel.clone());
let panel_state = ResizablePanelState::default();
self.state.update(cx, |state, cx| { self.state.update(cx, |state, cx| {
state.replace_panel(ix, panel_state, cx); state.replace_panel(ix, ResizablePanelState::default(), cx);
}); });
cx.emit(PanelEvent::LayoutChanged); cx.emit(PanelEvent::LayoutChanged);
} }
} }
/// If children is empty, remove self from parent view. /// If children is empty, remove self from parent view.
pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_root() { if self.is_root() {
return; return;
} }
@@ -296,11 +296,7 @@ impl StackPanel {
} }
/// Find the first top left in the stack. /// Find the first top left in the stack.
pub(super) fn left_top_tab_panel( pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
&self,
check_parent: bool,
cx: &App,
) -> Option<Entity<TabPanel>> {
if check_parent { if check_parent {
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) { if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) { if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
@@ -324,11 +320,7 @@ impl StackPanel {
} }
/// Find the first top right in the stack. /// Find the first top right in the stack.
pub(super) fn right_top_tab_panel( pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
&self,
check_parent: bool,
cx: &App,
) -> Option<Entity<TabPanel>> {
if check_parent { if check_parent {
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) { if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) { if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
@@ -357,7 +349,7 @@ impl StackPanel {
} }
/// Remove all panels from the stack. /// Remove all panels from the stack.
pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) { pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.panels.clear(); self.panels.clear();
self.state.update(cx, |state, cx| { self.state.update(cx, |state, cx| {
state.clear(); state.clear();
@@ -366,7 +358,7 @@ impl StackPanel {
} }
/// Change the axis of the stack panel. /// Change the axis of the stack panel.
pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) { pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
self.axis = axis; self.axis = axis;
cx.notify(); cx.notify();
} }

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } state = { path = "../state" }
device = { path = "../device" }
gpui.workspace = true gpui.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true

View File

@@ -4,12 +4,13 @@ use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Error};
use common::{EventUtils, BOOTSTRAP_RELAYS}; use common::EventUtils;
use device::Announcement;
use gpui::{App, AppContext, Context, Entity, Global, Task}; use gpui::{App, AppContext, Context, Entity, Global, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
pub use person::*; pub use person::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{Announcement, NostrRegistry, TIMEOUT}; use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
mod person; mod person;
@@ -139,20 +140,14 @@ impl PersonRegistry {
/// Handle nostr notifications /// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) { async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await { while let Some(notification) = notifications.next().await {
let RelayPoolNotification::Message { message, .. } = notification else { let ClientNotification::Message { message, .. } = notification else {
// Skip if the notification is not a message // Skip if the notification is not a message
continue; continue;
}; };
if let RelayMessage::Event { event, .. } = message { if let RelayMessage::Event { event, .. } = message {
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
match event.kind { match event.kind {
Kind::Metadata => { Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let metadata = Metadata::from_json(&event.content).unwrap_or_default();
@@ -230,9 +225,13 @@ impl PersonRegistry {
.authors(authors) .authors(authors)
.limit(limit); .limit(limit);
client // Construct target for subscription
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) let target = BOOTSTRAP_RELAYS
.await?; .into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
client.subscribe(target).close_on(opts).await?;
Ok(()) Ok(())
} }

View File

@@ -1,9 +1,11 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use device::Announcement;
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person /// Person
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -86,7 +88,12 @@ impl Person {
.picture .picture
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .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()) .unwrap_or_else(|| "brand/avatar.png".into())
} }

View File

@@ -3,8 +3,9 @@ use std::cell::Cell;
use std::collections::HashSet; use std::collections::HashSet;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{ use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Subscription, Task, Window, Subscription, Task, Window,
@@ -28,8 +29,8 @@ pub fn init(window: &mut Window, cx: &mut App) {
/// Authentication request /// Authentication request
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest { pub struct AuthRequest {
pub url: RelayUrl, url: RelayUrl,
pub challenge: String, challenge: String,
} }
impl Hash for AuthRequest { impl Hash for AuthRequest {
@@ -45,6 +46,14 @@ impl AuthRequest {
url, url,
} }
} }
pub fn url(&self) -> &RelayUrl {
&self.url
}
pub fn challenge(&self) -> &str {
&self.challenge
}
} }
struct GlobalRelayAuth(Entity<RelayAuth>); struct GlobalRelayAuth(Entity<RelayAuth>);
@@ -55,7 +64,7 @@ impl Global for GlobalRelayAuth {}
#[derive(Debug)] #[derive(Debug)]
pub struct RelayAuth { pub struct RelayAuth {
/// Entity for managing auth requests /// Entity for managing auth requests
requests: HashSet<AuthRequest>, requests: HashSet<Arc<AuthRequest>>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
@@ -91,14 +100,14 @@ impl RelayAuth {
subscriptions.push( subscriptions.push(
// Observe the current state // Observe the current state
cx.observe_in(&entity, window, |this, _, window, cx| { cx.observe_in(&entity, window, |this, _state, window, cx| {
let settings = AppSettings::global(cx); let settings = AppSettings::global(cx);
let mode = AppSettings::get_auth_mode(cx); let mode = AppSettings::get_auth_mode(cx);
for req in this.requests.clone().into_iter() { for req in this.requests.iter() {
let is_trusted_relay = settings.read(cx).is_trusted_relay(&req.url, cx); let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
if is_trusted_relay && mode == AuthMode::Auto { if trusted_relay && mode == AuthMode::Auto {
// Automatically authenticate if the relay is authenticated before // Automatically authenticate if the relay is authenticated before
this.response(req, window, cx); this.response(req, window, cx);
} else { } else {
@@ -111,7 +120,9 @@ impl RelayAuth {
tasks.push( tasks.push(
// Handle nostr notifications // Handle nostr notifications
cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }), cx.background_spawn(async move {
Self::handle_notifications(&client, &tx).await;
}),
); );
tasks.push( tasks.push(
@@ -136,25 +147,45 @@ impl RelayAuth {
// Handle nostr notifications // Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<AuthRequest>) { async fn handle_notifications(client: &Client, tx: &flume::Sender<AuthRequest>) {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
while let Ok(notification) = notifications.recv().await { while let Some(notification) = notifications.next().await {
if let RelayPoolNotification::Message { match notification {
message: RelayMessage::Auth { challenge }, ClientNotification::Message { relay_url, message } => {
relay_url, match message {
} = notification RelayMessage::Auth { challenge } => {
{ if challenges.insert(challenge.clone()) {
let request = AuthRequest::new(challenge, relay_url); let request = AuthRequest::new(challenge, relay_url);
tx.send_async(request).await.ok();
}
}
RelayMessage::Ok {
event_id, message, ..
} => {
let msg = MachineReadablePrefix::parse(&message);
let mut tracker = tracker().write().await;
if let Err(e) = tx.send_async(request).await { // Handle authentication messages
log::error!("Failed to send auth request: {}", e); if let Some(MachineReadablePrefix::AuthRequired) = msg {
// Keep track of events that need to be resent after authentication
tracker.add_to_pending(event_id, relay_url);
} else {
// Keep track of events sent by Coop
tracker.sent(event_id)
}
}
_ => {}
}
} }
ClientNotification::Shutdown => break,
_ => {}
} }
} }
} }
/// Add a new authentication request. /// Add a new authentication request.
fn add_request(&mut self, request: AuthRequest, cx: &mut Context<Self>) { fn add_request(&mut self, request: AuthRequest, cx: &mut Context<Self>) {
self.requests.insert(request); self.requests.insert(Arc::new(request));
cx.notify(); cx.notify();
} }
@@ -165,57 +196,55 @@ impl RelayAuth {
/// Reask for approval for all pending requests. /// Reask for approval for all pending requests.
pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context<Self>) {
for request in self.requests.clone().into_iter() { for request in self.requests.iter() {
self.ask_for_approval(request, window, cx); self.ask_for_approval(request, window, cx);
} }
} }
/// Respond to an authentication request. /// Respond to an authentication request.
fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) { fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let settings = AppSettings::global(cx); let settings = AppSettings::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let challenge = req.challenge.to_owned(); let req = req.clone();
let url = req.url.to_owned(); let challenge = req.challenge().to_string();
let async_req = req.clone();
let challenge_clone = challenge.clone();
let url_clone = url.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
// Construct event // Construct event
let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone()) let builder = EventBuilder::auth(async_req.challenge(), async_req.url().clone());
.sign(&signer) let event = client.sign_event_builder(builder).await?;
.await?;
// Get the event ID // Get the event ID
let id = event.id; let id = event.id;
// Get the relay // Get the relay
let relay = client.pool().relay(url_clone).await?; let relay = client
let relay_url = relay.url(); .relay(async_req.url())
.await?
.context("Relay not found")?;
// Subscribe to notifications // Subscribe to notifications
let mut notifications = relay.notifications(); let mut notifications = relay.notifications();
// Send the AUTH message // Send the AUTH message
relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?; relay
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
.await?;
while let Ok(notification) = notifications.recv().await { while let Some(notification) = notifications.next().await {
match notification { match notification {
RelayNotification::Message { RelayNotification::Message {
message: RelayMessage::Ok { event_id, .. }, message: RelayMessage::Ok { event_id, .. },
} => { } => {
if id == event_id { if id == event_id {
// Re-subscribe to previous subscription // Re-subscribe to previous subscription
relay.resubscribe().await?; // relay.resubscribe().await?;
// Get all pending events that need to be resent // Get all pending events that need to be resent
let mut tracker = tracker().write().await; let mut tracker = tracker().write().await;
let ids: Vec<EventId> = tracker.pending_resend(relay_url); let ids: Vec<EventId> = tracker.pending_resend(relay.url());
for id in ids.into_iter() { for id in ids.into_iter() {
if let Some(event) = client.database().event_by_id(&id).await? { if let Some(event) = client.database().event_by_id(&id).await? {
@@ -228,7 +257,6 @@ impl RelayAuth {
} }
} }
RelayNotification::AuthenticationFailed => break, RelayNotification::AuthenticationFailed => break,
RelayNotification::Shutdown => break,
_ => {} _ => {}
} }
} }
@@ -236,47 +264,56 @@ impl RelayAuth {
Err(anyhow!("Authentication failed")) Err(anyhow!("Authentication failed"))
}); });
self._tasks.push( cx.spawn_in(window, async move |this, cx| {
// Handle response in the background let result = task.await;
cx.spawn_in(window, async move |this, cx| { let url = req.url();
match task.await {
this.update_in(cx, |this, window, cx| {
match result {
Ok(_) => { Ok(_) => {
this.update_in(cx, |this, window, cx| { window.clear_notification(challenge, cx);
// Clear the current notification window.push_notification(format!("{} has been authenticated", url), cx);
window.clear_notification(challenge, cx);
// Push a new notification // Save the authenticated relay to automatically authenticate future requests
window.push_notification(format!("{url} has been authenticated"), cx); settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
// Save the authenticated relay to automatically authenticate future requests // Remove the challenge from the list of pending authentications
settings.update(cx, |this, cx| { this.requests.remove(&req);
this.add_trusted_relay(url, cx); cx.notify();
});
// Remove the challenge from the list of pending authentications
this.requests.remove(&req);
cx.notify();
})
.expect("Entity has been released");
} }
Err(e) => { Err(e) => {
this.update_in(cx, |_, window, cx| { window.push_notification(Notification::error(e.to_string()), cx);
window.push_notification(Notification::error(e.to_string()), cx);
})
.expect("Entity has been released");
} }
}; }
}), })
); .ok();
})
.detach();
} }
/// Push a popup to approve the authentication request. /// Push a popup to approve the authentication request.
fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) { fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let url = SharedString::from(req.url.clone().to_string()); let notification = self.notification(req, cx);
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
window.push_notification(notification, cx);
})
.ok();
})
.detach();
}
/// Build a notification for the authentication request.
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
let req = req.clone();
let url = SharedString::from(req.url().to_string());
let entity = cx.entity().downgrade(); let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false)); let loading = Rc::new(Cell::new(false));
let note = Notification::new() Notification::new()
.custom_id(SharedString::from(&req.challenge)) .custom_id(SharedString::from(&req.challenge))
.autohide(false) .autohide(false)
.icon(IconName::Info) .icon(IconName::Info)
@@ -299,7 +336,7 @@ impl RelayAuth {
.into_any_element() .into_any_element()
}) })
.action(move |_window, _cx| { .action(move |_window, _cx| {
let entity = entity.clone(); let view = entity.clone();
let req = req.clone(); let req = req.clone();
Button::new("approve") Button::new("approve")
@@ -310,24 +347,18 @@ impl RelayAuth {
.disabled(loading.get()) .disabled(loading.get())
.on_click({ .on_click({
let loading = Rc::clone(&loading); let loading = Rc::clone(&loading);
move |_ev, window, cx| { move |_ev, window, cx| {
// Set loading state to true // Set loading state to true
loading.set(true); loading.set(true);
// Process to approve the request // Process to approve the request
entity view.update(cx, |this, cx| {
.update(cx, |this, cx| { this.response(&req, window, cx);
this.response(req.clone(), window, cx); })
}) .ok();
.ok();
} }
}) })
}); })
// Push the notification to the current window
window.push_notification(note, cx);
// Bring the window to the front
cx.activate(true);
} }
} }

View File

@@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -47,8 +47,8 @@ setting_accessors! {
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AuthMode { pub enum AuthMode {
#[default] #[default]
Manual,
Auto, Auto,
Manual,
} }
/// Signer kind /// Signer kind
@@ -121,7 +121,7 @@ pub struct AppSettings {
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks /// Background tasks
_tasks: SmallVec<[Task<()>; 1]>, tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
} }
impl AppSettings { impl AppSettings {
@@ -136,7 +136,7 @@ impl AppSettings {
} }
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let load_settings = Self::get_from_database(false, cx); let load_settings = Self::get_from_database(cx);
let mut tasks = smallvec![]; let mut tasks = smallvec![];
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -151,28 +151,33 @@ impl AppSettings {
tasks.push( tasks.push(
// Load the initial settings // Load the initial settings
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
if let Ok(settings) = load_settings.await { let settings = load_settings.await.unwrap_or(Settings::default());
this.update(cx, |this, cx| { log::info!("Settings: {settings:?}");
this.values = settings;
cx.notify(); // Update the settings state
}) this.update(cx, |this, cx| {
.ok(); this.set_settings(settings, cx);
} })?;
Ok(())
}), }),
); );
Self { Self {
values: Settings::default(), values: Settings::default(),
tasks,
_subscriptions: subscriptions, _subscriptions: subscriptions,
_tasks: tasks,
} }
} }
/// Update settings
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
self.values = settings;
cx.notify();
}
/// Get settings from the database /// Get settings from the database
/// fn get_from_database(cx: &App) -> Task<Result<Settings, Error>> {
/// If `current_user` is true, the settings will be retrieved for current user.
/// Otherwise, Coop will load the latest settings from the database.
fn get_from_database(current_user: bool, cx: &App) -> Task<Result<Settings, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -183,16 +188,16 @@ impl AppSettings {
.identifier(SETTINGS_IDENTIFIER) .identifier(SETTINGS_IDENTIFIER)
.limit(1); .limit(1);
if current_user { // If the signer is available, get settings belonging to the current user
let signer = client.signer().await?; if let Some(signer) = client.signer() {
let public_key = signer.get_public_key().await?; if let Ok(public_key) = signer.get_public_key().await {
// Push author to the filter
// Push author to the filter filter = filter.author(public_key);
filter = filter.author(public_key); }
} }
if let Some(event) = client.database().query(filter).await?.first_owned() { if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default())) Ok(serde_json::from_str(&event.content)?)
} else { } else {
Err(anyhow!("Not found")) Err(anyhow!("Not found"))
} }
@@ -201,18 +206,18 @@ impl AppSettings {
/// Load settings /// Load settings
pub fn load(&mut self, cx: &mut Context<Self>) { pub fn load(&mut self, cx: &mut Context<Self>) {
let task = Self::get_from_database(true, cx); let task = Self::get_from_database(cx);
self._tasks.push( self.tasks.push(
// Run task in the background // Run task in the background
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
if let Ok(settings) = task.await { let settings = task.await?;
this.update(cx, |this, cx| { // Update settings
this.values = settings; this.update(cx, |this, cx| {
cx.notify(); this.set_settings(settings, cx);
}) })?;
.ok();
} Ok(())
}), }),
); );
} }
@@ -221,35 +226,36 @@ impl AppSettings {
pub fn save(&mut self, cx: &mut Context<Self>) { pub fn save(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let settings = self.values.clone();
if let Ok(content) = serde_json::to_string(&self.values) { self.tasks.push(cx.background_spawn(async move {
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let signer = client.signer().context("Signer not found")?;
let signer = client.signer().await?; let public_key = signer.get_public_key().await?;
let public_key = signer.get_public_key().await?; let content = serde_json::to_string(&settings)?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content) let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(SETTINGS_IDENTIFIER)) .tag(Tag::identifier(SETTINGS_IDENTIFIER))
.build(public_key) .build(public_key)
.sign(&Keys::generate()) .sign(&Keys::generate())
.await?; .await?;
client.database().save_event(&event).await?; // Save event to the local database only
client.database().save_event(&event).await?;
Ok(()) Ok(())
}); }));
task.detach();
}
} }
/// Check if the given relay is trusted /// Check if the given relay is already authenticated
pub fn is_trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool { pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.contains(url) self.values.trusted_relays.iter().any(|relay| {
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
})
} }
/// Add a relay to the trusted list /// Add a relay to the trusted list
pub fn add_trusted_relay(&mut self, url: RelayUrl, cx: &mut Context<Self>) { pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
self.values.trusted_relays.insert(url); self.values.trusted_relays.insert(url.clone());
cx.notify(); cx.notify();
} }

View File

@@ -10,6 +10,7 @@ common = { path = "../common" }
nostr-sdk.workspace = true nostr-sdk.workspace = true
nostr-lmdb.workspace = true nostr-lmdb.workspace = true
nostr-connect.workspace = true nostr-connect.workspace = true
nostr-gossip-memory.workspace = true
gpui.workspace = true gpui.workspace = true
gpui_tokio.workspace = true gpui_tokio.workspace = true
@@ -24,3 +25,4 @@ serde_json.workspace = true
rustls = "0.23" rustls = "0.23"
petname = "2.0.2" petname = "2.0.2"
whoami = "1.6.1"

View File

@@ -0,0 +1,59 @@
use std::sync::OnceLock;
/// Client name (Application name)
pub const CLIENT_NAME: &str = "Coop";
/// COOP's public key
pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv";
/// App ID
pub const APP_ID: &str = "su.reya.coop";
/// Keyring name
pub const KEYRING: &str = "Coop Safe Storage";
/// Default timeout for subscription
pub const TIMEOUT: u64 = 3;
/// Default delay for searching
pub const FIND_DELAY: u64 = 600;
/// Default limit for searching
pub const FIND_LIMIT: usize = 20;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default subscription id for device gift wrap events
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 vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
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://user.kindpag.es",
];
static APP_NAME: OnceLock<String> = OnceLock::new();
/// Get the app name
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = whoami::devicename();
let platform = whoami::platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}

View File

@@ -1,101 +0,0 @@
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RelayState {
#[default]
Initial,
NotSet,
Set,
}
impl RelayState {
pub fn is_initial(&self) -> bool {
matches!(self, RelayState::Initial)
}
}
/// Identity
#[derive(Debug, Clone, Default)]
pub struct Identity {
/// The public key of the account
pub public_key: Option<PublicKey>,
/// Whether the identity is owned by the user
pub owned: bool,
/// Status of the current user NIP-65 relays
relay_list: RelayState,
/// Status of the current user NIP-17 relays
messaging_relays: RelayState,
}
impl AsRef<Identity> for Identity {
fn as_ref(&self) -> &Identity {
self
}
}
impl Identity {
pub fn new() -> Self {
Self {
public_key: None,
owned: true,
relay_list: RelayState::default(),
messaging_relays: RelayState::default(),
}
}
/// Resets the relay states to their default values.
pub fn reset_relay_state(&mut self) {
self.relay_list = RelayState::default();
self.messaging_relays = RelayState::default();
}
/// Sets the state of the NIP-65 relays.
pub fn set_relay_list_state(&mut self, state: RelayState) {
self.relay_list = state;
}
/// Returns the state of the NIP-65 relays.
pub fn relay_list_state(&self) -> RelayState {
self.relay_list
}
/// Sets the state of the NIP-17 relays.
pub fn set_messaging_relays_state(&mut self, state: RelayState) {
self.messaging_relays = state;
}
/// Returns the state of the NIP-17 relays.
pub fn messaging_relays_state(&self) -> RelayState {
self.messaging_relays
}
/// Force getting the public key of the identity.
///
/// Panics if the public key is not set.
pub fn public_key(&self) -> PublicKey {
self.public_key.unwrap()
}
/// Returns true if the identity has a public key.
pub fn has_public_key(&self) -> bool {
self.public_key.is_some()
}
/// Sets the public key of the identity.
pub fn set_public_key(&mut self, public_key: PublicKey) {
self.public_key = Some(public_key);
}
/// Unsets the public key of the identity.
pub fn unset_public_key(&mut self) {
self.public_key = None;
}
/// Sets whether the identity is owned by the user.
pub fn set_owned(&mut self, owned: bool) {
self.owned = owned;
}
}

File diff suppressed because it is too large Load Diff

132
crates/state/src/signer.rs Normal file
View File

@@ -0,0 +1,132 @@
use std::borrow::Cow;
use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
#[derive(Debug)]
pub struct CoopSigner {
signer: RwLock<Arc<dyn NostrSigner>>,
/// Signer's public key
signer_pkey: RwLock<Option<PublicKey>>,
/// Whether coop is creating a new identity
creating: AtomicBool,
/// By default, Coop generates a new signer for new users.
///
/// This flag indicates whether the signer is user-owned or Coop-generated.
owned: AtomicBool,
}
impl CoopSigner {
pub fn new<T>(signer: T) -> Self
where
T: IntoNostrSigner,
{
Self {
signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None),
creating: AtomicBool::new(false),
owned: AtomicBool::new(false),
}
}
/// Get the current signer.
pub async fn get(&self) -> Arc<dyn NostrSigner> {
self.signer.read().await.clone()
}
/// Get public key
pub fn public_key(&self) -> Option<PublicKey> {
self.signer_pkey.read_blocking().to_owned()
}
/// Get the flag indicating whether the signer is creating a new identity.
pub fn creating(&self) -> bool {
self.creating.load(Ordering::SeqCst)
}
/// Get the flag indicating whether the signer is user-owned.
pub fn owned(&self) -> bool {
self.owned.load(Ordering::SeqCst)
}
/// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T, owned: bool)
where
T: IntoNostrSigner,
{
let new_signer = new.into_nostr_signer();
let public_key = new_signer.get_public_key().await.ok();
let mut signer = self.signer.write().await;
let mut signer_pkey = self.signer_pkey.write().await;
// Switch to the new signer
*signer = new_signer;
// Update the public key
*signer_pkey = public_key;
// Update the owned flag
self.owned.store(owned, Ordering::SeqCst);
}
}
impl NostrSigner for CoopSigner {
#[allow(mismatched_lifetime_syntaxes)]
fn backend(&self) -> SignerBackend {
SignerBackend::Custom(Cow::Borrowed("custom"))
}
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
Box::pin(async move { self.get().await.get_public_key().await })
}
fn sign_event<'a>(
&'a self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'a, Result<Event, SignerError>> {
Box::pin(async move { self.get().await.sign_event(unsigned).await })
}
fn nip04_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self.get()
.await
.nip04_decrypt(public_key, encrypted_content)
.await
})
}
fn nip44_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
}
}

View File

@@ -10,7 +10,7 @@ use theme::ActiveTheme;
use crate::indicator::Indicator; use crate::indicator::Indicator;
use crate::tooltip::Tooltip; use crate::tooltip::Tooltip;
use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt}; use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant { pub struct ButtonCustomVariant {
@@ -20,50 +20,6 @@ pub struct ButtonCustomVariant {
active: Hsla, active: Hsla,
} }
pub trait ButtonVariants: Sized {
fn with_variant(self, variant: ButtonVariant) -> Self;
/// With the primary style for the Button.
fn primary(self) -> Self {
self.with_variant(ButtonVariant::Primary)
}
/// With the secondary style for the Button.
fn secondary(self) -> Self {
self.with_variant(ButtonVariant::Secondary)
}
/// With the danger style for the Button.
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
/// With the warning style for the Button.
fn warning(self) -> Self {
self.with_variant(ButtonVariant::Warning)
}
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: false })
}
/// With the ghost style for the Button.
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
/// With the transparent style for the Button.
fn transparent(self) -> Self {
self.with_variant(ButtonVariant::Transparent)
}
/// With the custom style for the Button.
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
impl ButtonCustomVariant { impl ButtonCustomVariant {
pub fn new(_window: &Window, cx: &App) -> Self { pub fn new(_window: &Window, cx: &App) -> Self {
Self { Self {
@@ -110,6 +66,50 @@ pub enum ButtonVariant {
Custom(ButtonCustomVariant), Custom(ButtonCustomVariant),
} }
pub trait ButtonVariants: Sized {
fn with_variant(self, variant: ButtonVariant) -> Self;
/// With the primary style for the Button.
fn primary(self) -> Self {
self.with_variant(ButtonVariant::Primary)
}
/// With the secondary style for the Button.
fn secondary(self) -> Self {
self.with_variant(ButtonVariant::Secondary)
}
/// With the danger style for the Button.
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
/// With the warning style for the Button.
fn warning(self) -> Self {
self.with_variant(ButtonVariant::Warning)
}
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: false })
}
/// With the ghost style for the Button.
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
/// With the transparent style for the Button.
fn transparent(self) -> Self {
self.with_variant(ButtonVariant::Transparent)
}
/// With the custom style for the Button.
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
/// A Button element. /// A Button element.
#[derive(IntoElement)] #[derive(IntoElement)]
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@@ -124,17 +124,15 @@ pub struct Button {
children: Vec<AnyElement>, children: Vec<AnyElement>,
variant: ButtonVariant, variant: ButtonVariant,
center: bool,
rounded: bool,
size: Size, size: Size,
disabled: bool, disabled: bool,
reverse: bool,
bold: bool,
cta: bool,
loading: bool, loading: bool,
loading_icon: Option<Icon>,
rounded: bool,
compact: bool,
underline: bool,
caret: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>, on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
@@ -161,21 +159,19 @@ impl Button {
style: StyleRefinement::default(), style: StyleRefinement::default(),
icon: None, icon: None,
label: None, label: None,
variant: ButtonVariant::default(),
disabled: false, disabled: false,
selected: false, selected: false,
variant: ButtonVariant::default(), underline: false,
compact: false,
caret: false,
rounded: false, rounded: false,
size: Size::Medium, size: Size::Medium,
tooltip: None, tooltip: None,
on_click: None, on_click: None,
on_hover: None, on_hover: None,
loading: false, loading: false,
reverse: false,
center: true,
bold: false,
cta: false,
children: Vec::new(), children: Vec::new(),
loading_icon: None,
tab_index: 0, tab_index: 0,
tab_stop: true, tab_stop: true,
} }
@@ -211,33 +207,21 @@ impl Button {
self self
} }
/// Set reverse the position between icon and label. /// Set true to make the button compact (no padding).
pub fn reverse(mut self) -> Self { pub fn compact(mut self) -> Self {
self.reverse = true; self.compact = true;
self self
} }
/// Set bold the button (label will be use the semi-bold font). /// Set true to show the caret indicator.
pub fn bold(mut self) -> Self { pub fn caret(mut self) -> Self {
self.bold = true; self.caret = true;
self self
} }
/// Disable centering the button's content. /// Set true to show the underline indicator.
pub fn no_center(mut self) -> Self { pub fn underline(mut self) -> Self {
self.center = false; self.underline = true;
self
}
/// Set the cta style of the button.
pub fn cta(mut self) -> Self {
self.cta = true;
self
}
/// Set the loading icon of the button.
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
self.loading_icon = Some(icon.into());
self self
} }
@@ -346,7 +330,7 @@ impl RenderOnce for Button {
}; };
let focus_handle = window let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) .use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
.read(cx) .read(cx)
.clone(); .clone();
@@ -358,10 +342,11 @@ impl RenderOnce for Button {
.tab_stop(self.tab_stop), .tab_stop(self.tab_stop),
) )
}) })
.relative()
.flex_shrink_0() .flex_shrink_0()
.flex() .flex()
.items_center() .items_center()
.when(self.center, |this| this.justify_center()) .justify_center()
.cursor_default() .cursor_default()
.overflow_hidden() .overflow_hidden()
.refine_style(&self.style) .refine_style(&self.style)
@@ -369,39 +354,15 @@ impl RenderOnce for Button {
false => this.rounded(cx.theme().radius), false => this.rounded(cx.theme().radius),
true => this.rounded_full(), true => this.rounded_full(),
}) })
.map(|this| { .when(!self.compact, |this| {
if self.label.is_none() && self.children.is_empty() { if self.label.is_none() && self.children.is_empty() {
// Icon Button // Icon Button
match self.size { match self.size {
Size::Size(px) => this.size(px), Size::Size(px) => this.size(px),
Size::XSmall => { Size::XSmall => this.size_5(),
if self.cta { Size::Small => this.size_6(),
this.w_10().h_5() Size::Medium => this.size_7(),
} else { _ => this.size_9(),
this.size_5()
}
}
Size::Small => {
if self.cta {
this.w_12().h_6()
} else {
this.size_6()
}
}
Size::Medium => {
if self.cta {
this.w_12().h_7()
} else {
this.size_7()
}
}
_ => {
if self.cta {
this.w_16().h_9()
} else {
this.size_9()
}
}
} }
} else { } else {
// Normal Button // Normal Button
@@ -410,8 +371,6 @@ impl RenderOnce for Button {
Size::XSmall => { Size::XSmall => {
if self.icon.is_some() { if self.icon.is_some() {
this.h_6().pl_2().pr_2p5() this.h_6().pl_2().pr_2p5()
} else if self.cta {
this.h_6().px_4()
} else { } else {
this.h_6().px_2() this.h_6().px_2()
} }
@@ -419,8 +378,6 @@ impl RenderOnce for Button {
Size::Small => { Size::Small => {
if self.icon.is_some() { if self.icon.is_some() {
this.h_7().pl_2().pr_2p5() this.h_7().pl_2().pr_2p5()
} else if self.cta {
this.h_7().px_4()
} else { } else {
this.h_7().px_2() this.h_7().px_2()
} }
@@ -442,13 +399,27 @@ impl RenderOnce for Button {
} }
} }
}) })
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| { .refine_style(&self.style)
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if self.disabled {
cx.stop_propagation();
return;
}
// Avoid focus on mouse down. // Avoid focus on mouse down.
window.prevent_default(); window.prevent_default();
}) })
.when_some(self.on_click.filter(|_| clickable), |this, on_click| { .when_some(self.on_click, |this, on_click| {
this.on_click(move |event, window, cx| { this.on_click(move |event, window, cx| {
(on_click)(event, window, cx); // Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
}) })
}) })
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| { .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
@@ -459,7 +430,6 @@ impl RenderOnce for Button {
.child({ .child({
h_flex() h_flex()
.id("label") .id("label")
.when(self.reverse, |this| this.flex_row_reverse())
.justify_center() .justify_center()
.map(|this| match self.size { .map(|this| match self.size {
Size::XSmall => this.text_xs().gap_1(), Size::XSmall => this.text_xs().gap_1(),
@@ -471,22 +441,18 @@ impl RenderOnce for Button {
this.child(icon.with_size(icon_size)) this.child(icon.with_size(icon_size))
}) })
}) })
.when(self.loading, |this| { .when(self.loading, |this| this.child(Indicator::new()))
this.child(
Indicator::new()
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
)
})
.when_some(self.label, |this, label| { .when_some(self.label, |this, label| {
this.child( this.child(div().flex_none().line_height(relative(1.)).child(label))
div()
.flex_none()
.line_height(relative(1.))
.child(label)
.when(self.bold, |this| this.font_semibold()),
)
}) })
.children(self.children) .children(self.children)
.when(self.caret, |this| {
this.justify_between().gap_0p5().child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().text_muted),
)
})
}) })
.text_color(normal_style.fg) .text_color(normal_style.fg)
.when(!self.disabled && !self.selected, |this| { .when(!self.disabled && !self.selected, |this| {
@@ -504,6 +470,17 @@ impl RenderOnce for Button {
let selected_style = style.selected(cx); let selected_style = style.selected(cx);
this.bg(selected_style.bg).text_color(selected_style.fg) this.bg(selected_style.bg).text_color(selected_style.fg)
}) })
.when(self.selected && self.underline, |this| {
this.child(
div()
.absolute()
.bottom_0()
.left_0()
.h_px()
.w_full()
.bg(cx.theme().element_background),
)
})
.when(self.disabled, |this| { .when(self.disabled, |this| {
let disabled_style = style.disabled(cx); let disabled_style = style.disabled(cx);
this.cursor_not_allowed() this.cursor_not_allowed()

View File

@@ -61,8 +61,8 @@ impl RenderOnce for Divider {
.absolute() .absolute()
.rounded_full() .rounded_full()
.map(|this| match self.axis { .map(|this| match self.axis {
Axis::Vertical => this.w(px(2.)).h_full(), Axis::Vertical => this.w(px(1.)).h_full(),
Axis::Horizontal => this.h(px(2.)).w_full(), Axis::Horizontal => this.h(px(1.)).w_full(),
}) })
.bg(self.color.unwrap_or(cx.theme().border_variant)), .bg(self.color.unwrap_or(cx.theme().border_variant)),
) )

View File

@@ -246,8 +246,7 @@ impl Element for ContextMenu {
let menu = PopupMenu::build(window, cx, |menu, window, cx| { let menu = PopupMenu::build(window, cx, |menu, window, cx| {
(builder)(menu, window, cx) (builder)(menu, window, cx)
}) });
.into_element();
let _subscription = window.subscribe(&menu, cx, { let _subscription = window.subscribe(&menu, cx, {
let shared_state = shared_state.clone(); let shared_state = shared_state.clone();

View File

@@ -58,6 +58,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
}) })
} }
} }
impl PopupMenuExt for Button {} impl PopupMenuExt for Button {}
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@@ -1074,7 +1075,9 @@ impl PopupMenu {
} }
impl FluentBuilder for PopupMenu {} impl FluentBuilder for PopupMenu {}
impl EventEmitter<DismissEvent> for PopupMenu {} impl EventEmitter<DismissEvent> for PopupMenu {}
impl Focusable for PopupMenu { impl Focusable for PopupMenu {
fn focus_handle(&self, _: &App) -> FocusHandle { fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone() self.focus_handle.clone()

View File

@@ -1,776 +0,0 @@
use std::ops::Deref;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, AsKeystroke,
Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render,
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity,
Window,
};
use theme::ActiveTheme;
use crate::button::Button;
use crate::list::ListItem;
use crate::popover::Popover;
use crate::scroll::{Scrollbar, ScrollbarState};
use crate::{h_flex, v_flex, Icon, IconName, Selectable, Sizable as _, StyledExt};
actions!(
menu,
[
/// Trigger confirm action when user presses enter button
Confirm,
/// Trigger dismiss action when user presses escape button
Dismiss,
/// Select the next item when user presses up button
SelectNext,
/// Select the previous item when user preses down button
SelectPrev
]
);
const ITEM_HEIGHT: Pixels = px(26.);
pub fn init(cx: &mut App) {
let context = Some("PopupMenu");
cx.bind_keys([
KeyBinding::new("enter", Confirm, context),
KeyBinding::new("escape", Dismiss, context),
KeyBinding::new("up", SelectPrev, context),
KeyBinding::new("down", SelectNext, context),
]);
}
pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static {
/// Create a popup menu with the given items, anchored to the TopLeft corner
fn popup_menu(
self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Popover<PopupMenu> {
self.popup_menu_with_anchor(Corner::TopLeft, f)
}
/// Create a popup menu with the given items, anchored to the given corner
fn popup_menu_with_anchor(
mut self,
anchor: impl Into<Corner>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Popover<PopupMenu> {
let style = self.style().clone();
let id = self.interactivity().element_id.clone();
Popover::new(SharedString::from(format!("popup-menu:{id:?}")))
.no_style()
.trigger(self)
.trigger_style(style)
.anchor(anchor.into())
.content(move |window, cx| {
PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx))
})
}
}
impl PopupMenuExt for Button {}
enum PopupMenuItem {
Title(SharedString),
Separator,
Item {
icon: Option<Icon>,
label: SharedString,
action: Option<Box<dyn Action>>,
#[allow(clippy::type_complexity)]
handler: Rc<dyn Fn(&mut Window, &mut App)>,
},
ElementItem {
#[allow(clippy::type_complexity)]
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
#[allow(clippy::type_complexity)]
handler: Rc<dyn Fn(&mut Window, &mut App)>,
},
Submenu {
icon: Option<Icon>,
label: SharedString,
menu: Entity<PopupMenu>,
},
}
impl PopupMenuItem {
fn is_clickable(&self) -> bool {
!matches!(self, PopupMenuItem::Separator)
}
fn is_separator(&self) -> bool {
matches!(self, PopupMenuItem::Separator)
}
fn has_icon(&self) -> bool {
matches!(self, PopupMenuItem::Item { icon: Some(_), .. })
}
}
pub struct PopupMenu {
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
focus_handle: FocusHandle,
menu_items: Vec<PopupMenuItem>,
has_icon: bool,
selected_index: Option<usize>,
min_width: Pixels,
max_width: Pixels,
hovered_menu_ix: Option<usize>,
bounds: Bounds<Pixels>,
scrollable: bool,
scroll_handle: ScrollHandle,
scroll_state: ScrollbarState,
action_focus_handle: Option<FocusHandle>,
#[allow(dead_code)]
subscriptions: Vec<Subscription>,
}
impl PopupMenu {
pub fn build(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
) -> Entity<Self> {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
let subscriptions =
vec![
cx.on_blur(&focus_handle, window, |this: &mut PopupMenu, window, cx| {
this.dismiss(&Dismiss, window, cx)
}),
];
let menu = Self {
focus_handle,
action_focus_handle: None,
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
min_width: px(120.),
max_width: px(500.),
has_icon: false,
hovered_menu_ix: None,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
scroll_state: ScrollbarState::default(),
subscriptions,
};
f(menu, window, cx)
})
}
/// Bind the focus handle of the menu, when clicked, it will focus back to this handle and then dispatch the action
pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
self.action_focus_handle = Some(focus_handle.clone());
self
}
/// Set min width of the popup menu, default is 120px
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
self.min_width = width.into();
self
}
/// Set max width of the popup menu, default is 500px
pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
self.max_width = width.into();
self
}
/// Set the menu to be scrollable to show vertical scrollbar.
///
/// NOTE: If this is true, the sub-menus will cannot be support.
pub fn scrollable(mut self) -> Self {
self.scrollable = true;
self
}
/// Add Menu Item
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.add_menu_item(label, None, action);
self
}
/// Add Menu to open link
pub fn link(mut self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
let href = href.into();
self.menu_items.push(PopupMenuItem::Item {
icon: None,
label: label.into(),
action: None,
handler: Rc::new(move |_window, cx| cx.open_url(&href)),
});
self
}
/// Add Menu to open link
pub fn link_with_icon(
mut self,
label: impl Into<SharedString>,
icon: impl Into<Icon>,
href: impl Into<String>,
) -> Self {
let href = href.into();
self.menu_items.push(PopupMenuItem::Item {
icon: Some(icon.into()),
label: label.into(),
action: None,
handler: Rc::new(move |_window, cx| cx.open_url(&href)),
});
self
}
/// Add Menu Item with Icon
pub fn menu_with_icon(
mut self,
label: impl Into<SharedString>,
icon: impl Into<Icon>,
action: Box<dyn Action>,
) -> Self {
self.add_menu_item(label, Some(icon.into()), action);
self
}
/// Add Menu Item with check icon
pub fn menu_with_check(
mut self,
label: impl Into<SharedString>,
checked: bool,
action: Box<dyn Action>,
) -> Self {
if checked {
self.add_menu_item(label, Some(IconName::Check.into()), action);
} else {
self.add_menu_item(label, None, action);
}
self
}
/// Add Menu Item with custom element render.
pub fn menu_with_element<F, E>(mut self, builder: F, action: Box<dyn Action>) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.menu_items.push(PopupMenuItem::ElementItem {
render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
handler: self.wrap_handler(action),
});
self
}
#[allow(clippy::type_complexity)]
fn wrap_handler(&self, action: Box<dyn Action>) -> Rc<dyn Fn(&mut Window, &mut App)> {
let action_focus_handle = self.action_focus_handle.clone();
Rc::new(move |window, cx| {
window.activate_window();
// Focus back to the user expected focus handle
// Then the actions listened on that focus handle can be received
//
// For example:
//
// TabPanel
// |- PopupMenu
// |- PanelContent (actions are listened here)
//
// The `PopupMenu` and `PanelContent` are at the same level in the TabPanel
// If the actions are listened on the `PanelContent`,
// it can't receive the actions from the `PopupMenu`, unless we focus on `PanelContent`.
if let Some(handle) = action_focus_handle.as_ref() {
window.focus(handle);
}
window.dispatch_action(action.boxed_clone(), cx);
})
}
fn add_menu_item(
&mut self,
label: impl Into<SharedString>,
icon: Option<Icon>,
action: Box<dyn Action>,
) -> &mut Self {
if icon.is_some() {
self.has_icon = true;
}
self.menu_items.push(PopupMenuItem::Item {
icon,
label: label.into(),
action: Some(action.boxed_clone()),
handler: self.wrap_handler(action),
});
self
}
/// Add a title menu item
pub fn title(mut self, label: impl Into<SharedString>) -> Self {
if self.menu_items.is_empty() {
return self;
}
if let Some(PopupMenuItem::Title(_)) = self.menu_items.last() {
return self;
}
self.menu_items.push(PopupMenuItem::Title(label.into()));
self
}
/// Add a separator Menu Item
pub fn separator(mut self) -> Self {
if self.menu_items.is_empty() {
return self;
}
if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
return self;
}
self.menu_items.push(PopupMenuItem::Separator);
self
}
pub fn submenu(
self,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.submenu_with_icon(None, label, window, cx, f)
}
/// Add a Submenu item with icon
pub fn submenu_with_icon(
mut self,
icon: Option<Icon>,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
let submenu = PopupMenu::build(window, cx, f);
let parent_menu = cx.entity().downgrade();
submenu.update(cx, |view, _| {
view.parent_menu = Some(parent_menu);
});
self.menu_items.push(PopupMenuItem::Submenu {
icon,
label: label.into(),
menu: submenu,
});
self
}
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
if let Some(ix) = self.hovered_menu_ix {
if let Some(item) = self.menu_items.get(ix) {
return match item {
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
_ => None,
};
}
}
None
}
pub fn is_empty(&self) -> bool {
self.menu_items.is_empty()
}
fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
self.menu_items
.iter()
.enumerate()
.filter(|(_, item)| item.is_clickable())
}
fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
cx.stop_propagation();
window.prevent_default();
self.selected_index = Some(ix);
self.confirm(&Confirm, window, cx);
}
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.selected_index {
let item = self.menu_items.get(index);
match item {
Some(PopupMenuItem::Item { handler, .. }) => {
handler(window, cx);
self.dismiss(&Dismiss, window, cx)
}
Some(PopupMenuItem::ElementItem { handler, .. }) => {
handler(window, cx);
self.dismiss(&Dismiss, window, cx)
}
_ => {}
}
}
}
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
let count = self.clickable_menu_items().count();
if count > 0 {
let last_ix = count.saturating_sub(1);
let ix = self
.selected_index
.map(|index| if index == last_ix { 0 } else { index + 1 })
.unwrap_or(0);
self.selected_index = Some(ix);
cx.notify();
}
}
fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
let count = self.clickable_menu_items().count();
if count > 0 {
let last_ix = count.saturating_sub(1);
let ix = self
.selected_index
.map(|index| {
if index == last_ix {
0
} else {
index.saturating_sub(1)
}
})
.unwrap_or(last_ix);
self.selected_index = Some(ix);
cx.notify();
}
}
// TODO: fix this
#[allow(clippy::only_used_in_recursion)]
fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
if self.active_submenu().is_some() {
return;
}
cx.emit(DismissEvent);
// Dismiss parent menu, when this menu is dismissed
if let Some(parent_menu) = self.parent_menu.clone().and_then(|menu| menu.upgrade()) {
parent_menu.update(cx, |view, cx| {
view.hovered_menu_ix = None;
view.dismiss(&Dismiss, window, cx);
})
}
}
fn render_keybinding(
action: Option<Box<dyn Action>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if let Some(action) = action {
if let Some(keybinding) = window.bindings_for_action(action.deref()).first() {
let el = div().text_color(cx.theme().text_muted).children(
keybinding
.keystrokes()
.iter()
.map(|key| key_shortcut(key.as_keystroke().clone())),
);
return Some(el);
}
}
None
}
fn render_icon(
has_icon: bool,
icon: Option<Icon>,
_window: &Window,
_cx: &Context<Self>,
) -> Option<impl IntoElement> {
let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None };
if !has_icon {
return None;
}
let icon = h_flex()
.w_3p5()
.h_3p5()
.items_center()
.justify_center()
.text_sm()
.map(|this| {
if let Some(icon) = icon {
this.child(icon.clone().small())
} else {
this.children(icon_placeholder.clone())
}
});
Some(icon)
}
}
impl FluentBuilder for PopupMenu {}
impl EventEmitter<DismissEvent> for PopupMenu {}
impl Focusable for PopupMenu {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PopupMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone();
let has_icon = self.menu_items.iter().any(|item| item.has_icon());
let items_count = self.menu_items.len();
let max_width = self.max_width;
let bounds = self.bounds;
let window_haft_height = window.window_bounds().get_bounds().size.height * 0.5;
let max_height = window_haft_height.min(px(450.));
v_flex()
.id("popup-menu")
.key_context("PopupMenu")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_prev))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::dismiss))
.on_mouse_down_out(cx.listener(|this, _, window, cx| this.dismiss(&Dismiss, window, cx)))
.popover_style(cx)
.relative()
.p_1()
.child(
div()
.id("popup-menu-items")
.when(self.scrollable, |this| {
this.max_h(max_height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
})
.child(
v_flex()
.gap_y_0p5()
.min_w(self.min_width)
.max_w(self.max_width)
.min_w(rems(8.))
.child({
canvas(
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _, _| {},
)
.absolute()
.size_full()
})
.children(
self.menu_items
.iter_mut()
.enumerate()
// Skip last separator
.filter(|(ix, item)| !(*ix == items_count - 1 && item.is_separator()))
.map(|(ix, item)| {
let this = ListItem::new(("menu-item", ix))
.relative()
.items_center()
.py_0()
.px_2()
.rounded_md()
.text_sm()
.on_mouse_enter(cx.listener(move |this, _, _window, cx| {
this.hovered_menu_ix = Some(ix);
cx.notify();
}));
match item {
PopupMenuItem::Title(label) => {
this.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone())
)
},
PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child(
div()
.rounded_none()
.h(px(1.))
.mx_neg_1()
.my_0p5()
.bg(cx.theme().border_disabled),
),
PopupMenuItem::ElementItem { render, .. } => this
.on_click(
cx.listener(move |this, _, window, cx| {
this.on_click(ix, window, cx)
}),
)
.child(
h_flex()
.min_h(ITEM_HEIGHT)
.items_center()
.gap_x_1()
.children(Self::render_icon(has_icon, None, window, cx))
.child((render)(window, cx)),
),
PopupMenuItem::Item {
icon, label, action, ..
} => {
let action = action.as_ref().map(|action| action.boxed_clone());
let key = Self::render_keybinding(action, window, cx);
this.on_click(
cx.listener(move |this, _, window, cx| {
this.on_click(ix, window, cx)
}),
)
.child(
h_flex()
.h(ITEM_HEIGHT)
.items_center()
.gap_x_1p5()
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
.child(
h_flex()
.flex_1()
.gap_2()
.items_center()
.justify_between()
.child(label.clone())
.children(key),
),
)
}
PopupMenuItem::Submenu { icon, label, menu } => this
.when(self.hovered_menu_ix == Some(ix), |this| this.selected(true))
.child(
h_flex()
.items_start()
.child(
h_flex()
.size_full()
.items_center()
.gap_x_1p5()
.children(Self::render_icon(
has_icon,
icon.clone(),
window,
cx,
))
.child(
h_flex()
.flex_1()
.gap_2()
.items_center()
.justify_between()
.child(label.clone())
.child(IconName::CaretRight),
),
)
.when_some(self.hovered_menu_ix, |this, hovered_ix| {
let (anchor, left) = if window.bounds().size.width
- bounds.origin.x
< max_width
{
(Corner::TopRight, -px(15.))
} else {
(Corner::TopLeft, bounds.size.width - px(10.))
};
let top = if bounds.origin.y + bounds.size.height
> window.bounds().size.height
{
px(32.)
} else {
-px(10.)
};
if hovered_ix == ix {
this.child(
anchored()
.anchor(anchor)
.child(
div()
.occlude()
.top(top)
.left(left)
.child(menu.clone()),
)
.snap_to_window_with_margin(Edges::all(px(8.))),
)
} else {
this
}
}),
),
}
}),
),
),
)
.when(self.scrollable, |this| {
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
this.child(
div()
.absolute()
.top_0()
.left_0()
.right_0p5()
.bottom_0()
.child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)),
)
})
}
}
/// Return the Platform specific keybinding string by KeyStroke
pub fn key_shortcut(key: Keystroke) -> String {
if cfg!(target_os = "macos") {
return format!("{key}");
}
let mut parts = vec![];
if key.modifiers.control {
parts.push("Ctrl");
}
if key.modifiers.alt {
parts.push("Alt");
}
if key.modifiers.platform {
parts.push("Win");
}
if key.modifiers.shift {
parts.push("Shift");
}
// Capitalize the first letter
let key = if let Some(first_c) = key.key.chars().next() {
format!("{}{}", first_c.to_uppercase(), &key.key[1..])
} else {
key.key.to_string()
};
parts.push(&key);
parts.join("+")
}

View File

@@ -183,39 +183,43 @@ impl<T: Styled> StyleSized<T> for T {
fn input_pl(self, size: Size) -> Self { fn input_pl(self, size: Size) -> Self {
match size { match size {
Size::Large => self.pl_5(), Size::XSmall => self.pl_1(),
Size::Medium => self.pl_3(), Size::Medium => self.pl_3(),
Size::Large => self.pl_5(),
_ => self.pl_2(), _ => self.pl_2(),
} }
} }
fn input_pr(self, size: Size) -> Self { fn input_pr(self, size: Size) -> Self {
match size { match size {
Size::Large => self.pr_5(), Size::XSmall => self.pr_1(),
Size::Medium => self.pr_3(), Size::Medium => self.pr_3(),
Size::Large => self.pr_5(),
_ => self.pr_2(), _ => self.pr_2(),
} }
} }
fn input_px(self, size: Size) -> Self { fn input_px(self, size: Size) -> Self {
match size { match size {
Size::Large => self.px_5(), Size::XSmall => self.px_1(),
Size::Medium => self.px_3(), Size::Medium => self.px_3(),
Size::Large => self.px_5(),
_ => self.px_2(), _ => self.px_2(),
} }
} }
fn input_py(self, size: Size) -> Self { fn input_py(self, size: Size) -> Self {
match size { match size {
Size::Large => self.py_5(), Size::XSmall => self.py_0p5(),
Size::Medium => self.py_2(), Size::Medium => self.py_2(),
Size::Large => self.py_5(),
_ => self.py_1(), _ => self.py_1(),
} }
} }
fn input_h(self, size: Size) -> Self { fn input_h(self, size: Size) -> Self {
match size { match size {
Size::XSmall => self.h_7(), Size::XSmall => self.h_6(),
Size::Small => self.h_8(), Size::Small => self.h_8(),
Size::Medium => self.h_9(), Size::Medium => self.h_9(),
Size::Large => self.h_12(), Size::Large => self.h_12(),