From ce1cff83ffeeb839b04e32fc48c39f6660adbe2e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 16:07:21 +0700 Subject: [PATCH] add group header, moderation menu, and pins strip --- Cargo.lock | 2 + crates/nip29_ui/Cargo.toml | 2 + crates/nip29_ui/src/details.rs | 268 +++++++++++++++++++++++++++++++ crates/nip29_ui/src/lib.rs | 279 ++++++++++++++++++++++++++++++++- crates/nip29_ui/src/message.rs | 64 +++++++- docs/nip29-migration-plan.md | 11 +- docs/nip29-usage.md | 7 +- 7 files changed, 619 insertions(+), 14 deletions(-) create mode 100644 crates/nip29_ui/src/details.rs diff --git a/Cargo.lock b/Cargo.lock index be2897d9..b8610a10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4621,11 +4621,13 @@ dependencies = [ "chat_ui", "common", "gpui-pre", + "log", "nip29", "nostr-sdk", "person", "settings", "smallvec", + "state", "theme", "ui", ] diff --git a/crates/nip29_ui/Cargo.toml b/crates/nip29_ui/Cargo.toml index 8ae990b1..b1a2a930 100644 --- a/crates/nip29_ui/Cargo.toml +++ b/crates/nip29_ui/Cargo.toml @@ -11,10 +11,12 @@ common = { path = "../common" } nip29 = { path = "../nip29" } person = { path = "../person" } settings = { path = "../settings" } +state = { path = "../state" } theme = { path = "../theme" } ui = { path = "../ui" } gpui.workspace = true nostr-sdk.workspace = true anyhow.workspace = true +log.workspace = true smallvec.workspace = true diff --git a/crates/nip29_ui/src/details.rs b/crates/nip29_ui/src/details.rs new file mode 100644 index 00000000..c4271acb --- /dev/null +++ b/crates/nip29_ui/src/details.rs @@ -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, 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, 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 = 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::>(), + ) + .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) -> 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, 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 +} diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index eb779c1b..b2e4de79 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -9,21 +9,24 @@ use gpui::{ IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; -use nip29::{Group, GroupEvent, GroupsRegistry, Membership}; +use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin}; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; +use ui::button::{Button, ButtonVariant, ButtonVariants}; +use ui::dialog::DialogButtonProps; use ui::dock::{Panel, PanelEvent}; use ui::input::{InputEvent, Textarea, TextareaState}; use ui::markdown::RenderedText; use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; +use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +mod details; mod message; const LOAD_OLDER_THRESHOLD: usize = 20; @@ -102,6 +105,26 @@ pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity SharedString { + match pin { + Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")), + Pin::Event(id) => { + let snippet = group + .messages() + .iter() + .find(|message| message.id == *id) + .and_then(|message| message.content.lines().next()) + .map(str::trim) + .filter(|line| !line.is_empty()); + + match snippet { + Some(snippet) => SharedString::from(snippet.chars().take(48).collect::()), + None => SharedString::from(id.to_hex().chars().take(8).collect::()), + } + } + } +} + pub struct GroupPanel { id: SharedString, focus_handle: FocusHandle, @@ -279,6 +302,229 @@ impl GroupPanel { .and_then(|group| Notice::of(group.read(cx))) } + fn delete_message( + &mut self, + group: WeakEntity, + id: EventId, + window: &mut Window, + cx: &mut Context, + ) { + let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else { + return; + }; + + 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(()) + })); + } + + fn toggle_pin( + &mut self, + group: WeakEntity, + id: EventId, + window: &mut Window, + cx: &mut Context, + ) { + let Ok(update) = group.update(cx, |group, cx| { + let mut pins = group.pins().pins().to_vec(); + + match pins.iter().position(|pin| *pin == Pin::Event(id)) { + Some(ix) => { + pins.remove(ix); + } + None => pins.push(Pin::Event(id)), + } + + group.update_pins(pins, cx) + }) else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = update.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + + fn confirm_leave(group: WeakEntity, window: &mut Window, cx: &mut App) { + let Some(name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else { + return; + }; + + let Some(key) = group.read_with(cx, |group, _cx| group.key().clone()).ok() else { + return; + }; + + window.open_dialog(cx, move |this, _window, _cx| { + let key = key.clone(); + + this.confirm() + .width(px(360.)) + .title(format!("Leave {name}?")) + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Leave") + .ok_variant(ButtonVariant::Danger), + ) + .child( + div() + .text_sm() + .child("You will stop receiving messages from this group."), + ) + .on_ok(move |_event, _window, cx| { + let registry = GroupsRegistry::global(cx); + registry.update(cx, |registry, cx| { + registry.leave(&key, cx); + }); + true + }) + }); + } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let (name, picture, seed) = { + let group = group.read(cx); + ( + group.display_name(), + group.display_image(), + group.key().cache_tag(), + ) + }; + + let details_group = self.group.clone(); + let leave_group = self.group.clone(); + + h_flex() + .flex_shrink_0() + .w_full() + .h_10() + .px_2() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child(Avatar::from_source(picture).seed(seed).small()) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_medium() + .truncate() + .child(name), + ) + .child( + Button::new("group-details") + .icon(IconName::Info) + .tooltip("Details") + .ghost() + .small() + .on_click(move |_event, window, cx| { + details::open(details_group.clone(), window, cx); + }), + ) + .child( + Button::new("group-leave") + .icon(IconName::Door) + .tooltip("Leave") + .ghost() + .small() + .on_click(move |_event, window, cx| { + Self::confirm_leave(leave_group.clone(), window, cx); + }), + ) + .into_any_element() + } + + fn render_banner(&self, cx: &mut Context) -> AnyElement { + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let Some(moved) = group.read(cx).elsewhere().cloned() else { + return div().into_any_element(); + }; + + let relay = moved.relay().clone(); + let message = SharedString::from(format!("This group may have moved to {relay}.")); + + h_flex() + .flex_shrink_0() + .w_full() + .gap_2() + .px_3() + .py_2() + .text_sm() + .border_b_1() + .border_color(cx.theme().border) + .child( + Icon::new(IconName::Info) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(div().flex_1().min_w_0().truncate().child(message)) + .child( + Button::new("group-moved") + .label("Join") + .small() + .on_click(cx.listener(move |_this, _event, _window, cx| { + let registry = GroupsRegistry::global(cx); + + registry.update(cx, |registry, cx| { + registry.join_key(moved.clone(), None, cx); + }); + })), + ) + .into_any_element() + } + + fn render_pins(&self, cx: &mut Context) -> AnyElement { + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let group = group.read(cx); + let pins = group.pins().pins(); + + if pins.is_empty() { + return div().into_any_element(); + } + + h_flex() + .flex_shrink_0() + .w_full() + .flex_wrap() + .gap_1() + .px_3() + .py_1p5() + .border_b_1() + .border_color(cx.theme().border) + .children(pins.iter().map(|pin| { + div() + .px_1p5() + .py_0p5() + .rounded(cx.theme().radius) + .bg(cx.theme().element_background) + .text_xs() + .text_color(cx.theme().text_muted) + .child(pin_label(group, pin)) + })) + .into_any_element() + } + fn render_welcome(&self, cx: &App) -> AnyElement { let (name, avatar) = match self.group.upgrade() { Some(group) => { @@ -419,7 +665,7 @@ impl GroupPanel { return div().into_any_element(); }; - let show_author = opens_run(messages, ix - 2); + let nostr = NostrRegistry::global(cx); let persons = PersonRegistry::global(cx); let content = self @@ -430,7 +676,27 @@ impl GroupPanel { }) .element(ix.into(), window, cx); - message::render(ix, message, content, show_author, cx) + let admin = nostr + .read(cx) + .current_user() + .is_some_and(|me| group.admins().contains(&me)); + + let show_author = opens_run(messages, ix - 2); + let pinned = group.pins().pins().contains(&Pin::Event(message.id)); + + message::render( + ix, + message, + content, + show_author, + message::Actions { + group: self.group.clone(), + panel: cx.weak_entity(), + admin, + pinned, + }, + cx, + ) } fn render_composer(&self, cx: &mut Context) -> impl IntoElement { @@ -512,6 +778,9 @@ impl Render for GroupPanel { v_flex() .size_full() .min_w_0() + .child(self.render_header(cx)) + .child(self.render_banner(cx)) + .child(self.render_pins(cx)) .child( v_flex() .flex_1() diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index fd3d7e12..f3170397 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -3,26 +3,38 @@ use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled, - StyledImage, div, img, px, + StyledImage, WeakEntity, div, img, px, }; +use nip29::Group; use person::PersonRegistry; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; +use ui::menu::{ContextMenu, PopupMenuItem}; use ui::message::MessageRow; +use crate::GroupPanel; + +pub(crate) struct Actions { + pub group: WeakEntity, + pub panel: WeakEntity, + 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); - MessageRow::new(ix) + let row = MessageRow::new(ix) .show_author(show_author) .hide_avatar(hide_avatar) .avatar( @@ -36,7 +48,53 @@ pub(crate) fn render( .when(!message.media.is_empty(), |this| { this.child(media(&message.media, cx)) }) - .into_any_element() + .into_any_element(); + + if !actions.admin { + return row; + } + + let id = message.id; + 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 pin = { + 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}"); + } + }, + ) + }; + + let delete = { + 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}"); + } + }) + }; + + menu.item(pin).item(delete) + }, + ) + .into_any_element() } fn media(media: &[SharedUri], cx: &App) -> AnyElement { diff --git a/docs/nip29-migration-plan.md b/docs/nip29-migration-plan.md index 7336e2f1..8dcba5bd 100644 --- a/docs/nip29-migration-plan.md +++ b/docs/nip29-migration-plan.md @@ -278,7 +278,7 @@ result, and create a group on a relay that accepts `9007`. Read-mostly group management, the surfaces the sidebar gave up in Phase 2 plus the two writes that need Phase 0. -- **Group details** — a dialog or sheet opened from the panel toolbar: name, picture, banner, +- **Group details** — a dialog opened from the panel's header row: name, picture, banner, about, flags (`private`, `restricted`, `hidden`, `closed`, `livekit`, `parent`, `children`), admins with their role labels (`Group::admins().entries()`), the roles the relay advertises (`Group::roles()`), members when the relay publishes `39002` (`Group::members()`), and pins @@ -287,11 +287,11 @@ that need Phase 0. `admins()`) → `group.delete_event(id, cx)`; Phase 0 makes the row disappear. - **Pin / unpin** — the same menu: `group.update_pins(pins, cx)` with the current list plus or minus `Pin::Event(id)`; show `Group::pins()` as a strip above the list. -- **Leave** — toolbar `Leave…` opens a confirmation naming the group (`Leave "Pizza Lovers"?`); +- **Leave** — header `Leave…` opens a confirmation naming the group (`Leave "Pizza Lovers"?`); on confirm `registry.leave(&key, cx)` and the panel closes (dispatch `ClosePanel`, as the screening dialog does). - **Fork / migration banner** — when `group.elsewhere()` is `Some(moved)`, a banner: - `This group may have moved to {relay}.` with a `Open` button calling + `This group may have moved to {relay}.` with a `Join` button calling `registry.join_key(moved, None, cx)`. Never auto-join: the NIP asks the client to offer. Validation: same commands; manually delete a message, pin and unpin, leave and re-join. @@ -371,3 +371,8 @@ this repo; `coop_web` check is the proxy. - `Join` never happens implicitly — not from `10009` alone, and not from `elsewhere()`. - The old crates are deleted only at the end, so any phase can be stopped and the app still runs. +- The dock renders no per-panel toolbar — `Panel::toolbar_buttons` is defined but never called — + so the group's `Details` and `Leave` actions live in a header row inside the panel. +- 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. diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index d343199d..0048ce78 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -569,11 +569,12 @@ before they reach the consumer, so a replay cannot double-hydrate. ## Not wired up yet -- **Details and administration are not built.** The sidebar's `Groups` tab lists +- **Administration is partly built.** 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 group details, moderation and pin surfaces are still to be - built. + 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. - **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