From 8c7c67267521352b473044842e132083e3882d91 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 17:10:27 +0700 Subject: [PATCH] add group management menu with metadata, invites, and members --- crates/nip29_ui/src/invite.rs | 59 +++++++++ crates/nip29_ui/src/lib.rs | 209 +++++++++++++++++++++++++++++++- crates/nip29_ui/src/message.rs | 88 +++++++++----- crates/nip29_ui/src/metadata.rs | 176 +++++++++++++++++++++++++++ crates/nip29_ui/src/users.rs | 209 ++++++++++++++++++++++++++++++++ docs/nip29-migration-plan.md | 16 ++- docs/nip29-usage.md | 10 +- 7 files changed, 724 insertions(+), 43 deletions(-) create mode 100644 crates/nip29_ui/src/invite.rs create mode 100644 crates/nip29_ui/src/metadata.rs create mode 100644 crates/nip29_ui/src/users.rs diff --git a/crates/nip29_ui/src/invite.rs b/crates/nip29_ui/src/invite.rs new file mode 100644 index 00000000..65314c8b --- /dev/null +++ b/crates/nip29_ui/src/invite.rs @@ -0,0 +1,59 @@ +use gpui::{App, AppContext, ParentElement, Styled, WeakEntity, Window, div, px}; +use nostr_sdk::prelude::SecretKey; +use theme::ActiveTheme; +use ui::dialog::DialogButtonProps; +use ui::input::InputState; +use ui::{WindowExtension, v_flex}; + +use crate::{GroupPanel, field}; + +const CODE_LEN: usize = 12; + +pub(crate) fn open(panel: 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) + }); + + window.open_dialog(cx, move |this, _window, cx| { + let panel = panel.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; + } + + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.create_invite(code, window, cx); + }) { + log::warn!("nip29: creating an invite failed: {error}"); + } + + true + }) + }); +} diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index b2e4de79..e57d6c42 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -5,11 +5,11 @@ use chat::Message; use chat_ui::text::rendered_text; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, - IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, - Styled, Subscription, Task, WeakEntity, Window, div, list, px, + AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, + Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, + Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; -use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin}; +use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin}; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; @@ -19,15 +19,19 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariant, ButtonVariants}; use ui::dialog::DialogButtonProps; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use ui::markdown::RenderedText; +use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; mod details; +mod invite; mod message; +mod metadata; +mod users; const LOAD_OLDER_THRESHOLD: usize = 20; const RUN_WINDOW_SECS: u64 = 300; @@ -105,6 +109,15 @@ pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity, cx: &App) -> Div { + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child(label) + .child(Input::new(input)) +} + pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString { match pin { Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")), @@ -302,6 +315,143 @@ impl GroupPanel { .and_then(|group| Notice::of(group.read(cx))) } + fn edit_metadata( + &mut self, + metadata: GroupMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let Ok(edit) = self + .group + .update(cx, |group, cx| group.edit_metadata(&metadata, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = edit.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + + fn create_invite(&mut self, code: String, window: &mut Window, cx: &mut Context) { + let Some(group) = self.group.upgrade() else { + return; + }; + + let link = { + let group = group.read(cx); + + group.metadata().and_then(|metadata| { + metadata + .naddr(group.key().relay(), Some(&code)) + .ok() + .map(SharedString::from) + }) + }; + + 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; + }; + + let Ok(invite) = self + .group + .update(cx, |group, cx| group.create_invite(&code, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = invite.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + return Ok(()); + } + + this.update_in(cx, |_this, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(link.to_string())); + window.push_notification(Notification::success("Invite link copied"), cx); + })?; + + Ok(()) + })); + } + + fn put_user( + &mut self, + public_key: PublicKey, + roles: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let Ok(put) = self + .group + .update(cx, |group, cx| group.put_user(public_key, roles, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = put.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + + fn remove_user(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context) { + let Ok(remove) = self + .group + .update(cx, |group, cx| group.remove_user(public_key, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = remove.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + + fn react( + &mut self, + group: WeakEntity, + id: EventId, + emoji: String, + window: &mut Window, + cx: &mut Context, + ) { + let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, cx)) else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = react.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + fn delete_message( &mut self, group: WeakEntity, @@ -396,15 +546,22 @@ impl GroupPanel { return div().into_any_element(); }; - let (name, picture, seed) = { + let (name, picture, seed, admin) = { let group = group.read(cx); + let admin = NostrRegistry::global(cx) + .read(cx) + .current_user() + .is_some_and(|me| group.admins().contains(&me)); + ( group.display_name(), group.display_image(), group.key().cache_tag(), + admin, ) }; + let panel = cx.weak_entity(); let details_group = self.group.clone(); let leave_group = self.group.clone(); @@ -436,6 +593,46 @@ impl GroupPanel { details::open(details_group.clone(), window, cx); }), ) + .when(admin, |this| { + this.child( + Button::new("group-manage") + .icon(IconName::Ellipsis) + .tooltip("Manage") + .ghost() + .small() + .dropdown_menu({ + let group = self.group.clone(); + + move |menu, _window, _cx| { + menu.item(PopupMenuItem::new("Edit group").on_click({ + let group = group.clone(); + let panel = panel.clone(); + + move |_event, window, cx| { + metadata::open(group.clone(), panel.clone(), window, cx); + } + })) + .item(PopupMenuItem::new("Invite people").on_click({ + let panel = panel.clone(); + + move |_event, window, cx| { + invite::open(panel.clone(), window, cx); + } + })) + .item( + PopupMenuItem::new("Members and roles").on_click({ + let group = group.clone(); + let panel = panel.clone(); + + move |_event, window, cx| { + users::open(group.clone(), panel.clone(), window, cx); + } + }), + ) + } + }), + ) + }) .child( Button::new("group-leave") .icon(IconName::Door) diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index f3170397..7fcd4d64 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -6,15 +6,18 @@ use gpui::{ StyledImage, WeakEntity, div, img, px, }; use nip29::Group; +use nostr_sdk::prelude::EventId; use person::PersonRegistry; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; -use ui::menu::{ContextMenu, PopupMenuItem}; +use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem}; use ui::message::MessageRow; use crate::GroupPanel; +const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"]; + pub(crate) struct Actions { pub group: WeakEntity, pub panel: WeakEntity, @@ -50,11 +53,8 @@ pub(crate) fn render( }) .into_any_element(); - if !actions.admin { - return row; - } - let id = message.id; + let admin = actions.admin; let pinned = actions.pinned; let group = actions.group; let panel = actions.panel; @@ -62,41 +62,73 @@ pub(crate) fn render( ContextMenu::new( format!("message-menu-{}", id.to_hex()), row, - move |menu, _window, _cx| { - let pin = { + move |menu, window, cx| { + let menu = menu.submenu("React", window, cx, { 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}"); - } - }, - ) - }; + move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id) + }); - let delete = { - let group = group.clone(); - let panel = panel.clone(); + if !admin { + return menu; + } - 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}"); - } + 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(); - menu.item(pin).item(delete) + 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, + panel: WeakEntity, + 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}"); + } + }), + ) + }) +} + 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 new file mode 100644 index 00000000..e5c8c58d --- /dev/null +++ b/crates/nip29_ui/src/metadata.rs @@ -0,0 +1,176 @@ +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::{GroupPanel, 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, + panel: WeakEntity, + 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 panel = panel.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; + + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.edit_metadata(next, window, cx); + }) { + log::warn!("nip29: editing the group failed: {error}"); + } + + true + } + }) + }); +} + +fn toggle( + flags: &Entity, + 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, cx: &App) -> Option { + let value = input.read(cx).value().trim().to_owned(); + + (!value.is_empty()).then_some(value) +} diff --git a/crates/nip29_ui/src/users.rs b/crates/nip29_ui/src/users.rs new file mode 100644 index 00000000..4abca9c1 --- /dev/null +++ b/crates/nip29_ui/src/users.rs @@ -0,0 +1,209 @@ +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled, + WeakEntity, Window, div, px, +}; +use nip29::{Group, Role}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::input::InputState; +use ui::notification::Notification; +use ui::switch::Switch; +use ui::{Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; + +use crate::{GroupPanel, field}; + +pub(crate) fn open( + group: WeakEntity, + panel: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1…")); + let selected = cx.new(|_cx| Vec::::new()); + + window.open_dialog(cx, move |this, _window, cx| { + let group = group.clone(); + let panel = panel.clone(); + let input = input.clone(); + let selected = selected.clone(); + + let admins = group + .read_with(cx, |group, _cx| group.admins().entries().to_vec()) + .unwrap_or_default(); + let roles = group + .read_with(cx, |group, _cx| group.roles().roles().to_vec()) + .unwrap_or_default(); + let chosen = selected.read(cx).clone(); + + this.width(px(460.)) + .max_h(px(620.)) + .show_close(true) + .title("Members and roles") + .child( + v_flex() + .gap_4() + .child( + v_flex() + .gap_2() + .child(div().text_sm().font_medium().child("Admins")) + .when(admins.is_empty(), |this| { + this.child( + div() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child("The relay lists no admins."), + ) + }) + .children( + admins + .iter() + .map(|(key, roles)| admin(key, roles, &panel, cx)), + ), + ) + .child(divider(cx)) + .child( + v_flex() + .gap_3() + .child(div().text_sm().font_medium().child("Add a member")) + .child(field("Public key", &input, cx)) + .when(!roles.is_empty(), |this| { + this.child( + v_flex().gap_2().children( + roles + .iter() + .map(|role| role_switch(role, &selected, &chosen)), + ), + ) + }) + .child(Button::new("add-user").label("Add").small().on_click({ + let panel = panel.clone(); + let input = input.clone(); + let selected = selected.clone(); + + move |_event, window, cx| { + let value = input.read(cx).value().trim().to_owned(); + + let public_key = match PublicKey::parse(&value) { + Ok(public_key) => public_key, + Err(error) => { + window.push_notification( + Notification::error(error.to_string()), + cx, + ); + return; + } + }; + + let roles = selected.read(cx).clone(); + + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.put_user(public_key, roles, window, cx); + }) { + log::warn!("nip29: adding a group member failed: {error}"); + } + + input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + selected.update(cx, |selected, cx| { + selected.clear(); + cx.notify(); + }); + } + })), + ), + ) + }); +} + +fn admin( + public_key: &PublicKey, + roles: &[String], + panel: &WeakEntity, + cx: &App, +) -> AnyElement { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + + h_flex() + .justify_between() + .gap_2() + .child( + h_flex() + .gap_2() + .min_w_0() + .child( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .xsmall(), + ) + .child( + v_flex() + .min_w_0() + .child(div().truncate().child(person.name())) + .when(!roles.is_empty(), |this| { + this.child( + div() + .truncate() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(SharedString::from(roles.join(", "))), + ) + }), + ), + ) + .child( + Button::new(SharedString::from(format!("del-{}", public_key.to_hex()))) + .label("Remove") + .ghost() + .small() + .on_click({ + let panel = panel.clone(); + + move |_event, _window, cx| { + if let Err(error) = panel.update_in(cx, |panel, window, cx| { + panel.remove_user(public_key, window, cx); + }) { + log::warn!("nip29: removing a group member failed: {error}"); + } + } + }), + ) + .into_any_element() +} + +fn role_switch(role: &Role, selected: &Entity>, chosen: &[String]) -> AnyElement { + let name = role.name.clone(); + let description = role.description.clone(); + + Switch::new(SharedString::from(format!("role-{}", role.name))) + .label(role.name.clone()) + .when_some(description, |this, description| { + this.description(description) + }) + .small() + .checked(chosen.contains(&role.name)) + .on_click({ + let selected = selected.clone(); + + move |next, _window, cx| { + selected.update(cx, |selected, cx| { + if *next { + if !selected.contains(&name) { + selected.push(name.clone()); + } + } else { + selected.retain(|role| role != &name); + } + + cx.notify(); + }); + } + }) + .into_any_element() +} diff --git a/docs/nip29-migration-plan.md b/docs/nip29-migration-plan.md index 8dcba5bd..6c2c594c 100644 --- a/docs/nip29-migration-plan.md +++ b/docs/nip29-migration-plan.md @@ -302,17 +302,19 @@ Validation: same commands; manually delete a message, pin and unpin, leave and r The remaining moderation surface, all through the calls the backend already exposes. -- **Edit metadata** — a dialog started from `group.metadata().cloned().unwrap_or_default()`: - name, about, picture, the flags. `children` is preserved as-is — `edit_metadata` re-emits - the full list, and a `9002` that drops one is rejected by the relay. +- **Edit metadata** — a dialog opened from the panel header's `Manage` menu, started from + `group.metadata().cloned().unwrap_or_default()`: name, about, picture, the flags. `children` + is preserved as-is — `edit_metadata` re-emits the full list, and a `9002` that drops one is + rejected by the relay. - **Invite** — `create_invite(code, cx)` with an input or generated code, then `metadata.naddr(relay, Some(code))` copied to the clipboard (`naddr` needs parsed metadata; a group whose relay publishes no `39000` cannot mint a link). - **Users and roles** — add by `npub` → `put_user(pubkey, roles, cx)`, remove → `remove_user(pubkey, cx)`; role choices come from `Group::roles()`. The relay is the authority for both the roles it supports and who may call these. -- **React** — the row menu sends `send_reaction(target, emoji, cx)`; a kind `7` row appears - inline (that is the current backend behaviour). +- **React** — a `React` submenu in the same row menu sends `send_reaction(target, emoji, cx)`; + a kind `7` row appears inline (that is the current backend behaviour). Unlike the items above + it is not admin-only, so the menu now exists for every member. Validation: same commands; manually exercise each write against a relay that grants the account admin, and confirm the refusal path shows the relay's message. @@ -376,3 +378,7 @@ this repo; `coop_web` check is the proxy. - The fork banner's button joins the target group rather than opening it: the panel holds no dock handle and the plan's call is `join_key` alone; the joined group then appears in the sidebar. +- The admin writes sit behind a `Manage` menu in the panel header, shown only while the current + user is in `Group::admins()`; the details dialog stays read-only. The message row menu is now + built for every member — `React` is not moderation — and only its `Pin`/`Delete` items are + gated on being an admin. diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index 0048ce78..20b332a8 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -569,12 +569,14 @@ before they reach the consumer, so a replay cannot double-hydrate. ## Not wired up yet -- **Administration is partly built.** The sidebar's `Groups` tab lists +- **Administration is built on the client side.** The sidebar's `Groups` tab lists `GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins - from a result. The panel now has a read-only details dialog, message delete and - pin/unpin for admins, a pins strip, a fork banner, and leave. Editing metadata, - invites, and adding or removing users and roles are still to be built. + from a result. The panel has a read-only details dialog, a header `Manage` menu + (edit metadata, invite link, members and roles) shown to admins, a message menu + with reactions and admin pin/delete, a pins strip, a fork banner, and leave. + What a given account may actually do is the relay's call; the client shows the + refusal verbatim. - **Remote deletions are not folded in.** A `9005` from another admin never reaches the standing REQ, so only our own accepted deletes evict the row. - **The LiveKit response shape is a guess.** The NIP names no fields, so