feat: add support for NIP-29 (#56)

Reviewed-on: reya/coop#56
This commit is contained in:
2026-10-01 04:23:18 +00:00
parent 21f96c7bbe
commit 1f350f511e
50 changed files with 6574 additions and 8181 deletions
+268
View File
@@ -0,0 +1,268 @@
use gpui::{
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
};
use nip29::{Group, GroupMetadata};
use nostr_sdk::prelude::PublicKey;
use person::PersonRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, cx| {
this.width(px(460.))
.max_h(px(600.))
.show_close(true)
.title("Group details")
.child(body(&group, cx))
});
}
fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
let Some(entity) = group.upgrade() else {
return div().into_any_element();
};
let group = entity.read(cx);
let mut body = v_flex().gap_3().text_sm().child(header(group, cx));
if let Some(metadata) = group.metadata() {
if let Some(about) = metadata.about.as_ref() {
body = body.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from(about.clone())),
);
}
let flags = flags(metadata);
if !flags.is_empty() {
body = body.child(section(
"Flags",
wrap(flags.into_iter().map(|f| chip(f, cx))),
cx,
));
}
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,
));
}
if !metadata.children.is_empty() {
body = body.child(section(
"Children",
wrap(
metadata
.children
.iter()
.map(|child| chip(child.as_str().to_owned(), cx)),
),
cx,
));
}
if let Some(kinds) = metadata.supported_kinds.as_ref() {
let items: Vec<AnyElement> = kinds
.iter()
.map(|kind| chip(kind.to_string(), cx))
.collect();
body = body.child(section(
"Supported kinds",
if items.is_empty() {
div()
.text_color(cx.theme().text_placeholder)
.child("None")
.into_any_element()
} else {
wrap(items)
},
cx,
));
}
}
let admins = group.admins().entries();
if !admins.is_empty() {
body = body.child(section(
"Admins",
v_flex()
.gap_1()
.children(admins.iter().map(|(key, roles)| admin(key, roles, cx)))
.into_any_element(),
cx,
));
}
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,
));
}
let members: Vec<&PublicKey> = group.members().iter().collect();
if !members.is_empty() {
body = body.child(section(
"Members",
v_flex()
.gap_1()
.children(members.into_iter().map(|key| person(key, cx)))
.into_any_element(),
cx,
));
}
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().text_muted)
.child(crate::pin_label(group, pin))
.into_any_element()
})
.collect::<Vec<_>>(),
)
.into_any_element(),
cx,
));
}
body.into_any_element()
}
fn header(group: &Group, cx: &App) -> AnyElement {
let key = group.key();
h_flex()
.gap_3()
.items_center()
.child(
Avatar::from_source(group.display_image())
.seed(key.cache_tag())
.large(),
)
.child(
v_flex()
.min_w_0()
.child(
div()
.text_base()
.font_semibold()
.truncate()
.child(group.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.truncate()
.child(SharedString::from(format!(
"{} · {}",
key.id().as_str(),
key.relay().as_str()
))),
),
)
.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().text_muted)
.child(label),
)
.child(content)
.into_any_element()
}
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
h_flex()
.flex_wrap()
.gap_1()
.children(items)
.into_any_element()
}
fn admin(public_key: &PublicKey, roles: &[String], cx: &App) -> AnyElement {
h_flex()
.justify_between()
.gap_2()
.child(person(public_key, cx))
.children(roles.iter().map(|role| chip(role.clone(), cx)))
.into_any_element()
}
fn person(public_key: &PublicKey, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
h_flex()
.gap_2()
.min_w_0()
.child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.xsmall(),
)
.child(div().truncate().child(person.name()))
.into_any_element()
}
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
div()
.px_1p5()
.py_0p5()
.rounded(cx.theme().radius)
.bg(cx.theme().element_background)
.text_xs()
.child(label.into())
.into_any_element()
}
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
let mut flags = Vec::new();
if metadata.private {
flags.push("Private");
}
if metadata.restricted {
flags.push("Restricted");
}
if metadata.hidden {
flags.push("Hidden");
}
if metadata.closed {
flags.push("Closed");
}
if metadata.livekit {
flags.push("LiveKit");
}
flags
}
+112
View File
@@ -0,0 +1,112 @@
use anyhow::Result;
use gpui::{
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
px,
};
use nip29::Group;
use nostr_sdk::prelude::SecretKey;
use theme::ActiveTheme;
use ui::dialog::DialogButtonProps;
use ui::input::InputState;
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
use crate::field;
const CODE_LEN: usize = 12;
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let code = SecretKey::generate().to_secret_hex();
let code: String = code.chars().take(CODE_LEN).collect();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Invite code")
.default_value(code)
});
window.open_dialog(cx, move |this, _window, cx| {
let group = group.clone();
let input = input.clone();
this.width(px(420.))
.confirm()
.title("Invite link")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Copy link"),
)
.child(
v_flex().gap_3().child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Share the code with whoever should be able to join. The relay must accept it."),
)
.child(field("Code", &input, cx)),
)
.on_ok(move |_event, window, cx| {
let code = input.read(cx).value().trim().to_owned();
if code.is_empty() {
return false;
}
let link = group
.read_with(cx, |group, _cx| {
group.metadata().and_then(|metadata| {
metadata
.naddr(group.key().relay(), Some(&code))
.ok()
.map(SharedString::from)
})
})
.ok()
.flatten();
let Some(link) = link else {
window.push_notification(
Notification::error(
"The relay doesn't publish group details, so no invite link can be made",
),
cx,
);
return false;
};
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
Ok(task) => {
window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = task.await {
cx.update(|window, cx| {
window.push_notification(
Notification::error(error.to_string()),
cx,
);
})?;
return Ok(());
}
cx.update(|window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
link.to_string(),
));
window.push_notification(
Notification::success("Invite link copied"),
cx,
);
})?;
Ok(())
})
.detach();
}
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
}
true
})
});
}
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
use chat::Message;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
SharedUri, Styled, StyledImage, WeakEntity, div, img, px,
};
use nip29::{Activity, Group};
use nostr_sdk::prelude::{EventId, Kind};
use person::PersonRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
use ui::message::MessageRow;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
use crate::GroupPanel;
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
pub(crate) struct Actions {
pub group: WeakEntity<Group>,
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
}
pub(crate) fn render(
ix: usize,
message: &Message,
content: AnyElement,
show_author: bool,
actions: Actions,
cx: &App,
) -> AnyElement {
let persons = PersonRegistry::global(cx);
let author = persons.read(cx).get(&message.author, cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let row = MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
.avatar(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0(),
)
.author(author.name())
.timestamp(message.created_at.to_human_time())
.child(content)
.when(!message.media.is_empty(), |this| {
this.child(media(&message.media, cx))
})
.into_any_element();
let id = message.id;
let admin = actions.admin;
let pinned = actions.pinned;
let group = actions.group;
let panel = actions.panel;
ContextMenu::new(
format!("message-menu-{}", id.to_hex()),
row,
move |menu, window, cx| {
let menu = menu.submenu("React", window, cx, {
let group = group.clone();
let panel = panel.clone();
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
});
if !admin {
return menu;
}
menu.separator()
.item({
let group = group.clone();
let panel = panel.clone();
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.toggle_pin(group.clone(), id, window, cx);
}) {
log::warn!("nip29: pinning a message failed: {error}");
}
},
)
})
.item({
let group = group.clone();
let panel = panel.clone();
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.delete_message(group.clone(), id, window, cx);
}) {
log::warn!("nip29: deleting a message failed: {error}");
}
})
})
},
)
.into_any_element()
}
fn react_menu(
menu: PopupMenu,
group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>,
id: EventId,
) -> PopupMenu {
REACTIONS.iter().fold(menu, |menu, emoji| {
let emoji = *emoji;
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
})
}
pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let subject = persons.read(cx).get(&activity.subject(), cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
h_flex()
.id(ix)
.w_full()
.gap_2()
.py_1()
.px_3()
.hover(|this| this.bg(cx.theme().surface_background))
.child(badge(activity, cx))
.when(!hide_avatar, |this| {
this.child(
Avatar::new(subject.avatar())
.seed(subject.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.child(
div()
.min_w_0()
.truncate()
.text_sm()
.font_semibold()
.child(subject.name()),
)
.child(
div()
.min_w_0()
.truncate()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from(action(activity))),
)
.child(
div()
.flex_shrink_0()
.ml_1()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(activity.created_at.to_ago()),
)
.into_any_element()
}
fn badge(activity: &Activity, cx: &App) -> AnyElement {
let (icon, color) = match activity.kind {
Kind::GroupJoinRequest => (IconName::Login, cx.theme().icon_accent),
Kind::GroupPutUser => (IconName::Login, cx.theme().icon_accent),
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().text_danger),
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().text_danger),
_ => (IconName::Info, cx.theme().icon_muted),
};
h_flex()
.flex_shrink_0()
.justify_center()
.size_8()
.child(Icon::new(icon).small().text_color(color))
.into_any_element()
}
fn action(activity: &Activity) -> String {
match activity.kind {
Kind::GroupJoinRequest => "joined the group".to_owned(),
Kind::GroupLeaveRequest => "left the group".to_owned(),
Kind::GroupRemoveUser => "was removed from the group".to_owned(),
Kind::GroupPutUser => format!("is now {}", activity.roles.join(", ")),
_ => String::new(),
}
}
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
if let [only] = media {
return div()
.child(
img(only.clone())
.border_1()
.border_color(cx.theme().border_variant)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
)
.into_any_element();
}
div()
.w_full()
.flex_1()
.flex()
.flex_row()
.flex_wrap()
.gap_2()
.children(media.iter().enumerate().map(|(ix, item)| {
div().id(format!("media-{ix}")).child(
img(item.clone())
.h_32()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius),
)
}))
.into_any_element()
}
+169
View File
@@ -0,0 +1,169 @@
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
use nip29::{Group, GroupMetadata};
use ui::dialog::DialogButtonProps;
use ui::input::InputState;
use ui::switch::Switch;
use ui::{WindowExtension, v_flex};
use crate::field;
#[derive(Clone, Copy, Default)]
struct Flags {
private: bool,
restricted: bool,
hidden: bool,
closed: bool,
livekit: bool,
}
impl Flags {
fn of(metadata: &GroupMetadata) -> Self {
Self {
private: metadata.private,
restricted: metadata.restricted,
hidden: metadata.hidden,
closed: metadata.closed,
livekit: metadata.livekit,
}
}
}
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let base = group
.read_with(cx, |group, _cx| group.metadata().cloned())
.ok()
.flatten()
.unwrap_or_default();
let name = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Group name")
.default_value(base.name.clone().unwrap_or_default())
});
let about = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("About")
.default_value(base.about.clone().unwrap_or_default())
});
let picture = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("https://example.com/picture.png")
.default_value(base.picture.clone().unwrap_or_default())
});
let flags = cx.new(|_cx| Flags::of(&base));
window.open_dialog(cx, move |this, _window, cx| {
let current = *flags.read(cx);
this.width(px(420.))
.confirm()
.title("Edit group")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Save"),
)
.child(
v_flex()
.gap_3()
.child(field("Name", &name, cx))
.child(field("About", &about, cx))
.child(field("Picture", &picture, cx))
.child(
v_flex()
.gap_2()
.child(
Switch::new("private")
.label("Private")
.description("Only members can read messages.")
.checked(current.private)
.on_click(toggle(&flags, |flags| &mut flags.private)),
)
.child(
Switch::new("restricted")
.label("Restricted")
.description("Only members can send messages.")
.checked(current.restricted)
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
)
.child(
Switch::new("hidden")
.label("Hidden")
.description("Hide the group from non-members.")
.checked(current.hidden)
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
)
.child(
Switch::new("closed")
.label("Closed")
.description("Ignore join requests.")
.checked(current.closed)
.on_click(toggle(&flags, |flags| &mut flags.closed)),
)
.child(
Switch::new("livekit")
.label("LiveKit")
.description("Announce audio and video support.")
.checked(current.livekit)
.on_click(toggle(&flags, |flags| &mut flags.livekit)),
),
),
)
.on_ok({
let group = group.clone();
let name = name.clone();
let about = about.clone();
let picture = picture.clone();
let flags = flags.clone();
move |_event, window, cx| {
let mut next = group
.read_with(cx, |group, _cx| group.metadata().cloned())
.ok()
.flatten()
.unwrap_or_default();
next.name = value(&name, cx);
next.about = value(&about, cx);
next.picture = value(&picture, cx);
let current = flags.read(cx);
next.private = current.private;
next.restricted = current.restricted;
next.hidden = current.hidden;
next.closed = current.closed;
next.livekit = current.livekit;
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
Ok(task) => crate::report(window, cx, task),
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
}
true
}
})
});
}
fn toggle(
flags: &Entity<Flags>,
select: fn(&mut Flags) -> &mut bool,
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
let flags = flags.clone();
move |next, _window, cx| {
flags.update(cx, |flags, cx| {
*select(flags) = *next;
cx.notify();
});
}
}
fn value(input: &Entity<InputState>, cx: &App) -> Option<String> {
let value = input.read(cx).value().trim().to_owned();
(!value.is_empty()).then_some(value)
}