wip
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user