Continue redesign for the v1 stable release (#5)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m32s
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:
610
Cargo.lock
generated
610
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,11 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
|||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
# Nostr
|
# Nostr
|
||||||
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||||
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use common::BOOTSTRAP_RELAYS;
|
|
||||||
use gpui::http_client::{AsyncBody, HttpClient};
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
@@ -243,12 +242,7 @@ impl AutoUpdater {
|
|||||||
.author(app_pubkey)
|
.author(app_pubkey)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Err(e) = client
|
// TODO
|
||||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::error!("Failed to subscribe to updates: {e}");
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,10 +279,7 @@ impl AutoUpdater {
|
|||||||
.author(app_pubkey)
|
.author(app_pubkey)
|
||||||
.ids(ids.clone());
|
.ids(ids.clone());
|
||||||
|
|
||||||
// Get all files for this release
|
// TODO
|
||||||
client
|
|
||||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl ChatRegistry {
|
|||||||
/// Create a new chat registry instance
|
/// Create a new chat registry instance
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let identity = nostr.read(cx).identity();
|
let nip17_state = nostr.read(cx).nip17_state();
|
||||||
|
|
||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
let device_signer = device.read(cx).device_signer.clone();
|
let device_signer = device.read(cx).device_signer.clone();
|
||||||
@@ -114,8 +114,8 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the identity
|
// Observe the identity
|
||||||
cx.observe(&identity, |this, state, cx| {
|
cx.observe(&nip17_state, |this, state, cx| {
|
||||||
if state.read(cx).messaging_relays_state() == RelayState::Set {
|
if state.read(cx) == &RelayState::Configured {
|
||||||
// Handle nostr notifications
|
// Handle nostr notifications
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(cx);
|
||||||
// Track unwrapping progress
|
// Track unwrapping progress
|
||||||
@@ -146,15 +146,15 @@ impl ChatRegistry {
|
|||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
NostrEvent::Eose => {
|
NostrEvent::Unwrapping(status) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_loading(status, cx);
|
||||||
this.get_rooms(cx);
|
this.get_rooms(cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
NostrEvent::Unwrapping(status) => {
|
NostrEvent::Eose => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_loading(status, cx);
|
|
||||||
this.get_rooms(cx);
|
this.get_rooms(cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -195,8 +195,8 @@ impl ChatRegistry {
|
|||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
while let Ok(notification) = notifications.recv().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
let RelayPoolNotification::Message { message, .. } = notification else {
|
let ClientNotification::Message { message, .. } = notification else {
|
||||||
// Skip non-message notifications
|
// Skip non-message notifications
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -310,27 +310,40 @@ impl ChatRegistry {
|
|||||||
/// Add a new room to the start of list.
|
/// Add a new room to the start of list.
|
||||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
I: Into<Room>,
|
I: Into<Room> + 'static,
|
||||||
{
|
{
|
||||||
self.rooms.insert(0, cx.new(|_| room.into()));
|
let nostr = NostrRegistry::global(cx);
|
||||||
cx.notify();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
if let Some(signer) = client.signer() {
|
||||||
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.rooms
|
||||||
|
.insert(0, cx.new(|_| room.into().organize(&public_key)));
|
||||||
|
cx.emit(ChatEvent::Ping);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit an open room event.
|
/// Emit an open room event.
|
||||||
///
|
///
|
||||||
/// If the room is new, add it to the registry.
|
/// If the room is new, add it to the registry.
|
||||||
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) {
|
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||||
if let Some(room) = room.upgrade() {
|
// Get the room's ID.
|
||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
// If the room is new, add it to the registry.
|
// If the room is new, add it to the registry.
|
||||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
self.rooms.insert(0, room);
|
self.rooms.insert(0, room.to_owned());
|
||||||
}
|
|
||||||
|
|
||||||
// Emit the open room event.
|
|
||||||
cx.emit(ChatEvent::OpenRoom(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit the open room event.
|
||||||
|
cx.emit(ChatEvent::OpenRoom(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close a room.
|
/// Close a room.
|
||||||
@@ -407,23 +420,20 @@ impl ChatRegistry {
|
|||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.get_rooms_from_database(cx);
|
let task = self.get_rooms_from_database(cx);
|
||||||
|
|
||||||
self.tasks.push(
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
// Run and finished in the background
|
match task.await {
|
||||||
cx.spawn(async move |this, cx| {
|
Ok(rooms) => {
|
||||||
match task.await {
|
this.update(cx, move |this, cx| {
|
||||||
Ok(rooms) => {
|
this.extend_rooms(rooms, cx);
|
||||||
this.update(cx, move |this, cx| {
|
this.sort(cx);
|
||||||
this.extend_rooms(rooms, cx);
|
})
|
||||||
this.sort(cx);
|
.ok();
|
||||||
})
|
}
|
||||||
.ok();
|
Err(e) => {
|
||||||
}
|
log::error!("Failed to load rooms: {e}")
|
||||||
Err(e) => {
|
}
|
||||||
log::error!("Failed to load rooms: {e}")
|
};
|
||||||
}
|
}));
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to load rooms from the database
|
/// Create a task to load rooms from the database
|
||||||
@@ -432,10 +442,13 @@ impl ChatRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Get contacts
|
||||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||||
|
|
||||||
|
// Construct authored filter
|
||||||
let authored_filter = Filter::new()
|
let authored_filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
||||||
@@ -443,6 +456,7 @@ impl ChatRegistry {
|
|||||||
// Get all authored events
|
// Get all authored events
|
||||||
let authored = client.database().query(authored_filter).await?;
|
let authored = client.database().query(authored_filter).await?;
|
||||||
|
|
||||||
|
// Construct addressed filter
|
||||||
let addressed_filter = Filter::new()
|
let addressed_filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
||||||
@@ -453,6 +467,7 @@ impl ChatRegistry {
|
|||||||
// Merge authored and addressed events
|
// Merge authored and addressed events
|
||||||
let events = authored.merge(addressed);
|
let events = authored.merge(addressed);
|
||||||
|
|
||||||
|
// Collect results
|
||||||
let mut rooms: HashSet<Room> = HashSet::new();
|
let mut rooms: HashSet<Room> = HashSet::new();
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
|
||||||
@@ -468,24 +483,21 @@ impl ChatRegistry {
|
|||||||
for (_id, mut messages) in grouped.into_iter() {
|
for (_id, mut messages) in grouped.into_iter() {
|
||||||
messages.sort_by_key(|m| Reverse(m.created_at));
|
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||||
|
|
||||||
|
// Always use the latest message
|
||||||
let Some(latest) = messages.first() else {
|
let Some(latest) = messages.first() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut room = Room::from(latest);
|
// Construct the room from the latest message.
|
||||||
|
//
|
||||||
if rooms.iter().any(|r| r.id == room.id) {
|
// Call `.organize` to ensure the current user is at the end of the list.
|
||||||
continue;
|
let mut room = Room::from(latest).organize(&public_key);
|
||||||
}
|
|
||||||
|
|
||||||
let mut public_keys = room.members();
|
|
||||||
public_keys.retain(|pk| pk != &public_key);
|
|
||||||
|
|
||||||
// Check if the user has responded to the room
|
// Check if the user has responded to the room
|
||||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||||
|
|
||||||
// Check if public keys are from the user's contacts
|
// Check if public keys are from the user's contacts
|
||||||
let is_contact = public_keys.iter().any(|k| contacts.contains(k));
|
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
||||||
|
|
||||||
// Set the room's kind based on status
|
// Set the room's kind based on status
|
||||||
if user_sent || is_contact {
|
if user_sent || is_contact {
|
||||||
@@ -499,6 +511,24 @@ impl ChatRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
|
///
|
||||||
|
/// If the room doesn't exist, it will be created.
|
||||||
|
/// Updates room ordering based on the most recent messages.
|
||||||
|
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
|
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||||
|
Some(room) => {
|
||||||
|
room.update(cx, |this, cx| {
|
||||||
|
this.push_message(message, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Push the new room to the front of the list
|
||||||
|
self.add_room(message.rumor, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||||
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
||||||
if let Some(ids) = ids {
|
if let Some(ids) = ids {
|
||||||
@@ -512,53 +542,6 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a nostr event into a message and push it to the belonging room
|
|
||||||
///
|
|
||||||
/// If the room doesn't exist, it will be created.
|
|
||||||
/// Updates room ordering based on the most recent messages.
|
|
||||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
// Get the unique id
|
|
||||||
let id = message.rumor.uniq_id();
|
|
||||||
// Get the author
|
|
||||||
let author = message.rumor.pubkey;
|
|
||||||
|
|
||||||
match self.rooms.iter().find(|room| room.read(cx).id == id) {
|
|
||||||
Some(room) => {
|
|
||||||
let new_message = message.rumor.created_at > room.read(cx).created_at;
|
|
||||||
let created_at = message.rumor.created_at;
|
|
||||||
|
|
||||||
// Update room
|
|
||||||
room.update(cx, |this, cx| {
|
|
||||||
// Update the last timestamp if the new message is newer
|
|
||||||
if new_message {
|
|
||||||
this.set_created_at(created_at, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set this room is ongoing if the new message is from current user
|
|
||||||
if author == nostr.read(cx).identity().read(cx).public_key() {
|
|
||||||
this.set_ongoing(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit the new message to the room
|
|
||||||
this.emit_message(message, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resort all rooms in the registry by their created at (after updated)
|
|
||||||
if new_message {
|
|
||||||
self.sort(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Push the new room to the front of the list
|
|
||||||
self.add_room(&message.rumor, cx);
|
|
||||||
|
|
||||||
// Notify the UI about the new room
|
|
||||||
cx.emit(ChatEvent::Ping);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unwraps a gift-wrapped event and processes its contents.
|
/// Unwraps a gift-wrapped event and processes its contents.
|
||||||
async fn extract_rumor(
|
async fn extract_rumor(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -597,8 +580,8 @@ impl ChatRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try with the user's signer
|
// Try with the user's signer
|
||||||
let user_signer = client.signer().await?;
|
let user_signer = client.signer().context("Signer not found")?;
|
||||||
let unwrapped = UnwrappedGift::from_gift_wrap(&user_signer, gift_wrap).await?;
|
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
|
||||||
|
|
||||||
Ok(unwrapped)
|
Ok(unwrapped)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
|
|
||||||
|
use common::EventUtils;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
pub gift_wrap: EventId,
|
pub gift_wrap: EventId,
|
||||||
|
pub room: u64,
|
||||||
pub rumor: UnsignedEvent,
|
pub rumor: UnsignedEvent,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewMessage {
|
impl NewMessage {
|
||||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||||
Self { gift_wrap, rumor }
|
let room = rumor.uniq_id();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
gift_wrap,
|
||||||
|
room,
|
||||||
|
rumor,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use common::EventUtils;
|
use common::EventUtils;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -11,7 +11,7 @@ use nostr_sdk::prelude::*;
|
|||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use state::{tracker, NostrRegistry};
|
use state::{tracker, NostrRegistry};
|
||||||
|
|
||||||
use crate::NewMessage;
|
use crate::{ChatRegistry, NewMessage};
|
||||||
|
|
||||||
const SEND_RETRY: usize = 10;
|
const SEND_RETRY: usize = 10;
|
||||||
|
|
||||||
@@ -99,16 +99,20 @@ pub enum RoomKind {
|
|||||||
Ongoing,
|
Ongoing,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Room {
|
pub struct Room {
|
||||||
/// Conversation ID
|
/// Conversation ID
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
|
||||||
/// The timestamp of the last message in the room
|
/// The timestamp of the last message in the room
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
|
|
||||||
/// Subject of the room
|
/// Subject of the room
|
||||||
pub subject: Option<SharedString>,
|
pub subject: Option<SharedString>,
|
||||||
|
|
||||||
/// All members of the room
|
/// All members of the room
|
||||||
pub members: Vec<PublicKey>,
|
pub(super) members: Vec<PublicKey>,
|
||||||
|
|
||||||
/// Kind
|
/// Kind
|
||||||
pub kind: RoomKind,
|
pub kind: RoomKind,
|
||||||
}
|
}
|
||||||
@@ -145,11 +149,7 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
fn from(val: &UnsignedEvent) -> Self {
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
let id = val.uniq_id();
|
let id = val.uniq_id();
|
||||||
let created_at = val.created_at;
|
let created_at = val.created_at;
|
||||||
|
|
||||||
// Get the members from the event's tags and event's pubkey
|
|
||||||
let members = val.extract_public_keys();
|
let members = val.extract_public_keys();
|
||||||
|
|
||||||
// Get subject from tags
|
|
||||||
let subject = val
|
let subject = val
|
||||||
.tags
|
.tags
|
||||||
.find(TagKind::Subject)
|
.find(TagKind::Subject)
|
||||||
@@ -165,23 +165,45 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<UnsignedEvent> for Room {
|
||||||
|
fn from(val: UnsignedEvent) -> Self {
|
||||||
|
Room::from(&val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Room {
|
impl Room {
|
||||||
/// Constructs a new room with the given receiver and tags.
|
/// Constructs a new room with the given receiver and tags.
|
||||||
pub fn new<T>(author: PublicKey, receivers: T) -> Self
|
pub fn new<T>(author: PublicKey, receivers: T) -> Self
|
||||||
where
|
where
|
||||||
T: IntoIterator<Item = PublicKey>,
|
T: IntoIterator<Item = PublicKey>,
|
||||||
{
|
{
|
||||||
|
// Map receiver public keys to tags
|
||||||
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
|
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
|
||||||
|
|
||||||
|
// Construct an unsigned event for a direct message
|
||||||
|
//
|
||||||
|
// WARNING: never sign this event
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(author);
|
.build(author);
|
||||||
|
|
||||||
// Generate event ID
|
// Ensure that the ID is set
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
|
|
||||||
Room::from(&event)
|
Room::from(&event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Organizes the members of the room by moving the target member to the end.
|
||||||
|
///
|
||||||
|
/// Always call this function to ensure the current user is at the end of the list.
|
||||||
|
pub fn organize(mut self, target: &PublicKey) -> Self {
|
||||||
|
if let Some(index) = self.members.iter().position(|member| member == target) {
|
||||||
|
let member = self.members.remove(index);
|
||||||
|
self.members.push(member);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the kind of the room and returns the modified room
|
/// Sets the kind of the room and returns the modified room
|
||||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||||
self.kind = kind;
|
self.kind = kind;
|
||||||
@@ -216,28 +238,6 @@ impl Room {
|
|||||||
self.members.clone()
|
self.members.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the members of the room with their messaging relays
|
|
||||||
pub fn members_with_relays(&self, cx: &App) -> Task<Vec<(PublicKey, Vec<RelayUrl>)>> {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let mut tasks = vec![];
|
|
||||||
|
|
||||||
for member in self.members.iter() {
|
|
||||||
let task = nostr.read(cx).messaging_relays(member, cx);
|
|
||||||
tasks.push((*member, task));
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let mut results = vec![];
|
|
||||||
|
|
||||||
for (public_key, task) in tasks.into_iter() {
|
|
||||||
let urls = task.await;
|
|
||||||
results.push((public_key, urls));
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if the room has more than two members (group)
|
/// Checks if the room has more than two members (group)
|
||||||
pub fn is_group(&self) -> bool {
|
pub fn is_group(&self) -> bool {
|
||||||
self.members.len() > 2
|
self.members.len() > 2
|
||||||
@@ -266,17 +266,7 @@ impl Room {
|
|||||||
/// Display member is always different from the current user.
|
/// Display member is always different from the current user.
|
||||||
pub fn display_member(&self, cx: &App) -> Person {
|
pub fn display_member(&self, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
persons.read(cx).get(&self.members[0], cx)
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
|
|
||||||
let target_member = self
|
|
||||||
.members
|
|
||||||
.iter()
|
|
||||||
.find(|&member| member != &public_key)
|
|
||||||
.or_else(|| self.members.first())
|
|
||||||
.expect("Room should have at least one member");
|
|
||||||
|
|
||||||
persons.read(cx).get(target_member, cx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge the names of the first two members of the room.
|
/// Merge the names of the first two members of the room.
|
||||||
@@ -297,7 +287,7 @@ impl Room {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
if profiles.len() > 2 {
|
if profiles.len() > 3 {
|
||||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,9 +297,21 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits a new message signal to the current room
|
/// Push a new message to the current room
|
||||||
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) {
|
pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
|
let created_at = message.rumor.created_at;
|
||||||
|
let new_message = created_at > self.created_at;
|
||||||
|
|
||||||
|
// Emit the incoming message event
|
||||||
cx.emit(RoomEvent::Incoming(message));
|
cx.emit(RoomEvent::Incoming(message));
|
||||||
|
|
||||||
|
if new_message {
|
||||||
|
self.set_created_at(created_at, cx);
|
||||||
|
// Sort chats after emitting a new message
|
||||||
|
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||||
|
this.sort(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits a signal to reload the current room's messages.
|
/// Emits a signal to reload the current room's messages.
|
||||||
@@ -325,7 +327,7 @@ impl Room {
|
|||||||
let id = SubscriptionId::new(format!("room-{}", self.id));
|
let id = SubscriptionId::new(format!("room-{}", self.id));
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Subscription options
|
// Subscription options
|
||||||
@@ -343,7 +345,9 @@ impl Room {
|
|||||||
|
|
||||||
// Subscribe to get member's gossip relays
|
// Subscribe to get member's gossip relays
|
||||||
client
|
client
|
||||||
.subscribe_with_id(id.clone(), filter, Some(opts))
|
.subscribe(filter)
|
||||||
|
.close_on(opts)
|
||||||
|
.with_id(id.clone())
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,68 +379,78 @@ impl Room {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new message event (unsigned)
|
/// Create a new unsigned message event
|
||||||
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
|
pub fn create_message(
|
||||||
|
&self,
|
||||||
|
content: &str,
|
||||||
|
replies: Vec<EventId>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<UnsignedEvent, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
// Get current user
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
|
|
||||||
// Get room's subject
|
|
||||||
let subject = self.subject.clone();
|
let subject = self.subject.clone();
|
||||||
|
let content = content.to_string();
|
||||||
|
|
||||||
let mut tags = vec![];
|
let mut member_and_relay_hints = HashMap::new();
|
||||||
|
|
||||||
// Add receivers
|
// Populate the hashmap with member and relay hint tasks
|
||||||
//
|
|
||||||
// NOTE: current user will be removed from the list of receivers
|
|
||||||
for member in self.members.iter() {
|
for member in self.members.iter() {
|
||||||
// Get relay hint if available
|
let hint = nostr.read(cx).relay_hint(member, cx);
|
||||||
let relay_url = nostr.read(cx).relay_hint(member, cx);
|
member_and_relay_hints.insert(member.to_owned(), hint);
|
||||||
|
|
||||||
// Construct a public key tag with relay hint
|
|
||||||
let tag = TagStandard::PublicKey {
|
|
||||||
public_key: member.to_owned(),
|
|
||||||
relay_url,
|
|
||||||
alias: None,
|
|
||||||
uppercase: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
tags.push(Tag::from_standardized_without_cell(tag));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subject tag if it's present
|
cx.background_spawn(async move {
|
||||||
if let Some(value) = subject {
|
let signer = client.signer().context("Signer not found")?;
|
||||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
let public_key = signer.get_public_key().await?;
|
||||||
value.to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reply/quote tag
|
// List of event tags for each receiver
|
||||||
if replies.len() == 1 {
|
let mut tags = vec![];
|
||||||
tags.push(Tag::event(replies[0]))
|
|
||||||
} else {
|
for (member, task) in member_and_relay_hints.into_iter() {
|
||||||
for id in replies {
|
// Skip current user
|
||||||
let tag = TagStandard::Quote {
|
if member == public_key {
|
||||||
event_id: id.to_owned(),
|
continue;
|
||||||
relay_url: None,
|
}
|
||||||
public_key: None,
|
|
||||||
|
// Get relay hint if available
|
||||||
|
let relay_url = task.await;
|
||||||
|
|
||||||
|
// Construct a public key tag with relay hint
|
||||||
|
let tag = TagStandard::PublicKey {
|
||||||
|
public_key: member,
|
||||||
|
relay_url,
|
||||||
|
alias: None,
|
||||||
|
uppercase: false,
|
||||||
};
|
};
|
||||||
tags.push(Tag::from_standardized_without_cell(tag))
|
|
||||||
|
tags.push(Tag::from_standardized_without_cell(tag));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Construct a direct message event
|
// Add subject tag if present
|
||||||
//
|
if let Some(value) = subject {
|
||||||
// WARNING: never sign and send this event to relays
|
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
value.to_string(),
|
||||||
.tags(tags)
|
)));
|
||||||
.build(public_key);
|
}
|
||||||
|
|
||||||
// Ensure the event id has been generated
|
// Add all reply tags
|
||||||
event.ensure_id();
|
for id in replies {
|
||||||
|
tags.push(Tag::event(id))
|
||||||
|
}
|
||||||
|
|
||||||
event
|
// Construct a direct message event
|
||||||
|
//
|
||||||
|
// WARNING: never sign and send this event to relays
|
||||||
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||||
|
.tags(tags)
|
||||||
|
.build(Keys::generate().public_key());
|
||||||
|
|
||||||
|
// Ensure the event ID has been generated
|
||||||
|
event.ensure_id();
|
||||||
|
|
||||||
|
Ok(event)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to send a message to all room members
|
/// Create a task to send a message to all room members
|
||||||
@@ -448,46 +462,27 @@ impl Room {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
// Get current user's public key and relays
|
let mut members = self.members();
|
||||||
let current_user = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let current_user_relays = nostr.read(cx).messaging_relays(¤t_user, cx);
|
|
||||||
|
|
||||||
let rumor = rumor.to_owned();
|
let rumor = rumor.to_owned();
|
||||||
|
|
||||||
// Get all members and their messaging relays
|
|
||||||
let task = self.members_with_relays(cx);
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let current_user_relays = current_user_relays.await;
|
let current_user = signer.get_public_key().await?;
|
||||||
let mut members = task.await;
|
|
||||||
|
|
||||||
// Remove the current user's public key from the list of receivers
|
// Remove the current user's public key from the list of receivers
|
||||||
// the current user will be handled separately
|
// the current user will be handled separately
|
||||||
members.retain(|(this, _)| this != ¤t_user);
|
members.retain(|this| this != ¤t_user);
|
||||||
|
|
||||||
// Collect the send reports
|
// Collect the send reports
|
||||||
let mut reports: Vec<SendReport> = vec![];
|
let mut reports: Vec<SendReport> = vec![];
|
||||||
|
|
||||||
for (receiver, relays) in members.into_iter() {
|
for receiver in members.into_iter() {
|
||||||
// Check if there are any relays to send the message to
|
|
||||||
if relays.is_empty() {
|
|
||||||
reports.push(SendReport::new(receiver).relays_not_found());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure relay connection
|
|
||||||
for url in relays.iter() {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
client.connect_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the gift wrap event
|
// Construct the gift wrap event
|
||||||
let event =
|
let event =
|
||||||
EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?;
|
EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
|
||||||
|
|
||||||
// Send the gift wrap event to the messaging relays
|
// Send the gift wrap event to the messaging relays
|
||||||
match client.send_event_to(relays, &event).await {
|
match client.send_event(&event).to_nip17().await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let id = output.id().to_owned();
|
let id = output.id().to_owned();
|
||||||
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
||||||
@@ -525,24 +520,12 @@ impl Room {
|
|||||||
|
|
||||||
// Construct the gift-wrapped event
|
// Construct the gift-wrapped event
|
||||||
let event =
|
let event =
|
||||||
EventBuilder::gift_wrap(&signer, ¤t_user, rumor.clone(), vec![]).await?;
|
EventBuilder::gift_wrap(signer, ¤t_user, rumor.clone(), vec![]).await?;
|
||||||
|
|
||||||
// Only send a backup message to current user if sent successfully to others
|
// Only send a backup message to current user if sent successfully to others
|
||||||
if reports.iter().all(|r| r.is_sent_success()) {
|
if reports.iter().all(|r| r.is_sent_success()) {
|
||||||
// Check if there are any relays to send the event to
|
|
||||||
if current_user_relays.is_empty() {
|
|
||||||
reports.push(SendReport::new(current_user).relays_not_found());
|
|
||||||
return Ok(reports);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure relay connection
|
|
||||||
for url in current_user_relays.iter() {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
client.connect_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the event to the messaging relays
|
// Send the event to the messaging relays
|
||||||
match client.send_event_to(current_user_relays, &event).await {
|
match client.send_event(&event).to_nip17().await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
reports.push(SendReport::new(current_user).status(output));
|
reports.push(SendReport::new(current_user).status(output));
|
||||||
}
|
}
|
||||||
@@ -580,7 +563,7 @@ impl Room {
|
|||||||
|
|
||||||
if let Some(event) = client.database().event_by_id(id).await? {
|
if let Some(event) = client.database().event_by_id(id).await? {
|
||||||
for url in urls.into_iter() {
|
for url in urls.into_iter() {
|
||||||
let relay = client.pool().relay(url).await?;
|
let relay = client.relay(url).await?.context("Relay not found")?;
|
||||||
let id = relay.send_event(&event).await?;
|
let id = relay.send_event(&event).await?;
|
||||||
|
|
||||||
let resent: Output<EventId> = Output {
|
let resent: Output<EventId> = Output {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
|
use anyhow::Error;
|
||||||
use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport};
|
use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport};
|
||||||
use common::{nip96_upload, RenderedTimestamp};
|
use common::{nip96_upload, RenderedTimestamp};
|
||||||
use dock::panel::{Panel, PanelEvent};
|
use dock::panel::{Panel, PanelEvent};
|
||||||
@@ -244,27 +244,21 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current room entity
|
|
||||||
let Some(room) = self.room.upgrade().map(|this| this.read(cx)) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get replies_to if it's present
|
// Get replies_to if it's present
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Create a temporary message for optimistic update
|
// Get a task to create temporary message for optimistic update
|
||||||
let rumor = room.create_message(&content, replies.as_ref(), cx);
|
let Ok(get_rumor) = self
|
||||||
let rumor_id = rumor.id.unwrap();
|
.room
|
||||||
|
.read_with(cx, |this, cx| this.create_message(&content, replies, cx))
|
||||||
// Create a task for sending the message in the background
|
else {
|
||||||
let send_message = room.send_message(&rumor, cx);
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Optimistically update message list
|
// Optimistically update message list
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
// Wait for the delay
|
let mut rumor = get_rumor.await?;
|
||||||
cx.background_executor()
|
let rumor_id = rumor.id();
|
||||||
.timer(Duration::from_millis(100))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Update the message list and reset the states
|
// Update the message list and reset the states
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
@@ -280,43 +274,50 @@ impl ChatPanel {
|
|||||||
|
|
||||||
// Update the message list
|
// Update the message list
|
||||||
this.insert_message(&rumor, true, cx);
|
this.insert_message(&rumor, true, cx);
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
if let Ok(task) = this
|
||||||
let result = send_message.await;
|
.room
|
||||||
|
.read_with(cx, |this, cx| this.send_message(&rumor, cx))
|
||||||
|
{
|
||||||
|
this.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(reports) => {
|
Ok(reports) => {
|
||||||
// Update room's status
|
// Update room's status
|
||||||
this.room
|
this.room
|
||||||
.update(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
if this.kind != RoomKind::Ongoing {
|
if this.kind != RoomKind::Ongoing {
|
||||||
// Update the room kind to ongoing,
|
// Update the room kind to ongoing,
|
||||||
// but keep the room kind if send failed
|
// but keep the room kind if send failed
|
||||||
if reports.iter().all(|r| !r.is_sent_success()) {
|
if reports.iter().all(|r| !r.is_sent_success()) {
|
||||||
this.kind = RoomKind::Ongoing;
|
this.kind = RoomKind::Ongoing;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Insert the sent reports
|
||||||
|
this.reports_by_id.insert(rumor_id, reports);
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
})
|
Err(e) => {
|
||||||
.ok();
|
window.push_notification(e.to_string(), cx);
|
||||||
|
}
|
||||||
// Insert the sent reports
|
}
|
||||||
this.reports_by_id.insert(rumor_id, reports);
|
})
|
||||||
|
.ok();
|
||||||
cx.notify();
|
}))
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}));
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a message into the chat panel
|
/// Insert a message into the chat panel
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
nostr.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@@ -19,5 +20,3 @@ log.workspace = true
|
|||||||
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
qrcode = "0.14.1"
|
qrcode = "0.14.1"
|
||||||
whoami = "1.6.1"
|
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -12,44 +12,6 @@ const SECONDS_IN_MINUTE: i64 = 60;
|
|||||||
const MINUTES_IN_HOUR: i64 = 60;
|
const MINUTES_IN_HOUR: i64 = 60;
|
||||||
const HOURS_IN_DAY: i64 = 24;
|
const HOURS_IN_DAY: i64 = 24;
|
||||||
const DAYS_IN_MONTH: i64 = 30;
|
const DAYS_IN_MONTH: i64 = 30;
|
||||||
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
|
||||||
|
|
||||||
pub trait RenderedProfile {
|
|
||||||
fn avatar(&self) -> SharedString;
|
|
||||||
fn display_name(&self) -> SharedString;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderedProfile for Profile {
|
|
||||||
fn avatar(&self) -> SharedString {
|
|
||||||
self.metadata()
|
|
||||||
.picture
|
|
||||||
.as_ref()
|
|
||||||
.filter(|picture| !picture.is_empty())
|
|
||||||
.map(|picture| {
|
|
||||||
let url = format!(
|
|
||||||
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
|
||||||
);
|
|
||||||
url.into()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_name(&self) -> SharedString {
|
|
||||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
|
||||||
if !display_name.is_empty() {
|
|
||||||
return SharedString::from(display_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = self.metadata().name.as_ref() {
|
|
||||||
if !name.is_empty() {
|
|
||||||
return SharedString::from(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait RenderedTimestamp {
|
pub trait RenderedTimestamp {
|
||||||
fn to_human_time(&self) -> SharedString;
|
fn to_human_time(&self) -> SharedString;
|
||||||
|
|||||||
@@ -1,66 +1,11 @@
|
|||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
pub use constants::*;
|
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
pub use nip96::*;
|
pub use nip96::*;
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
|
|
||||||
mod constants;
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
mod nip96;
|
mod nip96;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
|
||||||
static APP_NAME: OnceLock<String> = OnceLock::new();
|
|
||||||
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
|
|
||||||
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Get the app name
|
|
||||||
pub fn app_name() -> &'static String {
|
|
||||||
APP_NAME.get_or_init(|| {
|
|
||||||
let devicename = whoami::devicename();
|
|
||||||
let platform = whoami::platform();
|
|
||||||
|
|
||||||
format!("{CLIENT_NAME} on {platform} ({devicename})")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default NIP-65 Relays. Used for new account
|
|
||||||
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
|
|
||||||
NIP65_RELAYS.get_or_init(|| {
|
|
||||||
vec![
|
|
||||||
(
|
|
||||||
RelayUrl::parse("wss://nostr.mom").unwrap(),
|
|
||||||
Some(RelayMetadata::Read),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(),
|
|
||||||
Some(RelayMetadata::Read),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
RelayUrl::parse("wss://nos.lol").unwrap(),
|
|
||||||
Some(RelayMetadata::Write),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
RelayUrl::parse("wss://relay.snort.social").unwrap(),
|
|
||||||
Some(RelayMetadata::Write),
|
|
||||||
),
|
|
||||||
(RelayUrl::parse("wss://relay.primal.net").unwrap(), None),
|
|
||||||
(RelayUrl::parse("wss://relay.damus.io").unwrap(), None),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default NIP-17 Relays. Used for new account
|
|
||||||
pub fn default_nip17_relays() -> &'static Vec<RelayUrl> {
|
|
||||||
NIP17_RELAYS.get_or_init(|| {
|
|
||||||
vec![
|
|
||||||
RelayUrl::parse("wss://nip17.com").unwrap(),
|
|
||||||
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -72,11 +72,10 @@ pub async fn nip96_upload(
|
|||||||
let json: Value = res.json().await?;
|
let json: Value = res.json().await?;
|
||||||
|
|
||||||
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
||||||
let signer = if client.has_signer().await {
|
let signer = client
|
||||||
client.signer().await?
|
.signer()
|
||||||
} else {
|
.cloned()
|
||||||
Keys::generate().into_nostr_signer()
|
.unwrap_or(Keys::generate().into_nostr_signer());
|
||||||
};
|
|
||||||
|
|
||||||
let url = upload(&signer, &config, file, None).await?;
|
let url = upload(&signer, &config, file, None).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
]
|
|
||||||
);
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use common::{shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS};
|
use common::{shorten_pubkey, RenderedTimestamp};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
||||||
@@ -10,7 +11,7 @@ use gpui::{
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{NostrAddress, NostrRegistry};
|
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -21,61 +22,129 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<
|
|||||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Screening
|
||||||
pub struct Screening {
|
pub struct Screening {
|
||||||
profile: Person,
|
/// Public Key of the person being screened.
|
||||||
|
public_key: PublicKey,
|
||||||
|
|
||||||
|
/// Whether the person's address is verified.
|
||||||
verified: bool,
|
verified: bool,
|
||||||
|
|
||||||
|
/// Whether the person is followed by current user.
|
||||||
followed: bool,
|
followed: bool,
|
||||||
|
|
||||||
|
/// Last time the person was active.
|
||||||
last_active: Option<Timestamp>,
|
last_active: Option<Timestamp>,
|
||||||
mutual_contacts: Vec<Profile>,
|
|
||||||
_tasks: SmallVec<[Task<()>; 3]>,
|
/// All mutual contacts of the person being screened.
|
||||||
|
mutual_contacts: Vec<PublicKey>,
|
||||||
|
|
||||||
|
/// Async tasks
|
||||||
|
tasks: SmallVec<[Task<()>; 3]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screening {
|
impl Screening {
|
||||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let http_client = cx.http_client();
|
cx.defer_in(window, move |this, _window, cx| {
|
||||||
let nostr = NostrRegistry::global(cx);
|
this.check_contact(cx);
|
||||||
let client = nostr.read(cx).client();
|
this.check_wot(cx);
|
||||||
|
this.check_last_activity(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
this.verify_identifier(cx);
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
// Check WOT
|
|
||||||
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> = cx.background_spawn({
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
async move {
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let signer_pubkey = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
// Check if user is in contact list
|
|
||||||
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
|
||||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
|
||||||
|
|
||||||
// Check mutual contacts
|
|
||||||
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
|
||||||
let mut mutual_contacts = vec![];
|
|
||||||
|
|
||||||
if let Ok(events) = client.database().query(contact_list).await {
|
|
||||||
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
|
||||||
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
|
|
||||||
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
|
|
||||||
mutual_contacts.push(profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((followed, mutual_contacts))
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check the last activity
|
Self {
|
||||||
let activity_check = cx.background_spawn(async move {
|
public_key,
|
||||||
|
verified: false,
|
||||||
|
followed: false,
|
||||||
|
last_active: None,
|
||||||
|
mutual_contacts: vec![],
|
||||||
|
tasks: smallvec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_contact(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let public_key = self.public_key;
|
||||||
|
|
||||||
|
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let signer_pubkey = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Check if user is in contact list
|
||||||
|
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||||
|
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||||
|
|
||||||
|
Ok(followed)
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = task.await.unwrap_or(false);
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.followed = result;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let public_key = self.public_key;
|
||||||
|
|
||||||
|
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let signer_pubkey = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Check mutual contacts
|
||||||
|
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||||
|
let mut mutual_contacts = vec![];
|
||||||
|
|
||||||
|
if let Ok(events) = client.database().query(filter).await {
|
||||||
|
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||||
|
mutual_contacts.push(event.pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(mutual_contacts)
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(contacts) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.mutual_contacts = contacts;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to fetch mutual contacts: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let public_key = self.public_key;
|
||||||
|
|
||||||
|
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().author(public_key).limit(1);
|
let filter = Filter::new().author(public_key).limit(1);
|
||||||
let mut activity: Option<Timestamp> = None;
|
let mut activity: Option<Timestamp> = None;
|
||||||
|
|
||||||
|
// Construct target for subscription
|
||||||
|
let target = BOOTSTRAP_RELAYS
|
||||||
|
.into_iter()
|
||||||
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
if let Ok(mut stream) = client
|
if let Ok(mut stream) = client
|
||||||
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2))
|
.stream_events(target)
|
||||||
|
.timeout(Duration::from_secs(TIMEOUT))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
while let Some((_url, event)) = stream.next().await {
|
while let Some((_url, event)) = stream.next().await {
|
||||||
@@ -88,91 +157,74 @@ impl Screening {
|
|||||||
activity
|
activity
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify the NIP05 address if available
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let addr_check = profile.metadata().nip05.and_then(|address| {
|
let result = task.await;
|
||||||
Nip05Address::parse(&address).ok().map(|addr| {
|
|
||||||
cx.background_spawn(async move { addr.verify(&http_client, &public_key).await })
|
this.update(cx, |this, cx| {
|
||||||
|
this.last_active = result;
|
||||||
|
cx.notify();
|
||||||
})
|
})
|
||||||
});
|
.ok();
|
||||||
|
}));
|
||||||
tasks.push(
|
|
||||||
// Run the contact check in the background
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
if let Ok((followed, mutual_contacts)) = contact_check.await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.followed = followed;
|
|
||||||
this.mutual_contacts = mutual_contacts;
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Run the activity check in the background
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let active = activity_check.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.last_active = active;
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Run the NIP-05 verification in the background
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
if let Some(task) = addr_check {
|
|
||||||
if let Ok(verified) = task.await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.verified = verified;
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
profile,
|
|
||||||
verified: false,
|
|
||||||
followed: false,
|
|
||||||
last_active: None,
|
|
||||||
mutual_contacts: vec![],
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn address(&self, _cx: &Context<Self>) -> Option<String> {
|
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
|
||||||
self.profile.metadata().nip05
|
let http_client = cx.http_client();
|
||||||
|
let public_key = self.public_key;
|
||||||
|
|
||||||
|
// Skip if the user doesn't have a NIP-05 identifier
|
||||||
|
let Some(address) = self.address(cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let task: Task<Result<bool, Error>> =
|
||||||
|
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = task.await.unwrap_or(false);
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.verified = result;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
|
fn profile(&self, cx: &Context<Self>) -> Person {
|
||||||
let Ok(bech32) = self.profile.public_key().to_bech32();
|
let persons = PersonRegistry::global(cx);
|
||||||
|
persons.read(cx).get(&self.public_key, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
|
||||||
|
self.profile(cx)
|
||||||
|
.metadata()
|
||||||
|
.nip05
|
||||||
|
.and_then(|addr| Nip05Address::parse(&addr).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
|
||||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let public_key = self.profile.public_key();
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
|
||||||
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||||
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?;
|
let builder = EventBuilder::report(vec![tag], "");
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Send the report to the public relays
|
// Send the report to the public relays
|
||||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |_, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
||||||
if task.await.is_ok() {
|
if task.await.is_ok() {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.close_modal(cx);
|
window.close_modal(cx);
|
||||||
@@ -180,8 +232,7 @@ impl Screening {
|
|||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
})
|
}));
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -194,25 +245,27 @@ impl Screening {
|
|||||||
this.title(SharedString::from("Mutual contacts")).child(
|
this.title(SharedString::from("Mutual contacts")).child(
|
||||||
v_flex().gap_1().pb_4().child(
|
v_flex().gap_1().pb_4().child(
|
||||||
uniform_list("contacts", total, move |range, _window, cx| {
|
uniform_list("contacts", total, move |range, _window, cx| {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
let mut items = Vec::with_capacity(total);
|
let mut items = Vec::with_capacity(total);
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
if let Some(contact) = contacts.get(ix) {
|
let Some(contact) = contacts.get(ix) else {
|
||||||
items.push(
|
continue;
|
||||||
h_flex()
|
};
|
||||||
.h_11()
|
let profile = persons.read(cx).get(contact, cx);
|
||||||
.w_full()
|
|
||||||
.px_2()
|
items.push(
|
||||||
.gap_1p5()
|
h_flex()
|
||||||
.rounded(cx.theme().radius)
|
.h_11()
|
||||||
.text_sm()
|
.w_full()
|
||||||
.hover(|this| {
|
.px_2()
|
||||||
this.bg(cx.theme().elevated_surface_background)
|
.gap_1p5()
|
||||||
})
|
.rounded(cx.theme().radius)
|
||||||
.child(Avatar::new(contact.avatar()).size(rems(1.75)))
|
.text_sm()
|
||||||
.child(contact.display_name()),
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
);
|
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
|
||||||
}
|
.child(profile.name()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
@@ -226,7 +279,9 @@ impl Screening {
|
|||||||
|
|
||||||
impl Render for Screening {
|
impl Render for Screening {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
|
let profile = self.profile(cx);
|
||||||
|
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
||||||
|
|
||||||
let total_mutuals = self.mutual_contacts.len();
|
let total_mutuals = self.mutual_contacts.len();
|
||||||
let last_active = self.last_active.map(|_| true);
|
let last_active = self.last_active.map(|_| true);
|
||||||
|
|
||||||
@@ -238,12 +293,12 @@ impl Render for Screening {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.text_center()
|
.text_center()
|
||||||
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
|
.child(Avatar::new(profile.avatar()).size(rems(4.)))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(self.profile.name()),
|
.child(profile.name()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use assets::Assets;
|
use assets::Assets;
|
||||||
use common::{APP_ID, CLIENT_NAME};
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||||
SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
|
SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
|
||||||
WindowDecorations, WindowKind, WindowOptions,
|
WindowDecorations, WindowKind, WindowOptions,
|
||||||
};
|
};
|
||||||
|
use state::{APP_ID, CLIENT_NAME};
|
||||||
use ui::Root;
|
use ui::Root;
|
||||||
|
|
||||||
use crate::actions::Quit;
|
|
||||||
|
|
||||||
mod actions;
|
|
||||||
mod command_bar;
|
|
||||||
mod dialogs;
|
mod dialogs;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
|
actions!(coop, [Quit]);
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|||||||
@@ -29,6 +29,26 @@ impl GreeterPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
if let Some(public_key) = signer.public_key() {
|
||||||
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
Workspace::add_panel(
|
||||||
|
profile::init(public_key, window, cx),
|
||||||
|
DockPlacement::Center,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for GreeterPanel {
|
impl Panel for GreeterPanel {
|
||||||
@@ -62,12 +82,13 @@ impl Render for GreeterPanel {
|
|||||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let identity = nostr.read(cx).identity();
|
let nip65_state = nostr.read(cx).nip65_state();
|
||||||
|
let nip17_state = nostr.read(cx).nip17_state();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
let owned = signer.owned();
|
||||||
|
|
||||||
let relay_list_state = identity.read(cx).relay_list_state();
|
let required_actions = nip65_state.read(cx) == &RelayState::NotConfigured
|
||||||
let messaging_relay_state = identity.read(cx).messaging_relays_state();
|
|| nip17_state.read(cx) == &RelayState::NotConfigured;
|
||||||
let required_actions =
|
|
||||||
relay_list_state == RelayState::NotSet || messaging_relay_state == RelayState::NotSet;
|
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -128,14 +149,14 @@ impl Render for GreeterPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.when(relay_list_state == RelayState::NotSet, |this| {
|
.when(nip65_state.read(cx).not_configured(), |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("relaylist")
|
Button::new("relaylist")
|
||||||
.icon(Icon::new(IconName::Relay))
|
.icon(Icon::new(IconName::Relay))
|
||||||
.label("Set up relay list")
|
.label("Set up relay list")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.no_center()
|
.justify_start()
|
||||||
.on_click(move |_ev, window, cx| {
|
.on_click(move |_ev, window, cx| {
|
||||||
Workspace::add_panel(
|
Workspace::add_panel(
|
||||||
relay_list::init(window, cx),
|
relay_list::init(window, cx),
|
||||||
@@ -146,31 +167,28 @@ impl Render for GreeterPanel {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(
|
.when(nip17_state.read(cx).not_configured(), |this| {
|
||||||
messaging_relay_state == RelayState::NotSet,
|
this.child(
|
||||||
|this| {
|
Button::new("import")
|
||||||
this.child(
|
.icon(Icon::new(IconName::Relay))
|
||||||
Button::new("import")
|
.label("Set up messaging relays")
|
||||||
.icon(Icon::new(IconName::Relay))
|
.ghost()
|
||||||
.label("Set up messaging relays")
|
.small()
|
||||||
.ghost()
|
.justify_start()
|
||||||
.small()
|
.on_click(move |_ev, window, cx| {
|
||||||
.no_center()
|
Workspace::add_panel(
|
||||||
.on_click(move |_ev, window, cx| {
|
messaging_relays::init(window, cx),
|
||||||
Workspace::add_panel(
|
DockPlacement::Center,
|
||||||
messaging_relays::init(window, cx),
|
window,
|
||||||
DockPlacement::Center,
|
cx,
|
||||||
window,
|
);
|
||||||
cx,
|
}),
|
||||||
);
|
)
|
||||||
}),
|
}),
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!identity.read(cx).owned, |this| {
|
.when(!owned, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -195,7 +213,7 @@ impl Render for GreeterPanel {
|
|||||||
.label("Connect account via Nostr Connect")
|
.label("Connect account via Nostr Connect")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.no_center()
|
.justify_start()
|
||||||
.on_click(move |_ev, window, cx| {
|
.on_click(move |_ev, window, cx| {
|
||||||
Workspace::add_panel(
|
Workspace::add_panel(
|
||||||
connect::init(window, cx),
|
connect::init(window, cx),
|
||||||
@@ -211,7 +229,7 @@ impl Render for GreeterPanel {
|
|||||||
.label("Import a secret key or bunker")
|
.label("Import a secret key or bunker")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.no_center()
|
.justify_start()
|
||||||
.on_click(move |_ev, window, cx| {
|
.on_click(move |_ev, window, cx| {
|
||||||
Workspace::add_panel(
|
Workspace::add_panel(
|
||||||
import::init(window, cx),
|
import::init(window, cx),
|
||||||
@@ -248,7 +266,7 @@ impl Render for GreeterPanel {
|
|||||||
.label("Backup account")
|
.label("Backup account")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.no_center(),
|
.justify_start(),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("profile")
|
Button::new("profile")
|
||||||
@@ -256,15 +274,10 @@ impl Render for GreeterPanel {
|
|||||||
.label("Update profile")
|
.label("Update profile")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.no_center()
|
.justify_start()
|
||||||
.on_click(move |_ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
Workspace::add_panel(
|
this.add_profile_panel(window, cx)
|
||||||
profile::init(window, cx),
|
})),
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("invite")
|
Button::new("invite")
|
||||||
@@ -272,7 +285,7 @@ impl Render for GreeterPanel {
|
|||||||
.label("Invite friends")
|
.label("Invite friends")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.no_center(),
|
.justify_start(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use dock::panel::{Panel, PanelEvent};
|
use dock::panel::{Panel, PanelEvent};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -89,7 +89,7 @@ impl MessagingRelayPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -156,32 +156,20 @@ impl MessagingRelayPanel {
|
|||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let tags: Vec<Tag> = self
|
||||||
let relays = self.relays.clone();
|
.relays
|
||||||
|
.iter()
|
||||||
|
.map(|relay| Tag::relay(relay.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
// Construct nip17 event builder
|
||||||
let signer = client.signer().await?;
|
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
let tags: Vec<Tag> = relays
|
|
||||||
.iter()
|
|
||||||
.map(|relay| Tag::relay(relay.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
|
||||||
.tags(tags)
|
|
||||||
.sign(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set messaging relays
|
// Set messaging relays
|
||||||
client.send_event_to(urls, &event).await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
|
|
||||||
// Connect to messaging relays
|
|
||||||
for relay in relays.iter() {
|
|
||||||
client.add_relay(relay).await.ok();
|
|
||||||
client.connect_relay(relay).await.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use ui::input::{InputState, TextInput};
|
|||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
||||||
cx.new(|cx| ProfilePanel::new(window, cx))
|
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -31,6 +31,9 @@ pub struct ProfilePanel {
|
|||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// User's public key
|
||||||
|
public_key: PublicKey,
|
||||||
|
|
||||||
/// User's name text input
|
/// User's name text input
|
||||||
name_input: Entity<InputState>,
|
name_input: Entity<InputState>,
|
||||||
|
|
||||||
@@ -51,13 +54,10 @@ pub struct ProfilePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProfilePanel {
|
impl ProfilePanel {
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||||
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
||||||
|
|
||||||
// Hidden input for avatar url
|
|
||||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||||
|
|
||||||
// Use multi-line input for bio
|
// Use multi-line input for bio
|
||||||
let bio_input = cx.new(|cx| {
|
let bio_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
@@ -66,13 +66,10 @@ impl ProfilePanel {
|
|||||||
.placeholder("A short introduce about you.")
|
.placeholder("A short introduce about you.")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get user's profile and update inputs
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
|
|
||||||
// Set all input's values with current profile
|
// Set all input's values with current profile
|
||||||
this.set_profile(profile, window, cx);
|
this.set_profile(profile, window, cx);
|
||||||
});
|
});
|
||||||
@@ -80,6 +77,7 @@ impl ProfilePanel {
|
|||||||
Self {
|
Self {
|
||||||
name: "Update Profile".into(),
|
name: "Update Profile".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
public_key,
|
||||||
name_input,
|
name_input,
|
||||||
avatar_input,
|
avatar_input,
|
||||||
bio_input,
|
bio_input,
|
||||||
@@ -209,7 +207,7 @@ impl ProfilePanel {
|
|||||||
|
|
||||||
fn set_metadata(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn set_metadata(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
let public_key = self.public_key;
|
||||||
|
|
||||||
// Get the old metadata
|
// Get the old metadata
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -289,9 +287,7 @@ impl Focusable for ProfilePanel {
|
|||||||
|
|
||||||
impl Render for ProfilePanel {
|
impl Render for ProfilePanel {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let shorten_pkey = SharedString::from(shorten_pubkey(public_key, 8));
|
|
||||||
|
|
||||||
// Get the avatar
|
// Get the avatar
|
||||||
let avatar_input = self.avatar_input.read(cx).value();
|
let avatar_input = self.avatar_input.read(cx).value();
|
||||||
@@ -390,7 +386,7 @@ impl Render for ProfilePanel {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.copy(
|
this.copy(
|
||||||
public_key.to_bech32().unwrap(),
|
this.public_key.to_bech32().unwrap(),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use common::BOOTSTRAP_RELAYS;
|
|
||||||
use dock::panel::{Panel, PanelEvent};
|
use dock::panel::{Panel, PanelEvent};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -12,7 +11,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, BOOTSTRAP_RELAYS};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
@@ -96,7 +95,7 @@ impl RelayListPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
|
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -167,11 +166,11 @@ impl RelayListPanel {
|
|||||||
let relays = self.relays.clone();
|
let relays = self.relays.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
let builder = EventBuilder::relay_list(relays);
|
||||||
let event = EventBuilder::relay_list(relays).sign(&signer).await?;
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Set relay list for current user
|
// Set relay list for current user
|
||||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,23 +14,24 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::context_menu::ContextMenuExt;
|
use ui::context_menu::ContextMenuExt;
|
||||||
use ui::modal::ModalButtonProps;
|
use ui::modal::ModalButtonProps;
|
||||||
use ui::{h_flex, StyledExt, WindowExtension};
|
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
use crate::dialogs::screening;
|
use crate::dialogs::screening;
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct RoomListItem {
|
pub struct RoomEntry {
|
||||||
ix: usize,
|
ix: usize,
|
||||||
public_key: Option<PublicKey>,
|
public_key: Option<PublicKey>,
|
||||||
name: Option<SharedString>,
|
name: Option<SharedString>,
|
||||||
avatar: Option<SharedString>,
|
avatar: Option<SharedString>,
|
||||||
created_at: Option<SharedString>,
|
created_at: Option<SharedString>,
|
||||||
kind: Option<RoomKind>,
|
kind: Option<RoomKind>,
|
||||||
|
selected: bool,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomListItem {
|
impl RoomEntry {
|
||||||
pub fn new(ix: usize) -> Self {
|
pub fn new(ix: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ix,
|
ix,
|
||||||
@@ -40,6 +41,7 @@ impl RoomListItem {
|
|||||||
created_at: None,
|
created_at: None,
|
||||||
kind: None,
|
kind: None,
|
||||||
handler: None,
|
handler: None,
|
||||||
|
selected: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +79,25 @@ impl RoomListItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for RoomListItem {
|
impl Selectable for RoomEntry {
|
||||||
|
fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for RoomEntry {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let screening = AppSettings::get_screening(cx);
|
let screening = AppSettings::get_screening(cx);
|
||||||
|
|
||||||
|
let public_key = self.public_key;
|
||||||
|
let is_selected = self.is_selected();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(self.ix)
|
.id(self.ix)
|
||||||
.h_9()
|
.h_9()
|
||||||
@@ -110,13 +126,21 @@ impl RenderOnce for RoomListItem {
|
|||||||
.justify_between()
|
.justify_between()
|
||||||
.when_some(self.name, |this, name| {
|
.when_some(self.name, |this, name| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
h_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
.justify_between()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.truncate()
|
.truncate()
|
||||||
.font_medium()
|
.font_medium()
|
||||||
.child(name),
|
.child(name)
|
||||||
|
.when(is_selected, |this| {
|
||||||
|
this.child(
|
||||||
|
Icon::new(IconName::CheckCircle)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().icon_accent),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -129,15 +153,17 @@ impl RenderOnce for RoomListItem {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.when_some(self.public_key, |this, public_key| {
|
.when_some(public_key, |this, public_key| {
|
||||||
this.context_menu(move |this, _window, _cx| {
|
this.context_menu(move |this, _window, _cx| {
|
||||||
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
|
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
|
||||||
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
|
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
|
||||||
})
|
})
|
||||||
.when_some(self.handler, |this, handler| {
|
})
|
||||||
this.on_click(move |event, window, cx| {
|
.when_some(self.handler, |this, handler| {
|
||||||
handler(event, window, cx);
|
this.on_click(move |event, window, cx| {
|
||||||
|
handler(event, window, cx);
|
||||||
|
|
||||||
|
if let Some(public_key) = public_key {
|
||||||
if self.kind != Some(RoomKind::Ongoing) && screening {
|
if self.kind != Some(RoomKind::Ongoing) && screening {
|
||||||
let screening = screening::init(public_key, window, cx);
|
let screening = screening::init(public_key, window, cx);
|
||||||
|
|
||||||
@@ -152,12 +178,12 @@ impl RenderOnce for RoomListItem {
|
|||||||
.on_cancel(move |_event, window, cx| {
|
.on_cancel(move |_event, window, cx| {
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||||
// Prevent closing the modal on click
|
// Prevent closing the modal on click
|
||||||
// Modal will be automatically closed after closing panel
|
// modal will be automatically closed after closing panel
|
||||||
false
|
false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,35 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use chat::{ChatEvent, ChatRegistry, RoomKind};
|
use anyhow::Error;
|
||||||
use common::RenderedTimestamp;
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
|
use common::{DebouncedDelay, RenderedTimestamp};
|
||||||
use dock::panel::{Panel, PanelEvent};
|
use dock::panel::{Panel, PanelEvent};
|
||||||
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
deferred, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled,
|
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
|
||||||
Subscription, Window,
|
Task, Window,
|
||||||
};
|
};
|
||||||
use list_item::RoomListItem;
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{NostrRegistry, FIND_DELAY};
|
||||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::divider::Divider;
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::{h_flex, v_flex, IconName, Selectable, Sizable, StyledExt};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{
|
||||||
|
h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
|
||||||
|
};
|
||||||
|
|
||||||
mod list_item;
|
mod entry;
|
||||||
|
|
||||||
|
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||||
cx.new(|cx| Sidebar::new(window, cx))
|
cx.new(|cx| Sidebar::new(window, cx))
|
||||||
@@ -30,12 +43,42 @@ pub struct Sidebar {
|
|||||||
/// Image cache
|
/// Image cache
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
|
|
||||||
|
/// Find input state
|
||||||
|
find_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Debounced delay for find input
|
||||||
|
find_debouncer: DebouncedDelay<Self>,
|
||||||
|
|
||||||
|
/// Whether a search is in progress
|
||||||
|
finding: bool,
|
||||||
|
|
||||||
|
/// Whether the find input is focused
|
||||||
|
find_focused: bool,
|
||||||
|
|
||||||
|
/// Find results
|
||||||
|
find_results: Entity<Option<Vec<PublicKey>>>,
|
||||||
|
|
||||||
|
/// Async find operation
|
||||||
|
find_task: Option<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
|
/// Whether there are search results
|
||||||
|
has_search: bool,
|
||||||
|
|
||||||
/// Whether there are new chat requests
|
/// Whether there are new chat requests
|
||||||
new_requests: bool,
|
new_requests: bool,
|
||||||
|
|
||||||
|
/// Selected public keys
|
||||||
|
selected_pkeys: Entity<HashSet<PublicKey>>,
|
||||||
|
|
||||||
/// Chatroom filter
|
/// Chatroom filter
|
||||||
filter: Entity<RoomKind>,
|
filter: Entity<RoomKind>,
|
||||||
|
|
||||||
|
/// User's contacts
|
||||||
|
contact_list: Entity<Option<Vec<PublicKey>>>,
|
||||||
|
|
||||||
|
/// Async tasks
|
||||||
|
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
}
|
}
|
||||||
@@ -44,9 +87,49 @@ impl Sidebar {
|
|||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let filter = cx.new(|_| RoomKind::Ongoing);
|
let filter = cx.new(|_| RoomKind::Ongoing);
|
||||||
|
let contact_list = cx.new(|_| None);
|
||||||
|
let selected_pkeys = cx.new(|_| HashSet::new());
|
||||||
|
let find_results = cx.new(|_| None);
|
||||||
|
let find_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder(INPUT_PLACEHOLDER)
|
||||||
|
.clean_on_escape()
|
||||||
|
});
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to find input events
|
||||||
|
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
|
||||||
|
let delay = Duration::from_millis(FIND_DELAY);
|
||||||
|
|
||||||
|
match event {
|
||||||
|
InputEvent::PressEnter { .. } => {
|
||||||
|
this.search(window, cx);
|
||||||
|
}
|
||||||
|
InputEvent::Change => {
|
||||||
|
if state.read(cx).value().is_empty() {
|
||||||
|
// Clear results when input is empty
|
||||||
|
this.reset(window, cx);
|
||||||
|
} else {
|
||||||
|
// Run debounced search
|
||||||
|
this.find_debouncer
|
||||||
|
.fire_new(delay, window, cx, |this, window, cx| {
|
||||||
|
this.debounced_search(window, cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputEvent::Focus => {
|
||||||
|
this.set_input_focus(window, cx);
|
||||||
|
this.get_contact_list(window, cx);
|
||||||
|
}
|
||||||
|
InputEvent::Blur => {
|
||||||
|
this.set_input_focus(window, cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe for registry new events
|
// Subscribe for registry new events
|
||||||
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
||||||
@@ -61,12 +144,206 @@ impl Sidebar {
|
|||||||
name: "Sidebar".into(),
|
name: "Sidebar".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
image_cache: RetainAllImageCache::new(cx),
|
||||||
|
find_input,
|
||||||
|
find_debouncer: DebouncedDelay::new(),
|
||||||
|
find_results,
|
||||||
|
find_task: None,
|
||||||
|
find_focused: false,
|
||||||
|
finding: false,
|
||||||
|
has_search: false,
|
||||||
new_requests: false,
|
new_requests: false,
|
||||||
|
contact_list,
|
||||||
|
selected_pkeys,
|
||||||
filter,
|
filter,
|
||||||
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the contact list.
|
||||||
|
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let task = nostr.read(cx).get_contact_list(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(contacts) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_contact_list(contacts, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the contact list with new contacts.
|
||||||
|
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = PublicKey>,
|
||||||
|
{
|
||||||
|
self.contact_list.update(cx, |this, cx| {
|
||||||
|
*this = Some(contacts.into_iter().collect());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger the debounced search
|
||||||
|
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.search(window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search
|
||||||
|
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
// Return if a search is already in progress
|
||||||
|
if self.finding {
|
||||||
|
if self.find_task.is_none() {
|
||||||
|
window.push_notification("There is another search in progress", cx);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Cancel the ongoing search request
|
||||||
|
self.find_task = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query
|
||||||
|
let query = self.find_input.read(cx).value();
|
||||||
|
|
||||||
|
// Return if the query is empty
|
||||||
|
if query.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block the input until the search completes
|
||||||
|
self.set_finding(true, window, cx);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let find_users = nostr.read(cx).search(&query, cx);
|
||||||
|
|
||||||
|
// Run task in the main thread
|
||||||
|
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let rooms = find_users.await?;
|
||||||
|
// Update the UI with the search results
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_results(rooms, cx);
|
||||||
|
this.set_finding(false, window, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
|
self.find_results.update(cx, |this, cx| {
|
||||||
|
*this = Some(results);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
// Disable the input to prevent duplicate requests
|
||||||
|
self.find_input.update(cx, |this, cx| {
|
||||||
|
this.set_disabled(status, cx);
|
||||||
|
this.set_loading(status, cx);
|
||||||
|
});
|
||||||
|
// Set the search status
|
||||||
|
self.finding = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.find_focused = !self.find_focused;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
// Reset the find panel
|
||||||
|
if !self.find_focused {
|
||||||
|
self.reset(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
// Clear all search results
|
||||||
|
self.find_results.update(cx, |this, cx| {
|
||||||
|
*this = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all selected public keys
|
||||||
|
self.selected_pkeys.update(cx, |this, cx| {
|
||||||
|
this.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset the search status
|
||||||
|
self.set_finding(false, window, cx);
|
||||||
|
|
||||||
|
// Cancel the current search task
|
||||||
|
self.find_task = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a public key in the sidebar.
|
||||||
|
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||||
|
self.selected_pkeys.update(cx, |this, cx| {
|
||||||
|
if this.contains(public_key) {
|
||||||
|
this.remove(public_key);
|
||||||
|
} else {
|
||||||
|
this.insert(public_key.to_owned());
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a public key is selected in the sidebar.
|
||||||
|
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
|
||||||
|
self.selected_pkeys.read(cx).contains(public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all selected public keys in the sidebar.
|
||||||
|
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
|
||||||
|
self.selected_pkeys.read(cx).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new room
|
||||||
|
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let async_chat = chat.downgrade();
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
// Get all selected public keys
|
||||||
|
let receivers = self.get_selected(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Create a new room and emit it
|
||||||
|
async_chat.update_in(cx, |this, _window, cx| {
|
||||||
|
let room = cx.new(|_| Room::new(public_key, receivers).kind(RoomKind::Ongoing));
|
||||||
|
this.emit_room(&room, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Reset the find panel
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.reset(window, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the active filter.
|
/// Get the active filter.
|
||||||
fn current_filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
|
fn current_filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
|
||||||
self.filter.read(cx) == kind
|
self.filter.read(cx) == kind
|
||||||
@@ -92,15 +369,15 @@ impl Sidebar {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(ix, item)| {
|
.map(|(ix, item)| {
|
||||||
let room = item.read(cx);
|
let room = item.read(cx);
|
||||||
let weak_room = item.downgrade();
|
let room_clone = item.clone();
|
||||||
let public_key = room.display_member(cx).public_key();
|
let public_key = room.display_member(cx).public_key();
|
||||||
let handler = cx.listener(move |_this, _ev, _window, cx| {
|
let handler = cx.listener(move |_this, _ev, _window, cx| {
|
||||||
ChatRegistry::global(cx).update(cx, |s, cx| {
|
ChatRegistry::global(cx).update(cx, |s, cx| {
|
||||||
s.emit_room(weak_room.clone(), cx);
|
s.emit_room(&room_clone, cx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
RoomListItem::new(range.start + ix)
|
RoomEntry::new(range.start + ix)
|
||||||
.name(room.display_name(cx))
|
.name(room.display_name(cx))
|
||||||
.avatar(room.display_image(cx))
|
.avatar(room.display_image(cx))
|
||||||
.public_key(public_key)
|
.public_key(public_key)
|
||||||
@@ -111,6 +388,72 @@ impl Sidebar {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the contact list
|
||||||
|
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
|
// Get the contact list
|
||||||
|
let Some(results) = self.find_results.read(cx) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map the contact list to a list of elements
|
||||||
|
results
|
||||||
|
.get(range.clone())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, public_key)| {
|
||||||
|
let selected = self.is_selected(public_key, cx);
|
||||||
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
|
let pkey_clone = public_key.to_owned();
|
||||||
|
let handler = cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.select(&pkey_clone, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
RoomEntry::new(range.start + ix)
|
||||||
|
.name(profile.name())
|
||||||
|
.avatar(profile.avatar())
|
||||||
|
.on_click(handler)
|
||||||
|
.selected(selected)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the contact list
|
||||||
|
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
|
// Get the contact list
|
||||||
|
let Some(contacts) = self.contact_list.read(cx) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map the contact list to a list of elements
|
||||||
|
contacts
|
||||||
|
.get(range.clone())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, public_key)| {
|
||||||
|
let selected = self.is_selected(public_key, cx);
|
||||||
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
|
let pkey_clone = public_key.to_owned();
|
||||||
|
let handler = cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.select(&pkey_clone, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
RoomEntry::new(range.start + ix)
|
||||||
|
.name(profile.name())
|
||||||
|
.avatar(profile.avatar())
|
||||||
|
.on_click(handler)
|
||||||
|
.selected(selected)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for Sidebar {
|
impl Panel for Sidebar {
|
||||||
@@ -133,89 +476,124 @@ impl Render for Sidebar {
|
|||||||
let loading = chat.read(cx).loading();
|
let loading = chat.read(cx).loading();
|
||||||
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
|
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
|
||||||
|
|
||||||
|
// Whether the find panel should be shown
|
||||||
|
let show_find_panel = self.has_search || self.find_focused;
|
||||||
|
|
||||||
|
// Set button label based on total selected users
|
||||||
|
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
|
||||||
|
"Create Group DM"
|
||||||
|
} else {
|
||||||
|
"Create DM"
|
||||||
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(self.image_cache.clone())
|
.image_cache(self.image_cache.clone())
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.relative()
|
||||||
.gap_2()
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.w_full()
|
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
TextInput::new(&self.find_input)
|
||||||
.flex_1()
|
.appearance(false)
|
||||||
.h_full()
|
.bordered(false)
|
||||||
.gap_2()
|
.small()
|
||||||
.p_2()
|
.text_xs()
|
||||||
.justify_center()
|
.when(!self.find_input.read(cx).loading, |this| {
|
||||||
.child(
|
this.suffix(
|
||||||
Button::new("all")
|
Button::new("find-icon")
|
||||||
.map(|this| {
|
.icon(IconName::Search)
|
||||||
if self.current_filter(&RoomKind::Ongoing, cx) {
|
.tooltip("Press Enter to search")
|
||||||
this.icon(IconName::InboxFill)
|
.transparent()
|
||||||
} else {
|
.small(),
|
||||||
this.icon(IconName::Inbox)
|
)
|
||||||
}
|
}),
|
||||||
})
|
|
||||||
.label("Inbox")
|
|
||||||
.tooltip("All ongoing conversations")
|
|
||||||
.xsmall()
|
|
||||||
.bold()
|
|
||||||
.ghost()
|
|
||||||
.flex_1()
|
|
||||||
.rounded_none()
|
|
||||||
.selected(self.current_filter(&RoomKind::Ongoing, cx))
|
|
||||||
.on_click(cx.listener(|this, _, _, cx| {
|
|
||||||
this.set_filter(RoomKind::Ongoing, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("requests")
|
|
||||||
.map(|this| {
|
|
||||||
if self.current_filter(&RoomKind::Request, cx) {
|
|
||||||
this.icon(IconName::FistbumpFill)
|
|
||||||
} else {
|
|
||||||
this.icon(IconName::Fistbump)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.label("Requests")
|
|
||||||
.tooltip("Incoming new conversations")
|
|
||||||
.xsmall()
|
|
||||||
.bold()
|
|
||||||
.ghost()
|
|
||||||
.flex_1()
|
|
||||||
.rounded_none()
|
|
||||||
.selected(!self.current_filter(&RoomKind::Ongoing, cx))
|
|
||||||
.when(self.new_requests, |this| {
|
|
||||||
this.child(
|
|
||||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.on_click(cx.listener(|this, _, _, cx| {
|
|
||||||
this.set_filter(RoomKind::default(), cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.h_full()
|
|
||||||
.px_2()
|
|
||||||
.border_l_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.child(
|
|
||||||
Button::new("option")
|
|
||||||
.icon(IconName::Ellipsis)
|
|
||||||
.small()
|
|
||||||
.ghost(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when(!loading && total_rooms == 0, |this| {
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h(TABBAR_HEIGHT)
|
||||||
|
.justify_center()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.when(show_find_panel, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("search-results")
|
||||||
|
.icon(IconName::Search)
|
||||||
|
.label("Search")
|
||||||
|
.tooltip("All search results")
|
||||||
|
.small()
|
||||||
|
.underline()
|
||||||
|
.ghost()
|
||||||
|
.font_semibold()
|
||||||
|
.rounded_none()
|
||||||
|
.h_full()
|
||||||
|
.flex_1()
|
||||||
|
.selected(true),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new("all")
|
||||||
|
.map(|this| {
|
||||||
|
if self.current_filter(&RoomKind::Ongoing, cx) {
|
||||||
|
this.icon(IconName::InboxFill)
|
||||||
|
} else {
|
||||||
|
this.icon(IconName::Inbox)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.when(!show_find_panel, |this| this.label("Inbox"))
|
||||||
|
.tooltip("All ongoing conversations")
|
||||||
|
.small()
|
||||||
|
.underline()
|
||||||
|
.ghost()
|
||||||
|
.font_semibold()
|
||||||
|
.rounded_none()
|
||||||
|
.h_full()
|
||||||
|
.flex_1()
|
||||||
|
.disabled(show_find_panel)
|
||||||
|
.selected(
|
||||||
|
!show_find_panel && self.current_filter(&RoomKind::Ongoing, cx),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(|this, _ev, _window, cx| {
|
||||||
|
this.set_filter(RoomKind::Ongoing, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(Divider::vertical())
|
||||||
|
.child(
|
||||||
|
Button::new("requests")
|
||||||
|
.map(|this| {
|
||||||
|
if self.current_filter(&RoomKind::Request, cx) {
|
||||||
|
this.icon(IconName::FistbumpFill)
|
||||||
|
} else {
|
||||||
|
this.icon(IconName::Fistbump)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.when(!show_find_panel, |this| this.label("Requests"))
|
||||||
|
.tooltip("Incoming new conversations")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.underline()
|
||||||
|
.font_semibold()
|
||||||
|
.rounded_none()
|
||||||
|
.h_full()
|
||||||
|
.flex_1()
|
||||||
|
.disabled(show_find_panel)
|
||||||
|
.selected(
|
||||||
|
!show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx),
|
||||||
|
)
|
||||||
|
.when(self.new_requests, |this| {
|
||||||
|
this.child(div().size_1().rounded_full().bg(cx.theme().cursor))
|
||||||
|
})
|
||||||
|
.on_click(cx.listener(|this, _ev, _window, cx| {
|
||||||
|
this.set_filter(RoomKind::default(), cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div().px_2p5().child(deferred(
|
div().mt_2().px_2().child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.p_3()
|
.p_3()
|
||||||
.h_24()
|
.h_24()
|
||||||
@@ -238,47 +616,138 @@ impl Render for Sidebar {
|
|||||||
"Start a conversation with someone to get started.",
|
"Start a conversation with someone to get started.",
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
)),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.h_full()
|
||||||
.px_1p5()
|
.px_1p5()
|
||||||
.w_full()
|
.mt_2()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.overflow_y_hidden()
|
.overflow_y_hidden()
|
||||||
.child(
|
.when(show_find_panel, |this| {
|
||||||
uniform_list(
|
this.gap_3()
|
||||||
"rooms",
|
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
|
||||||
total_rooms,
|
this.child(
|
||||||
cx.processor(|this, range, _window, cx| {
|
v_flex()
|
||||||
this.render_list_items(range, cx)
|
.gap_1()
|
||||||
}),
|
.flex_1()
|
||||||
)
|
.border_b_1()
|
||||||
.h_full(),
|
.border_color(cx.theme().border_variant)
|
||||||
)
|
.child(
|
||||||
.when(loading, |this| {
|
h_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(Icon::new(IconName::ChevronDown))
|
||||||
|
.child(SharedString::from("Results")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
uniform_list(
|
||||||
|
"rooms",
|
||||||
|
results.len(),
|
||||||
|
cx.processor(|this, range, _window, cx| {
|
||||||
|
this.render_results(range, cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.flex_1()
|
||||||
|
.h_full(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.flex_1()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(Icon::new(IconName::ChevronDown))
|
||||||
|
.child(SharedString::from("Suggestions")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
uniform_list(
|
||||||
|
"contacts",
|
||||||
|
contacts.len(),
|
||||||
|
cx.processor(move |this, range, _window, cx| {
|
||||||
|
this.render_contacts(range, cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.flex_1()
|
||||||
|
.h_full(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when(!show_find_panel, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div().absolute().top_2().left_0().w_full().px_8().child(
|
uniform_list(
|
||||||
h_flex()
|
"rooms",
|
||||||
.gap_2()
|
total_rooms,
|
||||||
.w_full()
|
cx.processor(|this, range, _window, cx| {
|
||||||
.h_9()
|
this.render_list_items(range, cx)
|
||||||
.justify_center()
|
}),
|
||||||
.bg(cx.theme().background.opacity(0.85))
|
)
|
||||||
.border_color(cx.theme().border_disabled)
|
.flex_1()
|
||||||
.border_1()
|
.h_full(),
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
||||||
.rounded_full()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
|
||||||
.child(SharedString::from("Getting messages...")),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.bottom_0()
|
||||||
|
.left_0()
|
||||||
|
.h_9()
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.child(
|
||||||
|
Button::new("create")
|
||||||
|
.label(button_label)
|
||||||
|
.primary()
|
||||||
|
.small()
|
||||||
|
.shadow_lg()
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.create_room(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(loading, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.bottom_2()
|
||||||
|
.left_0()
|
||||||
|
.h_9()
|
||||||
|
.w_full()
|
||||||
|
.px_8()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.w_full()
|
||||||
|
.h_9()
|
||||||
|
.justify_center()
|
||||||
|
.bg(cx.theme().background.opacity(0.85))
|
||||||
|
.border_color(cx.theme().border_disabled)
|
||||||
|
.border_1()
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
|
.rounded_full()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
||||||
|
.child(SharedString::from("Getting messages...")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use dock::dock::DockPlacement;
|
use dock::dock::DockPlacement;
|
||||||
use dock::panel::PanelView;
|
use dock::panel::{PanelStyle, PanelView};
|
||||||
use dock::{ClosePanel, DockArea, DockItem};
|
use dock::{ClosePanel, DockArea, DockItem};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -11,13 +11,14 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, RelayState};
|
||||||
use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
|
||||||
use titlebar::TitleBar;
|
use titlebar::TitleBar;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::{h_flex, v_flex, Icon, IconName, Root, Sizable, WindowExtension};
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::popup_menu::PopupMenuExt;
|
||||||
|
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
|
||||||
|
|
||||||
use crate::command_bar::CommandBar;
|
|
||||||
use crate::panels::greeter;
|
use crate::panels::greeter;
|
||||||
use crate::sidebar;
|
use crate::sidebar;
|
||||||
|
|
||||||
@@ -32,9 +33,6 @@ pub struct Workspace {
|
|||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// App's Command Bar
|
|
||||||
command_bar: Entity<CommandBar>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||||
}
|
}
|
||||||
@@ -43,19 +41,10 @@ impl Workspace {
|
|||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let titlebar = cx.new(|_| TitleBar::new());
|
let titlebar = cx.new(|_| TitleBar::new());
|
||||||
let command_bar = cx.new(|cx| CommandBar::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx).style(PanelStyle::TabBar));
|
||||||
let dock =
|
|
||||||
cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar));
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Automatically sync theme with system appearance
|
|
||||||
window.observe_window_appearance(|window, cx| {
|
|
||||||
Theme::sync_system_appearance(Some(window), cx);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe all events emitted by the chat registry
|
// Observe all events emitted by the chat registry
|
||||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||||
@@ -108,7 +97,6 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
titlebar,
|
titlebar,
|
||||||
dock,
|
dock,
|
||||||
command_bar,
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,36 +163,95 @@ impl Workspace {
|
|||||||
|
|
||||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let identity = nostr.read(cx).identity();
|
let nip65 = nostr.read(cx).nip65_state();
|
||||||
|
let nip17 = nostr.read(cx).nip17_state();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
let current_user = signer.public_key();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.h(TITLEBAR_HEIGHT)
|
.h(TITLEBAR_HEIGHT)
|
||||||
.flex_1()
|
.flex_shrink_0()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(identity.read(cx).public_key, |this, public_key| {
|
.when_some(current_user.as_ref(), |this, public_key| {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
|
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
Button::new("current-user")
|
||||||
.gap_0p5()
|
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
||||||
.child(
|
.small()
|
||||||
Icon::new(IconName::ChevronDown)
|
.caret()
|
||||||
.small()
|
.compact()
|
||||||
.text_color(cx.theme().text_muted),
|
.transparent()
|
||||||
),
|
.popup_menu(move |this, _window, _cx| {
|
||||||
|
this.label(profile.name())
|
||||||
|
.separator()
|
||||||
|
.menu("Profile", Box::new(ClosePanel))
|
||||||
|
.menu("Backup", Box::new(ClosePanel))
|
||||||
|
.menu("Themes", Box::new(ClosePanel))
|
||||||
|
.menu("Settings", Box::new(ClosePanel))
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.when(nostr.read(cx).creating_signer(), |this| {
|
||||||
|
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||||
|
SharedString::from("Coop is creating a new identity for you..."),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.when(!nostr.read(cx).connected(), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Connecting...")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|this| match nip65.read(cx) {
|
||||||
|
RelayState::Checking => this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Fetching user's relay list...")),
|
||||||
|
),
|
||||||
|
RelayState::NotConfigured => this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_6()
|
||||||
|
.w_full()
|
||||||
|
.px_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().warning_foreground)
|
||||||
|
.bg(cx.theme().warning_background)
|
||||||
|
.rounded_sm()
|
||||||
|
.child(SharedString::from("User hasn't configured a relay list")),
|
||||||
|
),
|
||||||
|
_ => this,
|
||||||
|
})
|
||||||
|
.map(|this| match nip17.read(cx) {
|
||||||
|
RelayState::Checking => {
|
||||||
|
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||||
|
SharedString::from("Fetching user's messaging relay list..."),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
RelayState::NotConfigured => this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_6()
|
||||||
|
.w_full()
|
||||||
|
.px_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().warning_foreground)
|
||||||
|
.bg(cx.theme().warning_background)
|
||||||
|
.rounded_sm()
|
||||||
|
.child(SharedString::from(
|
||||||
|
"User hasn't configured a messaging relay list",
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
_ => this,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_center(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
h_flex().flex_1().w_full().child(self.command_bar.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
|
||||||
h_flex().flex_1()
|
h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,12 +262,11 @@ impl Render for Workspace {
|
|||||||
|
|
||||||
// Titlebar elements
|
// Titlebar elements
|
||||||
let left = self.titlebar_left(window, cx).into_any_element();
|
let left = self.titlebar_left(window, cx).into_any_element();
|
||||||
let center = self.titlebar_center(window, cx).into_any_element();
|
|
||||||
let right = self.titlebar_right(window, cx).into_any_element();
|
let right = self.titlebar_right(window, cx).into_any_element();
|
||||||
|
|
||||||
// Update title bar children
|
// Update title bar children
|
||||||
self.titlebar.update(cx, |this, _cx| {
|
self.titlebar.update(cx, |this, _cx| {
|
||||||
this.set_children(vec![left, center, right]);
|
this.set_children(vec![left, right]);
|
||||||
});
|
});
|
||||||
|
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ pub enum DeviceState {
|
|||||||
Set,
|
Set,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DeviceState {
|
||||||
|
pub fn initial(&self) -> bool {
|
||||||
|
matches!(self, DeviceState::Initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requesting(&self) -> bool {
|
||||||
|
matches!(self, DeviceState::Requesting)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self) -> bool {
|
||||||
|
matches!(self, DeviceState::Set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Announcement
|
/// Announcement
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use common::app_name;
|
|
||||||
pub use device::*;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
|
use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
|
||||||
|
|
||||||
mod device;
|
mod device;
|
||||||
|
|
||||||
|
pub use device::*;
|
||||||
|
|
||||||
const IDENTIFIER: &str = "coop:device";
|
const IDENTIFIER: &str = "coop:device";
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
@@ -30,17 +30,17 @@ pub struct DeviceRegistry {
|
|||||||
/// Device signer
|
/// Device signer
|
||||||
pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>,
|
pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>,
|
||||||
|
|
||||||
|
/// Device state
|
||||||
|
pub state: DeviceState,
|
||||||
|
|
||||||
/// Device requests
|
/// Device requests
|
||||||
requests: Entity<HashSet<Event>>,
|
requests: Entity<HashSet<Event>>,
|
||||||
|
|
||||||
/// Device state
|
|
||||||
state: DeviceState,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
/// Subscriptions
|
/// Subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRegistry {
|
impl DeviceRegistry {
|
||||||
@@ -58,7 +58,8 @@ impl DeviceRegistry {
|
|||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let identity = nostr.read(cx).identity();
|
let nip65_state = nostr.read(cx).nip65_state();
|
||||||
|
let nip17_state = nostr.read(cx).nip17_state();
|
||||||
|
|
||||||
let device_signer = cx.new(|_| None);
|
let device_signer = cx.new(|_| None);
|
||||||
let requests = cx.new(|_| HashSet::default());
|
let requests = cx.new(|_| HashSet::default());
|
||||||
@@ -70,21 +71,26 @@ impl DeviceRegistry {
|
|||||||
let mut tasks = vec![];
|
let mut tasks = vec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the identity entity
|
// Observe the NIP-65 state
|
||||||
cx.observe(&identity, |this, state, cx| {
|
cx.observe(&nip65_state, |this, state, cx| {
|
||||||
match state.read(cx).relay_list_state() {
|
match state.read(cx) {
|
||||||
RelayState::Initial => {
|
RelayState::Idle => {
|
||||||
this.reset(cx);
|
this.reset(cx);
|
||||||
}
|
}
|
||||||
RelayState::Set => {
|
RelayState::Configured => {
|
||||||
this.get_announcement(cx);
|
this.get_announcement(cx);
|
||||||
|
|
||||||
if state.read(cx).messaging_relays_state() == RelayState::Set {
|
|
||||||
this.get_messages(cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the NIP-17 state
|
||||||
|
cx.observe(&nip17_state, |this, state, cx| {
|
||||||
|
if state.read(cx) == &RelayState::Configured {
|
||||||
|
this.get_messages(cx);
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,8 +136,8 @@ impl DeviceRegistry {
|
|||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
while let Ok(notification) = notifications.recv().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
if let RelayPoolNotification::Message {
|
if let ClientNotification::Message {
|
||||||
message: RelayMessage::Event { event, .. },
|
message: RelayMessage::Event { event, .. },
|
||||||
..
|
..
|
||||||
} = notification
|
} = notification
|
||||||
@@ -162,7 +168,7 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Verify the author of an event
|
/// Verify the author of an event
|
||||||
async fn verify_author(client: &Client, event: &Event) -> bool {
|
async fn verify_author(client: &Client, event: &Event) -> bool {
|
||||||
if let Ok(signer) = client.signer().await {
|
if let Some(signer) = client.signer() {
|
||||||
if let Ok(public_key) = signer.get_public_key().await {
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
return public_key == event.pubkey;
|
return public_key == event.pubkey;
|
||||||
}
|
}
|
||||||
@@ -172,7 +178,7 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Encrypt and store device keys in the local database.
|
/// Encrypt and store device keys in the local database.
|
||||||
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
|
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Encrypt the value
|
// Encrypt the value
|
||||||
@@ -193,7 +199,7 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Get device keys from the local database.
|
/// Get device keys from the local database.
|
||||||
async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -244,6 +250,8 @@ impl DeviceRegistry {
|
|||||||
*this = Some(Arc::new(signer));
|
*this = Some(Arc::new(signer));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log::info!("Device Signer set");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the device state
|
/// Set the device state
|
||||||
@@ -265,40 +273,44 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let device_signer = self.device_signer.read(cx).clone();
|
let device_signer = self.device_signer.read(cx).clone();
|
||||||
|
let messaging_relays = nostr.read(cx).messaging_relays(cx);
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let urls = messaging_relays.await;
|
let urls = messaging_relays.await;
|
||||||
|
let user_signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = user_signer.get_public_key().await?;
|
||||||
|
|
||||||
// Get messages with dekey
|
// Get messages with dekey
|
||||||
if let Some(signer) = device_signer.as_ref() {
|
if let Some(signer) = device_signer.as_ref() {
|
||||||
if let Ok(pkey) = signer.get_public_key().await {
|
let device_pkey = signer.get_public_key().await?;
|
||||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(pkey);
|
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_pkey);
|
||||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||||
|
|
||||||
if let Err(e) = client
|
// Construct target for subscription
|
||||||
.subscribe_with_id_to(&urls, id, vec![filter], None)
|
let target = urls
|
||||||
.await
|
.iter()
|
||||||
{
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
log::error!("Failed to subscribe to gift wrap events: {e}");
|
.collect::<HashMap<_, _>>();
|
||||||
}
|
|
||||||
}
|
client.subscribe(target).with_id(id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get messages with user key
|
// Get messages with user key
|
||||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||||
|
|
||||||
if let Err(e) = client
|
// Construct target for subscription
|
||||||
.subscribe_with_id_to(urls, id, vec![filter], None)
|
let target = urls
|
||||||
.await
|
.iter()
|
||||||
{
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
log::error!("Failed to subscribe to gift wrap events: {e}");
|
.collect::<HashMap<_, _>>();
|
||||||
}
|
|
||||||
})
|
client.subscribe(target).with_id(id).await?;
|
||||||
.detach();
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach_and_log_err(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get device announcement for current user
|
/// Get device announcement for current user
|
||||||
@@ -306,11 +318,9 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
|
||||||
|
|
||||||
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Construct the filter for the device announcement event
|
// Construct the filter for the device announcement event
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -319,7 +329,8 @@ impl DeviceRegistry {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let mut stream = client
|
let mut stream = client
|
||||||
.stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT))
|
.stream_events(filter)
|
||||||
|
.timeout(Duration::from_secs(TIMEOUT))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
while let Some((_url, res)) = stream.next().await {
|
while let Some((_url, res)) = stream.next().await {
|
||||||
@@ -360,28 +371,21 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
// Generate a new device keys
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
|
||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
let n = keys.public_key();
|
let n = keys.public_key();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
|
||||||
let urls = write_relays.await;
|
|
||||||
|
|
||||||
// Construct an announcement event
|
// Construct an announcement event
|
||||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
let builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![
|
||||||
.tags(vec![
|
Tag::custom(TagKind::custom("n"), vec![n]),
|
||||||
Tag::custom(TagKind::custom("n"), vec![n]),
|
Tag::client(app_name()),
|
||||||
Tag::client(app_name()),
|
]);
|
||||||
])
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.sign(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Publish announcement
|
// Publish announcement
|
||||||
client.send_event_to(&urls, &event).await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
|
|
||||||
// Save device keys to the database
|
// Save device keys to the database
|
||||||
Self::set_keys(&client, &secret).await?;
|
Self::set_keys(&client, &secret).await?;
|
||||||
@@ -449,11 +453,9 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Construct a filter for device key requests
|
// Construct a filter for device key requests
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -462,7 +464,7 @@ impl DeviceRegistry {
|
|||||||
.since(Timestamp::now());
|
.since(Timestamp::now());
|
||||||
|
|
||||||
// Subscribe to the device key requests on user's write relays
|
// Subscribe to the device key requests on user's write relays
|
||||||
client.subscribe_to(&urls, vec![filter], None).await?;
|
client.subscribe(filter).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -475,11 +477,9 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Construct a filter for device key requests
|
// Construct a filter for device key requests
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -488,7 +488,7 @@ impl DeviceRegistry {
|
|||||||
.since(Timestamp::now());
|
.since(Timestamp::now());
|
||||||
|
|
||||||
// Subscribe to the device key requests on user's write relays
|
// Subscribe to the device key requests on user's write relays
|
||||||
client.subscribe_to(&urls, vec![filter], None).await?;
|
client.subscribe(filter).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -501,14 +501,11 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
|
||||||
|
|
||||||
let app_keys = nostr.read(cx).app_keys().clone();
|
let app_keys = nostr.read(cx).app_keys().clone();
|
||||||
let app_pubkey = app_keys.public_key();
|
let app_pubkey = app_keys.public_key();
|
||||||
|
|
||||||
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -535,19 +532,15 @@ impl DeviceRegistry {
|
|||||||
Ok(Some(keys))
|
Ok(Some(keys))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let urls = write_relays.await;
|
|
||||||
|
|
||||||
// Construct an event for device key request
|
// Construct an event for device key request
|
||||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
let builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![
|
||||||
.tags(vec![
|
Tag::client(app_name()),
|
||||||
Tag::client(app_name()),
|
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
|
||||||
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
|
]);
|
||||||
])
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.sign(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Send the event to write relays
|
// Send the event to write relays
|
||||||
client.send_event_to(&urls, &event).await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -620,12 +613,8 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let signer = client.signer().await?;
|
|
||||||
|
|
||||||
// Get device keys
|
// Get device keys
|
||||||
let keys = Self::get_keys(&client).await?;
|
let keys = Self::get_keys(&client).await?;
|
||||||
@@ -646,16 +635,14 @@ impl DeviceRegistry {
|
|||||||
//
|
//
|
||||||
// P tag: the current device's public key
|
// P tag: the current device's public key
|
||||||
// p tag: the requester's public key
|
// p tag: the requester's public key
|
||||||
let event = EventBuilder::new(Kind::Custom(4455), payload)
|
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||||
.tags(vec![
|
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
||||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
Tag::public_key(target),
|
||||||
Tag::public_key(target),
|
]);
|
||||||
])
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.sign(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Send the response event to the user's relay list
|
// Send the response event to the user's relay list
|
||||||
client.send_event_to(&urls, &event).await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ impl DockArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the panel style of the dock area.
|
/// Set the panel style of the dock area.
|
||||||
pub fn panel_style(mut self, style: PanelStyle) -> Self {
|
pub fn style(mut self, style: PanelStyle) -> Self {
|
||||||
self.panel_style = style;
|
self.panel_style = style;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return true if self or parent only have last panel.
|
/// Return true if self or parent only have last panel.
|
||||||
pub(super) fn is_last_panel(&self, cx: &App) -> bool {
|
pub fn is_last_panel(&self, cx: &App) -> bool {
|
||||||
if self.panels.len() > 1 {
|
if self.panels.len() > 1 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -79,12 +79,12 @@ impl StackPanel {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn panels_len(&self) -> usize {
|
pub fn panels_len(&self) -> usize {
|
||||||
self.panels.len()
|
self.panels.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the index of the panel.
|
/// Return the index of the panel.
|
||||||
pub(crate) fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
|
pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
|
||||||
self.panels.iter().position(|p| p == &panel)
|
self.panels.iter().position(|p| p == &panel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,11 +253,12 @@ impl StackPanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
|
|
||||||
self.remove_self_if_empty(window, cx);
|
self.remove_self_if_empty(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the old panel with the new panel at same index.
|
/// Replace the old panel with the new panel at same index.
|
||||||
pub(super) fn replace_panel(
|
pub fn replace_panel(
|
||||||
&mut self,
|
&mut self,
|
||||||
old_panel: Arc<dyn PanelView>,
|
old_panel: Arc<dyn PanelView>,
|
||||||
new_panel: Entity<StackPanel>,
|
new_panel: Entity<StackPanel>,
|
||||||
@@ -266,16 +267,15 @@ impl StackPanel {
|
|||||||
) {
|
) {
|
||||||
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
|
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
|
||||||
self.panels[ix] = Arc::new(new_panel.clone());
|
self.panels[ix] = Arc::new(new_panel.clone());
|
||||||
let panel_state = ResizablePanelState::default();
|
|
||||||
self.state.update(cx, |state, cx| {
|
self.state.update(cx, |state, cx| {
|
||||||
state.replace_panel(ix, panel_state, cx);
|
state.replace_panel(ix, ResizablePanelState::default(), cx);
|
||||||
});
|
});
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If children is empty, remove self from parent view.
|
/// If children is empty, remove self from parent view.
|
||||||
pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.is_root() {
|
if self.is_root() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -296,11 +296,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the first top left in the stack.
|
/// Find the first top left in the stack.
|
||||||
pub(super) fn left_top_tab_panel(
|
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||||
&self,
|
|
||||||
check_parent: bool,
|
|
||||||
cx: &App,
|
|
||||||
) -> Option<Entity<TabPanel>> {
|
|
||||||
if check_parent {
|
if check_parent {
|
||||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||||
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
||||||
@@ -324,11 +320,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the first top right in the stack.
|
/// Find the first top right in the stack.
|
||||||
pub(super) fn right_top_tab_panel(
|
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||||
&self,
|
|
||||||
check_parent: bool,
|
|
||||||
cx: &App,
|
|
||||||
) -> Option<Entity<TabPanel>> {
|
|
||||||
if check_parent {
|
if check_parent {
|
||||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||||
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
||||||
@@ -357,7 +349,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove all panels from the stack.
|
/// Remove all panels from the stack.
|
||||||
pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.panels.clear();
|
self.panels.clear();
|
||||||
self.state.update(cx, |state, cx| {
|
self.state.update(cx, |state, cx| {
|
||||||
state.clear();
|
state.clear();
|
||||||
@@ -366,7 +358,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Change the axis of the stack panel.
|
/// Change the axis of the stack panel.
|
||||||
pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
|
pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.axis = axis;
|
self.axis = axis;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
device = { path = "../device" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ use std::rc::Rc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use common::{EventUtils, BOOTSTRAP_RELAYS};
|
use common::EventUtils;
|
||||||
|
use device::Announcement;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
pub use person::*;
|
pub use person::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{Announcement, NostrRegistry, TIMEOUT};
|
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||||
|
|
||||||
mod person;
|
mod person;
|
||||||
|
|
||||||
@@ -139,20 +140,14 @@ impl PersonRegistry {
|
|||||||
/// Handle nostr notifications
|
/// Handle nostr notifications
|
||||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
|
||||||
|
|
||||||
while let Ok(notification) = notifications.recv().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
let RelayPoolNotification::Message { message, .. } = notification else {
|
let ClientNotification::Message { message, .. } = notification else {
|
||||||
// Skip if the notification is not a message
|
// Skip if the notification is not a message
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let RelayMessage::Event { event, .. } = message {
|
if let RelayMessage::Event { event, .. } = message {
|
||||||
if !processed_events.insert(event.id) {
|
|
||||||
// Skip if the event has already been processed
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Metadata => {
|
Kind::Metadata => {
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
@@ -230,9 +225,13 @@ impl PersonRegistry {
|
|||||||
.authors(authors)
|
.authors(authors)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
client
|
// Construct target for subscription
|
||||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
let target = BOOTSTRAP_RELAYS
|
||||||
.await?;
|
.into_iter()
|
||||||
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
client.subscribe(target).close_on(opts).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
use device::Announcement;
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::Announcement;
|
|
||||||
|
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||||
|
|
||||||
/// Person
|
/// Person
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -86,7 +88,12 @@ impl Person {
|
|||||||
.picture
|
.picture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|picture| !picture.is_empty())
|
.filter(|picture| !picture.is_empty())
|
||||||
.map(|picture| picture.into())
|
.map(|picture| {
|
||||||
|
let url = format!(
|
||||||
|
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||||
|
);
|
||||||
|
url.into()
|
||||||
|
})
|
||||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use std::cell::Cell;
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||||
Subscription, Task, Window,
|
Subscription, Task, Window,
|
||||||
@@ -28,8 +29,8 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
|||||||
/// Authentication request
|
/// Authentication request
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct AuthRequest {
|
pub struct AuthRequest {
|
||||||
pub url: RelayUrl,
|
url: RelayUrl,
|
||||||
pub challenge: String,
|
challenge: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Hash for AuthRequest {
|
impl Hash for AuthRequest {
|
||||||
@@ -45,6 +46,14 @@ impl AuthRequest {
|
|||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> &RelayUrl {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn challenge(&self) -> &str {
|
||||||
|
&self.challenge
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalRelayAuth(Entity<RelayAuth>);
|
struct GlobalRelayAuth(Entity<RelayAuth>);
|
||||||
@@ -55,7 +64,7 @@ impl Global for GlobalRelayAuth {}
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RelayAuth {
|
pub struct RelayAuth {
|
||||||
/// Entity for managing auth requests
|
/// Entity for managing auth requests
|
||||||
requests: HashSet<AuthRequest>,
|
requests: HashSet<Arc<AuthRequest>>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
@@ -91,14 +100,14 @@ impl RelayAuth {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the current state
|
// Observe the current state
|
||||||
cx.observe_in(&entity, window, |this, _, window, cx| {
|
cx.observe_in(&entity, window, |this, _state, window, cx| {
|
||||||
let settings = AppSettings::global(cx);
|
let settings = AppSettings::global(cx);
|
||||||
let mode = AppSettings::get_auth_mode(cx);
|
let mode = AppSettings::get_auth_mode(cx);
|
||||||
|
|
||||||
for req in this.requests.clone().into_iter() {
|
for req in this.requests.iter() {
|
||||||
let is_trusted_relay = settings.read(cx).is_trusted_relay(&req.url, cx);
|
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
|
||||||
|
|
||||||
if is_trusted_relay && mode == AuthMode::Auto {
|
if trusted_relay && mode == AuthMode::Auto {
|
||||||
// Automatically authenticate if the relay is authenticated before
|
// Automatically authenticate if the relay is authenticated before
|
||||||
this.response(req, window, cx);
|
this.response(req, window, cx);
|
||||||
} else {
|
} else {
|
||||||
@@ -111,7 +120,9 @@ impl RelayAuth {
|
|||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
// Handle nostr notifications
|
// Handle nostr notifications
|
||||||
cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }),
|
cx.background_spawn(async move {
|
||||||
|
Self::handle_notifications(&client, &tx).await;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
@@ -136,25 +147,45 @@ impl RelayAuth {
|
|||||||
// Handle nostr notifications
|
// Handle nostr notifications
|
||||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<AuthRequest>) {
|
async fn handle_notifications(client: &Client, tx: &flume::Sender<AuthRequest>) {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
|
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
||||||
|
|
||||||
while let Ok(notification) = notifications.recv().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
if let RelayPoolNotification::Message {
|
match notification {
|
||||||
message: RelayMessage::Auth { challenge },
|
ClientNotification::Message { relay_url, message } => {
|
||||||
relay_url,
|
match message {
|
||||||
} = notification
|
RelayMessage::Auth { challenge } => {
|
||||||
{
|
if challenges.insert(challenge.clone()) {
|
||||||
let request = AuthRequest::new(challenge, relay_url);
|
let request = AuthRequest::new(challenge, relay_url);
|
||||||
|
tx.send_async(request).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::Ok {
|
||||||
|
event_id, message, ..
|
||||||
|
} => {
|
||||||
|
let msg = MachineReadablePrefix::parse(&message);
|
||||||
|
let mut tracker = tracker().write().await;
|
||||||
|
|
||||||
if let Err(e) = tx.send_async(request).await {
|
// Handle authentication messages
|
||||||
log::error!("Failed to send auth request: {}", e);
|
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||||
|
// Keep track of events that need to be resent after authentication
|
||||||
|
tracker.add_to_pending(event_id, relay_url);
|
||||||
|
} else {
|
||||||
|
// Keep track of events sent by Coop
|
||||||
|
tracker.sent(event_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ClientNotification::Shutdown => break,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new authentication request.
|
/// Add a new authentication request.
|
||||||
fn add_request(&mut self, request: AuthRequest, cx: &mut Context<Self>) {
|
fn add_request(&mut self, request: AuthRequest, cx: &mut Context<Self>) {
|
||||||
self.requests.insert(request);
|
self.requests.insert(Arc::new(request));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,57 +196,55 @@ impl RelayAuth {
|
|||||||
|
|
||||||
/// Reask for approval for all pending requests.
|
/// Reask for approval for all pending requests.
|
||||||
pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
for request in self.requests.clone().into_iter() {
|
for request in self.requests.iter() {
|
||||||
self.ask_for_approval(request, window, cx);
|
self.ask_for_approval(request, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Respond to an authentication request.
|
/// Respond to an authentication request.
|
||||||
fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
|
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||||
let settings = AppSettings::global(cx);
|
let settings = AppSettings::global(cx);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let challenge = req.challenge.to_owned();
|
let req = req.clone();
|
||||||
let url = req.url.to_owned();
|
let challenge = req.challenge().to_string();
|
||||||
|
let async_req = req.clone();
|
||||||
let challenge_clone = challenge.clone();
|
|
||||||
let url_clone = url.clone();
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().await?;
|
|
||||||
|
|
||||||
// Construct event
|
// Construct event
|
||||||
let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone())
|
let builder = EventBuilder::auth(async_req.challenge(), async_req.url().clone());
|
||||||
.sign(&signer)
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Get the event ID
|
// Get the event ID
|
||||||
let id = event.id;
|
let id = event.id;
|
||||||
|
|
||||||
// Get the relay
|
// Get the relay
|
||||||
let relay = client.pool().relay(url_clone).await?;
|
let relay = client
|
||||||
let relay_url = relay.url();
|
.relay(async_req.url())
|
||||||
|
.await?
|
||||||
|
.context("Relay not found")?;
|
||||||
|
|
||||||
// Subscribe to notifications
|
// Subscribe to notifications
|
||||||
let mut notifications = relay.notifications();
|
let mut notifications = relay.notifications();
|
||||||
|
|
||||||
// Send the AUTH message
|
// Send the AUTH message
|
||||||
relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?;
|
relay
|
||||||
|
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
while let Ok(notification) = notifications.recv().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
match notification {
|
match notification {
|
||||||
RelayNotification::Message {
|
RelayNotification::Message {
|
||||||
message: RelayMessage::Ok { event_id, .. },
|
message: RelayMessage::Ok { event_id, .. },
|
||||||
} => {
|
} => {
|
||||||
if id == event_id {
|
if id == event_id {
|
||||||
// Re-subscribe to previous subscription
|
// Re-subscribe to previous subscription
|
||||||
relay.resubscribe().await?;
|
// relay.resubscribe().await?;
|
||||||
|
|
||||||
// Get all pending events that need to be resent
|
// Get all pending events that need to be resent
|
||||||
let mut tracker = tracker().write().await;
|
let mut tracker = tracker().write().await;
|
||||||
let ids: Vec<EventId> = tracker.pending_resend(relay_url);
|
let ids: Vec<EventId> = tracker.pending_resend(relay.url());
|
||||||
|
|
||||||
for id in ids.into_iter() {
|
for id in ids.into_iter() {
|
||||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||||
@@ -228,7 +257,6 @@ impl RelayAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RelayNotification::AuthenticationFailed => break,
|
RelayNotification::AuthenticationFailed => break,
|
||||||
RelayNotification::Shutdown => break,
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,47 +264,56 @@ impl RelayAuth {
|
|||||||
Err(anyhow!("Authentication failed"))
|
Err(anyhow!("Authentication failed"))
|
||||||
});
|
});
|
||||||
|
|
||||||
self._tasks.push(
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
// Handle response in the background
|
let result = task.await;
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
let url = req.url();
|
||||||
match task.await {
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
window.clear_notification(challenge, cx);
|
||||||
// Clear the current notification
|
window.push_notification(format!("{} has been authenticated", url), cx);
|
||||||
window.clear_notification(challenge, cx);
|
|
||||||
|
|
||||||
// Push a new notification
|
// Save the authenticated relay to automatically authenticate future requests
|
||||||
window.push_notification(format!("{url} has been authenticated"), cx);
|
settings.update(cx, |this, cx| {
|
||||||
|
this.add_trusted_relay(url, cx);
|
||||||
|
});
|
||||||
|
|
||||||
// Save the authenticated relay to automatically authenticate future requests
|
// Remove the challenge from the list of pending authentications
|
||||||
settings.update(cx, |this, cx| {
|
this.requests.remove(&req);
|
||||||
this.add_trusted_relay(url, cx);
|
cx.notify();
|
||||||
});
|
|
||||||
|
|
||||||
// Remove the challenge from the list of pending authentications
|
|
||||||
this.requests.remove(&req);
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.expect("Entity has been released");
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update_in(cx, |_, window, cx| {
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
})
|
|
||||||
.expect("Entity has been released");
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}),
|
})
|
||||||
);
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a popup to approve the authentication request.
|
/// Push a popup to approve the authentication request.
|
||||||
fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
|
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||||
let url = SharedString::from(req.url.clone().to_string());
|
let notification = self.notification(req, cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.push_notification(notification, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a notification for the authentication request.
|
||||||
|
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
|
||||||
|
let req = req.clone();
|
||||||
|
let url = SharedString::from(req.url().to_string());
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
let loading = Rc::new(Cell::new(false));
|
let loading = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
let note = Notification::new()
|
Notification::new()
|
||||||
.custom_id(SharedString::from(&req.challenge))
|
.custom_id(SharedString::from(&req.challenge))
|
||||||
.autohide(false)
|
.autohide(false)
|
||||||
.icon(IconName::Info)
|
.icon(IconName::Info)
|
||||||
@@ -299,7 +336,7 @@ impl RelayAuth {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.action(move |_window, _cx| {
|
.action(move |_window, _cx| {
|
||||||
let entity = entity.clone();
|
let view = entity.clone();
|
||||||
let req = req.clone();
|
let req = req.clone();
|
||||||
|
|
||||||
Button::new("approve")
|
Button::new("approve")
|
||||||
@@ -310,24 +347,18 @@ impl RelayAuth {
|
|||||||
.disabled(loading.get())
|
.disabled(loading.get())
|
||||||
.on_click({
|
.on_click({
|
||||||
let loading = Rc::clone(&loading);
|
let loading = Rc::clone(&loading);
|
||||||
|
|
||||||
move |_ev, window, cx| {
|
move |_ev, window, cx| {
|
||||||
// Set loading state to true
|
// Set loading state to true
|
||||||
loading.set(true);
|
loading.set(true);
|
||||||
|
|
||||||
// Process to approve the request
|
// Process to approve the request
|
||||||
entity
|
view.update(cx, |this, cx| {
|
||||||
.update(cx, |this, cx| {
|
this.response(&req, window, cx);
|
||||||
this.response(req.clone(), window, cx);
|
})
|
||||||
})
|
.ok();
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
// Push the notification to the current window
|
|
||||||
window.push_notification(note, cx);
|
|
||||||
|
|
||||||
// Bring the window to the front
|
|
||||||
cx.activate(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -47,8 +47,8 @@ setting_accessors! {
|
|||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum AuthMode {
|
pub enum AuthMode {
|
||||||
#[default]
|
#[default]
|
||||||
Manual,
|
|
||||||
Auto,
|
Auto,
|
||||||
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signer kind
|
/// Signer kind
|
||||||
@@ -121,7 +121,7 @@ pub struct AppSettings {
|
|||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
/// Background tasks
|
/// Background tasks
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
@@ -136,7 +136,7 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let load_settings = Self::get_from_database(false, cx);
|
let load_settings = Self::get_from_database(cx);
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
@@ -151,28 +151,33 @@ impl AppSettings {
|
|||||||
tasks.push(
|
tasks.push(
|
||||||
// Load the initial settings
|
// Load the initial settings
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Ok(settings) = load_settings.await {
|
let settings = load_settings.await.unwrap_or(Settings::default());
|
||||||
this.update(cx, |this, cx| {
|
log::info!("Settings: {settings:?}");
|
||||||
this.values = settings;
|
|
||||||
cx.notify();
|
// Update the settings state
|
||||||
})
|
this.update(cx, |this, cx| {
|
||||||
.ok();
|
this.set_settings(settings, cx);
|
||||||
}
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
values: Settings::default(),
|
values: Settings::default(),
|
||||||
|
tasks,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
_tasks: tasks,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update settings
|
||||||
|
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
||||||
|
self.values = settings;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get settings from the database
|
/// Get settings from the database
|
||||||
///
|
fn get_from_database(cx: &App) -> Task<Result<Settings, Error>> {
|
||||||
/// If `current_user` is true, the settings will be retrieved for current user.
|
|
||||||
/// Otherwise, Coop will load the latest settings from the database.
|
|
||||||
fn get_from_database(current_user: bool, cx: &App) -> Task<Result<Settings, Error>> {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
@@ -183,16 +188,16 @@ impl AppSettings {
|
|||||||
.identifier(SETTINGS_IDENTIFIER)
|
.identifier(SETTINGS_IDENTIFIER)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if current_user {
|
// If the signer is available, get settings belonging to the current user
|
||||||
let signer = client.signer().await?;
|
if let Some(signer) = client.signer() {
|
||||||
let public_key = signer.get_public_key().await?;
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
|
// Push author to the filter
|
||||||
// Push author to the filter
|
filter = filter.author(public_key);
|
||||||
filter = filter.author(public_key);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default()))
|
Ok(serde_json::from_str(&event.content)?)
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found"))
|
Err(anyhow!("Not found"))
|
||||||
}
|
}
|
||||||
@@ -201,18 +206,18 @@ impl AppSettings {
|
|||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
pub fn load(&mut self, cx: &mut Context<Self>) {
|
pub fn load(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = Self::get_from_database(true, cx);
|
let task = Self::get_from_database(cx);
|
||||||
|
|
||||||
self._tasks.push(
|
self.tasks.push(
|
||||||
// Run task in the background
|
// Run task in the background
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Ok(settings) = task.await {
|
let settings = task.await?;
|
||||||
this.update(cx, |this, cx| {
|
// Update settings
|
||||||
this.values = settings;
|
this.update(cx, |this, cx| {
|
||||||
cx.notify();
|
this.set_settings(settings, cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
Ok(())
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,35 +226,36 @@ impl AppSettings {
|
|||||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
let settings = self.values.clone();
|
||||||
|
|
||||||
if let Ok(content) = serde_json::to_string(&self.values) {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let signer = client.signer().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let content = serde_json::to_string(&settings)?;
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
.tag(Tag::identifier(SETTINGS_IDENTIFIER))
|
.tag(Tag::identifier(SETTINGS_IDENTIFIER))
|
||||||
.build(public_key)
|
.build(public_key)
|
||||||
.sign(&Keys::generate())
|
.sign(&Keys::generate())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
client.database().save_event(&event).await?;
|
// Save event to the local database only
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
}));
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the given relay is trusted
|
/// Check if the given relay is already authenticated
|
||||||
pub fn is_trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
||||||
self.values.trusted_relays.contains(url)
|
self.values.trusted_relays.iter().any(|relay| {
|
||||||
|
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a relay to the trusted list
|
/// Add a relay to the trusted list
|
||||||
pub fn add_trusted_relay(&mut self, url: RelayUrl, cx: &mut Context<Self>) {
|
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||||
self.values.trusted_relays.insert(url);
|
self.values.trusted_relays.insert(url.clone());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ common = { path = "../common" }
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr-lmdb.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
|
nostr-gossip-memory.workspace = true
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_tokio.workspace = true
|
gpui_tokio.workspace = true
|
||||||
@@ -24,3 +25,4 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
petname = "2.0.2"
|
petname = "2.0.2"
|
||||||
|
whoami = "1.6.1"
|
||||||
|
|||||||
59
crates/state/src/constants.rs
Normal file
59
crates/state/src/constants.rs
Normal 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})")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
132
crates/state/src/signer.rs
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use theme::ActiveTheme;
|
|||||||
|
|
||||||
use crate::indicator::Indicator;
|
use crate::indicator::Indicator;
|
||||||
use crate::tooltip::Tooltip;
|
use crate::tooltip::Tooltip;
|
||||||
use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt};
|
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ButtonCustomVariant {
|
pub struct ButtonCustomVariant {
|
||||||
@@ -20,50 +20,6 @@ pub struct ButtonCustomVariant {
|
|||||||
active: Hsla,
|
active: Hsla,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ButtonVariants: Sized {
|
|
||||||
fn with_variant(self, variant: ButtonVariant) -> Self;
|
|
||||||
|
|
||||||
/// With the primary style for the Button.
|
|
||||||
fn primary(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the secondary style for the Button.
|
|
||||||
fn secondary(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the danger style for the Button.
|
|
||||||
fn danger(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Danger)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the warning style for the Button.
|
|
||||||
fn warning(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Warning)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the ghost style for the Button.
|
|
||||||
fn ghost(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Ghost { alt: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the ghost style for the Button.
|
|
||||||
fn ghost_alt(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Ghost { alt: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the transparent style for the Button.
|
|
||||||
fn transparent(self) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With the custom style for the Button.
|
|
||||||
fn custom(self, style: ButtonCustomVariant) -> Self {
|
|
||||||
self.with_variant(ButtonVariant::Custom(style))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ButtonCustomVariant {
|
impl ButtonCustomVariant {
|
||||||
pub fn new(_window: &Window, cx: &App) -> Self {
|
pub fn new(_window: &Window, cx: &App) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -110,6 +66,50 @@ pub enum ButtonVariant {
|
|||||||
Custom(ButtonCustomVariant),
|
Custom(ButtonCustomVariant),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait ButtonVariants: Sized {
|
||||||
|
fn with_variant(self, variant: ButtonVariant) -> Self;
|
||||||
|
|
||||||
|
/// With the primary style for the Button.
|
||||||
|
fn primary(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the secondary style for the Button.
|
||||||
|
fn secondary(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the danger style for the Button.
|
||||||
|
fn danger(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Danger)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the warning style for the Button.
|
||||||
|
fn warning(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the ghost style for the Button.
|
||||||
|
fn ghost(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Ghost { alt: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the ghost style for the Button.
|
||||||
|
fn ghost_alt(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Ghost { alt: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the transparent style for the Button.
|
||||||
|
fn transparent(self) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the custom style for the Button.
|
||||||
|
fn custom(self, style: ButtonCustomVariant) -> Self {
|
||||||
|
self.with_variant(ButtonVariant::Custom(style))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Button element.
|
/// A Button element.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -124,17 +124,15 @@ pub struct Button {
|
|||||||
children: Vec<AnyElement>,
|
children: Vec<AnyElement>,
|
||||||
|
|
||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
center: bool,
|
|
||||||
rounded: bool,
|
|
||||||
size: Size,
|
size: Size,
|
||||||
|
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
reverse: bool,
|
|
||||||
bold: bool,
|
|
||||||
cta: bool,
|
|
||||||
|
|
||||||
loading: bool,
|
loading: bool,
|
||||||
loading_icon: Option<Icon>,
|
|
||||||
|
rounded: bool,
|
||||||
|
compact: bool,
|
||||||
|
underline: bool,
|
||||||
|
caret: bool,
|
||||||
|
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||||
@@ -161,21 +159,19 @@ impl Button {
|
|||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
icon: None,
|
icon: None,
|
||||||
label: None,
|
label: None,
|
||||||
|
variant: ButtonVariant::default(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
variant: ButtonVariant::default(),
|
underline: false,
|
||||||
|
compact: false,
|
||||||
|
caret: false,
|
||||||
rounded: false,
|
rounded: false,
|
||||||
size: Size::Medium,
|
size: Size::Medium,
|
||||||
tooltip: None,
|
tooltip: None,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
on_hover: None,
|
on_hover: None,
|
||||||
loading: false,
|
loading: false,
|
||||||
reverse: false,
|
|
||||||
center: true,
|
|
||||||
bold: false,
|
|
||||||
cta: false,
|
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
loading_icon: None,
|
|
||||||
tab_index: 0,
|
tab_index: 0,
|
||||||
tab_stop: true,
|
tab_stop: true,
|
||||||
}
|
}
|
||||||
@@ -211,33 +207,21 @@ impl Button {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set reverse the position between icon and label.
|
/// Set true to make the button compact (no padding).
|
||||||
pub fn reverse(mut self) -> Self {
|
pub fn compact(mut self) -> Self {
|
||||||
self.reverse = true;
|
self.compact = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set bold the button (label will be use the semi-bold font).
|
/// Set true to show the caret indicator.
|
||||||
pub fn bold(mut self) -> Self {
|
pub fn caret(mut self) -> Self {
|
||||||
self.bold = true;
|
self.caret = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable centering the button's content.
|
/// Set true to show the underline indicator.
|
||||||
pub fn no_center(mut self) -> Self {
|
pub fn underline(mut self) -> Self {
|
||||||
self.center = false;
|
self.underline = true;
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the cta style of the button.
|
|
||||||
pub fn cta(mut self) -> Self {
|
|
||||||
self.cta = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the loading icon of the button.
|
|
||||||
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
|
|
||||||
self.loading_icon = Some(icon.into());
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +330,7 @@ impl RenderOnce for Button {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let focus_handle = window
|
let focus_handle = window
|
||||||
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
|
.use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
@@ -358,10 +342,11 @@ impl RenderOnce for Button {
|
|||||||
.tab_stop(self.tab_stop),
|
.tab_stop(self.tab_stop),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.relative()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.when(self.center, |this| this.justify_center())
|
.justify_center()
|
||||||
.cursor_default()
|
.cursor_default()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
@@ -369,39 +354,15 @@ impl RenderOnce for Button {
|
|||||||
false => this.rounded(cx.theme().radius),
|
false => this.rounded(cx.theme().radius),
|
||||||
true => this.rounded_full(),
|
true => this.rounded_full(),
|
||||||
})
|
})
|
||||||
.map(|this| {
|
.when(!self.compact, |this| {
|
||||||
if self.label.is_none() && self.children.is_empty() {
|
if self.label.is_none() && self.children.is_empty() {
|
||||||
// Icon Button
|
// Icon Button
|
||||||
match self.size {
|
match self.size {
|
||||||
Size::Size(px) => this.size(px),
|
Size::Size(px) => this.size(px),
|
||||||
Size::XSmall => {
|
Size::XSmall => this.size_5(),
|
||||||
if self.cta {
|
Size::Small => this.size_6(),
|
||||||
this.w_10().h_5()
|
Size::Medium => this.size_7(),
|
||||||
} else {
|
_ => this.size_9(),
|
||||||
this.size_5()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Size::Small => {
|
|
||||||
if self.cta {
|
|
||||||
this.w_12().h_6()
|
|
||||||
} else {
|
|
||||||
this.size_6()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Size::Medium => {
|
|
||||||
if self.cta {
|
|
||||||
this.w_12().h_7()
|
|
||||||
} else {
|
|
||||||
this.size_7()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if self.cta {
|
|
||||||
this.w_16().h_9()
|
|
||||||
} else {
|
|
||||||
this.size_9()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal Button
|
// Normal Button
|
||||||
@@ -410,8 +371,6 @@ impl RenderOnce for Button {
|
|||||||
Size::XSmall => {
|
Size::XSmall => {
|
||||||
if self.icon.is_some() {
|
if self.icon.is_some() {
|
||||||
this.h_6().pl_2().pr_2p5()
|
this.h_6().pl_2().pr_2p5()
|
||||||
} else if self.cta {
|
|
||||||
this.h_6().px_4()
|
|
||||||
} else {
|
} else {
|
||||||
this.h_6().px_2()
|
this.h_6().px_2()
|
||||||
}
|
}
|
||||||
@@ -419,8 +378,6 @@ impl RenderOnce for Button {
|
|||||||
Size::Small => {
|
Size::Small => {
|
||||||
if self.icon.is_some() {
|
if self.icon.is_some() {
|
||||||
this.h_7().pl_2().pr_2p5()
|
this.h_7().pl_2().pr_2p5()
|
||||||
} else if self.cta {
|
|
||||||
this.h_7().px_4()
|
|
||||||
} else {
|
} else {
|
||||||
this.h_7().px_2()
|
this.h_7().px_2()
|
||||||
}
|
}
|
||||||
@@ -442,13 +399,27 @@ impl RenderOnce for Button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
|
.refine_style(&self.style)
|
||||||
|
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
|
||||||
|
// Stop handle any click event when disabled.
|
||||||
|
// To avoid handle dropdown menu open when button is disabled.
|
||||||
|
if self.disabled {
|
||||||
|
cx.stop_propagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Avoid focus on mouse down.
|
// Avoid focus on mouse down.
|
||||||
window.prevent_default();
|
window.prevent_default();
|
||||||
})
|
})
|
||||||
.when_some(self.on_click.filter(|_| clickable), |this, on_click| {
|
.when_some(self.on_click, |this, on_click| {
|
||||||
this.on_click(move |event, window, cx| {
|
this.on_click(move |event, window, cx| {
|
||||||
(on_click)(event, window, cx);
|
// Stop handle any click event when disabled.
|
||||||
|
// To avoid handle dropdown menu open when button is disabled.
|
||||||
|
if !clickable {
|
||||||
|
cx.stop_propagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
on_click(event, window, cx);
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
|
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
|
||||||
@@ -459,7 +430,6 @@ impl RenderOnce for Button {
|
|||||||
.child({
|
.child({
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("label")
|
.id("label")
|
||||||
.when(self.reverse, |this| this.flex_row_reverse())
|
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.map(|this| match self.size {
|
.map(|this| match self.size {
|
||||||
Size::XSmall => this.text_xs().gap_1(),
|
Size::XSmall => this.text_xs().gap_1(),
|
||||||
@@ -471,22 +441,18 @@ impl RenderOnce for Button {
|
|||||||
this.child(icon.with_size(icon_size))
|
this.child(icon.with_size(icon_size))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.when(self.loading, |this| {
|
.when(self.loading, |this| this.child(Indicator::new()))
|
||||||
this.child(
|
|
||||||
Indicator::new()
|
|
||||||
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.label, |this, label| {
|
.when_some(self.label, |this, label| {
|
||||||
this.child(
|
this.child(div().flex_none().line_height(relative(1.)).child(label))
|
||||||
div()
|
|
||||||
.flex_none()
|
|
||||||
.line_height(relative(1.))
|
|
||||||
.child(label)
|
|
||||||
.when(self.bold, |this| this.font_semibold()),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.children(self.children)
|
.children(self.children)
|
||||||
|
.when(self.caret, |this| {
|
||||||
|
this.justify_between().gap_0p5().child(
|
||||||
|
Icon::new(IconName::ChevronDown)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().text_muted),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.text_color(normal_style.fg)
|
.text_color(normal_style.fg)
|
||||||
.when(!self.disabled && !self.selected, |this| {
|
.when(!self.disabled && !self.selected, |this| {
|
||||||
@@ -504,6 +470,17 @@ impl RenderOnce for Button {
|
|||||||
let selected_style = style.selected(cx);
|
let selected_style = style.selected(cx);
|
||||||
this.bg(selected_style.bg).text_color(selected_style.fg)
|
this.bg(selected_style.bg).text_color(selected_style.fg)
|
||||||
})
|
})
|
||||||
|
.when(self.selected && self.underline, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.bottom_0()
|
||||||
|
.left_0()
|
||||||
|
.h_px()
|
||||||
|
.w_full()
|
||||||
|
.bg(cx.theme().element_background),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(self.disabled, |this| {
|
.when(self.disabled, |this| {
|
||||||
let disabled_style = style.disabled(cx);
|
let disabled_style = style.disabled(cx);
|
||||||
this.cursor_not_allowed()
|
this.cursor_not_allowed()
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ impl RenderOnce for Divider {
|
|||||||
.absolute()
|
.absolute()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.map(|this| match self.axis {
|
.map(|this| match self.axis {
|
||||||
Axis::Vertical => this.w(px(2.)).h_full(),
|
Axis::Vertical => this.w(px(1.)).h_full(),
|
||||||
Axis::Horizontal => this.h(px(2.)).w_full(),
|
Axis::Horizontal => this.h(px(1.)).w_full(),
|
||||||
})
|
})
|
||||||
.bg(self.color.unwrap_or(cx.theme().border_variant)),
|
.bg(self.color.unwrap_or(cx.theme().border_variant)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -246,8 +246,7 @@ impl Element for ContextMenu {
|
|||||||
|
|
||||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||||
(builder)(menu, window, cx)
|
(builder)(menu, window, cx)
|
||||||
})
|
});
|
||||||
.into_element();
|
|
||||||
|
|
||||||
let _subscription = window.subscribe(&menu, cx, {
|
let _subscription = window.subscribe(&menu, cx, {
|
||||||
let shared_state = shared_state.clone();
|
let shared_state = shared_state.clone();
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PopupMenuExt for Button {}
|
impl PopupMenuExt for Button {}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -1074,7 +1075,9 @@ impl PopupMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FluentBuilder for PopupMenu {}
|
impl FluentBuilder for PopupMenu {}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for PopupMenu {}
|
impl EventEmitter<DismissEvent> for PopupMenu {}
|
||||||
|
|
||||||
impl Focusable for PopupMenu {
|
impl Focusable for PopupMenu {
|
||||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
self.focus_handle.clone()
|
self.focus_handle.clone()
|
||||||
|
|||||||
@@ -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("+")
|
|
||||||
}
|
|
||||||
@@ -183,39 +183,43 @@ impl<T: Styled> StyleSized<T> for T {
|
|||||||
|
|
||||||
fn input_pl(self, size: Size) -> Self {
|
fn input_pl(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::Large => self.pl_5(),
|
Size::XSmall => self.pl_1(),
|
||||||
Size::Medium => self.pl_3(),
|
Size::Medium => self.pl_3(),
|
||||||
|
Size::Large => self.pl_5(),
|
||||||
_ => self.pl_2(),
|
_ => self.pl_2(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_pr(self, size: Size) -> Self {
|
fn input_pr(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::Large => self.pr_5(),
|
Size::XSmall => self.pr_1(),
|
||||||
Size::Medium => self.pr_3(),
|
Size::Medium => self.pr_3(),
|
||||||
|
Size::Large => self.pr_5(),
|
||||||
_ => self.pr_2(),
|
_ => self.pr_2(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_px(self, size: Size) -> Self {
|
fn input_px(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::Large => self.px_5(),
|
Size::XSmall => self.px_1(),
|
||||||
Size::Medium => self.px_3(),
|
Size::Medium => self.px_3(),
|
||||||
|
Size::Large => self.px_5(),
|
||||||
_ => self.px_2(),
|
_ => self.px_2(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_py(self, size: Size) -> Self {
|
fn input_py(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::Large => self.py_5(),
|
Size::XSmall => self.py_0p5(),
|
||||||
Size::Medium => self.py_2(),
|
Size::Medium => self.py_2(),
|
||||||
|
Size::Large => self.py_5(),
|
||||||
_ => self.py_1(),
|
_ => self.py_1(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_h(self, size: Size) -> Self {
|
fn input_h(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::XSmall => self.h_7(),
|
Size::XSmall => self.h_6(),
|
||||||
Size::Small => self.h_8(),
|
Size::Small => self.h_8(),
|
||||||
Size::Medium => self.h_9(),
|
Size::Medium => self.h_9(),
|
||||||
Size::Large => self.h_12(),
|
Size::Large => self.h_12(),
|
||||||
|
|||||||
Reference in New Issue
Block a user