feat: add contact-based chat creation and re-add chat screening (#54)
Reviewed-on: reya/coop#54
This commit is contained in:
@@ -15,7 +15,7 @@ use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(450.))
|
||||
.show_close(false)
|
||||
.overlay_closable(false)
|
||||
@@ -138,7 +138,7 @@ impl ImportIdentity {
|
||||
Ok(keys) => {
|
||||
nostr.update_in(cx, |this, window, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
window.close_modal(cx);
|
||||
window.close_dialog(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod import;
|
||||
pub mod new_chat;
|
||||
pub mod new_community;
|
||||
pub mod restore;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
|
||||
@@ -1,79 +1,307 @@
|
||||
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, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Task, WeakEntity,
|
||||
Window, div, px, uniform_list,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{StyledExt, WindowExtension, v_flex};
|
||||
use ui::nav::Nav;
|
||||
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
/// 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_dialog(cx, {
|
||||
let view = view.clone();
|
||||
move |this, _window, _cx| {
|
||||
this.width(px(420.))
|
||||
.title("New chat")
|
||||
.confirm()
|
||||
.button_props(
|
||||
DialogButtonProps::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 +319,138 @@ 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 profile = persons.read(cx).get(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
|
||||
Nav::new(start + offset)
|
||||
.label(profile.name())
|
||||
.text_sm()
|
||||
.when(!AppSettings::get_hide_avatar(cx), |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.toggle_member(&public_key, cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_members(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(self.members.iter().map(|public_key| {
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
let id = SharedString::from(format!("m-{}", public_key.to_hex()));
|
||||
|
||||
h_flex()
|
||||
.id(id)
|
||||
.gap_1()
|
||||
.p_0p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().ghost_element_background_alt)
|
||||
.text_size(px(10.))
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.child(
|
||||
Avatar::from_source(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(div().max_w_32().truncate().child(profile.name()))
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.toggle_member(&public_key, cx);
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
h_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)),
|
||||
.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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.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),
|
||||
)
|
||||
.when(!self.members.is_empty(), |this| {
|
||||
this.child(self.render_members(cx))
|
||||
})
|
||||
.map(|this| {
|
||||
if let Some(error) = self.error.clone() {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child(error),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(notice),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
uniform_list(
|
||||
"contacts",
|
||||
contact_count,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_contacts(range, contacts.as_slice(), cx)
|
||||
}),
|
||||
)
|
||||
.h_80(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use ui::input::{Input, InputState};
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name"));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
let name_input = name_input.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
@@ -14,6 +14,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.title("New community")
|
||||
.child(Input::new(&name_input))
|
||||
.on_ok(move |_event, _window, cx| {
|
||||
let community = CommunityRegistry::global(cx);
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
|
||||
if name.is_empty() {
|
||||
@@ -25,8 +26,9 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
CommunityRegistry::global(cx)
|
||||
.update(cx, |registry, cx| registry.create(metadata, cx));
|
||||
community.update(cx, |registry, cx| {
|
||||
registry.create(metadata, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
|
||||
@@ -65,8 +65,8 @@ impl RestoreEncryption {
|
||||
this.set_announcement(Keys::new(secret), cx);
|
||||
});
|
||||
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
// Close the current dialog
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
|
||||
@@ -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_dialogs(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_dialog(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_dialog(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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -93,7 +93,7 @@ impl Workspace {
|
||||
// Subscribe to the nostr events
|
||||
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
|
||||
if let StateEvent::SignerChanged = event {
|
||||
window.close_all_modals(cx);
|
||||
window.close_all_dialogs(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -242,7 +242,7 @@ impl Workspace {
|
||||
Command::ShowSettings => {
|
||||
let view = settings::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(520.))
|
||||
.show_close(true)
|
||||
.pb_2()
|
||||
@@ -387,7 +387,7 @@ impl Workspace {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let ent = device.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let ent = ent.clone();
|
||||
|
||||
this.confirm()
|
||||
@@ -410,7 +410,7 @@ impl Workspace {
|
||||
this.set_announcement(Keys::generate(), cx);
|
||||
})
|
||||
.ok();
|
||||
// true to close modal
|
||||
// true to close dialog
|
||||
true
|
||||
})
|
||||
});
|
||||
@@ -418,7 +418,7 @@ impl Workspace {
|
||||
|
||||
fn import_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let restore = cx.new(|cx| RestoreEncryption::new(window, cx));
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(420.))
|
||||
.title("Restore Encryption")
|
||||
.child(restore.clone())
|
||||
@@ -426,7 +426,7 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let themes = registry.read(cx).themes();
|
||||
|
||||
@@ -508,7 +508,7 @@ impl Workspace {
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let dialog_layer = Root::render_dialog_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
div()
|
||||
@@ -519,7 +519,7 @@ impl Render for Workspace {
|
||||
.child(self.dock.clone())
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
// Dialogs
|
||||
.children(dialog_layer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,18 @@ use chat::{ChatRegistry, Room, RoomKind};
|
||||
use common::DebouncedDelay;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity,
|
||||
Window, div, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
@@ -23,8 +25,6 @@ use ui::nav::Nav;
|
||||
use ui::notification::Notification;
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::sidebar::nav_avatar;
|
||||
|
||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||
|
||||
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||
@@ -337,6 +337,7 @@ impl SearchPanel {
|
||||
range: Range<usize>,
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<impl IntoElement + use<>> {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Get the results
|
||||
@@ -358,20 +359,21 @@ impl SearchPanel {
|
||||
this.select(&pkey_clone, cx);
|
||||
});
|
||||
|
||||
Nav::new(ElementId::NamedInteger(
|
||||
"search-result".into(),
|
||||
(range.start + ix) as u64,
|
||||
))
|
||||
.label(profile.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when_some(
|
||||
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||
|this, avatar| this.prefix(avatar),
|
||||
)
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
Nav::new(range.start + ix)
|
||||
.label(profile.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -382,6 +384,7 @@ impl SearchPanel {
|
||||
range: Range<usize>,
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<impl IntoElement + use<>> {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Get the contact list
|
||||
@@ -403,20 +406,21 @@ impl SearchPanel {
|
||||
this.select(&pkey_clone, cx);
|
||||
});
|
||||
|
||||
Nav::new(ElementId::NamedInteger(
|
||||
"contact".into(),
|
||||
(range.start + ix) as u64,
|
||||
))
|
||||
.label(profile.name().trim())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when_some(
|
||||
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||
|this, avatar| this.prefix(avatar),
|
||||
)
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
Nav::new(range.start + ix)
|
||||
.label(profile.name().trim())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
use common::TimestampExt;
|
||||
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
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,
|
||||
Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px,
|
||||
retain_all, uniform_list,
|
||||
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
||||
px, retain_all, uniform_list,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::nav::Nav;
|
||||
@@ -34,17 +38,18 @@ use ui::{
|
||||
};
|
||||
|
||||
use crate::Command;
|
||||
use crate::dialogs::import;
|
||||
use crate::dialogs::{import, screening};
|
||||
|
||||
mod tab;
|
||||
mod utils;
|
||||
|
||||
use tab::SidebarTab;
|
||||
pub(crate) use utils::{nav_avatar, nav_icon, pick_banner};
|
||||
pub(crate) use utils::{nav_icon, pick_banner};
|
||||
|
||||
pub enum SidebarRow {
|
||||
Room { room: Entity<Room> },
|
||||
Community { community: Entity<Community> },
|
||||
Contact { public_key: PublicKey },
|
||||
}
|
||||
|
||||
/// A collapsible group of rows in the sidebar's community view.
|
||||
@@ -81,10 +86,23 @@ pub struct Sidebar {
|
||||
active_tab: SidebarTab,
|
||||
/// The community the sidebar is browsing, if any
|
||||
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,
|
||||
admins_open: bool,
|
||||
members_open: bool,
|
||||
new_requests: bool,
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||
_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(
|
||||
&communities,
|
||||
window,
|
||||
@@ -136,10 +159,17 @@ impl Sidebar {
|
||||
banner: pick_banner(),
|
||||
active_tab: SidebarTab::Inbox,
|
||||
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,
|
||||
admins_open: true,
|
||||
members_open: true,
|
||||
new_requests: false,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -149,9 +179,155 @@ impl Sidebar {
|
||||
return;
|
||||
}
|
||||
self.active_tab = tab;
|
||||
self.close_contacts();
|
||||
self.close_requests();
|
||||
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.
|
||||
fn reset_community(&mut self, cx: &mut Context<Self>) {
|
||||
if self.community.take().is_none() {
|
||||
@@ -293,13 +469,34 @@ impl Sidebar {
|
||||
fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let sidebar = cx.entity().downgrade();
|
||||
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 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()
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().px_2().child(
|
||||
@@ -329,38 +526,58 @@ impl Sidebar {
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
NavItem::new(
|
||||
"new-chat",
|
||||
"New Chat",
|
||||
Icon::new(IconName::Message).small(),
|
||||
)
|
||||
.on_click(|_event, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::NewChat), cx)
|
||||
}),
|
||||
NavItem::new("chat", "New Chat", Icon::new(IconName::Message).small())
|
||||
.on_click(|_event, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::NewChat), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
|
||||
.when(self.new_requests, |this| {
|
||||
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
|
||||
.when(request_count > 0, |this| {
|
||||
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({
|
||||
let sidebar = sidebar.clone();
|
||||
move |_event, window, cx| {
|
||||
if let Err(error) = sidebar.update(cx, |this, cx| {
|
||||
this.new_requests = false;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::error!("Failed to clear new requests: {error}");
|
||||
}
|
||||
window.dispatch_action(Box::new(Command::ShowRequests), cx);
|
||||
}
|
||||
}),
|
||||
.when(requests_open, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.toggle_requests(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
|
||||
.on_click(|_event, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::ShowContactList), cx)
|
||||
}),
|
||||
.when(contacts_open, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.toggle_contacts(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
SidebarTab::Communities => this.child(
|
||||
@@ -391,7 +608,7 @@ impl Sidebar {
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(active_tab.list_title()),
|
||||
.child(list_title),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -399,7 +616,7 @@ impl Sidebar {
|
||||
.flex_1()
|
||||
.child(
|
||||
uniform_list(
|
||||
active_tab.list_id(),
|
||||
list_id,
|
||||
rows.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_rows(range, rows.as_slice(), cx)
|
||||
@@ -411,6 +628,43 @@ impl Sidebar {
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -537,6 +791,21 @@ impl Sidebar {
|
||||
|
||||
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
|
||||
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 => {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.read(cx)
|
||||
@@ -564,6 +833,8 @@ impl Sidebar {
|
||||
rows: &[SidebarRow],
|
||||
cx: &Context<Sidebar>,
|
||||
) -> Vec<AnyElement> {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
rows.get(range.clone())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -577,15 +848,22 @@ impl Sidebar {
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
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 room = room.clone();
|
||||
|
||||
Nav::new(SharedString::from(format!("room-{index}")))
|
||||
Nav::new(index)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
|
||||
this.prefix(avatar)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(picture)
|
||||
.seed(seed)
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
div()
|
||||
@@ -602,6 +880,52 @@ impl Sidebar {
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
|
||||
let screening = screening::init(peer, window, cx);
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
DialogButtonProps::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(index)
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.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()
|
||||
}
|
||||
@@ -613,11 +937,16 @@ impl Sidebar {
|
||||
let sidebar = cx.entity().downgrade();
|
||||
let community = community.clone();
|
||||
|
||||
Nav::new(SharedString::from(format!("com-{index}")))
|
||||
Nav::new(index)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
|
||||
this.prefix(avatar)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(picture)
|
||||
.seed(seed)
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
ui::dock::add_panel_to(
|
||||
@@ -708,6 +1037,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let person = persons.read(cx).get(public_key, cx);
|
||||
|
||||
@@ -715,10 +1045,14 @@ impl Sidebar {
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when_some(
|
||||
nav_avatar(Some(person.avatar_seed()), person.avatar(), cx),
|
||||
|this, avatar| this.prefix(avatar),
|
||||
)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px};
|
||||
use settings::AppSettings;
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::{Avatar, PixelAvatar};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
pub(crate) fn nav_avatar(
|
||||
seed: Option<impl Into<SharedString>>,
|
||||
picture: Option<impl Into<ImageSource>>,
|
||||
cx: &App,
|
||||
) -> Option<AnyElement> {
|
||||
if AppSettings::get_hide_avatar(cx) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match (seed.map(Into::into), picture.map(Into::into)) {
|
||||
(None, None) => None,
|
||||
(seed, Some(picture)) => Some(
|
||||
Avatar::from_source(picture)
|
||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
(Some(seed), None) => Some(
|
||||
PixelAvatar::new(seed)
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
|
||||
Reference in New Issue
Block a user