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

View File

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

View File

@@ -98,7 +98,7 @@ impl ChatRegistry {
/// Create a new chat registry instance
fn new(cx: &mut Context<Self>) -> Self {
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_signer = device.read(cx).device_signer.clone();
@@ -114,8 +114,8 @@ impl ChatRegistry {
subscriptions.push(
// Observe the identity
cx.observe(&identity, |this, state, cx| {
if state.read(cx).messaging_relays_state() == RelayState::Set {
cx.observe(&nip17_state, |this, state, cx| {
if state.read(cx) == &RelayState::Configured {
// Handle nostr notifications
this.handle_notifications(cx);
// Track unwrapping progress
@@ -146,15 +146,15 @@ impl ChatRegistry {
})
.ok();
}
NostrEvent::Eose => {
NostrEvent::Unwrapping(status) => {
this.update(cx, |this, cx| {
this.set_loading(status, cx);
this.get_rooms(cx);
})
.ok();
}
NostrEvent::Unwrapping(status) => {
NostrEvent::Eose => {
this.update(cx, |this, cx| {
this.set_loading(status, cx);
this.get_rooms(cx);
})
.ok();
@@ -195,8 +195,8 @@ impl ChatRegistry {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, .. } = notification else {
// Skip non-message notifications
continue;
};
@@ -310,27 +310,40 @@ impl ChatRegistry {
/// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where
I: Into<Room>,
I: Into<Room> + 'static,
{
self.rooms.insert(0, cx.new(|_| room.into()));
cx.notify();
let nostr = NostrRegistry::global(cx);
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.
///
/// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) {
if let Some(room) = room.upgrade() {
let id = room.read(cx).id;
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room);
}
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
}
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
}
/// Close a room.
@@ -407,23 +420,20 @@ impl ChatRegistry {
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.get_rooms_from_database(cx);
self.tasks.push(
// Run and finished in the background
cx.spawn(async move |this, cx| {
match task.await {
Ok(rooms) => {
this.update(cx, move |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to load rooms: {e}")
}
};
}),
);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(rooms) => {
this.update(cx, move |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to load rooms: {e}")
}
};
}));
}
/// Create a task to load rooms from the database
@@ -432,10 +442,13 @@ impl ChatRegistry {
let client = nostr.read(cx).client();
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?;
// Get contacts
let contacts = client.database().contacts_public_keys(public_key).await?;
// Construct authored filter
let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
@@ -443,6 +456,7 @@ impl ChatRegistry {
// Get all authored events
let authored = client.database().query(authored_filter).await?;
// Construct addressed filter
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
@@ -453,6 +467,7 @@ impl ChatRegistry {
// Merge authored and addressed events
let events = authored.merge(addressed);
// Collect results
let mut rooms: HashSet<Room> = HashSet::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
@@ -468,24 +483,21 @@ impl ChatRegistry {
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
let mut room = Room::from(latest);
if rooms.iter().any(|r| r.id == room.id) {
continue;
}
let mut public_keys = room.members();
public_keys.retain(|pk| pk != &public_key);
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// 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
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
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
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.
async fn extract_rumor(
client: &Client,
@@ -597,8 +580,8 @@ impl ChatRegistry {
};
// Try with the user's signer
let user_signer = client.signer().await?;
let unwrapped = UnwrappedGift::from_gift_wrap(&user_signer, gift_wrap).await?;
let user_signer = client.signer().context("Signer not found")?;
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
Ok(unwrapped)
}

View File

@@ -1,17 +1,25 @@
use std::hash::Hash;
use common::EventUtils;
use nostr_sdk::prelude::*;
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
pub gift_wrap: EventId,
pub room: u64,
pub rumor: UnsignedEvent,
}
impl NewMessage {
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::time::Duration;
use anyhow::Error;
use anyhow::{Context as AnyhowContext, Error};
use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
@@ -11,7 +11,7 @@ use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use state::{tracker, NostrRegistry};
use crate::NewMessage;
use crate::{ChatRegistry, NewMessage};
const SEND_RETRY: usize = 10;
@@ -99,16 +99,20 @@ pub enum RoomKind {
Ongoing,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Room {
/// Conversation ID
pub id: u64,
/// The timestamp of the last message in the room
pub created_at: Timestamp,
/// Subject of the room
pub subject: Option<SharedString>,
/// All members of the room
pub members: Vec<PublicKey>,
pub(super) members: Vec<PublicKey>,
/// Kind
pub kind: RoomKind,
}
@@ -145,11 +149,7 @@ impl From<&UnsignedEvent> for Room {
fn from(val: &UnsignedEvent) -> Self {
let id = val.uniq_id();
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.extract_public_keys();
// Get subject from tags
let subject = val
.tags
.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 {
/// Constructs a new room with the given receiver and tags.
pub fn new<T>(author: PublicKey, receivers: T) -> Self
where
T: IntoIterator<Item = PublicKey>,
{
// Map receiver public keys to tags
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, "")
.tags(tags)
.build(author);
// Generate event ID
// Ensure that the ID is set
event.ensure_id();
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
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = kind;
@@ -216,28 +238,6 @@ impl Room {
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)
pub fn is_group(&self) -> bool {
self.members.len() > 2
@@ -266,17 +266,7 @@ impl Room {
/// Display member is always different from the current user.
pub fn display_member(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(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)
persons.read(cx).get(&self.members[0], cx)
}
/// Merge the names of the first two members of the room.
@@ -297,7 +287,7 @@ impl Room {
.collect::<Vec<_>>()
.join(", ");
if profiles.len() > 2 {
if profiles.len() > 3 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
@@ -307,9 +297,21 @@ impl Room {
}
}
/// Emits a new message signal to the current room
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) {
/// Push a new message to the current room
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));
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.
@@ -325,7 +327,7 @@ impl Room {
let id = SubscriptionId::new(format!("room-{}", self.id));
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?;
// Subscription options
@@ -343,7 +345,9 @@ impl Room {
// Subscribe to get member's gossip relays
client
.subscribe_with_id(id.clone(), filter, Some(opts))
.subscribe(filter)
.close_on(opts)
.with_id(id.clone())
.await?;
}
@@ -375,68 +379,78 @@ impl Room {
})
}
/// Create a new message event (unsigned)
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
/// Create a new unsigned message event
pub fn create_message(
&self,
content: &str,
replies: Vec<EventId>,
cx: &App,
) -> Task<Result<UnsignedEvent, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user
let public_key = nostr.read(cx).identity().read(cx).public_key();
// Get room's subject
let subject = self.subject.clone();
let content = content.to_string();
let mut tags = vec![];
let mut member_and_relay_hints = HashMap::new();
// Add receivers
//
// NOTE: current user will be removed from the list of receivers
// Populate the hashmap with member and relay hint tasks
for member in self.members.iter() {
// Get relay hint if available
let relay_url = nostr.read(cx).relay_hint(member, cx);
// 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));
let hint = nostr.read(cx).relay_hint(member, cx);
member_and_relay_hints.insert(member.to_owned(), hint);
}
// Add subject tag if it's present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Add reply/quote tag
if replies.len() == 1 {
tags.push(Tag::event(replies[0]))
} else {
for id in replies {
let tag = TagStandard::Quote {
event_id: id.to_owned(),
relay_url: None,
public_key: None,
// List of event tags for each receiver
let mut tags = vec![];
for (member, task) in member_and_relay_hints.into_iter() {
// Skip current user
if member == public_key {
continue;
}
// Get relay hint if available
let relay_url = task.await;
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member,
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag))
tags.push(Tag::from_standardized_without_cell(tag));
}
}
// Construct a direct message event
//
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tags(tags)
.build(public_key);
// Add subject tag if present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Ensure the event id has been generated
event.ensure_id();
// Add all reply tags
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
@@ -448,46 +462,27 @@ impl Room {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user's public key and relays
let current_user = nostr.read(cx).identity().read(cx).public_key();
let current_user_relays = nostr.read(cx).messaging_relays(&current_user, cx);
let mut members = self.members();
let rumor = rumor.to_owned();
// Get all members and their messaging relays
let task = self.members_with_relays(cx);
cx.background_spawn(async move {
let signer = client.signer().await?;
let current_user_relays = current_user_relays.await;
let mut members = task.await;
let signer = client.signer().context("Signer not found")?;
let current_user = signer.get_public_key().await?;
// Remove the current user's public key from the list of receivers
// the current user will be handled separately
members.retain(|(this, _)| this != &current_user);
members.retain(|this| this != &current_user);
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for (receiver, relays) 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?;
}
for receiver in members.into_iter() {
// Construct the gift wrap 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
match client.send_event_to(relays, &event).await {
match client.send_event(&event).to_nip17().await {
Ok(output) => {
let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
@@ -525,24 +520,12 @@ impl Room {
// Construct the gift-wrapped 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
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
match client.send_event_to(current_user_relays, &event).await {
match client.send_event(&event).to_nip17().await {
Ok(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? {
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 resent: Output<EventId> = Output {

View File

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

View File

@@ -6,6 +6,7 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
@@ -19,5 +20,3 @@ log.workspace = true
dirs = "5.0"
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 HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
pub trait RenderedProfile {
fn avatar(&self) -> SharedString;
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 {
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 display::*;
pub use event::*;
pub use nip96::*;
use nostr_sdk::prelude::*;
pub use paths::*;
mod constants;
mod debounced_delay;
mod display;
mod event;
mod nip96;
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 config = nip96::ServerConfig::from_json(json.to_string())?;
let signer = if client.has_signer().await {
client.signer().await?
} else {
Keys::generate().into_nostr_signer()
};
let signer = client
.signer()
.cloned()
.unwrap_or(Keys::generate().into_nostr_signer());
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 anyhow::Error;
use common::{shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS};
use anyhow::{Context as AnyhowContext, Error};
use common::{shorten_pubkey, RenderedTimestamp};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
@@ -10,7 +11,7 @@ use gpui::{
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use smallvec::{smallvec, SmallVec};
use state::{NostrAddress, NostrRegistry};
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
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))
}
/// 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,
/// Whether the person is followed by current user.
followed: bool,
/// Last time the person was active.
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 {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let http_client = cx.http_client();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let mut tasks = smallvec![];
// 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))
}
cx.defer_in(window, move |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
this.verify_identifier(cx);
});
// Check the last activity
let activity_check = cx.background_spawn(async move {
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
}
}
fn check_contact(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
Ok(followed)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.followed = result;
cx.notify();
})
.ok();
}));
}
fn check_wot(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
mutual_contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
}));
}
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
if let Ok(mut stream) = client
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2))
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, event)) = stream.next().await {
@@ -88,91 +157,74 @@ impl Screening {
activity
});
// Verify the NIP05 address if available
let addr_check = profile.metadata().nip05.and_then(|address| {
Nip05Address::parse(&address).ok().map(|addr| {
cx.background_spawn(async move { addr.verify(&http_client, &public_key).await })
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.last_active = result;
cx.notify();
})
});
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,
}
.ok();
}));
}
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let public_key = self.public_key;
// Skip if the user doesn't have a NIP-05 identifier
let Some(address) = self.address(cx) else {
return;
};
let task: Task<Result<bool, Error>> =
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.verified = result;
cx.notify();
})
.ok();
}));
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.profile.public_key().to_bech32();
fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.profile.public_key();
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let tag = Tag::public_key_report(public_key, Report::Impersonation);
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?;
let builder = EventBuilder::report(vec![tag], "");
let event = client.sign_event_builder(builder).await?;
// Send the report to the public relays
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(())
});
cx.spawn_in(window, async move |_, cx| {
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
window.close_modal(cx);
@@ -180,8 +232,7 @@ impl Screening {
})
.ok();
}
})
.detach();
}));
}
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(
v_flex().gap_1().pb_4().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
for ix in range {
if let Some(contact) = contacts.get(ix) {
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| {
this.bg(cx.theme().elevated_surface_background)
})
.child(Avatar::new(contact.avatar()).size(rems(1.75)))
.child(contact.display_name()),
);
}
let Some(contact) = contacts.get(ix) else {
continue;
};
let profile = persons.read(cx).get(contact, cx);
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
.child(profile.name()),
);
}
items
@@ -226,7 +279,9 @@ impl Screening {
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let total_mutuals = self.mutual_contacts.len();
let last_active = self.last_active.map(|_| true);
@@ -238,12 +293,12 @@ impl Render for Screening {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
.child(Avatar::new(profile.avatar()).size(rems(4.)))
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(self.profile.name()),
.child(profile.name()),
),
)
.child(

View File

@@ -1,23 +1,21 @@
use std::sync::{Arc, Mutex};
use assets::Assets;
use common::{APP_ID, CLIENT_NAME};
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,
WindowDecorations, WindowKind, WindowOptions,
};
use state::{APP_ID, CLIENT_NAME};
use ui::Root;
use crate::actions::Quit;
mod actions;
mod command_bar;
mod dialogs;
mod panels;
mod sidebar;
mod workspace;
actions!(coop, [Quit]);
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,35 @@
use std::collections::HashSet;
use std::ops::Range;
use std::time::Duration;
use chat::{ChatEvent, ChatRegistry, RoomKind};
use common::RenderedTimestamp;
use anyhow::Error;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, RenderedTimestamp};
use dock::panel::{Panel, PanelEvent};
use entry::RoomEntry;
use gpui::prelude::FluentBuilder;
use gpui::{
deferred, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled,
Subscription, Window,
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
Task, Window,
};
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::divider::Divider;
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> {
cx.new(|cx| Sidebar::new(window, cx))
@@ -30,12 +43,42 @@ pub struct Sidebar {
/// Image cache
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
new_requests: bool,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// Chatroom filter
filter: Entity<RoomKind>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
@@ -44,9 +87,49 @@ impl Sidebar {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
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![];
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(
// Subscribe for registry new events
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
@@ -61,12 +144,206 @@ impl Sidebar {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
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,
contact_list,
selected_pkeys,
filter,
tasks: smallvec![],
_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.
fn current_filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
self.filter.read(cx) == kind
@@ -92,15 +369,15 @@ impl Sidebar {
.enumerate()
.map(|(ix, item)| {
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 handler = cx.listener(move |_this, _ev, _window, 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))
.avatar(room.display_image(cx))
.public_key(public_key)
@@ -111,6 +388,72 @@ impl Sidebar {
})
.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 {
@@ -133,89 +476,124 @@ impl Render for Sidebar {
let loading = chat.read(cx).loading();
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()
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.gap_2()
.child(
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.border_b_1()
.border_color(cx.theme().border)
.child(
h_flex()
.flex_1()
.h_full()
.gap_2()
.p_2()
.justify_center()
.child(
Button::new("all")
.map(|this| {
if self.current_filter(&RoomKind::Ongoing, cx) {
this.icon(IconName::InboxFill)
} else {
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(),
),
TextInput::new(&self.find_input)
.appearance(false)
.bordered(false)
.small()
.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(!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(
div().px_2p5().child(deferred(
div().mt_2().px_2().child(
v_flex()
.p_3()
.h_24()
@@ -238,47 +616,138 @@ impl Render for Sidebar {
"Start a conversation with someone to get started.",
),
)),
)),
),
)
})
.child(
v_flex()
.h_full()
.px_1p5()
.w_full()
.mt_2()
.flex_1()
.gap_1()
.overflow_y_hidden()
.child(
uniform_list(
"rooms",
total_rooms,
cx.processor(|this, range, _window, cx| {
this.render_list_items(range, cx)
}),
)
.h_full(),
)
.when(loading, |this| {
.when(show_find_panel, |this| {
this.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
this.child(
v_flex()
.gap_1()
.flex_1()
.border_b_1()
.border_color(cx.theme().border_variant)
.child(
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(
div().absolute().top_2().left_0().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...")),
),
uniform_list(
"rooms",
total_rooms,
cx.processor(|this, range, _window, cx| {
this.render_list_items(range, cx)
}),
)
.flex_1()
.h_full(),
)
}),
)
.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 dock::dock::DockPlacement;
use dock::panel::PanelView;
use dock::panel::{PanelStyle, PanelView};
use dock::{ClosePanel, DockArea, DockItem};
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -11,13 +11,14 @@ use gpui::{
};
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use state::{NostrRegistry, RelayState};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use titlebar::TitleBar;
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::sidebar;
@@ -32,9 +33,6 @@ pub struct Workspace {
/// App's Dock Area
dock: Entity<DockArea>,
/// App's Command Bar
command_bar: Entity<CommandBar>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>,
}
@@ -43,19 +41,10 @@ impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
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).panel_style(dock::panel::PanelStyle::TabBar));
let dock = cx.new(|cx| DockArea::new(window, cx).style(PanelStyle::TabBar));
let mut subscriptions = smallvec![];
subscriptions.push(
// Automatically sync theme with system appearance
window.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}),
);
subscriptions.push(
// Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
@@ -108,7 +97,6 @@ impl Workspace {
Self {
titlebar,
dock,
command_bar,
_subscriptions: subscriptions,
}
}
@@ -175,36 +163,95 @@ impl Workspace {
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
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(TITLEBAR_HEIGHT)
.flex_1()
.flex_shrink_0()
.justify_between()
.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 profile = persons.read(cx).get(&public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
this.child(
h_flex()
.gap_0p5()
Button::new("current-user")
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
.child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().text_muted),
),
.small()
.caret()
.compact()
.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 {
h_flex().flex_1()
h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
}
}
@@ -215,12 +262,11 @@ impl Render for Workspace {
// Titlebar elements
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();
// Update title bar children
self.titlebar.update(cx, |this, _cx| {
this.set_children(vec![left, center, right]);
this.set_children(vec![left, right]);
});
div()

View File

@@ -9,6 +9,20 @@ pub enum DeviceState {
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
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement {

View File

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

View File

@@ -351,7 +351,7 @@ impl DockArea {
}
/// 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
}

View File

@@ -65,7 +65,7 @@ impl StackPanel {
}
/// 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 {
return false;
}
@@ -79,12 +79,12 @@ impl StackPanel {
true
}
pub(super) fn panels_len(&self) -> usize {
pub fn panels_len(&self) -> usize {
self.panels.len()
}
/// 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)
}
@@ -253,11 +253,12 @@ impl StackPanel {
});
cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(window, cx);
}
/// Replace the old panel with the new panel at same index.
pub(super) fn replace_panel(
pub fn replace_panel(
&mut self,
old_panel: Arc<dyn PanelView>,
new_panel: Entity<StackPanel>,
@@ -266,16 +267,15 @@ impl StackPanel {
) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone());
let panel_state = ResizablePanelState::default();
self.state.update(cx, |state, cx| {
state.replace_panel(ix, panel_state, cx);
state.replace_panel(ix, ResizablePanelState::default(), cx);
});
cx.emit(PanelEvent::LayoutChanged);
}
}
/// 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() {
return;
}
@@ -296,11 +296,7 @@ impl StackPanel {
}
/// Find the first top left in the stack.
pub(super) fn left_top_tab_panel(
&self,
check_parent: bool,
cx: &App,
) -> Option<Entity<TabPanel>> {
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent {
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) {
@@ -324,11 +320,7 @@ impl StackPanel {
}
/// Find the first top right in the stack.
pub(super) fn right_top_tab_panel(
&self,
check_parent: bool,
cx: &App,
) -> Option<Entity<TabPanel>> {
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent {
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) {
@@ -357,7 +349,7 @@ impl StackPanel {
}
/// 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.state.update(cx, |state, cx| {
state.clear();
@@ -366,7 +358,7 @@ impl StackPanel {
}
/// 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;
cx.notify();
}

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use device::Announcement;
use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
@@ -86,7 +88,12 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.map(|picture| {
let url = format!(
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.unwrap_or_else(|| "brand/avatar.png".into())
}

View File

@@ -3,8 +3,9 @@ use std::cell::Cell;
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{anyhow, Error};
use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Subscription, Task, Window,
@@ -28,8 +29,8 @@ pub fn init(window: &mut Window, cx: &mut App) {
/// Authentication request
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest {
pub url: RelayUrl,
pub challenge: String,
url: RelayUrl,
challenge: String,
}
impl Hash for AuthRequest {
@@ -45,6 +46,14 @@ impl AuthRequest {
url,
}
}
pub fn url(&self) -> &RelayUrl {
&self.url
}
pub fn challenge(&self) -> &str {
&self.challenge
}
}
struct GlobalRelayAuth(Entity<RelayAuth>);
@@ -55,7 +64,7 @@ impl Global for GlobalRelayAuth {}
#[derive(Debug)]
pub struct RelayAuth {
/// Entity for managing auth requests
requests: HashSet<AuthRequest>,
requests: HashSet<Arc<AuthRequest>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
@@ -91,14 +100,14 @@ impl RelayAuth {
subscriptions.push(
// 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 mode = AppSettings::get_auth_mode(cx);
for req in this.requests.clone().into_iter() {
let is_trusted_relay = settings.read(cx).is_trusted_relay(&req.url, cx);
for req in this.requests.iter() {
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
this.response(req, window, cx);
} else {
@@ -111,7 +120,9 @@ impl RelayAuth {
tasks.push(
// 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(
@@ -136,25 +147,45 @@ impl RelayAuth {
// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<AuthRequest>) {
let mut notifications = client.notifications();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message {
message: RelayMessage::Auth { challenge },
relay_url,
} = notification
{
let request = AuthRequest::new(challenge, relay_url);
while let Some(notification) = notifications.next().await {
match notification {
ClientNotification::Message { relay_url, message } => {
match message {
RelayMessage::Auth { challenge } => {
if challenges.insert(challenge.clone()) {
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 {
log::error!("Failed to send auth request: {}", e);
// Handle authentication messages
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.
fn add_request(&mut self, request: AuthRequest, cx: &mut Context<Self>) {
self.requests.insert(request);
self.requests.insert(Arc::new(request));
cx.notify();
}
@@ -165,57 +196,55 @@ impl RelayAuth {
/// Reask for approval for all pending requests.
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);
}
}
/// 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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let challenge = req.challenge.to_owned();
let url = req.url.to_owned();
let challenge_clone = challenge.clone();
let url_clone = url.clone();
let req = req.clone();
let challenge = req.challenge().to_string();
let async_req = req.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
// Construct event
let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone())
.sign(&signer)
.await?;
let builder = EventBuilder::auth(async_req.challenge(), async_req.url().clone());
let event = client.sign_event_builder(builder).await?;
// Get the event ID
let id = event.id;
// Get the relay
let relay = client.pool().relay(url_clone).await?;
let relay_url = relay.url();
let relay = client
.relay(async_req.url())
.await?
.context("Relay not found")?;
// Subscribe to notifications
let mut notifications = relay.notifications();
// 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 {
RelayNotification::Message {
message: RelayMessage::Ok { event_id, .. },
} => {
if id == event_id {
// Re-subscribe to previous subscription
relay.resubscribe().await?;
// relay.resubscribe().await?;
// Get all pending events that need to be resent
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() {
if let Some(event) = client.database().event_by_id(&id).await? {
@@ -228,7 +257,6 @@ impl RelayAuth {
}
}
RelayNotification::AuthenticationFailed => break,
RelayNotification::Shutdown => break,
_ => {}
}
}
@@ -236,47 +264,56 @@ impl RelayAuth {
Err(anyhow!("Authentication failed"))
});
self._tasks.push(
// Handle response in the background
cx.spawn_in(window, async move |this, cx| {
match task.await {
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
let url = req.url();
this.update_in(cx, |this, window, cx| {
match result {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
// Clear the current notification
window.clear_notification(challenge, cx);
window.clear_notification(challenge, cx);
window.push_notification(format!("{} has been authenticated", url), cx);
// Push a new notification
window.push_notification(format!("{url} has been authenticated"), cx);
// Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
// Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
// Remove the challenge from the list of pending authentications
this.requests.remove(&req);
cx.notify();
})
.expect("Entity has been released");
// Remove the challenge from the list of pending authentications
this.requests.remove(&req);
cx.notify();
}
Err(e) => {
this.update_in(cx, |_, window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.expect("Entity has been released");
window.push_notification(Notification::error(e.to_string()), cx);
}
};
}),
);
}
})
.ok();
})
.detach();
}
/// Push a popup to approve the authentication request.
fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
let url = SharedString::from(req.url.clone().to_string());
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
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 loading = Rc::new(Cell::new(false));
let note = Notification::new()
Notification::new()
.custom_id(SharedString::from(&req.challenge))
.autohide(false)
.icon(IconName::Info)
@@ -299,7 +336,7 @@ impl RelayAuth {
.into_any_element()
})
.action(move |_window, _cx| {
let entity = entity.clone();
let view = entity.clone();
let req = req.clone();
Button::new("approve")
@@ -310,24 +347,18 @@ impl RelayAuth {
.disabled(loading.get())
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
entity
.update(cx, |this, cx| {
this.response(req.clone(), window, cx);
})
.ok();
view.update(cx, |this, cx| {
this.response(&req, window, cx);
})
.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 anyhow::{anyhow, Error};
use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
@@ -47,8 +47,8 @@ setting_accessors! {
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AuthMode {
#[default]
Manual,
Auto,
Manual,
}
/// Signer kind
@@ -121,7 +121,7 @@ pub struct AppSettings {
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
}
impl AppSettings {
@@ -136,7 +136,7 @@ impl AppSettings {
}
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 subscriptions = smallvec![];
@@ -151,28 +151,33 @@ impl AppSettings {
tasks.push(
// Load the initial settings
cx.spawn(async move |this, cx| {
if let Ok(settings) = load_settings.await {
this.update(cx, |this, cx| {
this.values = settings;
cx.notify();
})
.ok();
}
let settings = load_settings.await.unwrap_or(Settings::default());
log::info!("Settings: {settings:?}");
// Update the settings state
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
})?;
Ok(())
}),
);
Self {
values: Settings::default(),
tasks,
_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
///
/// 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>> {
fn get_from_database(cx: &App) -> Task<Result<Settings, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -183,16 +188,16 @@ impl AppSettings {
.identifier(SETTINGS_IDENTIFIER)
.limit(1);
if current_user {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// Push author to the filter
filter = filter.author(public_key);
// If the signer is available, get settings belonging to the current user
if let Some(signer) = client.signer() {
if let Ok(public_key) = signer.get_public_key().await {
// Push author to the filter
filter = filter.author(public_key);
}
}
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 {
Err(anyhow!("Not found"))
}
@@ -201,18 +206,18 @@ impl AppSettings {
/// Load settings
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
cx.spawn(async move |this, cx| {
if let Ok(settings) = task.await {
this.update(cx, |this, cx| {
this.values = settings;
cx.notify();
})
.ok();
}
let settings = task.await?;
// Update settings
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
})?;
Ok(())
}),
);
}
@@ -221,35 +226,36 @@ impl AppSettings {
pub fn save(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let settings = self.values.clone();
if let Ok(content) = serde_json::to_string(&self.values) {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
self.tasks.push(cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let content = serde_json::to_string(&settings)?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(SETTINGS_IDENTIFIER))
.build(public_key)
.sign(&Keys::generate())
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(SETTINGS_IDENTIFIER))
.build(public_key)
.sign(&Keys::generate())
.await?;
client.database().save_event(&event).await?;
// Save event to the local database only
client.database().save_event(&event).await?;
Ok(())
});
task.detach();
}
Ok(())
}));
}
/// Check if the given relay is trusted
pub fn is_trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.contains(url)
/// Check if the given relay is already authenticated
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
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
pub fn add_trusted_relay(&mut self, url: RelayUrl, cx: &mut Context<Self>) {
self.values.trusted_relays.insert(url);
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
self.values.trusted_relays.insert(url.clone());
cx.notify();
}

View File

@@ -10,6 +10,7 @@ common = { path = "../common" }
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-connect.workspace = true
nostr-gossip-memory.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
@@ -24,3 +25,4 @@ serde_json.workspace = true
rustls = "0.23"
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::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)]
pub struct ButtonCustomVariant {
@@ -20,50 +20,6 @@ pub struct ButtonCustomVariant {
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 {
pub fn new(_window: &Window, cx: &App) -> Self {
Self {
@@ -110,6 +66,50 @@ pub enum ButtonVariant {
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.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
@@ -124,17 +124,15 @@ pub struct Button {
children: Vec<AnyElement>,
variant: ButtonVariant,
center: bool,
rounded: bool,
size: Size,
disabled: bool,
reverse: bool,
bold: bool,
cta: 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_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
@@ -161,21 +159,19 @@ impl Button {
style: StyleRefinement::default(),
icon: None,
label: None,
variant: ButtonVariant::default(),
disabled: false,
selected: false,
variant: ButtonVariant::default(),
underline: false,
compact: false,
caret: false,
rounded: false,
size: Size::Medium,
tooltip: None,
on_click: None,
on_hover: None,
loading: false,
reverse: false,
center: true,
bold: false,
cta: false,
children: Vec::new(),
loading_icon: None,
tab_index: 0,
tab_stop: true,
}
@@ -211,33 +207,21 @@ impl Button {
self
}
/// Set reverse the position between icon and label.
pub fn reverse(mut self) -> Self {
self.reverse = true;
/// Set true to make the button compact (no padding).
pub fn compact(mut self) -> Self {
self.compact = true;
self
}
/// Set bold the button (label will be use the semi-bold font).
pub fn bold(mut self) -> Self {
self.bold = true;
/// Set true to show the caret indicator.
pub fn caret(mut self) -> Self {
self.caret = true;
self
}
/// Disable centering the button's content.
pub fn no_center(mut self) -> Self {
self.center = false;
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());
/// Set true to show the underline indicator.
pub fn underline(mut self) -> Self {
self.underline = true;
self
}
@@ -346,7 +330,7 @@ impl RenderOnce for Button {
};
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)
.clone();
@@ -358,10 +342,11 @@ impl RenderOnce for Button {
.tab_stop(self.tab_stop),
)
})
.relative()
.flex_shrink_0()
.flex()
.items_center()
.when(self.center, |this| this.justify_center())
.justify_center()
.cursor_default()
.overflow_hidden()
.refine_style(&self.style)
@@ -369,39 +354,15 @@ impl RenderOnce for Button {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
})
.map(|this| {
.when(!self.compact, |this| {
if self.label.is_none() && self.children.is_empty() {
// Icon Button
match self.size {
Size::Size(px) => this.size(px),
Size::XSmall => {
if self.cta {
this.w_10().h_5()
} else {
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()
}
}
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
Size::Medium => this.size_7(),
_ => this.size_9(),
}
} else {
// Normal Button
@@ -410,8 +371,6 @@ impl RenderOnce for Button {
Size::XSmall => {
if self.icon.is_some() {
this.h_6().pl_2().pr_2p5()
} else if self.cta {
this.h_6().px_4()
} else {
this.h_6().px_2()
}
@@ -419,8 +378,6 @@ impl RenderOnce for Button {
Size::Small => {
if self.icon.is_some() {
this.h_7().pl_2().pr_2p5()
} else if self.cta {
this.h_7().px_4()
} else {
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.
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| {
(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| {
@@ -459,7 +430,6 @@ impl RenderOnce for Button {
.child({
h_flex()
.id("label")
.when(self.reverse, |this| this.flex_row_reverse())
.justify_center()
.map(|this| match self.size {
Size::XSmall => this.text_xs().gap_1(),
@@ -471,22 +441,18 @@ impl RenderOnce for Button {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading, |this| {
this.child(
Indicator::new()
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
)
})
.when(self.loading, |this| this.child(Indicator::new()))
.when_some(self.label, |this, label| {
this.child(
div()
.flex_none()
.line_height(relative(1.))
.child(label)
.when(self.bold, |this| this.font_semibold()),
)
this.child(div().flex_none().line_height(relative(1.)).child(label))
})
.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)
.when(!self.disabled && !self.selected, |this| {
@@ -504,6 +470,17 @@ impl RenderOnce for Button {
let selected_style = style.selected(cx);
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| {
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()

View File

@@ -61,8 +61,8 @@ impl RenderOnce for Divider {
.absolute()
.rounded_full()
.map(|this| match self.axis {
Axis::Vertical => this.w(px(2.)).h_full(),
Axis::Horizontal => this.h(px(2.)).w_full(),
Axis::Vertical => this.w(px(1.)).h_full(),
Axis::Horizontal => this.h(px(1.)).w_full(),
})
.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| {
(builder)(menu, window, cx)
})
.into_element();
});
let _subscription = window.subscribe(&menu, cx, {
let shared_state = shared_state.clone();

View File

@@ -58,6 +58,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
})
}
}
impl PopupMenuExt for Button {}
#[allow(clippy::type_complexity)]
@@ -1074,7 +1075,9 @@ impl PopupMenu {
}
impl FluentBuilder for PopupMenu {}
impl EventEmitter<DismissEvent> for PopupMenu {}
impl Focusable for PopupMenu {
fn focus_handle(&self, _: &App) -> FocusHandle {
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 {
match size {
Size::Large => self.pl_5(),
Size::XSmall => self.pl_1(),
Size::Medium => self.pl_3(),
Size::Large => self.pl_5(),
_ => self.pl_2(),
}
}
fn input_pr(self, size: Size) -> Self {
match size {
Size::Large => self.pr_5(),
Size::XSmall => self.pr_1(),
Size::Medium => self.pr_3(),
Size::Large => self.pr_5(),
_ => self.pr_2(),
}
}
fn input_px(self, size: Size) -> Self {
match size {
Size::Large => self.px_5(),
Size::XSmall => self.px_1(),
Size::Medium => self.px_3(),
Size::Large => self.px_5(),
_ => self.px_2(),
}
}
fn input_py(self, size: Size) -> Self {
match size {
Size::Large => self.py_5(),
Size::XSmall => self.py_0p5(),
Size::Medium => self.py_2(),
Size::Large => self.py_5(),
_ => self.py_1(),
}
}
fn input_h(self, size: Size) -> Self {
match size {
Size::XSmall => self.h_7(),
Size::XSmall => self.h_6(),
Size::Small => self.h_8(),
Size::Medium => self.h_9(),
Size::Large => self.h_12(),