diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index a476e547..15a02885 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -12,7 +12,7 @@ use crate::LOCAL_KEYS; use crate::protocol::{ ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, - TimelineWindow, + TimelineWindow, tag_value, }; const REPLAY_LIMIT: usize = 100; @@ -34,6 +34,13 @@ pub struct Reaction { pub author: PublicKey, } +#[derive(Debug, Clone)] +pub struct Invite { + pub id: EventId, + pub code: String, + pub created_at: Timestamp, +} + #[derive(Debug, Clone)] pub enum Row { Message(Box), @@ -86,6 +93,8 @@ pub struct Group { rows: Vec, index: HashSet, reactions: HashMap>, + /// Active invite codes; revocations arrive as 9005 deletions. + invites: Vec, leave_requests: HashMap, window: TimelineWindow, oldest: Option, @@ -114,6 +123,7 @@ impl Group { rows: Vec::new(), index: HashSet::new(), reactions: HashMap::new(), + invites: Vec::new(), leave_requests: HashMap::new(), window: TimelineWindow::new(), oldest: None, @@ -193,6 +203,10 @@ impl Group { self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[]) } + pub fn invites(&self) -> &[Invite] { + &self.invites + } + pub fn loading_more(&self) -> bool { self.loading_more } @@ -375,6 +389,7 @@ impl Group { let filters = [ key.id().timeline_filter(REPLAY_LIMIT), key.id().membership_filter(me), + key.id().moderation_filter(REPLAY_LIMIT), ] .into_iter() .chain(key.id().state_filters()) @@ -523,13 +538,22 @@ impl Group { } pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context) { + if self.retract(id) { + cx.emit(GroupEvent::Updated); + cx.notify(); + } + } + + /// Remove an event and everything attached to it. + fn retract(&mut self, id: EventId) -> bool { self.window.remove(id); if !self.index.remove(&id) { - return; + return false; } self.rows.retain(|row| row.id() != id); + self.invites.retain(|invite| invite.id != id); self.reactions.remove(&id); for reactions in self.reactions.values_mut() { @@ -538,8 +562,7 @@ impl Group { self.reactions.retain(|_, reactions| !reactions.is_empty()); - cx.emit(GroupEvent::Updated); - cx.notify(); + true } pub(crate) fn eose(&mut self, cx: &mut Context) { @@ -557,7 +580,7 @@ impl Group { fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed { if STATE_KINDS.contains(&event.kind) { - return if self.absorb_state(event) { + return if self.absorb_state(event, me) { Absorbed::Updated } else { Absorbed::Ignored @@ -583,6 +606,28 @@ impl Group { }; } + if event.kind == Kind::GroupCreateInvite { + return if self.push_invite(event) { + Absorbed::Updated + } else { + Absorbed::Ignored + }; + } + + if event.kind == Kind::GroupDeleteEvent { + let retracted = event + .tags + .event_ids() + .next() + .is_some_and(|target| self.retract(target)); + + return if retracted { + Absorbed::Updated + } else { + Absorbed::Ignored + }; + } + if RENDER_KINDS.contains(&event.kind) { return Absorbed::Shown { fresh: self.push_row(event), @@ -625,18 +670,24 @@ impl Group { } } - fn absorb_state(&mut self, event: &Event) -> bool { + fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool { match event.kind { Kind::GroupMetadata => match GroupMetadata::parse(event) { Ok(metadata) => self.metadata = Some(metadata), Err(_) => return false, }, Kind::GroupAdmins => match GroupAdmins::parse(event) { - Ok(admins) => self.admins = admins, + Ok(admins) => { + self.admins = admins; + self.adopt_roster_membership(me); + } Err(_) => return false, }, Kind::GroupMembers => match GroupMembers::parse(event) { - Ok(members) => self.members = members, + Ok(members) => { + self.members = members; + self.adopt_roster_membership(me); + } Err(_) => return false, }, Kind::GroupRoles => match GroupRoles::parse(event) { @@ -653,6 +704,21 @@ impl Group { self.state_ids.insert(event.kind, event.id) != Some(event.id) } + fn adopt_roster_membership(&mut self, me: PublicKey) -> bool { + if self.membership != Membership::Unknown { + return false; + } + + let listed = self.admins.contains(&me) || self.members.iter().any(|member| *member == me); + + if !listed { + return false; + } + + self.membership = Membership::Member; + true + } + fn push_row(&mut self, event: &Event) -> bool { if !self.index.insert(event.id) { return false; @@ -686,6 +752,24 @@ impl Group { true } + fn push_invite(&mut self, event: &Event) -> bool { + let Some(code) = tag_value(&event.tags, "code") else { + return false; + }; + + if !self.index.insert(event.id) { + return false; + } + + self.invites.push(Invite { + id: event.id, + code: code.to_owned(), + created_at: event.created_at, + }); + + true + } + fn push_activity(&mut self, event: &Event) -> bool { let activity = match Activity::parse(event) { Ok(activity) => activity, diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index c0d765f5..d546f920 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -43,7 +43,7 @@ fn has_tag(tags: &Tags, name: &str) -> bool { tags.iter().any(|tag| tag.kind() == name) } -fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { +pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { tags.iter() .find(|tag| tag.kind() == name) .and_then(|tag| tag.content()) @@ -98,6 +98,13 @@ impl GroupId { .kinds(MEMBERSHIP_KINDS) } + pub fn moderation_filter(&self, limit: usize) -> Filter { + Filter::new() + .custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone()) + .kinds([Kind::GroupCreateInvite, Kind::GroupDeleteEvent]) + .limit(limit) + } + pub fn message( &self, content: &str, diff --git a/crates/nip29_ui/src/invite.rs b/crates/nip29_ui/src/invite.rs index 243c8eff..ef109ed6 100644 --- a/crates/nip29_ui/src/invite.rs +++ b/crates/nip29_ui/src/invite.rs @@ -1,112 +1,210 @@ use anyhow::Result; +use common::TimestampExt; +use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div, - px, + App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled, + WeakEntity, Window, div, px, }; -use nip29::Group; +use nip29::{Group, Invite}; use nostr_sdk::prelude::SecretKey; use theme::{ActiveTheme, TextStyle, Typeset as _}; -use ui::dialog::DialogButtonProps; +use ui::button::{Button, ButtonVariants}; use ui::input::InputState; use ui::notification::Notification; -use ui::{WindowExtension, v_flex}; +use ui::{Sizable, WindowExtension, h_flex, v_flex}; -use crate::util::field; +use crate::util::{field, report}; const CODE_LEN: usize = 12; pub(crate) fn open(group: WeakEntity, 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) + .default_value(random_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_style(TextStyle::Callout) - .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 - }) + this.max_h(px(600.)) + .show_close(true) + .title("Invites") + .child(body(group.clone(), input.clone(), cx)) }); } + +fn random_code() -> String { + SecretKey::generate() + .to_secret_hex() + .chars() + .take(CODE_LEN) + .collect() +} + +fn body(group: WeakEntity, input: Entity, cx: &App) -> impl IntoElement { + let invites = group + .upgrade() + .map(|entity| entity.read(cx).invites().to_vec()) + .unwrap_or_default(); + + v_flex() + .gap_3() + .text_style(TextStyle::Callout) + .child( + div() + .text_color(cx.theme().text_muted) + .child("Anyone with a code can ask to join. Revoking a code stops new joins, it doesn't remove anyone."), + ) + .when(invites.is_empty(), |this| { + this.child( + div() + .text_color(cx.theme().text_faint) + .child("No active invites"), + ) + }) + .children(invites.iter().map(|invite| invite_row(group.clone(), invite, cx))) + .child(create_row(group, input, cx)) +} + +fn invite_row(group: WeakEntity, invite: &Invite, cx: &App) -> impl IntoElement { + let created = div() + .flex_shrink_0() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child(invite.created_at.to_ago()); + + h_flex() + .justify_between() + .gap_2() + .child( + h_flex() + .min_w_0() + .gap_2() + .items_center() + .child( + div() + .min_w_0() + .truncate() + .child(SharedString::from(invite.code.clone())), + ) + .child(created), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .child(copy_button(&group, &invite.code)) + .child(revoke_button(&group, invite.id, &invite.code)), + ) +} + +fn copy_button(group: &WeakEntity, code: &str) -> Button { + let group = group.clone(); + let code = code.to_owned(); + + Button::new(SharedString::from(format!("invite-copy-{code}"))) + .label("Copy") + .ghost() + .small() + .on_click(move |_event, window, cx| { + let Some(link) = group + .read_with(cx, |group, _cx| invite_link(group, &code)) + .ok() + .flatten() + else { + window.push_notification( + Notification::error( + "The relay doesn't publish group details, so no invite link can be made", + ), + cx, + ); + return; + }; + + cx.write_to_clipboard(ClipboardItem::new_string(link)); + window.push_notification(Notification::success("Invite link copied"), cx); + }) +} + +fn revoke_button(group: &WeakEntity, id: nostr_sdk::prelude::EventId, code: &str) -> Button { + let group = group.clone(); + + 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}"), + } + }) +} + +fn create_row(group: WeakEntity, input: Entity, cx: &App) -> impl IntoElement { + h_flex() + .items_end() + .gap_2() + .child(field("New code", &input, cx).flex_1()) + .child( + Button::new("invite-create") + .label("Create & copy link") + .on_click(move |_event, window, cx| { + create(group.clone(), input.clone(), window, cx); + }), + ) +} + +fn invite_link(group: &Group, code: &str) -> Option { + group + .metadata() + .and_then(|metadata| metadata.naddr(group.key().relay(), Some(code)).ok()) +} + +fn create(group: WeakEntity, input: Entity, window: &mut Window, cx: &mut App) { + let code = input.read(cx).value().trim().to_owned(); + + if code.is_empty() { + window.push_notification(Notification::error("The invite code is empty"), cx); + return; + } + + let Some(link) = group + .read_with(cx, |group, _cx| invite_link(group, &code)) + .ok() + .flatten() + else { + window.push_notification( + Notification::error( + "The relay doesn't publish group details, so no invite link can be made", + ), + cx, + ); + return; + }; + + 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| { + input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + cx.write_to_clipboard(ClipboardItem::new_string(link)); + window.push_notification(Notification::success("Invite link copied"), cx); + })?; + + Ok(()) + }) + .detach(); + } + Err(error) => log::warn!("nip29: creating an invite failed: {error}"), + } +} diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 750570f5..7f46173f 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -26,7 +26,7 @@ use ui::markdown::RenderedText; use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex}; +use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; use util::{attachment_name, display_name, opens_run, pin_label, report}; mod details; @@ -310,6 +310,8 @@ pub struct GroupPanel { group: WeakEntity, list_state: ListState, head: Option, + /// The message the composer is replying to, if any. + reply_to: Option, rendered_texts_by_id: BTreeMap, input: Entity, /// Uploaded, non-encrypted attachments waiting to be sent. @@ -380,6 +382,7 @@ impl GroupPanel { group: group.downgrade(), list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)), head, + reply_to: None, rendered_texts_by_id: BTreeMap::new(), input, attachments: Vec::new(), @@ -452,6 +455,20 @@ impl GroupPanel { }); } + fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context) { + self.reply_to = Some(id); + self.input.update(cx, |input, cx| { + input.focus(window, cx); + }); + cx.notify(); + } + + fn cancel_reply(&mut self, cx: &mut Context) { + if self.reply_to.take().is_some() { + cx.notify(); + } + } + fn send(&mut self, window: &mut Window, cx: &mut Context) { let mut content = self.input.read(cx).value().trim().to_owned(); @@ -474,9 +491,11 @@ impl GroupPanel { return; } + let replies: Vec = self.reply_to.take().into_iter().collect(); + let Ok(send) = self .group - .update(cx, |group, cx| group.send_message(&content, vec![], cx)) + .update(cx, |group, cx| group.send_message(&content, replies, cx)) else { return; }; @@ -911,6 +930,9 @@ impl GroupPanel { .w_full() .p_2() .gap_1() + .when_some(self.reply_to, |this, target| { + this.child(self.render_reply_target(target, cx)) + }) .children(self.render_attachments(cx)) .child( h_flex() @@ -943,6 +965,73 @@ impl GroupPanel { .into_any_element() } + fn render_reply_target(&self, target: EventId, cx: &mut Context) -> AnyElement { + let context = self.group.upgrade().and_then(|entity| { + entity.read_with(cx, |group, cx| { + let message = group.message(target)?; + let persons = PersonRegistry::global(cx); + let author = persons.read(cx).get(&message.author, cx); + let line = message.content.lines().next().unwrap_or_default().trim(); + + Some(( + author.name(), + SharedString::from(line.chars().take(80).collect::()), + )) + }) + }); + + let (author, snippet) = context.unwrap_or_else(|| { + ( + SharedString::from("Message"), + SharedString::from(target.to_hex().chars().take(8).collect::()), + ) + }); + + h_flex() + .min_w_0() + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded(px(control_radius())) + .bg(cx.theme().surface_raised) + .text_style(TextStyle::Caption) + .child( + div() + .flex_shrink_0() + .text_color(cx.theme().text_muted) + .child("Replying to"), + ) + .child( + div() + .flex_shrink_0() + .max_w(px(140.)) + .truncate() + .font_semibold() + .text_color(cx.theme().text) + .child(author), + ) + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_color(cx.theme().text_faint) + .child(snippet), + ) + .child( + Button::new("cancel-reply") + .icon(IconName::Close) + .tooltip("Cancel reply") + .ghost() + .small() + .on_click(cx.listener(|this, _event, _window, cx| { + this.cancel_reply(cx); + })), + ) + .into_any_element() + } + fn render_attachments(&self, cx: &mut Context) -> Vec { self.attachments .iter() diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index ce440e0f..1dc0f2c8 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -52,6 +52,9 @@ pub(crate) fn render( ) .author(author.name()) .timestamp(message.created_at.to_human_time()) + .when_some(message.replies_to.first().copied(), |this, target| { + this.child(reply_context(target, &actions.group, cx)) + }) .child(content) .when(!message.media.is_empty(), |this| { this.child(media(&message.media, cx)) @@ -71,12 +74,22 @@ pub(crate) fn render( 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(); + let menu = menu + .item({ + let panel = panel.clone(); - move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id) - }); + PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| { + let _ = panel.update_in(cx, |panel, window, cx| { + panel.start_reply(id, window, cx); + }); + }) + }) + .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; @@ -89,11 +102,9 @@ pub(crate) fn render( PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click( move |_event, _window, cx| { - if let Err(error) = panel.update_in(cx, |panel, window, cx| { + let _ = panel.update_in(cx, |panel, window, cx| { panel.toggle_pin(group.clone(), id, window, cx); - }) { - log::warn!("nip29: pinning a message failed: {error}"); - } + }); }, ) }) @@ -102,11 +113,9 @@ pub(crate) fn render( 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| { + let _ = panel.update_in(cx, |panel, window, cx| { panel.delete_message(group.clone(), id, window, cx); - }) { - log::warn!("nip29: deleting a message failed: {error}"); - } + }); }) }) }, @@ -249,6 +258,58 @@ fn reaction_bar(reactions: &[Reaction], cx: &App) -> AnyElement { .into_any_element() } +fn reply_context(target: EventId, group: &WeakEntity, cx: &App) -> AnyElement { + let context = group.upgrade().and_then(|entity| { + entity.read_with(cx, |group, cx| { + let message = group.message(target)?; + let persons = PersonRegistry::global(cx); + let author = persons.read(cx).get(&message.author, cx); + let line = message.content.lines().next().unwrap_or_default().trim(); + + Some(( + author.name(), + SharedString::from(line.chars().take(80).collect::()), + )) + }) + }); + + let (author, snippet) = context.unwrap_or_else(|| { + ( + SharedString::from("Message"), + SharedString::from("original not loaded"), + ) + }); + + h_flex() + .min_w_0() + .gap_1() + .items_center() + .child( + Icon::new(IconName::Reply) + .small() + .text_color(cx.theme().text_faint), + ) + .child( + div() + .flex_shrink_0() + .max_w(px(120.)) + .truncate() + .font_semibold() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_muted) + .child(author), + ) + .child( + div() + .min_w_0() + .truncate() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child(snippet), + ) + .into_any_element() +} + fn media(media: &[SharedUri], cx: &App) -> AnyElement { if let [only] = media { return div() diff --git a/crates/nip29_ui/src/metadata.rs b/crates/nip29_ui/src/metadata.rs index d8850386..bdbd9dd5 100644 --- a/crates/nip29_ui/src/metadata.rs +++ b/crates/nip29_ui/src/metadata.rs @@ -1,9 +1,20 @@ -use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px}; -use nip29::{Group, GroupMetadata}; +use std::sync::Arc; + +use gpui::http_client::{AsyncBody, HttpClient}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled, + WeakEntity, Window, div, px, +}; +use nip29::{Group, GroupId, GroupMetadata}; +use nostr_sdk::prelude::RelayUrl; +use theme::{ActiveTheme, TextStyle, Typeset as _}; +use ui::button::{Button, ButtonVariants}; use ui::dialog::DialogButtonProps; use ui::input::InputState; +use ui::notification::Notification; use ui::switch::Switch; -use ui::{WindowExtension, v_flex}; +use ui::{Sizable, WindowExtension, h_flex, v_flex}; use crate::util::field; @@ -53,10 +64,40 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) .default_value(base.picture.clone().unwrap_or_default()) }); + let parent = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Parent group id, empty for a root group") + .default_value( + base.parent + .as_ref() + .map(|id| id.as_str().to_owned()) + .unwrap_or_default(), + ) + }); + + let children = cx.new(|_cx| base.children.clone()); let flags = cx.new(|_cx| Flags::of(&base)); + let livekit = cx.new(|_cx| None); + + if let Ok(relay) = group.read_with(cx, |group, _cx| group.key().relay().clone()) { + let http = cx.http_client(); + let probe = cx.background_spawn(async move { probe_livekit(http, relay).await }); + let livekit = livekit.clone(); + + cx.spawn(async move |cx| { + let supported = probe.await; + + livekit.update(cx, |livekit, cx| { + *livekit = Some(supported); + cx.notify(); + }); + }) + .detach(); + } window.open_dialog(cx, move |this, _window, cx| { let current = *flags.read(cx); + let supported = *livekit.read(cx) != Some(false); this.confirm() .title("Edit Group") @@ -71,6 +112,8 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) .child(field("Name", &name, cx)) .child(field("About", &about, cx)) .child(field("Picture", &picture, cx)) + .child(field("Parent", &parent, cx)) + .child(children_section(&children, cx)) .child( v_flex() .gap(px(theme::SPACE)) @@ -105,9 +148,26 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) .child( Switch::new("livekit") .label("LiveKit") - .description("Announce audio and video support.") + .description(if supported { + "Announce audio and video support." + } else { + "The relay doesn't announce LiveKit support." + }) .checked(current.livekit) - .on_click(toggle(&flags, |flags| &mut flags.livekit)), + .on_click({ + let flags = flags.clone(); + let livekit = livekit.clone(); + + move |next, _window, cx| { + if *next && *livekit.read(cx) == Some(false) { + return; + } + flags.update(cx, |flags, cx| { + flags.livekit = *next; + cx.notify(); + }); + } + }), ), ), ) @@ -116,6 +176,8 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) let name = name.clone(); let about = about.clone(); let picture = picture.clone(); + let parent = parent.clone(); + let children = children.clone(); let flags = flags.clone(); move |_event, window, cx| { @@ -129,6 +191,40 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) next.about = value(&about, cx); next.picture = value(&picture, cx); + match value(&parent, cx).map(GroupId::new) { + Some(Ok(id)) => next.parent = Some(id), + Some(Err(error)) => { + window.push_notification(Notification::error(error.to_string()), cx); + return false; + } + None => next.parent = None, + } + + next.children = children.read(cx).clone(); + + // The relay must reject these anyway; catch them early. + if let Some(id) = &next.parent { + let key = group + .read_with(cx, |group, _cx| group.key().id().as_str().to_owned()) + .ok(); + + if key.as_deref() == Some(id.as_str()) { + window.push_notification( + Notification::error("A group can't be its own parent"), + cx, + ); + return false; + } + + if next.children.iter().any(|child| child == id) { + window.push_notification( + Notification::error("The parent is one of this group's children"), + cx, + ); + return false; + } + } + let current = flags.read(cx); next.private = current.private; next.restricted = current.restricted; @@ -147,6 +243,88 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) }); } +/// NIP-29: relays announce LiveKit support with a 204 at this well-known path. +async fn probe_livekit(http: Arc, relay: RelayUrl) -> bool { + let base = relay + .as_str() + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1); + let url = format!("{}/.well-known/nip29/livekit", base.trim_end_matches('/')); + + match http.get(&url, AsyncBody::default(), true).await { + Ok(response) => response.status() == 204, + Err(_) => false, + } +} + +fn children_section(children: &Entity>, cx: &App) -> AnyElement { + let list = children.read(cx); + + let mut section = v_flex() + .gap_1() + .text_style(TextStyle::Callout) + .text_color(cx.theme().text_muted) + .child("Children"); + + if list.is_empty() { + return section + .child( + div() + .text_color(cx.theme().text_faint) + .child("No subgroups"), + ) + .into_any_element(); + } + + for (ix, child) in list.iter().enumerate() { + let order = h_flex() + .flex_shrink_0() + .gap_1() + .when(ix > 0, |this| { + this.child(move_button(children.clone(), ix, ix - 1, "up")) + }) + .when(ix + 1 < list.len(), |this| { + this.child(move_button(children.clone(), ix, ix + 1, "down")) + }); + + section = section.child( + h_flex() + .justify_between() + .gap_2() + .child( + div() + .min_w_0() + .truncate() + .text_color(cx.theme().text) + .child(SharedString::from(child.as_str().to_owned())), + ) + .child(order), + ); + } + + section.into_any_element() +} + +fn move_button( + children: Entity>, + ix: usize, + target: usize, + direction: &'static str, +) -> Button { + Button::new(SharedString::from(format!("child-{direction}-{ix}"))) + .label(if direction == "up" { "Up" } else { "Down" }) + .ghost() + .small() + .on_click(move |_event, _window, cx| { + children.update(cx, |children, cx| { + if target < children.len() { + children.swap(ix, target); + cx.notify(); + } + }); + }) +} + fn toggle( flags: &Entity, select: fn(&mut Flags) -> &mut bool, diff --git a/docs/nip29-comparison.md b/docs/nip29-comparison.md index 5a552cfb..fb23d890 100644 --- a/docs/nip29-comparison.md +++ b/docs/nip29-comparison.md @@ -16,12 +16,12 @@ trade-offs; each has real gaps the other closed. timeline refs, membership-from-9000/9001 (the spec's "implementation quirks" section), and fork/migration detection via admins' `kind:10009`. nostord implements none of those three. -- nostord is ahead on product surface: invite code listing/revocation, full - LiveKit AV (token endpoint + `kind:39004`), threads UI, subgroup hierarchy - management, member rosters as a membership source. -- Spec bugs in ours worth fixing: no `39002`/`39001` fallback for - membership. (Since this comparison: `duplicate:` join rejection, 9008 - delete-group and reaction aggregation were implemented.) +- nostord is ahead on product surface: full LiveKit AV (token endpoint + + `kind:39004`) and a threads pane. Invite code management, subgroup + reparent/reorder and reply chips landed here since the comparison. +- Spec bugs in ours worth fixing: none open. The `duplicate:` join + rejection, 9008 delete-group, reaction aggregation and the `39002`/`39001` + membership fallback were implemented. ## 1. Protocol compliance vs NIP-29 @@ -32,7 +32,7 @@ trade-offs; each has real gaps the other closed. | **9008 delete-group** | yes, plus confirm dialog | yes | | **9010 update-pin-list + 39005** | send + parse + UI | stub ("disabled until the pinning backend exists") | | `previous` tag (last 50 seen, excl. self, ≥3 × 8 chars) | `TimelineWindow` (`crates/nip29/src/protocol.rs`) | not built, not validated | -| Membership = latest of own 9000/9001 | `Membership::from_events` | derived from 39001/39002 rosters + local markers instead | +| Membership = latest of own 9000/9001 | `Membership::from_events`, rosters resolve `Unknown` as fallback | derived from 39001/39002 rosters + local markers instead | | Join request `code` tag / `naddr1…?invite=` | `GroupReference::parse` | yes, plus legacy formats (`wss://host'id`, `?relay=&group=`) | | `duplicate:` 9021 rejection = already member | treated as success (`mark_member`) | matched by wording, treated as success | | 39000 metadata: all flags + `supported_kinds` + `parent`/`child` | yes, plus banner and content-JSON fallback | yes, minus banner; tags only | @@ -85,30 +85,30 @@ waits for AUTH explicitly before REQs; both work on auth-gating relays. ### P2 — client-parity features -4. **Invite management.** We create a 9009 code and copy the link, but never - subscribe to 9009, so there is no list of active codes and no revoke - (nostord: active codes = 9009 minus 9005 revocations, per-code copy and - revoke). Natural fit: add 9009 to the group's state subscription and an - "Invites" section in the details dialog. -5. **Membership fallback to rosters.** We only derive membership from - 9000/9001. If a relay prunes moderation history (or the user was added - without us seeing the 9000), membership shows `Unknown` while 39002 lists - us. Consider nostord's "external add" adoption: own pubkey in 39001/39002 - → treat as member (or invite card), without weakening the 9000/9001 - override. -6. **LiveKit.** The flag is parsed and editable, but joining an AV group does - nothing. At minimum: the `/.well-known/nip29/livekit` probe (204) to gate - the toggle, and the token flow - (`/.well-known/nip29/livekit/` with NIP-98 kind 27235 plus a 39004 - participant subscription) if AV is on the roadmap. -7. **Subgroup management UI.** `parent` is set at creation (`CreateChannel`), - and `edit_metadata` already carries `parent`/`child` tags, but the edit - dialog cannot reparent or reorder children (the spec's 9002 child-list - replace semantics). nostord has a full Hierarchy tab (move under / make - root / reorder). Nothing reads NIP-11 `nip29.subgroups` to gate the UI. -8. **No reply/thread UI.** `send_message` accepts `replies` but - `GroupPanel::send` always passes empty; kind 11/1111 render inline only. - nostord has reply chips, a threads pane, and NIP-22 tag nesting. +4. ~~**Invite management.**~~ **done.** The group subscription now includes a + moderation filter (`9009` + `9005`, `GroupId::moderation_filter`), + `Group::invites()` exposes active codes (revocations arrive as 9005 + deletions and via the publisher's own `Sent` path), and the invite dialog + lists codes with per-code copy-link and revoke. +5. ~~**Membership fallback to rosters.**~~ **done.** `Group:: + adopt_roster_membership`: while the latest own 9000/9001 leaves membership + `Unknown`, being listed in 39001/39002 counts as `Member`. `Pending`, + `Refused` and `Removed` are never overridden. +6. **LiveKit — partially done.** The edit dialog probes + `/.well-known/nip29/livekit` (expecting 204) and gates the toggle with a + description when the relay doesn't announce support. Remaining: the token + flow (`/.well-known/nip29/livekit/` with NIP-98 kind 27235) and a + `kind:39004` participant subscription — both only useful once an AV + client is on the roadmap. +7. ~~**Subgroup management UI.**~~ **done.** The edit dialog has a Parent + field (empty = root; reparenting) and a children list with Up/Down + reordering (the spec's 9002 child-list replace semantics). Self-parent and + parent-is-child are rejected client-side; NIP-11 `nip29.subgroups` is not + read yet. +8. **Reply UI — partially done.** The composer can target a reply + (context menu → Reply, chip with cancel), sends kind 11 with `e` tags, and + messages render a reply-context line (author + first line) resolved from + loaded rows. Remaining: a dedicated threads pane (nostord has one). ### P3 — polish @@ -129,8 +129,9 @@ waits for AUTH explicitly before REQs; both work on auth-gating relays. 1. ~~Fix `duplicate:`-prefix handling on 9021~~ done. 2. ~~Exclude/aggregate kind-7 reactions in group rows~~ done. 3. ~~Add 9008 delete-group + confirm dialog~~ done. -4. Subscribe 9009 → invite list + revoke via 9005 (medium). -5. 39002/39001 membership fallback (medium). -6. Subgroup reparent/reorder in the edit dialog (medium, uses the existing - 9002 path). -7. LiveKit probe + token flow (larger; only if AV is in scope). +4. ~~Subscribe 9009 → invite list + revoke via 9005~~ done. +5. ~~39002/39001 membership fallback~~ done. +6. ~~Subgroup reparent/reorder in the edit dialog~~ done. +7. LiveKit probe (done, gates the toggle) + token flow (larger; only if AV is + in scope). +8. Reply sending + chips (done); a dedicated threads pane remains.