diff --git a/Cargo.lock b/Cargo.lock index fca25d88..a2e35e72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1463,6 +1463,7 @@ dependencies = [ "gpui-pre", "instant", "itertools 0.13.0", + "log", "nip60_ui", "nostr-sdk", "person", diff --git a/assets/icons/arrow-down-circle.svg b/assets/icons/arrow-down-circle.svg deleted file mode 100644 index fea6250e..00000000 --- a/assets/icons/arrow-down-circle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/caret-down.svg b/assets/icons/caret-down.svg deleted file mode 100644 index 36be93ed..00000000 --- a/assets/icons/caret-down.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/caret-right.svg b/assets/icons/caret-right.svg deleted file mode 100644 index 1d0cc50f..00000000 --- a/assets/icons/caret-right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/check-circle.svg b/assets/icons/check-circle.svg deleted file mode 100644 index 6b1b8d0f..00000000 --- a/assets/icons/check-circle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/door.svg b/assets/icons/door.svg deleted file mode 100644 index b9cb5533..00000000 --- a/assets/icons/door.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/emoji.svg b/assets/icons/emoji.svg deleted file mode 100644 index 2df5b844..00000000 --- a/assets/icons/emoji.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/face-grinning.svg b/assets/icons/face-grinning.svg new file mode 100644 index 00000000..ada21a91 --- /dev/null +++ b/assets/icons/face-grinning.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/group.svg b/assets/icons/group.svg deleted file mode 100644 index c150fe35..00000000 --- a/assets/icons/group.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/input.svg b/assets/icons/input.svg deleted file mode 100644 index 86efa0e7..00000000 --- a/assets/icons/input.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/invite.svg b/assets/icons/invite.svg deleted file mode 100644 index 228373b0..00000000 --- a/assets/icons/invite.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/lock-open.svg b/assets/icons/lock-open.svg new file mode 100644 index 00000000..b2367128 --- /dev/null +++ b/assets/icons/lock-open.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/message.svg b/assets/icons/message.svg deleted file mode 100644 index 87e04016..00000000 --- a/assets/icons/message.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/paper-plane-fill.svg b/assets/icons/paper-plane-fill.svg deleted file mode 100644 index 8231630f..00000000 --- a/assets/icons/paper-plane-fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/plus-chat.svg b/assets/icons/plus-chat.svg deleted file mode 100644 index e4649d28..00000000 --- a/assets/icons/plus-chat.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/profile.svg b/assets/icons/profile.svg deleted file mode 100644 index 0ed13004..00000000 --- a/assets/icons/profile.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/relay.svg b/assets/icons/relay.svg deleted file mode 100644 index ea4e7b43..00000000 --- a/assets/icons/relay.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/user-plus.svg b/assets/icons/user-plus.svg new file mode 100644 index 00000000..935a08c0 --- /dev/null +++ b/assets/icons/user-plus.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index d41e9f78..56ec583b 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -10,6 +10,7 @@ speech = { path = "../speech" } ui = { path = "../ui" } gpui-kit = { workspace = true, features = ["speech"] } common = { path = "../common" } +log.workspace = true person = { path = "../person" } chat = { path = "../chat" } settings = { path = "../settings" } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 57b4e07a..1be3d1a4 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -12,13 +12,14 @@ use gpui::{ Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity, - Window, div, img, list, px, red, relative, retain_all, svg, white, + Window, div, img, list, px, relative, retain_all, svg, }; +use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; -use gpui_kit::component::menu::DropdownMenu; +use gpui_kit::component::menu::{ContextMenuExt as _, DropdownMenu, PopupMenuItem}; use gpui_kit::component::notification::Notification; use gpui_kit::component::scroll::Scrollbar; use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform}; @@ -35,9 +36,9 @@ use state::{ FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file, }; use ui::{ - Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow, - REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage, - group_reactions, opens_run, rendered_text, + Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, + ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage, group_reactions, + opens_run, react_menu, rendered_text, }; use crate::file::*; @@ -674,6 +675,36 @@ impl ChatPanel { } } + /// Read a message aloud, tracking its reveal state until utterance end. + fn speak_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context) { + let Some(text) = self.message(id).map(|message| message.preview()) else { + return; + }; + + match self.speaker.speak(&text) { + Ok(duration) => { + self.speech_reveal = Some((*id, duration)); + + // Re-enable speaking when no end event ever arrives + let session = (*id, duration); + let timer = cx.background_executor().timer(duration + END_GRACE); + self.reveal_timeout = Some(cx.spawn(async move |this, cx| { + timer.await; + this.update(cx, |this, cx| { + if this.speech_reveal.as_ref() == Some(&session) { + this.speech_reveal = None; + cx.notify(); + } + })?; + + Ok(()) + })); + cx.notify(); + } + Err(error) => window.push_notification(Notification::error(error.to_string()), cx), + } + } + fn remove_reply(&mut self, id: &EventId, cx: &mut Context) { self.replies_to.update(cx, |this, cx| { this.remove(id); @@ -1026,7 +1057,9 @@ impl ChatPanel { .bg(cx.theme().secondary) .rounded(cx.theme().radius) .text_xs() - .child(div().size_1p5().rounded_full().bg(gpui::green())) + .child( + div().size_1p5().rounded_full().bg(cx.theme().success), + ) .child(SharedString::from(url.to_string())), ); } @@ -1058,7 +1091,7 @@ impl ChatPanel { .bg(cx.theme().secondary) .rounded(cx.theme().radius) .text_xs() - .child(div().size_1p5().rounded_full().bg(gpui::green())) + .child(div().size_1p5().rounded_full().bg(cx.theme().success)) .child(SharedString::from(url.to_string())), ); } @@ -1126,7 +1159,7 @@ impl ChatPanel { .rounded_full() .bg(cx.theme().warning.opacity(0.15)) .text_color(cx.theme().warning) - .child(Icon::new(IconName::Warning).small()), + .child(Icon::new(IconName::TriangleAlert).small()), ) .child( div() @@ -1237,7 +1270,7 @@ impl ChatPanel { None => rendered_text, }; - MessageRow::new(ix) + let row = MessageRow::new(ix) .show_author(show_author) .hide_avatar(hide_avatar) .avatar( @@ -1286,7 +1319,76 @@ impl ChatPanel { ) .on_double_click(cx.listener(move |this, _, _window, cx| { this.reply_to(&id, cx); - })) + })); + + let panel = cx.weak_entity(); + let speaking = self.speech_reveal.is_some(); + + div() + .id(SharedString::from(format!("message-menu-{}", id.to_hex()))) + .w_full() + .child(row) + .context_menu(move |menu, window, cx| { + let menu = menu.item({ + let panel = panel.clone(); + let id = id; + + PopupMenuItem::new("Reply").on_click(move |_, _window, cx| { + if let Err(error) = panel.update(cx, |panel, cx| { + panel.reply_to(&id, cx); + }) { + log::warn!("starting a reply failed: {error}"); + } + }) + }); + + let menu = menu.submenu("React", window, cx, { + let panel = panel.clone(); + + move |menu, _window, _cx| { + let panel = panel.clone(); + + react_menu(menu, REACTION_EMOJIS, &[], move |emoji, _window, cx| { + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.send_reaction(emoji, &id, window, cx); + }) { + log::warn!("sending a reaction failed: {error}"); + } + }) + } + }); + + menu.item({ + let panel = panel.clone(); + let id = id; + + PopupMenuItem::new("Copy message").on_click(move |_, _window, cx| { + if let Err(error) = panel.update(cx, |panel, cx| { + panel.copy_message(&id, cx); + }) { + log::warn!("copying a message failed: {error}"); + } + }) + }) + .item({ + let panel = panel.clone(); + let id = id; + let speaking = speaking; + + PopupMenuItem::new("Read aloud") + .disabled(speaking) + .on_click(move |_, _window, cx| { + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.speak_message(&id, window, cx); + }) { + log::warn!("reading the message aloud failed: {error}"); + } + }) + }) + .separator() + .menu("Copy author", Box::new(Command::Copy(pk))) + .menu("Seen on", Box::new(Command::Trace(id))) + }) .into_any_element() } @@ -1515,34 +1617,7 @@ impl ChatPanel { let actions = actions.speech( speaking, cx.listener(move |this, _event, window, cx| { - let text = this.message(&speech_id).map(|message| message.preview()); - - if let Some(text) = text { - match this.speaker.speak(&text) { - Ok(duration) => { - this.speech_reveal = Some((speech_id, duration)); - - // Re-enable speaking when no end event ever arrives - let session = (speech_id, duration); - let timer = cx.background_executor().timer(duration + END_GRACE); - this.reveal_timeout = Some(cx.spawn(async move |this, cx| { - timer.await; - this.update(cx, |this, cx| { - if this.speech_reveal.as_ref() == Some(&session) { - this.speech_reveal = None; - cx.notify(); - } - })?; - - Ok(()) - })); - cx.notify(); - } - Err(error) => { - window.push_notification(Notification::error(error.to_string()), cx) - } - } - } + this.speak_message(&speech_id, window, cx); }), ); @@ -1574,8 +1649,12 @@ impl ChatPanel { .items_center() .justify_center() .rounded_full() - .bg(red()) - .child(Icon::new(IconName::Close).size_2().text_color(white())), + .bg(cx.theme().danger) + .child( + Icon::new(IconName::Close) + .size_2() + .text_color(cx.theme().danger_foreground), + ), ) .on_click({ let url = url.clone(); @@ -1727,8 +1806,12 @@ impl ChatPanel { .rounded_full() .border_1() .border_color(cx.theme().border) - .bg(gpui::green()) - .child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())), + .bg(cx.theme().success) + .child( + Icon::new(IconName::Lock) + .size_2() + .text_color(cx.theme().success_foreground), + ), ) .on_click({ let url = file.url.clone(); @@ -1752,51 +1835,20 @@ impl ChatPanel { items } - fn render_reply(&self, id: &EventId, cx: &Context) -> impl IntoElement { + fn render_reply(&self, ix: usize, id: &EventId, cx: &Context) -> impl IntoElement { if let Some(text) = self.message(id) { let profile = self.directory.read(cx).person(&text.author, cx); - div() - .w_full() - .pl_2() - .border_l_2() - .border_color(cx.theme().list_active) - .child( - h_flex() - .justify_between() - .child( - div() - .flex() - .items_baseline() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("Replying to:") - .child(div().text_color(cx.theme().primary).child(profile.name())), - ) - .child( - Button::new("remove-reply") - .icon(IconName::Close) - .xsmall() - .ghost() - .on_click({ - let id = text.id; - cx.listener(move |this, _, _, cx| { - this.remove_reply(&id, cx); - }) - }), - ), - ) - .child( - div() - .w_full() - .text_xs() - .text_ellipsis() - .line_clamp(1) - .child(text.preview()), - ) + ReplyPreview::new(ix, profile.name(), text.preview()) + .on_remove({ + let id = text.id; + cx.listener(move |this, _, _, cx| { + this.remove_reply(&id, cx); + }) + }) + .into_any_element() } else { - div() + div().into_any_element() } } @@ -1807,8 +1859,8 @@ impl ChatPanel { ) -> impl IntoIterator { let mut items = vec![]; - for id in self.replies_to.read(cx).iter() { - items.push(self.render_reply(id, cx)); + for (ix, id) in self.replies_to.read(cx).iter().enumerate() { + items.push(self.render_reply(ix, id, cx)); } items @@ -1883,7 +1935,8 @@ impl ChatPanel { .unwrap_or_default(); Button::new("emoji") - .icon(IconName::Emoji) + .icon(IconName::FaceGrinning) + .tooltip("Emoji") .ghost() .disabled(disabled) .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| { @@ -1931,7 +1984,7 @@ impl Panel for ChatPanel { Some(vec![ Button::new("subject") - .icon(IconName::Input) + .icon(IconName::TextCursorInput) .tooltip("Change subject") .small() .ghost() @@ -1978,7 +2031,7 @@ impl Render for ChatPanel { .child(Input::new(&self.subject_input)) .child( Button::new("change") - .icon(IconName::CheckCircle) + .icon(IconName::CircleCheck) .label("Change") .secondary() .disabled(self.uploading) @@ -2093,9 +2146,10 @@ impl Render for ChatPanel { .child(self.render_config_menu(dictating, window, cx)) .child( Button::new("send") - .icon(IconName::PaperPlaneFill) + .icon(IconName::SendHorizontal) + .tooltip("Send") .disabled(dictating || self.uploading) - .ghost() + .primary() .on_click(cx.listener(move |this, _ev, window, cx| { this.send_text_message(window, cx); })), diff --git a/crates/nip29_ui/src/details.rs b/crates/nip29_ui/src/details.rs index 53a7a415..4c898bda 100644 --- a/crates/nip29_ui/src/details.rs +++ b/crates/nip29_ui/src/details.rs @@ -1,8 +1,9 @@ use gpui::{ AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px, - relative, }; use gpui_kit::base::StyledExt as _; +use gpui_kit::component::description_list::{DescriptionItem, DescriptionList}; +use gpui_kit::component::tag::Tag; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use nip29::{Group, GroupMetadata}; use ui::Avatar; @@ -23,6 +24,7 @@ fn body(group: &WeakEntity, cx: &App) -> AnyElement { let group = entity.read(cx); let mut body = v_flex().gap_3().text_xs().child(header(group, cx)); + let mut items = Vec::new(); if let Some(metadata) = group.metadata() { if let Some(about) = metadata.about.as_ref() { @@ -36,86 +38,100 @@ fn body(group: &WeakEntity, cx: &App) -> AnyElement { let flags = flags(metadata); if !flags.is_empty() { - body = body.child(section( - "Flags", - wrap(flags.into_iter().map(|f| chip(f, cx))), - cx, - )); + items.push( + DescriptionItem::new("Flags").value(wrap( + flags + .into_iter() + .map(|flag| Tag::secondary().child(flag).into_any_element()), + )), + ); } if let Some(parent) = metadata.parent.as_ref() { - body = body.child(section( - "Parent", - div() - .child(SharedString::from(parent.as_str().to_owned())) - .into_any_element(), - cx, - )); + items.push( + DescriptionItem::new("Parent").value( + div() + .child(SharedString::from(parent.as_str().to_owned())) + .into_any_element(), + ), + ); } if !metadata.children.is_empty() { - body = body.child(section( - "Children", - wrap( - metadata - .children - .iter() - .map(|child| chip(child.as_str().to_owned(), cx)), - ), - cx, - )); + items.push( + DescriptionItem::new("Children").value(wrap(metadata.children.iter().map( + |child| { + Tag::secondary() + .child(SharedString::from(child.as_str().to_owned())) + .into_any_element() + }, + ))), + ); } if let Some(kinds) = metadata.supported_kinds.as_ref() { - let items: Vec = kinds + let values: Vec = kinds .iter() - .map(|kind| chip(kind.to_string(), cx)) + .map(|kind| { + Tag::secondary() + .child(SharedString::from(kind.to_string())) + .into_any_element() + }) .collect(); - body = body.child(section( - "Supported kinds", - if items.is_empty() { + items.push( + DescriptionItem::new("Supported kinds").value(if values.is_empty() { div() .text_color(cx.theme().muted_foreground) .child("None") .into_any_element() } else { - wrap(items) - }, - cx, - )); + wrap(values) + }), + ); } } let roles = group.roles().roles(); if !roles.is_empty() { - body = body.child(section( - "Roles", - wrap(roles.iter().map(|role| chip(role.name.clone(), cx))), - cx, - )); + items.push( + DescriptionItem::new("Roles").value(wrap(roles.iter().map(|role| { + Tag::secondary() + .child(SharedString::from(role.name.clone())) + .into_any_element() + }))), + ); } let pins = group.pins().pins(); if !pins.is_empty() { - body = body.child(section( - "Pinned", - v_flex() - .gap_1() - .children( - pins.iter() - .map(|pin| { - div() - .truncate() - .text_color(cx.theme().muted_foreground) - .child(crate::util::pin_label(group, pin)) - .into_any_element() - }) - .collect::>(), - ) - .into_any_element(), - cx, - )); + items.push( + DescriptionItem::new("Pinned").value( + v_flex() + .gap_1() + .children( + pins.iter() + .map(|pin| { + div() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(crate::util::pin_label(group, pin)) + .into_any_element() + }) + .collect::>(), + ) + .into_any_element(), + ), + ); + } + + if !items.is_empty() { + body = body.child( + DescriptionList::vertical() + .bordered(false) + .columns(1) + .children(items), + ); } body.into_any_element() @@ -157,20 +173,6 @@ fn header(group: &Group, cx: &App) -> AnyElement { .into_any_element() } -fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement { - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_medium() - .text_color(cx.theme().muted_foreground) - .child(label), - ) - .child(content) - .into_any_element() -} - fn wrap(items: impl IntoIterator) -> AnyElement { h_flex() .flex_wrap() @@ -179,19 +181,6 @@ fn wrap(items: impl IntoIterator) -> AnyElement { .into_any_element() } -fn chip(label: impl Into, cx: &App) -> AnyElement { - div() - .px_2() - .py_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .text_color(cx.theme().foreground) - .text_xs() - .line_height(relative(1.)) - .child(label.into()) - .into_any_element() -} - fn flags(metadata: &GroupMetadata) -> Vec<&'static str> { let mut flags = Vec::new(); diff --git a/crates/nip29_ui/src/invite.rs b/crates/nip29_ui/src/invite.rs index d0e44121..b7cce3a7 100644 --- a/crates/nip29_ui/src/invite.rs +++ b/crates/nip29_ui/src/invite.rs @@ -5,7 +5,8 @@ use gpui::{ App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px, }; -use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; +use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::input::InputState; use gpui_kit::component::notification::Notification; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; @@ -126,19 +127,52 @@ fn copy_button(group: &WeakEntity, code: &str) -> Button { fn revoke_button(group: &WeakEntity, id: nostr_sdk::prelude::EventId, code: &str) -> Button { let group = group.clone(); + let code = code.to_owned(); Button::new(SharedString::from(format!("invite-revoke-{code}"))) .label("Revoke") .ghost() .small() .on_click(move |_event, window, cx| { - match group.update(cx, |group, cx| group.delete_event(id, cx)) { - Ok(task) => report(window, cx, task), - Err(error) => log::warn!("nip29: revoking an invite failed: {error}"), - } + confirm_revoke(group.clone(), id, code.clone(), window, cx); }) } +/// Confirm revoking an invite, then delete it and surface any relay error. +fn confirm_revoke( + group: WeakEntity, + id: nostr_sdk::prelude::EventId, + code: String, + window: &mut Window, + cx: &mut App, +) { + window.open_alert_dialog(cx, move |this, _window, _cx| { + let group = group.clone(); + + this.confirm() + .width(px(360.)) + .title(format!("Revoke invite {code}?")) + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Revoke") + .ok_variant(ButtonVariant::Danger), + ) + .child( + div() + .text_xs() + .child("Nobody will be able to ask to join with this code."), + ) + .on_ok(move |_event, window, cx| { + match group.update(cx, |group, cx| group.delete_event(id, cx)) { + Ok(task) => report(window, cx, task), + Err(error) => log::warn!("nip29: revoking an invite failed: {error}"), + } + true + }) + }); +} + fn create_row(group: WeakEntity, input: Entity, cx: &App) -> impl IntoElement { h_flex() .items_end() diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 6c0a9d44..ef65d882 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -27,7 +27,7 @@ use person::Directory; use serde::Deserialize; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use speech::speaker::Speaker; +use speech::speaker::{END_GRACE, Speaker}; use state::{NostrRegistry, pick_file, upload_file}; use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text}; use util::{attachment_name, display_name, opens_run, pin_label, report}; @@ -261,17 +261,43 @@ pub fn confirm_delete(group: WeakEntity, window: &mut Window, cx: &mut Ap }); } -/// Remove a member from the group and surface any relay error as a notification. -pub fn remove_member( +/// Confirm removing a member, then remove them and surface any relay error. +pub fn confirm_remove_member( group: WeakEntity, public_key: PublicKey, + member: SharedString, window: &mut Window, cx: &mut App, ) { - match group.update(cx, |group, cx| group.remove_user(public_key, cx)) { - Ok(task) => report(window, cx, task), - Err(error) => log::warn!("nip29: removing a group member failed: {error}"), - } + let Some(group_name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else { + return; + }; + + window.open_alert_dialog(cx, move |this, _window, _cx| { + let group = group.clone(); + + this.confirm() + .width(px(360.)) + .title(format!("Remove {member} from {group_name}?")) + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Remove") + .ok_variant(ButtonVariant::Danger), + ) + .child( + div() + .text_xs() + .child("The relay will remove them from this group."), + ) + .on_ok(move |_event, window, cx| { + match group.update(cx, |group, cx| group.remove_user(public_key, cx)) { + Ok(task) => report(window, cx, task), + Err(error) => log::warn!("nip29: removing a group member failed: {error}"), + } + true + }) + }); } /// Grant a member the admin role and surface any relay error as a notification. @@ -559,6 +585,39 @@ impl GroupPanel { } } + /// Read a message aloud, tracking its reveal state until utterance end. + fn speak( + &mut self, + id: EventId, + text: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + match self.speaker.speak(&text) { + Ok(duration) => { + self.speech_reveal = Some((id, duration)); + + let session = (id, duration); + let timer = cx.background_executor().timer(duration + END_GRACE); + + self.reveal_timeout = Some(cx.spawn(async move |this, cx| { + timer.await; + this.update(cx, |this, cx| { + if this.speech_reveal.as_ref() == Some(&session) { + this.speech_reveal = None; + cx.notify(); + } + })?; + + Ok(()) + })); + + cx.notify(); + } + Err(error) => window.push_notification(Notification::error(error.to_string()), cx), + } + } + fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context) { self.reply_to = Some(id); self.input.update(cx, |input, cx| { @@ -716,18 +775,46 @@ impl GroupPanel { window: &mut Window, cx: &mut Context, ) { - let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else { - return; - }; + window.open_alert_dialog(cx, move |this, _window, _cx| { + let group = group.clone(); - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - if let Err(error) = delete.await { - this.update_in(cx, |_this, window, cx| { - window.push_notification(Notification::error(error.to_string()), cx); - })?; - } - Ok(()) - })); + this.confirm() + .width(px(360.)) + .title("Delete message?") + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Delete") + .ok_variant(ButtonVariant::Danger), + ) + .child( + div() + .text_xs() + .child("The relay will delete this message for everyone."), + ) + .on_ok(move |_event, window, cx| { + let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) + else { + return true; + }; + + window + .spawn(cx, async move |cx| -> Result<()> { + if let Err(error) = delete.await { + cx.update(|window, cx| { + window.push_notification( + Notification::error(error.to_string()), + cx, + ); + })?; + } + Ok(()) + }) + .detach(); + + true + }) + }); } fn toggle_pin( @@ -1162,7 +1249,7 @@ impl GroupPanel { .items_end() .child( Button::new("upload") - .icon(ui::IconName::Paperclip) + .icon(IconName::Paperclip) .tooltip("Upload media") .loading(self.uploading) .disabled(dictating || self.uploading) @@ -1202,8 +1289,9 @@ impl GroupPanel { .child(self.render_emoji_menu(dictating, window, cx)) .child( Button::new("send") - .icon(ui::IconName::PaperPlaneFill) - .ghost() + .icon(IconName::SendHorizontal) + .tooltip("Send") + .primary() .disabled(dictating || self.uploading) .on_click(cx.listener(|this, _event, window, cx| { this.send(window, cx); @@ -1234,7 +1322,8 @@ impl GroupPanel { .unwrap_or_default(); Button::new("emoji") - .icon(ui::IconName::Emoji) + .icon(IconName::FaceGrinning) + .tooltip("Emoji") .ghost() .disabled(disabled) .dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| { @@ -1305,7 +1394,7 @@ impl GroupPanel { ) .child( Button::new("cancel-reply") - .icon(ui::IconName::Close) + .icon(IconName::Close) .tooltip("Cancel reply") .ghost() .small() @@ -1342,7 +1431,7 @@ impl GroupPanel { ) .child( Button::new(SharedString::from(format!("remove-attachment-{ix}"))) - .icon(ui::IconName::Close) + .icon(IconName::Close) .tooltip("Remove") .ghost() .xsmall() diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 4df8e660..f23e7b7b 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -3,19 +3,17 @@ use chat_ui::Command; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, - WeakEntity, div, + AnyElement, App, ClipboardItem, Entity, InteractiveElement, IntoElement, ParentElement, + SharedString, Styled, WeakEntity, div, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; -use gpui_kit::component::notification::Notification; -use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex}; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex}; use nip29::{Activity, Group, Reaction}; -use nostr_sdk::prelude::{EventId, Kind}; +use nostr_sdk::prelude::{EventId, Kind, ToBech32}; use person::Directory; use settings::AppSettings; -use speech::speaker::END_GRACE; use state::NostrRegistry; use ui::{ Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, @@ -129,35 +127,15 @@ pub(crate) fn render( SharedString::from(format!("message-copy-{id}")), message.preview(), ) - .speech(speaking, move |_event, _window, cx| { - if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| { - match panel.speaker.speak(&speech_text) { - Ok(duration) => { - panel.speech_reveal = Some((speech_id, duration)); + .speech(speaking, { + let speech_text = speech_text.clone(); - let session = (speech_id, duration); - let timer = cx.background_executor().timer(duration + END_GRACE); - - panel.reveal_timeout = Some(cx.spawn(async move |this, cx| { - timer.await; - this.update(cx, |this, cx| { - if this.speech_reveal.as_ref() == Some(&session) { - this.speech_reveal = None; - cx.notify(); - } - })?; - - Ok(()) - })); - - cx.notify(); - } - Err(error) => { - window.push_notification(Notification::error(error.to_string()), cx) - } + move |_event, _window, cx| { + if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| { + panel.speak(speech_id, speech_text.clone(), window, cx); + }) { + log::warn!("nip29: reading the message aloud failed: {error}"); } - }) { - log::warn!("nip29: reading the message aloud failed: {error}"); } }); @@ -189,9 +167,11 @@ pub(crate) fn render( let panel = panel.clone(); PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| { - let _ = panel.update_in(cx, |panel, window, cx| { + if let Err(error) = panel.update_in(cx, |panel, window, cx| { panel.start_reply(id, window, cx); - }); + }) { + log::warn!("nip29: starting a reply failed: {error}"); + } }) }) .submenu("React", window, cx, { @@ -211,6 +191,29 @@ pub(crate) fn render( } }) } + }) + .item({ + let panel = panel.clone(); + let speech_text = speech_text.clone(); + let speech_id = id; + + PopupMenuItem::new("Read aloud") + .disabled(speaking) + .on_click(move |_event, _window, cx| { + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.speak(speech_id, speech_text.clone(), window, cx); + }) { + log::warn!("nip29: reading the message aloud failed: {error}"); + } + }) + }) + .item({ + let author = author; + + PopupMenuItem::new("Copy author").on_click(move |_event, _window, cx| { + let npub = author.to_bech32().unwrap_or_default(); + cx.write_to_clipboard(ClipboardItem::new_string(npub)); + }) }); if !admin { diff --git a/crates/nip60_ui/src/lib.rs b/crates/nip60_ui/src/lib.rs index ef2bf436..df1b4644 100644 --- a/crates/nip60_ui/src/lib.rs +++ b/crates/nip60_ui/src/lib.rs @@ -2,9 +2,10 @@ use std::rc::Rc; -use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px}; -use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::input::{Input, InputState}; +use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, div, px}; +use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; +use gpui_kit::component::dialog::DialogButtonProps; +use gpui_kit::component::input::{InputState, NumberInput}; use gpui_kit::component::notification::Notification; use gpui_kit::component::{WindowExt, h_flex, v_flex}; use nip60::WalletRegistry; @@ -20,7 +21,12 @@ pub fn open_nutzap( recipient: PublicKey, target: Option, ) { - let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Amount (sats)") + .step(1.) + .min(1.) + }); let send: SubmitFn = { let input = input.clone(); @@ -33,12 +39,36 @@ pub fn open_nutzap( } }; - let wallet = WalletRegistry::global(cx); - wallet.update(cx, |registry, cx| { - registry.send_nutzap(recipient, amount, target, None, cx); + window.open_alert_dialog(cx, move |this, _window, _cx| { + this.confirm() + .width(px(360.)) + .title(format!("Send {amount} sats?")) + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Send") + .ok_variant(ButtonVariant::Danger), + ) + .child( + div() + .text_xs() + .child("A nutzap cannot be undone once sent."), + ) + .on_ok(move |_event, window, cx| { + let wallet = WalletRegistry::global(cx); + wallet.update(cx, |registry, cx| { + registry.send_nutzap(recipient, amount, target, None, cx); + }); + + // Close the confirmation, then the amount dialog beneath it. + window.close_dialog(cx); + window.close_dialog(cx); + true + }) }); - true + // The amount dialog stays open beneath the confirmation. + false }) }; @@ -50,28 +80,25 @@ pub fn open_nutzap( move |event, window, cx| send(event, window, cx) }) .child( - v_flex() - .gap_3() - .child(Input::new(&input).cleanable(true)) - .child( - h_flex() - .gap_1() - .justify_end() - .child( - Button::new("cancel") - .label("Cancel") - .ghost() - .on_click(|_, window, cx| window.close_dialog(cx)), - ) - .child(Button::new("send").label("Send").primary().on_click({ - let send = send.clone(); - move |event, window, cx| { - if send(event, window, cx) { - window.close_dialog(cx); - } + v_flex().gap_3().child(NumberInput::new(&input)).child( + h_flex() + .gap_1() + .justify_end() + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child(Button::new("send").label("Send").primary().on_click({ + let send = send.clone(); + move |event, window, cx| { + if send(event, window, cx) { + window.close_dialog(cx); } - })), - ), + } + })), + ), ) }); } diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs index 38d1a6b0..9f3c950f 100644 --- a/crates/nip60_ui/src/wallet.rs +++ b/crates/nip60_ui/src/wallet.rs @@ -6,15 +6,16 @@ use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement as _, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative, - uniform_list, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, + relative, uniform_list, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; -use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _}; +use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; +use gpui_kit::component::list::ListItem; use gpui_kit::component::notification::Notification; use gpui_kit::component::separator::Separator; use gpui_kit::component::{ @@ -227,7 +228,7 @@ impl SendDialog { }), cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { - this.send(window, cx); + this.confirm_send(window, cx); } }), ]; @@ -413,23 +414,45 @@ impl SendDialog { } } - /// Validate the selection and amount, then schedule the nutzap. - fn send(&mut self, _window: &mut Window, cx: &mut Context) -> bool { + /// Validate the form, then confirm before the irreversible nutzap. + fn confirm_send(&mut self, window: &mut Window, cx: &mut Context) { let Some(recipient) = self.selected else { self.set_error("Select a contact to send to", cx); - return false; + return; }; let Some(amount) = parse_amount(&self.amount_input, cx) else { self.set_error("Enter an amount in sats", cx); - return false; + return; }; - WalletRegistry::global(cx).update(cx, |registry, cx| { - registry.send_nutzap(recipient, amount, None, None, cx); - }); + let recipient_name = self.directory.read(cx).person(&recipient, cx).name(); - true + window.open_alert_dialog(cx, move |this, _window, _cx| { + this.confirm() + .width(px(360.)) + .title(format!("Send {amount} sats to {recipient_name}?")) + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Send") + .ok_variant(ButtonVariant::Danger), + ) + .child( + div() + .text_xs() + .child("A nutzap cannot be undone once sent."), + ) + .on_ok(move |_event, window, cx| { + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.send_nutzap(recipient, amount, None, None, cx); + }); + // Close the confirmation, then the send dialog beneath it. + window.close_dialog(cx); + window.close_dialog(cx); + true + }) + }); } fn clear_input(&mut self, window: &mut Window, cx: &mut Context) { @@ -468,28 +491,28 @@ impl SendDialog { let selected = self.selected == Some(public_key); let id = SharedString::from(format!("contact-{}", public_key.to_hex())); - h_flex() - .id(id) + let mut item = ListItem::new(id) + .accessibility_label(profile.name()) + .selected(selected) + .cursor_pointer() .w_full() .h_8() .px_2() - .gap_2() .rounded(cx.theme().radius) .text_sm() - .cursor_pointer() - .hover(|this| this.bg(cx.theme().list_hover)) - .when(selected, |this| this.bg(cx.theme().list_active)) - .when(!AppSettings::get_hide_avatar(cx), |this| { - this.child( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .small(), - ) - }) - .child(div().min_w_0().truncate().child(profile.name())) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_selection(&public_key, cx); - })) + })); + + if !AppSettings::get_hide_avatar(cx) { + item = item.child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ); + } + + item.child(div().flex_1().min_w_0().truncate().child(profile.name())) .into_any_element() }) .collect() @@ -507,12 +530,10 @@ impl SendDialog { .gap_1() .child( h_flex() - .id(id) .gap_1() .p_0p5() .rounded_full() .bg(cx.theme().secondary) - .hover(|this| this.bg(cx.theme().primary)) .child( Avatar::new(profile.avatar()) .seed(profile.avatar_seed()) @@ -526,10 +547,18 @@ impl SendDialog { .line_height(relative(1.0)) .child(profile.name()), ) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.selected = None; - cx.notify(); - })), + .child( + Button::new(id) + .icon(IconName::Close) + .tooltip("Remove recipient") + .accessibility_label("Remove recipient") + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _event, _window, cx| { + this.selected = None; + cx.notify(); + })), + ), ) .into_any_element() } @@ -602,9 +631,7 @@ impl Render for SendDialog { .label("Send") .primary() .on_click(cx.listener(move |this, _event, window, cx| { - if this.send(window, cx) { - window.close_dialog(cx); - } + this.confirm_send(window, cx); })), ), ) @@ -613,7 +640,12 @@ impl Render for SendDialog { /// Create a lightning invoice or redeem a pasted cashu token fn open_receive_dialog(window: &mut Window, cx: &mut App) { - let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + let amount_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Amount (sats)") + .step(1.) + .min(1.) + }); let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…")); let create_invoice: SubmitFn = { @@ -678,7 +710,7 @@ fn open_receive_dialog(window: &mut Window, cx: &mut App) { h_flex() .gap_1() .w_full() - .child(Input::new(&amount_input).cleanable(true)) + .child(NumberInput::new(&amount_input)) .child( Button::new("invoice") .label("Invoice") diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs deleted file mode 100644 index 3a085f4b..00000000 --- a/crates/ui/src/icon.rs +++ /dev/null @@ -1,75 +0,0 @@ -use gpui::SharedString; -use gpui_kit::assets::IconNamed; - -/// Coop icons kept from before the gpui-kit migration; absent from the kit bundle. -#[derive(Clone, Copy)] -pub enum IconName { - ArrowDownCircle, - ArrowLeft, - Book, - CaretDown, - CaretRight, - CheckCircle, - Close, - Compass, - Door, - Emoji, - Group, - Input, - Invite, - Link, - Lock, - Message, - Paperclip, - PaperPlaneFill, - Plus, - PlusChat, - Profile, - Relay, - Speech, - Search, - Settings, - Settings2, - Unlock, - Upload, - Warning, - Wallet, -} - -impl IconNamed for IconName { - fn path(self) -> SharedString { - match self { - Self::ArrowDownCircle => "icons/arrow-down-circle.svg", - Self::ArrowLeft => "icons/arrow-left.svg", - Self::Book => "icons/book.svg", - Self::CaretDown => "icons/caret-down.svg", - Self::CaretRight => "icons/caret-right.svg", - Self::CheckCircle => "icons/check-circle.svg", - Self::Close => "icons/close.svg", - Self::Compass => "icons/compass.svg", - Self::Door => "icons/door.svg", - Self::Emoji => "icons/emoji.svg", - Self::Group => "icons/group.svg", - Self::Input => "icons/input.svg", - Self::Invite => "icons/invite.svg", - Self::Link => "icons/link.svg", - Self::Lock => "icons/lock.svg", - Self::Message => "icons/message.svg", - Self::Paperclip => "icons/paperclip.svg", - Self::PaperPlaneFill => "icons/paper-plane-fill.svg", - Self::Plus => "icons/plus.svg", - Self::PlusChat => "icons/plus-chat.svg", - Self::Profile => "icons/profile.svg", - Self::Relay => "icons/relay.svg", - Self::Speech => "icons/speech.svg", - Self::Search => "icons/search.svg", - Self::Settings => "icons/settings.svg", - Self::Settings2 => "icons/settings2.svg", - Self::Unlock => "icons/unlock.svg", - Self::Upload => "icons/upload.svg", - Self::Warning => "icons/warning.svg", - Self::Wallet => "icons/wallet.svg", - } - .into() - } -} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index acec50ca..db8c088a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,13 +1,11 @@ pub use avatar::Avatar; pub use form::*; -pub use icon::IconName; pub use message::*; pub use text::*; pub use window_drag::title_bar_drag_handlers; pub mod avatar; pub mod form; -pub mod icon; pub mod message; pub mod text; pub mod window_drag; diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs index 89b124ce..75f1d46e 100644 --- a/crates/ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -251,6 +251,7 @@ pub struct ReplyPreview { author: SharedString, preview: SharedString, on_click: Option, + on_remove: Option, } impl ReplyPreview { @@ -266,6 +267,7 @@ impl ReplyPreview { author: author.into(), preview: preview.into(), on_click: None, + on_remove: None, } } @@ -278,6 +280,16 @@ impl ReplyPreview { self.on_click = Some(Box::new(listener)); self } + + /// Show a remove button, calling the handler when it is clicked. + #[must_use] + pub fn on_remove( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_remove = Some(Box::new(handler)); + self + } } impl Styled for ReplyPreview { @@ -294,6 +306,7 @@ impl RenderOnce for ReplyPreview { author, preview, on_click, + on_remove, } = self; div() @@ -304,8 +317,28 @@ impl RenderOnce for ReplyPreview { .border_color(cx.theme().list_active) .text_xs() .refine_style(&style) - .child(div().font_semibold().child(author)) - .child(div().w_full().text_ellipsis().line_clamp(1).child(preview)) + .child( + h_flex() + .w_full() + .child( + div() + .flex_1() + .min_w_0() + .child(div().font_semibold().child(author)) + .child(div().w_full().text_ellipsis().line_clamp(1).child(preview)), + ) + .when_some(on_remove, |this, on_remove| { + this.child( + Button::new("remove-reply") + .icon(IconName::X) + .xsmall() + .ghost() + .tooltip("Remove reply") + .flex_shrink_0() + .on_click(on_remove), + ) + }), + ) .hover(|this| this.bg(cx.theme().list_hover)) .when_some(on_click, |this, on_click| this.on_click(on_click)) .into_any_element() @@ -732,7 +765,7 @@ impl RenderOnce for MessageActions { .when_some(speech, |this, (disabled, speech)| { this.child( Button::new("speech") - .icon(crate::IconName::Speech) + .icon(IconName::Speech) .tooltip("Read aloud") .small() .ghost() diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index eb18a35c..d7154f0c 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -2,17 +2,18 @@ use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, - Styled, Subscription, Task, Window, div, px, relative, + Styled, Subscription, Task, Window, div, px, }; +use gpui_kit::assets::IconName; +use gpui_kit::component::alert::{Alert, AlertVariant}; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_kit::component::form::{field, v_form}; use gpui_kit::component::input::{Input, InputEvent, InputState}; -use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; +use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, v_flex}; use instant::Duration; use nostr_connect::prelude::*; use state::{NostrRegistry, StateEvent}; -use ui::IconName; pub fn open(window: &mut Window, cx: &mut App) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); @@ -277,7 +278,7 @@ impl ImportIdentity { DialogFooter::new().child( Button::new("unlock") .primary() - .icon(IconName::Unlock) + .icon(IconName::LockOpen) .label("Unlock") .loading(self.loading) .disabled(self.loading) @@ -345,26 +346,18 @@ impl Render for ImportIdentity { }) .when(bunker_warning, |this| { this.child( - div() - .p_2() - .rounded(cx.theme().radius_lg) - .bg(cx.theme().warning.opacity(0.15)) - .text_xs() - .text_color(cx.theme().warning) - .line_height(relative(1.2)) - .child(div().child(BUNKER_WARN)), + Alert::new("bunker-warning", BUNKER_WARN) + .with_variant(AlertVariant::Warning) + .icon(IconName::TriangleAlert) + .small(), ) }) .when(key_warning, |this| { this.child( - div() - .p_2() - .rounded(cx.theme().radius_lg) - .bg(cx.theme().warning.opacity(0.15)) - .text_xs() - .text_color(cx.theme().warning) - .line_height(relative(1.2)) - .child(div().child(KEY_WARN)), + Alert::new("key-warning", KEY_WARN) + .with_variant(AlertVariant::Warning) + .icon(IconName::TriangleAlert) + .small(), ) }), ) diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index 81d92ad0..19f32224 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -13,6 +13,7 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::list::ListItem; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; use person::Directory; @@ -20,8 +21,6 @@ use settings::AppSettings; use state::NostrRegistry; use ui::Avatar; -use crate::nav::Nav; - /// Maximum number of members a chat can have. const MAX_MEMBERS: usize = 3; @@ -325,27 +324,30 @@ impl NewChat { contacts: &[PublicKey], cx: &mut Context, ) -> Vec { - let start = range.start; - contacts .get(range) .into_iter() .flatten() - .enumerate() - .map(|(offset, public_key)| { + .map(|public_key| { let profile = self.directory.read(cx).person(public_key, cx); let public_key = *public_key; - Nav::new(start + offset) - .label(profile.name()) + ListItem::new(public_key.to_hex()) + .accessibility_label(profile.name()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() .when(!AppSettings::get_hide_avatar(cx), |this| { - this.prefix( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ), ) }) + .child(div().flex_1().min_w_0().truncate().child(profile.name())) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_member(&public_key, cx); })) @@ -369,7 +371,7 @@ impl NewChat { .p_0p5() .rounded_full() .bg(cx.theme().secondary) - .hover(|this| this.bg(cx.theme().primary)) + .hover(|this| this.bg(cx.theme().secondary_hover)) .child( Avatar::new(profile.avatar()) .seed(profile.avatar_seed()) diff --git a/crates/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index f41562f9..0ca1ce97 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -1,20 +1,22 @@ use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement, + AnyElement, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, - Subscription, Task, WeakEntity, Window, div, px, rems, + Subscription, Task, WeakEntity, Window, div, px, }; -use gpui_base::{Popup, Select}; -use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants}; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem}; +use gpui_kit::component::select::{Select, SelectEvent, SelectState}; use gpui_kit::component::separator::Separator; use gpui_kit::component::switch::Switch; -use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; +use gpui_kit::component::{ + ActiveTheme, Disableable, IndexPath, Sizable, WindowExt, h_flex, v_flex, +}; use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -72,13 +74,10 @@ struct NewGroup { name_input: Entity, about_input: Entity, custom_relay_input: Entity, - /// The relays offered in the relay select. - relays: Vec, /// The selected relay; `None` selects the custom relay field. selected_relay: Option, - /// Whether the relay select popup is open. - relay_select_open: bool, - relay_options_focus: FocusHandle, + /// The relay select; its options end with the custom relay choice. + relay_select: Entity>, /// Keep the group in the NIP-44 encrypted section of the user's group list. list_private: bool, /// The uploaded group picture, ready to be published with the metadata. @@ -113,8 +112,6 @@ impl NewGroup { .placeholder("What is this group about?") }); - let relay_options_focus = cx.focus_handle(); - let relays: Vec = DEFAULT_RELAYS .iter() .filter_map(|url| match RelayUrl::parse(url) { @@ -127,9 +124,37 @@ impl NewGroup { .collect(); let selected_relay = relays.first().cloned(); + let mut options: Vec = relays + .iter() + .map(|relay| RelayOption { + label: relay_label(relay), + value: Some(relay.clone()), + }) + .collect(); + options.push(RelayOption { + label: "Custom relay".into(), + value: None, + }); + let registry = GroupsRegistry::global(cx); let mut subscriptions = vec![]; + let delegate = RelayDelegate { options }; + let selected_index = delegate.position(&selected_relay); + let relay_select = cx.new(|cx| SelectState::new(delegate, selected_index, window, cx)); + + subscriptions.push(cx.subscribe_in( + &relay_select, + window, + |this, _select, event, _window, cx| { + let SelectEvent::Confirm(value) = event; + + if let Some(choice) = value { + this.select_relay(choice.clone(), cx); + } + }, + )); + subscriptions.push(cx.subscribe_in( ®istry, window, @@ -165,10 +190,8 @@ impl NewGroup { name_input, about_input, custom_relay_input, - relays, selected_relay, - relay_select_open: false, - relay_options_focus, + relay_select, list_private: false, picture: None, uploading: false, @@ -275,20 +298,6 @@ impl NewGroup { cx.notify(); } - fn set_relay_select_open(&mut self, open: bool, cx: &mut Context) { - self.relay_select_open = open; - cx.notify(); - } - - fn activate_relay_option(&mut self, index: usize, cx: &mut Context) { - if index < self.relays.len() { - self.select_relay(Some(self.relays[index].clone()), cx); - } else { - self.select_relay(None, cx); - } - self.set_relay_select_open(false, cx); - } - fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { self.uploading = uploading; cx.notify(); @@ -339,7 +348,7 @@ impl NewGroup { } } - fn render_relay(&self, selected: &str, cx: &mut Context) -> impl IntoElement { + fn render_relay(&self, cx: &mut Context) -> impl IntoElement { if let Some(parent) = &self.parent { return v_flex().gap_1p5().child(field_label("Relay", cx)).child( div() @@ -352,108 +361,13 @@ impl NewGroup { ); } - let this = cx.entity().downgrade(); - let open = self.relay_select_open; - - let trigger = h_flex() - .id("relay-trigger") - .justify_between() - .w_full() - .h_8() - .px_3() - .rounded(cx.theme().radius) - .text_xs() - .cursor_default() - .bg(if open { - cx.theme().list_active - } else { - cx.theme().secondary - }) - .when(!open, |this| { - this.hover(|this| this.bg(cx.theme().primary)) - .active(|this| this.bg(cx.theme().list_active)) - }) - .on_click(cx.listener(move |this, _, _window, cx| { - this.relay_select_open = !open; - cx.notify(); - })) - .child(selected.to_owned()) - .child( - Icon::new(IconName::ChevronDown) - .small() - .text_color(cx.theme().muted_foreground), - ); - - let select = Select::new("relay-select") - .open(open) - .on_open_change({ - let this = this.clone(); - move |open, _, cx| { - let _ = this.update(cx, |this, cx| { - this.set_relay_select_open(open, cx); - }); - } - }) - .content_focus_handle(&self.relay_options_focus) - .accessibility_label("Relay") - .accessibility_value(selected.to_owned()) - .w_full() - .child(trigger); - - let options = v_flex() - .id("relay-options") - .occlude() - .track_focus(&self.relay_options_focus) - .key_context("Select") - .on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.set_relay_select_open(false, cx); - })) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .shadow_md() - .rounded(cx.theme().radius_lg) - .p_1() - .gap_y_1() - .min_w(rems(8.)) - .text_color(cx.theme().foreground) - .children(self.relays.iter().enumerate().map(|(index, relay)| { - h_flex() - .id(SharedString::from(format!("option-{relay}"))) - .px_1p5() - .py_1() - .rounded(cx.theme().radius) - .text_xs() - .cursor_default() - .hover(|this| this.bg(cx.theme().primary)) - .child(relay_label(relay)) - .on_click(cx.listener(move |this, _, _, cx| { - this.activate_relay_option(index, cx); - })) - })) - .child(Separator::horizontal()) - .child( - h_flex() - .id("relay-add") - .px_1p5() - .py_1() - .rounded(cx.theme().radius) - .text_xs() - .cursor_default() - .child("Custom relay") - .hover(|this| this.bg(cx.theme().primary)) - .on_click(cx.listener(|this, _, _, cx| { - this.activate_relay_option(this.relays.len(), cx); - })), - ); - v_flex() .gap_1p5() .child(field_label("Relay", cx)) .when(self.selected_relay.is_none(), |this| { this.child(Input::new(&self.custom_relay_input)) }) - .child(Popup::new("relay-popup", select).when(open, |this| this.content(options))) + .child(Select::new(&self.relay_select).accessibility_label("Relay")) } fn render_access(&self, cx: &mut Context) -> impl IntoElement { @@ -567,12 +481,6 @@ impl NewGroup { impl Render for NewGroup { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let selected = self - .selected_relay - .as_ref() - .map(relay_label) - .unwrap_or_else(|| "Add relay".into()); - let web_url = self .effective_relay(cx) .ok() @@ -614,7 +522,7 @@ impl Render for NewGroup { ), ) .child(input_field("Name", Input::new(&self.name_input), cx)) - .child(self.render_relay(&selected, cx)) + .child(self.render_relay(cx)) .child(input_field("About", Textarea::new(&self.about_input), cx)) .child(self.render_access(cx)) .child(self.render_list_setting(cx)) @@ -646,6 +554,76 @@ impl Render for NewGroup { } } +/// One choice in the relay select: a fixed relay, or the custom relay field. +#[derive(Clone)] +struct RelayOption { + label: SharedString, + value: Option, +} + +impl SearchableListItem for RelayOption { + type Value = Option; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &Self::Value { + &self.value + } + + /// The trigger reads "Add relay" while the custom relay field is active. + fn display_title(&self) -> Option { + (self.value.is_none()).then(|| "Add relay".into_any_element()) + } +} + +/// Relay options with a separator before the trailing custom relay choice. +struct RelayDelegate { + options: Vec, +} + +impl SearchableListDelegate for RelayDelegate { + type Item = RelayOption; + + fn items_count(&self, _section: usize) -> usize { + self.options.len() + } + + fn item(&self, ix: IndexPath) -> Option<&RelayOption> { + self.options.get(ix.row) + } + + fn position(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.options + .iter() + .position(|option| option.value() == value) + .map(IndexPath::new) + } + + fn render_item( + &self, + _ix: IndexPath, + item: &RelayOption, + _checked: bool, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + (item.value.is_none()).then(|| { + v_flex() + .w_full() + .gap_1() + .child(Separator::horizontal()) + .child(item.title()) + .into_any_element() + }) + } +} + fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div { v_flex().gap_1p5().child(field_label(text, cx)).child(input) } diff --git a/crates/workspace/src/dialogs/relays.rs b/crates/workspace/src/dialogs/relays.rs index 620a3b94..1618b448 100644 --- a/crates/workspace/src/dialogs/relays.rs +++ b/crates/workspace/src/dialogs/relays.rs @@ -8,10 +8,9 @@ use gpui::{ WeakEntity, Window, div, rems, }; use gpui_kit::assets::IconName; -use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::{Input, InputEvent, InputState}; -use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; +use gpui_kit::component::menu::{ContextMenu, ContextMenuExt as _, DropdownMenu, PopupMenuItem}; use gpui_kit::component::scroll::ScrollableElement; use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; @@ -174,7 +173,7 @@ impl RelayManager { }) } - fn gossip_rows(&self, cx: &mut Context) -> Vec> { + fn gossip_rows(&self, cx: &mut Context) -> Vec>> { let mut rows = Vec::new(); for (relay, metadata) in self.gossip.relays.iter() { @@ -245,7 +244,7 @@ impl RelayManager { }) } - fn inbox_rows(&self, cx: &mut Context) -> Vec> { + fn inbox_rows(&self, cx: &mut Context) -> Vec>> { let mut rows = Vec::new(); for relay in self.inbox.relays.iter() { @@ -635,8 +634,9 @@ fn relay_row( badge: Option, on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, -) -> Stateful
{ +) -> ContextMenu> { let label = SharedString::from(url.to_string()); + let on_remove = std::rc::Rc::new(on_remove); h_flex() .id(label.clone()) @@ -647,6 +647,16 @@ fn relay_row( .justify_between() .rounded(cx.theme().radius) .bg(cx.theme().sidebar) + .context_menu({ + let on_remove = on_remove.clone(); + + move |menu, _window, _cx| { + menu.item(PopupMenuItem::new("Remove").on_click({ + let on_remove = on_remove.clone(); + move |event, window, cx| on_remove(event, window, cx) + })) + } + }) .child( h_flex() .gap_1() @@ -654,11 +664,8 @@ fn relay_row( .child(label) .when_some(badge, |this, badge| { this.child( - div() - .p_0p5() - .rounded_xs() - .text_xs() - .font_medium() + gpui_kit::component::tag::Tag::secondary() + .xsmall() .child(badge), ) }), @@ -667,10 +674,13 @@ fn relay_row( Button::new("remove") .icon(IconName::X) .xsmall() - .ghost() + .danger() .invisible() .group_hover("", |this| this.visible()) - .on_click(on_remove), + .on_click({ + let on_remove = on_remove.clone(); + move |event, window, cx| on_remove(event, window, cx) + }), ) } diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 6defb5bb..7558594e 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -4,8 +4,8 @@ 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, + App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, + Styled, Subscription, Task, Window, div, px, relative, uniform_list, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -313,7 +313,6 @@ impl Screening { .gap_1p5() .rounded(cx.theme().radius) .text_xs() - .hover(|this| this.bg(cx.theme().primary)) .child( Avatar::new(profile.avatar()) .seed(profile.avatar_seed()) @@ -545,6 +544,7 @@ impl Render for Screening { .child( Button::new("mutuals") .icon(IconName::Info) + .tooltip("Show mutual contacts") .xsmall() .ghost() .disabled(mutuals == 0) diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 725530e0..135151bc 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -662,6 +662,7 @@ impl Render for Preferences { .child( Button::new("update-file-server") .icon(IconName::Check) + .tooltip("Update") .ghost() .size_8() .on_click(cx.listener(move |this, _ev, window, cx| { diff --git a/crates/workspace/src/dock/window_controls.rs b/crates/workspace/src/dock/window_controls.rs index 708538cf..82c0b469 100644 --- a/crates/workspace/src/dock/window_controls.rs +++ b/crates/workspace/src/dock/window_controls.rs @@ -4,6 +4,7 @@ use gpui::{ StatefulInteractiveElement, Styled, Window, div, px, }; use gpui_kit::assets::IconName; +use gpui_kit::component::tooltip::Tooltip; use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex}; use super::TAB_BAR_HEIGHT; @@ -53,6 +54,15 @@ impl ControlIcon { } } + fn label(&self) -> &'static str { + match self { + Self::Minimize => "Minimize", + Self::Restore => "Restore", + Self::Maximize => "Maximize", + Self::Close => "Close", + } + } + fn window_control_area(&self) -> gpui::WindowControlArea { match self { Self::Minimize => gpui::WindowControlArea::Min, @@ -102,6 +112,8 @@ impl RenderOnce for ControlIcon { let active_bg = self.active_bg(cx); let icon = self.clone(); + let label = self.label(); + div() .id(self.id()) .flex() @@ -112,6 +124,7 @@ impl RenderOnce for ControlIcon { .content_center() .items_center() .text_color(cx.theme().foreground) + .tooltip(move |window, cx| Tooltip::new(label).build(window, cx)) .hover(|style| style.bg(hover_bg).text_color(hover_fg)) .active(|style| style.bg(active_bg).text_color(hover_fg)) .when(is_windows, |this| { diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 11f56705..0ad9d495 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -13,10 +13,11 @@ use gpui::{ Action, App, AppContext, Context, Entity, Focusable, InteractiveElement, IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px, }; -use gpui_kit::component::button::Button; +use gpui_kit::component::button::{Button, ButtonVariant}; use gpui_kit::component::command::{ Command as CommandPalette, CommandGroup, CommandItem, CommandState, }; +use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use gpui_kit::component::notification::{Notification, NotificationType}; use gpui_kit::component::{ @@ -36,7 +37,6 @@ use crate::sidebar::Sidebar; mod dialogs; mod dock; -mod nav; mod panels; mod sidebar; @@ -618,6 +618,12 @@ impl Workspace { .child(SharedString::from(ENC_WARN)), ), ) + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Reset key") + .ok_variant(ButtonVariant::Danger), + ) .on_ok(move |_ev, _window, cx| { ent.update(cx, |this, cx| { this.set_announcement(Keys::generate(), cx); diff --git a/crates/workspace/src/nav.rs b/crates/workspace/src/nav.rs deleted file mode 100644 index 05d99a47..00000000 --- a/crates/workspace/src/nav.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::rc::Rc; - -use gpui::prelude::FluentBuilder as _; -use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement, - ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, - Styled, Window, div, -}; -use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex}; - -/// A single navigation row, such as an entry in a sidebar list. -#[derive(IntoElement)] -pub struct Nav { - id: ElementId, - interactivity: Interactivity, - style: StyleRefinement, - prefix: Option, - label: SharedString, - suffix: Option, - selected: bool, - clickable: bool, - center_prefix: bool, - #[allow(clippy::type_complexity)] - on_click: Option>, -} - -impl Nav { - pub fn new(id: impl Into) -> Self { - let id = id.into(); - let mut interactivity = Interactivity::default(); - interactivity.element_id = Some(id.clone()); - - Self { - id, - interactivity, - style: StyleRefinement::default(), - prefix: None, - label: SharedString::default(), - suffix: None, - selected: false, - clickable: false, - center_prefix: false, - on_click: None, - } - } - - /// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance. - pub fn clickable(mut self, clickable: bool) -> Self { - self.clickable = clickable; - self - } - - /// Whether to center the prefix in the avatar-width column. - pub fn center_prefix(mut self, center: bool) -> Self { - self.center_prefix = center; - self - } - - /// Sets the element shown before the label, such as an avatar or icon. - pub fn prefix(mut self, prefix: impl IntoElement) -> Self { - self.prefix = Some(prefix.into_any_element()); - self - } - - /// Sets the row's label. - pub fn label(mut self, label: impl Into) -> Self { - self.label = label.into(); - self - } - - /// Sets the element shown after the label, such as a timestamp or badge. - pub fn suffix(mut self, suffix: impl IntoElement) -> Self { - self.suffix = Some(suffix.into_any_element()); - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_click = Some(Rc::new(handler)); - self - } -} - -impl Selectable for Nav { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - fn is_selected(&self) -> bool { - self.selected - } -} - -impl InteractiveElement for Nav { - fn interactivity(&mut self) -> &mut Interactivity { - &mut self.interactivity - } -} - -impl Styled for Nav { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for Nav { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let clickable = self.on_click.is_some() || self.clickable; - - h_flex() - .id(self.id) - .h_8() - .w_full() - .px_2() - .gap_2() - .rounded(cx.theme().radius) - .when_some(self.prefix, |this, prefix| { - this.child( - h_flex() - .flex_shrink_0() - .when(self.center_prefix, |this| this.w_6().justify_center()) - .child(prefix), - ) - }) - .child(div().flex_1().min_w_0().truncate().child(self.label)) - .when_some(self.suffix, |this, suffix| { - this.child(div().flex_shrink_0().child(suffix)) - }) - .when(clickable, |this| { - this.cursor_pointer() - .hover(|this| this.bg(cx.theme().list_hover)) - .when(self.selected, |this| this.bg(cx.theme().list_active)) - }) - .when_some(self.on_click, |this, handler| { - this.on_click(move |event, window, cx| handler(event, window, cx)) - }) - .refine_style(&self.style) - } -} diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index 059eb876..4cc1ff58 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -12,6 +12,7 @@ use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_kit::component::scroll::ScrollableElement; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use instant::Duration; @@ -229,6 +230,7 @@ impl ContactListPanel { fn render_list_items(&mut self, cx: &mut Context) -> Vec { let mut items = Vec::new(); + let panel = cx.weak_entity(); for (ix, public_key) in self.contacts.iter().enumerate() { let profile = self.directory.read(cx).person(public_key, cx); @@ -243,7 +245,24 @@ impl ContactListPanel { .px_2() .justify_between() .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().primary)) + .hover(|this| this.bg(cx.theme().list_hover)) + .context_menu({ + let panel = panel.clone(); + let public_key = *public_key; + + move |menu, _window, _cx| { + menu.item(PopupMenuItem::new("Remove").on_click({ + let panel = panel.clone(); + move |_, _window, cx| { + if let Err(error) = panel.update(cx, |panel, cx| { + panel.remove(&public_key, cx); + }) { + log::warn!("removing a contact failed: {error}"); + } + } + })) + } + }) .child( h_flex() .gap_2() @@ -256,18 +275,21 @@ impl ContactListPanel { .child(profile.name()), ) .child( - Button::new("remove_{ix}") - .icon(IconName::X) - .xsmall() - .ghost() - .invisible() - .group_hover("", |this| this.visible()) - .on_click({ - let public_key = public_key.to_owned(); - cx.listener(move |this, _ev, _window, cx| { - this.remove(&public_key, cx); - }) - }), + Button::new(SharedString::from(format!( + "remove-{}", + public_key.to_hex() + ))) + .icon(IconName::X) + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click({ + let public_key = public_key.to_owned(); + cx.listener(move |this, _ev, _window, cx| { + this.remove(&public_key, cx); + }) + }), ), ) } diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index 5807094a..3e4802a0 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -13,6 +13,7 @@ use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; +use gpui_kit::component::list::ListItem; use gpui_kit::component::scroll::ScrollableElement; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use nostr_sdk::prelude::*; @@ -23,7 +24,6 @@ use ui::Avatar; use super::browse; use crate::dock::add_panel_to; -use crate::nav::Nav; use crate::sidebar::open_room; /// How many requests and follow ups to show per section. @@ -457,9 +457,19 @@ impl GreeterPanel { v_flex() .w_full() .child( - Nav::new("browse-group") - .prefix(Icon::new(IconName::Compass)) - .label("Browse Public Groups") + ListItem::new("browse-group") + .accessibility_label("Browse Public Groups") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child(div().flex_shrink_0().child(Icon::new(IconName::Compass))) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child("Browse Public Groups"), + ) .text_sm() .on_click(cx.listener(|this, _, window, cx| { let panel = browse::init(this.dock.clone(), window, cx); @@ -473,9 +483,19 @@ impl GreeterPanel { })), ) .child( - Nav::new("browse-posts") - .prefix(Icon::new(IconName::Link)) - .label("Browse Nostr via Jumble") + ListItem::new("browse-posts") + .accessibility_label("Browse Nostr via Jumble") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child(div().flex_shrink_0().child(Icon::new(IconName::Link))) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child("Browse Nostr via Jumble"), + ) .text_sm() .on_click(|_, _, cx| { cx.open_url(JUMBLE_URL); diff --git a/crates/workspace/src/sidebar/group_view.rs b/crates/workspace/src/sidebar/group_view.rs index 954ecd5c..76c62793 100644 --- a/crates/workspace/src/sidebar/group_view.rs +++ b/crates/workspace/src/sidebar/group_view.rs @@ -6,18 +6,19 @@ use gpui::{ AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement, ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list, }; +use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; +use gpui_kit::component::list::ListItem; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use nip29::Group; use nostr_sdk::prelude::*; use settings::AppSettings; use state::NostrRegistry; -use ui::{Avatar, IconName}; +use ui::Avatar; use super::Sidebar; use crate::dialogs::profile; -use crate::nav::Nav; /// A collapsible section of the group view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -222,19 +223,25 @@ impl Sidebar { GroupSection::Members => self.members_open, }; let icon = if open { - IconName::CaretDown + IconName::ChevronDown } else { - IconName::CaretRight + IconName::ChevronRight }; let label = section.label(); - Nav::new(label) - .label(label) - .suffix( - Icon::new(icon) - .small() - .text_color(cx.theme().muted_foreground), - ) + ListItem::new(label) + .accessibility_label(label) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child(div().flex_1().min_w_0().truncate().child(label)) + .suffix(move |_, cx| { + div().flex_shrink_0().child( + Icon::new(icon) + .small() + .text_color(cx.theme().muted_foreground), + ) + }) .text_xs() .font_semibold() .text_color(cx.theme().muted_foreground) @@ -262,6 +269,7 @@ impl Sidebar { let group = group.clone(); let public_key = *public_key; + let member = person.name(); let member_is_admin = !roles.is_empty(); let is_self = NostrRegistry::global(cx) @@ -269,31 +277,40 @@ impl Sidebar { .current_user() .is_some_and(|me| me == public_key); - let nav = Nav::new(public_key.to_hex()) - .label(person.name()) + let item = ListItem::new(public_key.to_hex()) + .accessibility_label(member.clone()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() - .clickable(true) .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small(), + ), ) }) + .child(div().flex_1().min_w_0().truncate().child(person.name())) .when(!roles.is_empty(), |this| { - this.suffix( - div() - .font_normal() - .truncate() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(roles.join(", "))), - ) + let roles = SharedString::from(roles.join(", ")); + + this.suffix(move |_, cx| { + div().flex_shrink_0().child( + div() + .font_normal() + .truncate() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(roles.clone()), + ) + }) }); let row = div() .id(public_key.to_hex()) .w_full() - .child(nav) + .child(item) .context_menu(move |menu, _window, _cx| { let menu = menu .item( @@ -326,13 +343,21 @@ impl Sidebar { }) }); - menu.separator() - .item(PopupMenuItem::new("Remove").on_click({ + menu.separator().item({ + let member = member.clone(); + PopupMenuItem::new("Remove").on_click({ let group = group.clone(); move |_event, window, cx| { - nip29_ui::remove_member(group.clone(), public_key, window, cx); + nip29_ui::confirm_remove_member( + group.clone(), + public_key, + member.clone(), + window, + cx, + ); } - })) + }) + }) }); row.into_any_element() diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 3ae83d35..5334b115 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -7,12 +7,14 @@ use gpui::{ }; #[cfg(not(target_arch = "wasm32"))] use gpui::{AnyElement, Entity}; +use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; #[cfg(not(target_arch = "wasm32"))] use gpui_kit::component::Disableable; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::dialog::DialogButtonProps; +use gpui_kit::component::list::ListItem; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::popover::Popover; use gpui_kit::component::separator::Separator; @@ -21,12 +23,11 @@ use nostr_sdk::prelude::*; use person::shorten_pubkey; use settings::AppSettings; use state::{CLIENT_NAME, NostrRegistry}; -use ui::{Avatar, IconName}; +use ui::Avatar; use super::Sidebar; use crate::Command; use crate::dock::TAB_BAR_HEIGHT; -use crate::nav::Nav; impl Sidebar { pub(super) fn render_user( @@ -101,7 +102,7 @@ impl Sidebar { ) .child( Button::new("sidebar-new") - .icon(IconName::PlusChat) + .icon(IconName::MessageSquarePlus) .tooltip("New") .ghost() .small() @@ -211,7 +212,7 @@ impl Sidebar { }) } - pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context) -> Div { + pub(super) fn render_footer(&self, loading: bool, cx: &mut Context) -> Div { v_flex() .p_2() .flex_shrink_0() @@ -220,10 +221,19 @@ impl Sidebar { .justify_start() .when(!loading, |this| { this.child( - Nav::new("settings") - .prefix(Icon::new(IconName::Settings).small()) - .center_prefix(true) - .label("Settings") + ListItem::new("settings") + .accessibility_label("Settings") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child( + div() + .w_6() + .justify_center() + .flex_shrink_0() + .child(Icon::new(IconName::Settings).small()), + ) + .child(div().flex_1().min_w_0().truncate().child("Settings")) .text_sm() .on_click(|_, window, cx| { window.dispatch_action(Box::new(Command::ShowSettings), cx) @@ -243,7 +253,7 @@ impl Sidebar { .when(!up_to_date, |this| { this.child( Button::new("update") - .icon(IconName::ArrowDownCircle) + .icon(IconName::CircleArrowDown) .tooltip(status) .small() .ghost() @@ -253,7 +263,7 @@ impl Sidebar { .when(staged, |this| { this.child( Button::new("restart") - .icon(IconName::ArrowDownCircle) + .icon(IconName::CircleArrowDown) .tooltip("Quit and relaunch into the installed update") .small() .ghost() diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index ec846d49..980fa61e 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -2,19 +2,18 @@ use std::rc::Rc; use chat::{ChatRegistry, RoomKind}; use gpui::prelude::FluentBuilder; -use gpui::{ - AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list, -}; +use gpui::{AnyElement, App, Context, IntoElement, ParentElement, Styled, div, uniform_list}; +use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; +use gpui_kit::component::badge::Badge; +use gpui_kit::component::list::ListItem; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use nip29::GroupsRegistry; use settings::AppSettings; -use ui::IconName; use super::Sidebar; use super::rows::SidebarRow; use crate::Command; -use crate::nav::Nav; /// A collapsible section of the sidebar's main list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,6 +37,7 @@ impl Sidebar { pub(super) fn render_list(&mut self, cx: &mut Context) -> AnyElement { let requests_open = self.requests_open; let contacts_open = self.contacts_open && !requests_open; + let new_requests = self.new_requests; let rows = Rc::new(self.rows_for(cx)); // Whether to show the create button for the contact picker let create_button = contacts_open && !self.selected_contacts.is_empty(); @@ -69,9 +69,17 @@ impl Sidebar { .justify_start() .when(cfg!(not(target_arch = "wasm32")), |this| { this.child( - Nav::new("wallet") - .prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small()) - .label("Wallet") + ListItem::new("wallet") + .accessibility_label("Wallet") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child( + div().flex_shrink_0().child( + Icon::new(gpui_kit::assets::IconName::Wallet).small(), + ), + ) + .child(div().flex_1().min_w_0().truncate().child("Wallet")) .text_sm() .on_click(|_, window, cx| { window.dispatch_action(Box::new(crate::Command::ShowWallet), cx) @@ -79,48 +87,55 @@ impl Sidebar { ) }) .child( - Nav::new("browse") - .prefix(Icon::new(IconName::Compass).small()) - .label("Browse") + ListItem::new("browse") + .accessibility_label("Browse") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child( + div() + .flex_shrink_0() + .child(Icon::new(IconName::Compass).small()), + ) + .child(div().flex_1().min_w_0().truncate().child("Browse")) .text_sm() .on_click(|_, window, cx| { window.dispatch_action(Box::new(Command::ShowBrowse), cx) }), ) .child( - Nav::new("reqs") - .prefix(Icon::new(IconName::Invite).small()) - .label("Requests") + ListItem::new("reqs") + .accessibility_label("Requests") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child( + div() + .flex_shrink_0() + .child(Icon::new(IconName::UserPlus).small()), + ) + .child(div().flex_1().min_w_0().truncate().child("Requests")) .text_sm() .when(request_count > 0, |this| { - this.suffix( - h_flex() - .gap_1() - .child( - h_flex() - .py_0p5() - .px_1() - .min_w_6() - .justify_center() - .text_xs() - .font_medium() - .text_color(cx.theme().muted_foreground) - .text_center() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .child(SharedString::from( - request_count.to_string(), - )), - ) - .when(self.new_requests && !requests_open, |this| { - this.child( - div() - .size_1() - .rounded_full() - .bg(cx.theme().primary), + this.suffix(move |_, cx| { + div().flex_shrink_0().child( + h_flex() + .gap_1() + .child( + Badge::new() + .count(request_count) + .child(div().size_4()), ) - }), - ) + .when(new_requests && !requests_open, |this| { + this.child( + div() + .size_1() + .rounded_full() + .bg(cx.theme().primary), + ) + }), + ) + }) }) .when(requests_open, |this| this.bg(cx.theme().list_active)) .on_click(cx.listener(|this, _event, _window, cx| { @@ -128,9 +143,17 @@ impl Sidebar { })), ) .child( - Nav::new("contacts") - .prefix(Icon::new(IconName::Book).small()) - .label("Contacts") + ListItem::new("contacts") + .accessibility_label("Contacts") + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child( + div() + .flex_shrink_0() + .child(Icon::new(IconName::Book).small()), + ) + .child(div().flex_1().min_w_0().truncate().child("Contacts")) .text_sm() .when(contacts_open, |this| this.bg(cx.theme().list_active)) .on_click(cx.listener(|this, _event, window, cx| { diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index 753028d5..7f713b87 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -5,23 +5,25 @@ use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement, - SharedString, Styled, WeakEntity, Window, div, px, + SharedString, Styled, WeakEntity, Window, div, }; +use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle}; +use gpui_kit::component::list::ListItem; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; -use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex}; +use gpui_kit::component::tag::Tag; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex}; use nip29::Group; use nostr_sdk::prelude::*; use settings::AppSettings; -use ui::{Avatar, IconName}; +use ui::Avatar; use super::Sidebar; use super::list::ListSection; use super::search::MessageHit; use crate::dialogs::{profile, screening}; -use crate::nav::Nav; /// Open a room in the dock, screening new requests when enabled. pub(crate) fn open_room( @@ -92,13 +94,10 @@ impl Sidebar { ) -> Vec { let hide_avatar = AppSettings::get_hide_avatar(cx); - rows.get(range.clone()) + rows.get(range) .into_iter() .flatten() - .enumerate() - .map(|(offset, row)| { - let index = range.start + offset; - + .map(|row| { let element = match row { SidebarRow::Section(section) => { let section = *section; @@ -111,18 +110,24 @@ impl Sidebar { }; let icon = if open { - IconName::CaretDown + IconName::ChevronDown } else { - IconName::CaretRight + IconName::ChevronRight }; - Nav::new(label) - .label(label) - .suffix( - Icon::new(icon) - .small() - .text_color(cx.theme().muted_foreground), - ) + ListItem::new(label) + .accessibility_label(label) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() + .child(div().flex_1().min_w_0().truncate().child(label)) + .suffix(move |_, cx| { + div().flex_shrink_0().child( + Icon::new(icon) + .small() + .text_color(cx.theme().muted_foreground), + ) + }) .text_xs() .font_semibold() .text_color(cx.theme().muted_foreground) @@ -143,8 +148,11 @@ impl Sidebar { SidebarRow::Heading(label) => { let label = *label; - Nav::new(label) - .label(label) + ListItem::new(label) + .accessibility_label(label) + .h_8() + .rounded(cx.theme().radius) + .child(div().flex_1().min_w_0().truncate().child(label)) .text_xs() .font_semibold() .text_color(cx.theme().muted_foreground) @@ -171,48 +179,48 @@ impl Sidebar { let dock = self.dock.clone(); let room = room.clone(); - let nav = Nav::new(index) - .label(name) + let item = ListItem::new(id) + .accessibility_label(name.clone()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(room.read(cx).display_image(cx)) - .seed(seed) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(room.read(cx).display_image(cx)) + .seed(seed) + .small(), + ), + ) + }) + .child(div().flex_1().min_w_0().truncate().child(name)) + .suffix(move |_, cx| { + div().flex_shrink_0().child( + h_flex() + .gap_1() + .when(spam, |this| { + this.child(Tag::danger().xsmall().child("SPAM")) + }) + .when(!spam, |this| { + this.child( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(created_at.clone()), + ) + }), ) }) - .suffix( - h_flex() - .gap_1() - .when(spam, |this| { - this.child( - div() - .px_1() - .rounded(cx.theme().radius) - .bg(cx.theme().danger.opacity(0.15)) - .text_size(px(8.0)) - .text_color(cx.theme().danger) - .child("SPAM"), - ) - }) - .when(!spam, |this| { - this.child( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(created_at), - ) - }), - ) .on_click(move |_event, window, cx| { open_room(&room, &dock, window, cx); }); div() - .id(index) + .id(id) .w_full() - .child(nav) + .child(item) .context_menu(move |menu, _window, _cx| { let menu = menu.item( PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) @@ -241,16 +249,22 @@ impl Sidebar { let sidebar = cx.entity().downgrade(); let public_key = public_key.to_owned(); - Nav::new(index) - .label(person.name()) + ListItem::new(public_key.to_hex()) + .accessibility_label(person.name()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small(), + ), ) }) + .child(div().flex_1().min_w_0().truncate().child(person.name())) .selected(selected) .on_click(move |_event, _window, cx| { if let Err(error) = sidebar.update(cx, |this, cx| { @@ -265,16 +279,22 @@ impl Sidebar { let person = self.directory.read(cx).person(public_key, cx); let public_key = public_key.to_owned(); - Nav::new(index) - .label(person.name()) + ListItem::new(public_key.to_hex()) + .accessibility_label(person.name()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small(), + ), ) }) + .child(div().flex_1().min_w_0().truncate().child(person.name())) .on_click(move |_event, window, cx| { profile::open(public_key, window, cx); }) @@ -298,24 +318,32 @@ impl Sidebar { .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) .map(SharedString::from); - let nav = Nav::new(index) - .label(name) + let item = ListItem::new(tag.clone()) + .accessibility_label(name.clone()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(group.read(cx).display_image()) - .seed(seed) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(group.read(cx).display_image()) + .seed(seed) + .small(), + ), ) }) + .child(div().flex_1().min_w_0().truncate().child(name)) .when_some(suffix, |this, suffix| { - this.suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(suffix), - ) + this.suffix(move |_, cx| { + div().flex_shrink_0().child( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(suffix.clone()), + ) + }) }) .on_click(move |_event, window, cx| { crate::dock::add_panel_to( @@ -334,9 +362,9 @@ impl Sidebar { }); div() - .id(index) + .id(tag.clone()) .w_full() - .child(nav) + .child(item) .context_menu(move |menu, _window, _cx| { menu.item( PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) @@ -371,24 +399,32 @@ impl Sidebar { let room = hit.room.clone(); let content = hit.content.clone(); - Nav::new(index) - .label(content) + ListItem::new(hit.author.to_hex()) + .accessibility_label(content.clone()) + .h_8() + .rounded(cx.theme().radius) + .cursor_pointer() .text_sm() .font_medium() .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .small(), + this.child( + div().flex_shrink_0().child( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small(), + ), + ) + }) + .child(div().flex_1().min_w_0().truncate().child(content)) + .suffix(move |_, cx| { + div().flex_shrink_0().child( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(detail.clone()), ) }) - .suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(detail), - ) .on_click(move |_event, window, cx| { open_room(&room, &dock, window, cx); })