5 Commits
Author SHA1 Message Date
reya 828986cd5c re-add screening dialog 2026-09-29 08:12:53 +07:00
reya 314fdcb827 update new chat dialog 2026-09-29 07:50:14 +07:00
reya 77eba03ae1 fix modal (dialog) 2026-09-28 10:34:53 +07:00
reya 9b06428f44 add requests panel 2026-09-28 10:06:29 +07:00
reya d3c3263c58 add contact panel 2026-09-28 09:27:48 +07:00
7 changed files with 1303 additions and 141 deletions
Generated
+42 -35
View File
@@ -699,11 +699,11 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitcoin-consensus-encoding" name = "bitcoin-consensus-encoding"
version = "1.2.0" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f"
dependencies = [ dependencies = [
"bitcoin-internals", "bitcoin-internals 0.7.0",
"hex-conservative 1.3.0", "hex-conservative 1.3.0",
"serde", "serde",
] ]
@@ -714,6 +714,12 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5"
[[package]]
name = "bitcoin-internals"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2"
[[package]] [[package]]
name = "bitcoin-io" name = "bitcoin-io"
version = "0.1.101" version = "0.1.101"
@@ -740,7 +746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e"
dependencies = [ dependencies = [
"bitcoin-consensus-encoding", "bitcoin-consensus-encoding",
"bitcoin-internals", "bitcoin-internals 0.6.0",
"hex-conservative 1.3.0", "hex-conservative 1.3.0",
"serde", "serde",
] ]
@@ -987,9 +993,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.7" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -2229,9 +2235,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.13" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]] [[package]]
name = "fixedbitset" name = "fixedbitset"
@@ -4052,9 +4058,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.105" version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -4753,9 +4759,9 @@ dependencies = [
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.18.0" version = "4.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46"
dependencies = [ dependencies = [
"futures-lite", "futures-lite",
"log", "log",
@@ -6996,9 +7002,9 @@ dependencies = [
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]] [[package]]
name = "skrifa" name = "skrifa"
@@ -7037,9 +7043,9 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.1" version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]] [[package]]
name = "smol" name = "smol"
@@ -7441,12 +7447,12 @@ dependencies = [
[[package]] [[package]]
name = "tauri-winrt-notification" name = "tauri-winrt-notification"
version = "0.7.3" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
dependencies = [ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
"windows 0.61.3", "windows 0.62.2",
"windows-version", "windows-version",
] ]
@@ -8363,9 +8369,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -8376,19 +8382,20 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.78" version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"tokio",
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -8396,9 +8403,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -8409,9 +8416,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -8542,9 +8549,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.105" version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -9832,18 +9839,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.58" version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.58" version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+31 -24
View File
@@ -401,34 +401,41 @@ impl RenderOnce for Modal {
.flex() .flex()
.items_center() .items_center()
.justify_center() .justify_center()
.relative()
.when_some(self.title, |this, title| { .when_some(self.title, |this, title| {
this.h_10().font_semibold().text_center().child(title) this.h_10().font_semibold().text_center().child(title)
})
.when(self.show_close, |this| {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
this.child(
div()
.absolute()
.top_0()
.bottom_0()
.right_2()
.flex()
.items_center()
.child(
Button::new("close")
.icon(IconName::CloseCircleFill)
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().icon_muted)
.color(cx.theme().ghost_element_background)
.hover(cx.theme().ghost_element_background)
.active(cx.theme().ghost_element_background),
)
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}),
),
)
}), }),
) )
.when(self.show_close, |this| {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
this.child(
Button::new("close")
.icon(IconName::CloseCircleFill)
.absolute()
.top_1p5()
.right_2()
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().icon_muted)
.color(cx.theme().ghost_element_background)
.hover(cx.theme().ghost_element_background)
.active(cx.theme().ghost_element_background),
)
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}),
)
})
.child( .child(
div() div()
.pt_px() .pt_px()
+2 -2
View File
@@ -101,8 +101,8 @@ impl RenderOnce for Nav {
h_flex() h_flex()
.gap_1() .gap_1()
.flex_1() .flex_1()
.child(div().truncate().min_w_0().child(self.label)) .min_w_0()
.child(div().flex_1()) .child(div().flex_1().min_w_0().truncate().child(self.label))
.when_some(self.suffix, |this, suffix| { .when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix)) this.child(div().flex_shrink_0().child(suffix))
}), }),
+1
View File
@@ -2,4 +2,5 @@ pub mod import;
pub mod new_chat; pub mod new_chat;
pub mod new_community; pub mod new_community;
pub mod restore; pub mod restore;
pub mod screening;
pub mod settings; pub mod settings;
+336 -47
View File
@@ -1,79 +1,306 @@
use std::ops::Range;
use std::rc::Rc;
use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind}; use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Subscription, WeakEntity, Window, div, px, Styled, Subscription, Task, WeakEntity, Window, div, px, uniform_list,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::NostrRegistry; use state::NostrRegistry;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, PanelHandle}; use ui::dock::{DockArea, DockPlacement, PanelHandle};
use ui::input::{Input, InputEvent, InputState}; 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) { pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| NewChat::new(dock, window, cx)); let view = cx.new(|cx| NewChat::new(dock, window, cx));
window.open_modal(cx, move |this, _window, _cx| { window.open_modal(cx, {
this.width(px(420.)).title("New chat").child(view.clone()) 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 { pub struct NewChat {
/// The dock a started chat opens in
dock: WeakEntity<DockArea>, dock: WeakEntity<DockArea>,
/// Find input, used to filter contacts or enter a new public key
/// Public key input
input: Entity<InputState>, input: Entity<InputState>,
/// Current user's contacts
/// Error message 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>, error: Option<SharedString>,
tasks: Vec<Task<Result<(), Error>>>,
/// Input subscription
_subscription: Option<Subscription>, _subscription: Option<Subscription>,
} }
impl NewChat { impl NewChat {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self { 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| { let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event { 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 { Self {
dock, dock,
input, input,
contacts: None,
members: Vec::new(),
resolving: false,
error: None, error: None,
tasks: Vec::new(),
_subscription: Some(subscription), _subscription: Some(subscription),
} }
} }
fn start_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) { /// Load the signed-in user's contact list.
let value = self.input.read(cx).value().to_string(); 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 { let nostr = NostrRegistry::global(cx);
self.set_error("Public key is invalid", cx); let client = nostr.read(cx).client();
let Some(current_user) = nostr.read(cx).current_user() else {
return; 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 nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let Some(current_user) = nostr.read(cx).current_user() else { let Some(current_user) = nostr.read(cx).current_user() else {
self.set_error("You are not signed in", cx); self.set_error("You are not signed in", cx);
return; return false;
}; };
if peer == current_user { let room = Room::new(current_user, self.members.clone())
self.set_error("You cannot chat with yourself", cx);
return;
}
let room = Room::new(current_user, [peer])
.organize(&current_user) .organize(&current_user)
.kind(RoomKind::Ongoing); .kind(RoomKind::Ongoing);
@@ -91,43 +318,105 @@ impl NewChat {
cx, cx,
); );
window.close_modal(cx); true
} }
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) { fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into()); self.error = Some(message.into());
cx.notify(); 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 { impl Render for NewChat {
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 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() v_flex()
.gap_4() .gap_3()
.child( .child(
v_flex() v_flex()
.gap_1() .child(
.text_color(cx.theme().text_muted) h_flex()
.child("Public key of the person you want to chat with") .gap_1()
.child(Input::new(&self.input)), .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( .child(
Button::new("start-chat") uniform_list(
.label("Start chat") "contacts",
.primary() contact_count,
.font_semibold() cx.processor(move |this, range, _window, cx| {
.on_click(cx.listener(|this, _event, window, cx| { this.render_contacts(range, contacts.as_slice(), 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),
) )
}) .h_80(),
)
} }
} }
+545
View File
@@ -0,0 +1,545 @@
use std::collections::HashMap;
use anyhow::Error;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
}
/// Screening
pub struct Screening {
/// Public Key of the person being screened.
public_key: PublicKey,
/// Whether the person's address is verified.
verified: bool,
/// Whether the person is followed by current user.
followed: bool,
/// Last time the person was active.
last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
this.tasks.clear();
window.close_all_modals(cx);
}));
cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
this.verify_identifier(cx);
});
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
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 Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
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 Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// 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 != current_user) {
mutual_contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
}));
}
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
if let Ok(mut stream) = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
activity = Some(event.created_at);
}
}
}
activity
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.last_active = result;
cx.notify();
})
.ok();
}));
}
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let public_key = self.public_key;
// Skip if the user doesn't have a NIP-05 identifier
let Some(address) = self.address(cx) else {
return;
};
let task: Task<Result<bool, Error>> =
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.verified = result;
cx.notify();
})
.ok();
}));
}
fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Tag::from(Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
});
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
.finalize_async(&signer)
.await?;
// Send the report to the public relays
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
window.close_modal(cx);
window.push_notification("Report submitted successfully", cx);
})
.ok();
}
}));
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
window.open_modal(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
for ix in range {
let Some(contact) = contacts.get(ix) else {
continue;
};
let profile = persons.read(cx).get(contact, cx);
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).small())
.child(profile.name()),
);
}
items
})
.h(px(300.)),
),
)
});
}
}
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const CONTACT: &str = "This person is one of your contacts.";
const NOT_CONTACT: &str = "This person is not one of your contacts.";
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar()).large())
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.name()),
),
)
.child(
h_flex()
.gap_3()
.child(
h_flex()
.p_1()
.flex_1()
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
.text_center()
.line_height(relative(1.))
.child(shorten_pubkey),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Warning)
.small()
.warning()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_3()
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip(RELAY_INFO),
),
)
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(t) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
t.to_human_time()
)))
} else {
this.child(SharedString::from(NO_ACTIVITY))
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
.child({
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
} else {
SharedString::from(
"Friendly Address (NIP-05) validation",
)
}
})
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
} else {
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from(NO_NIP05)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
.text_sm()
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if mutuals > 0 {
SharedString::from(mutuals_str)
} else {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
)
}
}
fn status_badge(status: Option<bool>, cx: &App) -> Div {
h_flex()
.size_6()
.justify_center()
.flex_shrink_0()
.map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircle).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
} else {
this.child(Indicator::new().small())
}
})
}
+346 -33
View File
@@ -1,27 +1,31 @@
use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use anyhow::Error;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt; use common::TimestampExt;
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
retain_all, uniform_list, px, retain_all, uniform_list,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::modal::ModalButtonProps;
use ui::nav::Nav; use ui::nav::Nav;
use ui::nav_item::NavItem; use ui::nav_item::NavItem;
use ui::notification::Notification; use ui::notification::Notification;
@@ -34,7 +38,7 @@ use ui::{
}; };
use crate::Command; use crate::Command;
use crate::dialogs::import; use crate::dialogs::{import, screening};
mod tab; mod tab;
mod utils; mod utils;
@@ -45,6 +49,7 @@ pub(crate) use utils::{nav_avatar, nav_icon, pick_banner};
pub enum SidebarRow { pub enum SidebarRow {
Room { room: Entity<Room> }, Room { room: Entity<Room> },
Community { community: Entity<Community> }, Community { community: Entity<Community> },
Contact { public_key: PublicKey },
} }
/// A collapsible group of rows in the sidebar's community view. /// A collapsible group of rows in the sidebar's community view.
@@ -81,10 +86,23 @@ pub struct Sidebar {
active_tab: SidebarTab, active_tab: SidebarTab,
/// The community the sidebar is browsing, if any /// The community the sidebar is browsing, if any
community: Option<WeakEntity<Community>>, community: Option<WeakEntity<Community>>,
/// Whether the inbox list is showing contacts to start a chat with
contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens
contacts: Option<Vec<PublicKey>>,
/// Contacts selected in the contact picker
selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list
contacts_scroll: UniformListScrollHandle,
/// Whether the inbox list is showing chat requests instead of rooms
requests_open: bool,
/// Scroll state of the requests list
requests_scroll: UniformListScrollHandle,
channels_open: bool, channels_open: bool,
admins_open: bool, admins_open: bool,
members_open: bool, members_open: bool,
new_requests: bool, new_requests: bool,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>, _subscriptions: SmallVec<[Subscription; 4]>,
} }
@@ -105,6 +123,11 @@ impl Sidebar {
}), }),
); );
// Keep the room and request counts in sync with the chat registry.
subscriptions.push(cx.observe(&chat, |_this, _chat, cx| {
cx.notify();
}));
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&communities, &communities,
window, window,
@@ -136,10 +159,17 @@ impl Sidebar {
banner: pick_banner(), banner: pick_banner(),
active_tab: SidebarTab::Inbox, active_tab: SidebarTab::Inbox,
community: None, community: None,
contacts_open: false,
contacts: None,
selected_contacts: HashSet::new(),
contacts_scroll: UniformListScrollHandle::new(),
requests_open: false,
requests_scroll: UniformListScrollHandle::new(),
channels_open: true, channels_open: true,
admins_open: true, admins_open: true,
members_open: true, members_open: true,
new_requests: false, new_requests: false,
tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -149,9 +179,155 @@ impl Sidebar {
return; return;
} }
self.active_tab = tab; self.active_tab = tab;
self.close_contacts();
self.close_requests();
cx.notify(); cx.notify();
} }
/// Leave the contact picker and clear its selection.
fn close_contacts(&mut self) {
self.contacts_open = false;
self.selected_contacts.clear();
}
/// Show or hide the contact picker in the inbox list.
fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.contacts_open = !self.contacts_open;
if !self.contacts_open {
self.selected_contacts.clear();
} else {
self.close_requests();
if self.contacts.is_none() {
self.load_contacts(window, cx);
}
}
cx.notify();
}
/// Leave the requests list in the inbox.
fn close_requests(&mut self) {
self.requests_open = false;
}
/// Show or hide the chat requests in the inbox list.
fn toggle_requests(&mut self, cx: &mut Context<Self>) {
self.requests_open = !self.requests_open;
if self.requests_open {
self.close_contacts();
self.new_requests = false;
}
cx.notify();
}
/// Load the signed-in user's contact list into the contact picker.
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.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) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
}));
}
/// Toggle a contact's selection in the contact picker.
fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
if !self.selected_contacts.remove(public_key) {
self.selected_contacts.insert(public_key.to_owned());
}
cx.notify();
}
/// Start a chat with the contacts selected in the picker.
fn create_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_contacts.is_empty() {
return;
}
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let chat = ChatRegistry::global(cx);
let async_chat = chat.downgrade();
let dock = self.dock.clone();
let receivers: Vec<PublicKey> = self.selected_contacts.iter().copied().collect();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let room = async_chat.update_in(cx, |chat, _window, cx| {
let room = cx.new(|_| {
Room::new(public_key, receivers)
.organize(&public_key)
.kind(RoomKind::Ongoing)
});
chat.track_room(&room, cx);
room
})?;
cx.update(|window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
window,
cx,
);
})?;
// Leave the picker now that the chat is open
this.update(cx, |this, cx| {
this.close_contacts();
cx.notify();
})?;
Ok(())
}));
}
/// Leave the community view, returning the sidebar to its tab list. /// Leave the community view, returning the sidebar to its tab list.
fn reset_community(&mut self, cx: &mut Context<Self>) { fn reset_community(&mut self, cx: &mut Context<Self>) {
if self.community.take().is_none() { if self.community.take().is_none() {
@@ -293,13 +469,34 @@ impl Sidebar {
fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement {
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();
let active_tab = self.active_tab; let active_tab = self.active_tab;
let requests_open = self.requests_open && active_tab == SidebarTab::Inbox;
let contacts_open = self.contacts_open && active_tab == SidebarTab::Inbox && !requests_open;
let rows = Rc::new(self.rows_for(active_tab, cx)); let rows = Rc::new(self.rows_for(active_tab, cx));
let scroll_handle = &self.scroll_handles[active_tab.index()];
// Whether to show the create button for the active tab
let create_button = contacts_open && !self.selected_contacts.is_empty();
let (list_id, scroll_handle, list_title) = if contacts_open {
("sidebar-contacts", &self.contacts_scroll, "Contacts")
} else if requests_open {
("sidebar-requests", &self.requests_scroll, "Requests")
} else {
(
active_tab.list_id(),
&self.scroll_handles[active_tab.index()],
active_tab.list_title(),
)
};
let request_count = ChatRegistry::global(cx)
.read(cx)
.count(&RoomKind::Request, cx);
v_flex() v_flex()
.size_full() .size_full()
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.relative()
.gap_2() .gap_2()
.child( .child(
div().px_2().child( div().px_2().child(
@@ -329,38 +526,58 @@ impl Sidebar {
.px_2() .px_2()
.gap_1() .gap_1()
.child( .child(
NavItem::new( NavItem::new("chat", "New Chat", Icon::new(IconName::Message).small())
"new-chat", .on_click(|_event, window, cx| {
"New Chat", window.dispatch_action(Box::new(Command::NewChat), cx)
Icon::new(IconName::Message).small(), }),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::NewChat), cx)
}),
) )
.child( .child(
NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
.when(self.new_requests, |this| { .when(request_count > 0, |this| {
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) this.suffix(
h_flex()
.gap_1()
.items_center()
.child(
h_flex()
.py_0p5()
.px_1()
.min_w_6()
.justify_center()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.text_center()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(SharedString::from(
request_count.to_string(),
)),
)
.when(self.new_requests && !requests_open, |this| {
this.child(
div()
.size_1()
.rounded_full()
.bg(cx.theme().cursor),
)
}),
)
}) })
.on_click({ .when(requests_open, |this| {
let sidebar = sidebar.clone(); this.bg(cx.theme().ghost_element_active)
move |_event, window, cx| { })
if let Err(error) = sidebar.update(cx, |this, cx| { .on_click(cx.listener(|this, _event, _window, cx| {
this.new_requests = false; this.toggle_requests(cx);
cx.notify(); })),
}) {
log::error!("Failed to clear new requests: {error}");
}
window.dispatch_action(Box::new(Command::ShowRequests), cx);
}
}),
) )
.child( .child(
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
.on_click(|_event, window, cx| { .when(contacts_open, |this| {
window.dispatch_action(Box::new(Command::ShowContactList), cx) this.bg(cx.theme().ghost_element_active)
}), })
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_contacts(window, cx);
})),
), ),
), ),
SidebarTab::Communities => this.child( SidebarTab::Communities => this.child(
@@ -391,7 +608,7 @@ impl Sidebar {
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.child(active_tab.list_title()), .child(list_title),
) )
.child( .child(
div() div()
@@ -399,7 +616,7 @@ impl Sidebar {
.flex_1() .flex_1()
.child( .child(
uniform_list( uniform_list(
active_tab.list_id(), list_id,
rows.len(), rows.len(),
cx.processor(move |this, range, _window, cx| { cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx) this.render_rows(range, rows.as_slice(), cx)
@@ -411,6 +628,43 @@ impl Sidebar {
) )
.child(Scrollbar::vertical(scroll_handle)), .child(Scrollbar::vertical(scroll_handle)),
) )
.when(create_button, |this| {
this.child(
v_flex()
.absolute()
.bottom_0()
.left_0()
.justify_center()
.h_20()
.w_full()
.px_4()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.child(
Button::new("create-chat")
.label(if self.selected_contacts.len() > 1 {
"Create Group Chat"
} else {
"Create Chat"
})
.primary()
.on_click(cx.listener(|this, _ev, window, cx| {
this.create_chat(window, cx);
})),
)
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child("Selected")
.child(div().font_semibold().child(SharedString::from(
self.selected_contacts.len().to_string(),
))),
),
)
})
.into_any_element() .into_any_element()
} }
@@ -537,6 +791,21 @@ impl Sidebar {
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> { fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab { match tab {
SidebarTab::Inbox if self.contacts_open => self
.contacts
.clone()
.unwrap_or_default()
.into_iter()
.map(|public_key| SidebarRow::Contact { public_key })
.collect(),
SidebarTab::Inbox if self.requests_open => {
let chat = ChatRegistry::global(cx);
chat.read(cx)
.rooms(&RoomKind::Request, cx)
.into_iter()
.map(|room| SidebarRow::Room { room })
.collect()
}
SidebarTab::Inbox => { SidebarTab::Inbox => {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
chat.read(cx) chat.read(cx)
@@ -577,6 +846,8 @@ impl Sidebar {
let picture = room.read(cx).display_image(cx); let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let kind = room.read(cx).kind;
let peer = room.read(cx).display_member(cx).public_key();
let dock = self.dock.clone(); let dock = self.dock.clone();
let room = room.clone(); let room = room.clone();
@@ -602,6 +873,48 @@ impl Sidebar {
window, window,
cx, cx,
); );
if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
let screening = screening::init(peer, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.confirm()
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
true
})
});
}
})
.into_any_element()
}
SidebarRow::Contact { public_key } => {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let selected = self.selected_contacts.contains(public_key);
let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned();
Nav::new(SharedString::from(format!("contact-{index}")))
.label(person.name())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(person.avatar_seed()), person.avatar(), cx),
|this, avatar| this.prefix(avatar),
)
.selected(selected)
.on_click(move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.toggle_contact(&public_key, cx);
}) {
log::error!("Failed to select contact: {error}");
}
}) })
.into_any_element() .into_any_element()
} }