This commit is contained in:
2026-11-11 18:00:18 +07:00
parent 7e89f63a7c
commit 2f9559228f
44 changed files with 1135 additions and 946 deletions
+71 -82
View File
@@ -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<Group>, 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<Group>, 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<AnyElement> = kinds
let values: Vec<AnyElement> = 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::<Vec<_>>(),
)
.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::<Vec<_>>(),
)
.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<Item = AnyElement>) -> AnyElement {
h_flex()
.flex_wrap()
@@ -179,19 +181,6 @@ fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
.into_any_element()
}
fn chip(label: impl Into<SharedString>, 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();
+39 -5
View File
@@ -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<Group>, code: &str) -> Button {
fn revoke_button(group: &WeakEntity<Group>, 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<Group>,
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<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
h_flex()
.items_end()
+113 -24
View File
@@ -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<Group>, 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<Group>,
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<Self>,
) {
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>) {
self.reply_to = Some(id);
self.input.update(cx, |input, cx| {
@@ -716,18 +775,46 @@ impl GroupPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
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()
+38 -35
View File
@@ -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 {