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);
})