update new chat dialog
This commit is contained in:
@@ -1,79 +1,306 @@
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
use chat::{ChatRegistry, Room, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, WeakEntity, Window, div, px,
|
||||
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, WeakEntity, Window, div, px, uniform_list,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{StyledExt, WindowExtension, v_flex};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::nav::Nav;
|
||||
use ui::{Disableable, IconName, Selectable, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::sidebar::nav_avatar;
|
||||
|
||||
/// Maximum number of members a chat can have.
|
||||
const MAX_MEMBERS: usize = 3;
|
||||
|
||||
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| NewChat::new(dock, window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(420.)).title("New chat").child(view.clone())
|
||||
window.open_modal(cx, {
|
||||
let view = view.clone();
|
||||
move |this, _window, _cx| {
|
||||
this.width(px(420.))
|
||||
.title("New chat")
|
||||
.confirm()
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Start"),
|
||||
)
|
||||
.on_ok({
|
||||
let view = view.clone();
|
||||
move |_, window, cx| view.update(cx, |this, cx| this.start_chat(window, cx))
|
||||
})
|
||||
.child(view.clone())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub struct NewChat {
|
||||
/// The dock a started chat opens in
|
||||
dock: WeakEntity<DockArea>,
|
||||
|
||||
/// Public key input
|
||||
/// Find input, used to filter contacts or enter a new public key
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
/// Current user's contacts
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Public keys selected as members of the chat
|
||||
members: Vec<PublicKey>,
|
||||
/// Whether a NIP-05 address is being resolved
|
||||
resolving: bool,
|
||||
error: Option<SharedString>,
|
||||
|
||||
/// Input subscription
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl NewChat {
|
||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
|
||||
});
|
||||
|
||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.start_chat(window, cx);
|
||||
this.submit(window, cx)
|
||||
}
|
||||
});
|
||||
|
||||
// Load the contacts once the view is mounted
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_contacts(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
input,
|
||||
contacts: None,
|
||||
members: Vec::new(),
|
||||
resolving: false,
|
||||
error: None,
|
||||
tasks: Vec::new(),
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
/// Load the signed-in user's contact list.
|
||||
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.contacts.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(peer) = PublicKey::parse(&value) else {
|
||||
self.set_error("Public key is invalid", cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.author(current_user)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: Vec<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts = Some(contacts);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(error.to_string(), cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// The contacts matching the current input, or every contact when empty.
|
||||
fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
|
||||
let Some(contacts) = self.contacts.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let query = self.input.read(cx).value().trim().to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
return contacts.clone();
|
||||
}
|
||||
|
||||
contacts
|
||||
.iter()
|
||||
.filter(|public_key| {
|
||||
if public_key.to_hex().to_lowercase().contains(&query) {
|
||||
return true;
|
||||
}
|
||||
let name = persons.read(cx).get(public_key, cx).name();
|
||||
name.to_lowercase().contains(&query)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the input into a public key and add it as a member.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.resolving {
|
||||
return;
|
||||
}
|
||||
|
||||
let query = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A raw public key or an npub can be added directly.
|
||||
if let Ok(public_key) = PublicKey::parse(&query) {
|
||||
if self.try_add(public_key, cx) {
|
||||
self.clear_input(window, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A NIP-05 address is resolved before being added.
|
||||
if let Ok(address) = Nip05Address::parse(&query) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let task = nostr.read(cx).query_address(address, cx);
|
||||
|
||||
self.set_resolving(true, window, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_resolving(false, window, cx);
|
||||
|
||||
match result {
|
||||
Ok(public_key) => {
|
||||
if this.try_add(public_key, cx) {
|
||||
this.clear_input(window, cx);
|
||||
}
|
||||
}
|
||||
Err(error) => this.set_error(error.to_string(), cx),
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to a local search by contact name.
|
||||
let mut matches = self.filtered_contacts(cx);
|
||||
match matches.len() {
|
||||
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
|
||||
1 => {
|
||||
let public_key = matches.remove(0);
|
||||
if self.try_add(public_key, cx) {
|
||||
self.clear_input(window, cx);
|
||||
}
|
||||
}
|
||||
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a public key to the members if possible, otherwise show an error.
|
||||
fn try_add(&mut self, public_key: PublicKey, cx: &mut Context<Self>) -> bool {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
self.set_error("You are not signed in", cx);
|
||||
return false;
|
||||
};
|
||||
|
||||
if public_key == current_user {
|
||||
self.set_error("You cannot add yourself", cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.members.contains(&public_key) {
|
||||
self.set_error("This person is already added", cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.members.len() >= MAX_MEMBERS {
|
||||
self.set_error(format!("A chat can have up to {MAX_MEMBERS} members"), cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.members.push(public_key);
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Toggle a contact's membership.
|
||||
fn toggle_member(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
if self.members.iter().any(|member| member == public_key) {
|
||||
self.members.retain(|member| member != public_key);
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
} else {
|
||||
self.try_add(public_key.to_owned(), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
|
||||
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(resolving, window, cx);
|
||||
});
|
||||
self.resolving = resolving;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
if self.members.is_empty() {
|
||||
self.set_error("Add at least one person to chat with", cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
self.set_error("You are not signed in", cx);
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
if peer == current_user {
|
||||
self.set_error("You cannot chat with yourself", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let room = Room::new(current_user, [peer])
|
||||
let room = Room::new(current_user, self.members.clone())
|
||||
.organize(¤t_user)
|
||||
.kind(RoomKind::Ongoing);
|
||||
|
||||
@@ -91,43 +318,105 @@ impl NewChat {
|
||||
cx,
|
||||
);
|
||||
|
||||
window.close_modal(cx);
|
||||
true
|
||||
}
|
||||
|
||||
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.error = Some(message.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_contacts(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
contacts: &[PublicKey],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let start = range.start;
|
||||
|
||||
contacts
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(offset, public_key)| {
|
||||
let selected = self.members.contains(public_key);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
|
||||
Nav::new(SharedString::from(format!("contact-{}", start + offset)))
|
||||
.label(profile.name())
|
||||
.text_sm()
|
||||
.when_some(
|
||||
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||
|this, avatar| this.prefix(avatar),
|
||||
)
|
||||
.selected(selected)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.toggle_member(&public_key, cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewChat {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let contacts = Rc::new(self.filtered_contacts(cx));
|
||||
let contact_count = contacts.len();
|
||||
let notice = SharedString::from(format!("Group Chat can have up to {MAX_MEMBERS} members"));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Public key of the person you want to chat with")
|
||||
.child(Input::new(&self.input)),
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Input::new(&self.input).flex_1().text_sm().cleanable(true))
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("Add person")
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(self.resolving)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(error) = self.error.clone() {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child(error),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(notice),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("start-chat")
|
||||
.label("Start chat")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.start_chat(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child(error),
|
||||
uniform_list(
|
||||
"contacts",
|
||||
contact_count,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_contacts(range, contacts.as_slice(), cx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.h_80(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user