From 857795a92e6840ce9d5edb418bb17234a6f90fff Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 1 Oct 2026 08:25:52 +0700 Subject: [PATCH] add group attachments and sidebar toggle --- Cargo.lock | 1 + crates/nip29_ui/Cargo.toml | 1 + crates/nip29_ui/src/lib.rs | 224 +++++++++++++++++++++--- crates/theme/src/colors.rs | 4 +- crates/workspace/src/dialogs/mod.rs | 1 + crates/workspace/src/dialogs/profile.rs | 166 ++++++++++++++++++ crates/workspace/src/lib.rs | 30 +++- crates/workspace/src/sidebar/mod.rs | 78 ++++++--- docs/nip29-usage.md | 16 +- 9 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 crates/workspace/src/dialogs/profile.rs diff --git a/Cargo.lock b/Cargo.lock index 612fdba6..0f72adfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4588,6 +4588,7 @@ dependencies = [ "nip29", "nostr-sdk", "person", + "serde", "settings", "smallvec", "state", diff --git a/crates/nip29_ui/Cargo.toml b/crates/nip29_ui/Cargo.toml index b1a2a930..f4ab6f89 100644 --- a/crates/nip29_ui/Cargo.toml +++ b/crates/nip29_ui/Cargo.toml @@ -19,4 +19,5 @@ gpui.workspace = true nostr-sdk.workspace = true anyhow.workspace = true log.workspace = true +serde.workspace = true smallvec.workspace = true diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index e5c036be..b3bbc182 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -4,15 +4,18 @@ use anyhow::Result; use chat_ui::text::rendered_text; use gpui::prelude::FluentBuilder; use gpui::{ - 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, + Action, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, + FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, + ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, + Window, div, list, px, }; use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row}; use nostr_sdk::prelude::*; use person::PersonRegistry; +use serde::Deserialize; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::NostrRegistry; +use state::{NostrRegistry, upload}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariant, ButtonVariants}; @@ -24,7 +27,7 @@ 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}; +use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex}; mod details; mod invite; @@ -32,6 +35,14 @@ mod message; mod metadata; mod users; +/// Ask the shell to show or hide the sidebar's information for a group. +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = nip29_ui, no_json)] +pub struct ToggleGroupMeta { + pub relay: RelayUrl, + pub group: String, +} + const LOAD_OLDER_THRESHOLD: usize = 20; const RUN_WINDOW_SECS: u64 = 300; @@ -87,6 +98,16 @@ fn display_name(group: &Group) -> SharedString { ) } +fn attachment_name(url: &Url) -> SharedString { + url.path_segments() + .and_then(|mut segments| segments.next_back()) + .filter(|name| !name.is_empty()) + .map_or_else( + || SharedString::from(url.as_str()), + |name| SharedString::from(name.to_owned()), + ) +} + fn opens_run(rows: &[Row], index: usize) -> bool { let (Some(current), Some(previous)) = ( rows.get(index), @@ -146,6 +167,10 @@ pub struct GroupPanel { head: Option, rendered_texts_by_id: BTreeMap, input: Entity, + /// Uploaded, non-encrypted attachments waiting to be sent. + attachments: Vec, + /// Whether an upload is in flight. + uploading: bool, tasks: SmallVec<[Task>; 4]>, _subscriptions: SmallVec<[Subscription; 3]>, } @@ -212,6 +237,8 @@ impl GroupPanel { head, rendered_texts_by_id: BTreeMap::new(), input, + attachments: Vec::new(), + uploading: false, tasks: smallvec![], _subscriptions: subscriptions, } @@ -281,7 +308,22 @@ impl GroupPanel { } fn send(&mut self, window: &mut Window, cx: &mut Context) { - let content = self.input.read(cx).value().trim().to_owned(); + let mut content = self.input.read(cx).value().trim().to_owned(); + + if !self.attachments.is_empty() { + let urls = self + .attachments + .iter() + .map(Url::to_string) + .collect::>() + .join("\n"); + + content = if content.is_empty() { + urls + } else { + format!("{content}\n{urls}") + }; + } if content.is_empty() { return; @@ -297,6 +339,7 @@ impl GroupPanel { self.input.update(cx, |input, cx| { input.set_value("", window, cx); }); + self.attachments.clear(); self.tasks .push(cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { @@ -309,6 +352,57 @@ impl GroupPanel { })); } + fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let server = AppSettings::get_file_server(cx); + + let path = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + // Selecting no file means the prompt was cancelled + let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + return Ok(()); + }; + + this.update(cx, |this, cx| this.set_uploading(true, cx))?; + + match upload(server, path, cx).await { + Ok(url) => { + this.update_in(cx, |this, _window, cx| { + this.attachments.push(url); + this.set_uploading(false, cx); + })?; + } + Err(error) => { + this.update_in(cx, |this, window, cx| { + this.set_uploading(false, cx); + + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { + self.uploading = uploading; + cx.notify(); + } + + fn remove_attachment(&mut self, url: &Url, cx: &mut Context) { + self.attachments.retain(|attachment| attachment != url); + cx.notify(); + } + fn notice(&self, cx: &App) -> Option { self.group .upgrade() @@ -546,17 +640,24 @@ impl GroupPanel { return div().into_any_element(); }; - let (name, picture, seed, admin) = { + let (about, relay, group_id, admin) = { let group = group.read(cx); + + let about = group + .metadata() + .and_then(|metadata| metadata.about.clone()) + .filter(|about| !about.trim().is_empty()) + .map(SharedString::from); + 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(), + about, + group.key().relay().clone(), + group.key().id().as_str().to_owned(), admin, ) }; @@ -573,15 +674,30 @@ impl GroupPanel { .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), + .text_sm() + .text_color(cx.theme().text_muted) + .when_some(about, |this, about| this.child(about)), + ) + .child( + Button::new("group-info") + .icon(IconName::PanelLeftOpen) + .tooltip("Toggle group meta panel in the sidebar") + .ghost() + .small() + .on_click(move |_event, window, cx| { + window.dispatch_action( + Box::new(ToggleGroupMeta { + relay: relay.clone(), + group: group_id.clone(), + }), + cx, + ); + }), ) .child( Button::new("group-details") @@ -908,25 +1024,81 @@ impl GroupPanel { supports_text && writable }); - h_flex() + v_flex() .flex_shrink_0() .w_full() .p_2() .gap_1() - .items_end() - .child(Textarea::new(&self.input).appearance(false).flex_1()) + .children(self.render_attachments(cx)) .child( - Button::new("send") - .icon(IconName::PaperPlaneFill) - .tooltip("Send") - .ghost() - .large() - .disabled(!can_send) - .on_click(cx.listener(|this, _event, window, cx| { - this.send(window, cx); - })), + h_flex() + .gap_1() + .items_end() + .child( + Button::new("upload") + .icon(IconName::Upload) + .tooltip("Upload file") + .loading(self.uploading) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(|this, _event, window, cx| { + this.upload(window, cx); + })), + ) + .child(Textarea::new(&self.input).appearance(false).flex_1()) + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .tooltip("Send") + .ghost() + .large() + .disabled(!can_send) + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ), ) } + + fn render_attachments(&self, cx: &mut Context) -> Vec { + self.attachments + .iter() + .enumerate() + .map(|(ix, url)| { + let url = url.clone(); + + h_flex() + .items_center() + .gap_1() + .h_8() + .px_2() + .border_1() + .border_color(cx.theme().border_variant) + .rounded(cx.theme().radius) + .text_xs() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::Link).xsmall()) + .child( + div() + .max_w(px(200.)) + .truncate() + .child(attachment_name(&url)), + ) + .child( + Button::new(SharedString::from(format!("remove-attachment-{ix}"))) + .icon(IconName::Close) + .tooltip("Remove") + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _event, _window, cx| { + this.remove_attachment(&url, cx); + })), + ) + .into_any_element() + }) + .collect() + } } impl Panel for GroupPanel { diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index fc6bd659..9496ba0c 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -117,7 +117,7 @@ impl ThemeColors { window_border: hsl(240.0, 5.9, 78.0), border: neutral().light().step_6(), - border_variant: neutral().light().step_5(), + border_variant: neutral().light().step_4(), border_focused: brand().light().step_7(), border_selected: brand().light().step_7(), border_transparent: gpui::transparent_black(), @@ -203,7 +203,7 @@ impl ThemeColors { window_border: hsl(240.0, 3.7, 28.0), border: neutral().dark().step_6(), - border_variant: neutral().dark().step_5(), + border_variant: neutral().dark().step_4(), border_focused: brand().dark().step_7(), border_selected: brand().dark().step_7(), border_transparent: gpui::transparent_black(), diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index ff9b1615..b34b5c16 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -2,6 +2,7 @@ pub mod import; pub mod join_group; pub mod new_chat; pub mod new_group; +pub mod profile; pub mod restore; pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs new file mode 100644 index 00000000..733ba71e --- /dev/null +++ b/crates/workspace/src/dialogs/profile.rs @@ -0,0 +1,166 @@ +use anyhow::Error; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext, ClipboardItem, Context, Div, IntoElement, ParentElement, Render, SharedString, + Styled, Task, Window, div, px, +}; +use instant::Duration; +use nostr_sdk::prelude::*; +use person::{Person, PersonRegistry, shorten_pubkey}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex}; + +/// Open a read-only profile for `public_key`. +pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) { + let profile = cx.new(|_| ProfileDialog::new(public_key)); + + window.open_dialog(cx, move |this, _window, _cx| { + this.width(px(360.)).title("Profile").child(profile.clone()) + }); +} + +struct ProfileDialog { + public_key: PublicKey, + copied: bool, + tasks: Vec>>, +} + +impl ProfileDialog { + fn new(public_key: PublicKey) -> Self { + Self { + public_key, + copied: false, + tasks: vec![], + } + } + + fn profile(&self, cx: &App) -> Person { + let persons = PersonRegistry::global(cx); + persons.read(cx).get(&self.public_key, cx) + } + + fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context) { + let item = ClipboardItem::new_string(value); + cx.write_to_clipboard(item); + + self.set_copied(true, window, cx); + } + + fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context) { + self.copied = status; + cx.notify(); + + if status { + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + // Reset the copied state after a delay + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.set_copied(false, window, cx); + }) + .ok(); + }) + .ok(); + + Ok(()) + })); + } + } +} + +impl Render for ProfileDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let profile = self.profile(cx); + let metadata = profile.metadata(); + + // Get the public key as short string + let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); + + let about = metadata + .about + .filter(|about| !about.trim().is_empty()) + .map(SharedString::from); + + let website = metadata + .website + .filter(|website| !website.trim().is_empty()) + .map(SharedString::from); + + v_flex() + .gap_3() + .w_full() + .child( + v_flex() + .h_40() + .w_full() + .items_center() + .justify_center() + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .large(), + ), + ) + .child( + v_flex() + .gap_1p5() + .child(label("Name", cx)) + .child(div().text_sm().child(profile.name())), + ) + .when_some(about, |this, about| { + this.child( + v_flex() + .gap_1p5() + .child(label("About", cx)) + .child(div().text_sm().child(about)), + ) + }) + .when_some(website, |this, website| { + this.child( + v_flex() + .gap_1p5() + .child(label("Website", cx)) + .child(div().text_sm().child(website)), + ) + }) + .child( + v_flex().gap_1p5().child(label("Public Key", cx)).child( + h_flex() + .h_8() + .w_full() + .justify_center() + .gap_3() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary_background) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(shorten_pkey) + .child( + Button::new("copy") + .icon({ + if self.copied { + IconName::CheckCircle + } else { + IconName::Copy + } + }) + .xsmall() + .secondary() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.copy(this.public_key.to_bech32().unwrap(), window, cx); + })), + ), + ), + ) + } +} + +fn label(label: &'static str, cx: &App) -> Div { + div() + .text_sm() + .text_color(cx.theme().text_muted) + .child(label) +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index f09eff4d..dca86664 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -10,6 +10,7 @@ use gpui::{ Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, }; +use nip29::{GroupId, GroupKey}; use nostr_sdk::prelude::*; use serde::Deserialize; use smallvec::{SmallVec, smallvec}; @@ -66,6 +67,7 @@ enum Command { pub struct Workspace { dock: Entity, + sidebar: Entity, /// Async tasks tasks: Vec>>, /// Event subscriptions @@ -80,6 +82,7 @@ impl Workspace { let (dock, _) = dock::dock_area("coop", window, cx); let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx)); + let left_sidebar = sidebar.clone(); let mut subscriptions = smallvec![]; @@ -187,7 +190,7 @@ impl Workspace { ); cx.defer_in(window, move |this, window, cx| { - let sidebar = PanelHandle::new(sidebar); + let sidebar = PanelHandle::new(left_sidebar); let greeter = PanelHandle::new(greeter::init(window, cx)); this.dock.update(cx, |this, cx| { @@ -201,8 +204,20 @@ impl Workspace { }); }); + // Listen app-wide, so a group panel can toggle the sidebar without holding it. + let workspace = cx.entity().downgrade(); + + App::on_action(cx, move |action: &nip29_ui::ToggleGroupMeta, cx| { + let Some(workspace) = workspace.upgrade() else { + return; + }; + + workspace.update(cx, |this, cx| this.toggle_group_meta(action, cx)); + }); + Self { dock, + sidebar, tasks: vec![], _subscriptions: subscriptions, } @@ -382,6 +397,19 @@ impl Workspace { } } + /// Show or hide the sidebar's information for the group a panel belongs to. + fn toggle_group_meta(&mut self, action: &nip29_ui::ToggleGroupMeta, cx: &mut Context) { + let Ok(id) = GroupId::new(action.group.clone()) else { + return; + }; + + let key = GroupKey::new(action.relay.clone(), id); + + self.sidebar.update(cx, |sidebar, cx| { + sidebar.toggle_group(&key, cx); + }); + } + fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context) { const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \ Your identity is completely decoupled from all encryption processes to protect your privacy."; diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 670e5e82..9bbdf144 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -13,7 +13,7 @@ use gpui::{ Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px, retain_all, uniform_list, }; -use nip29::{Group, GroupsEvent, GroupsRegistry, Membership}; +use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership}; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; @@ -38,7 +38,7 @@ use ui::{ }; use crate::Command; -use crate::dialogs::{import, screening}; +use crate::dialogs::{import, profile, screening}; mod tab; mod utils; @@ -87,6 +87,8 @@ pub struct Sidebar { /// The group whose information the sidebar is browsing, if any. group: Option>, + /// Whether the sidebar is showing the group's information instead of its tabs. + group_open: bool, /// Scroll state of the group's member list. group_scroll: UniformListScrollHandle, /// Whether the group view's admins section is expanded. @@ -165,6 +167,7 @@ impl Sidebar { banner: pick_banner(), active_tab: SidebarTab::Inbox, group: None, + group_open: false, group_scroll: UniformListScrollHandle::new(), admins_open: true, members_open: true, @@ -183,14 +186,41 @@ impl Sidebar { /// Show the group view, replacing the tab list. fn show_group(&mut self, group: &Entity, cx: &mut Context) { self.group = Some(group.downgrade()); + self.group_open = true; + cx.notify(); + } + + /// Show or hide the group view depending on which group is being viewed. + pub(crate) fn toggle_group(&mut self, key: &GroupKey, cx: &mut Context) { + let groups = GroupsRegistry::global(cx); + + let Some(entity) = groups.read(cx).group(key, cx) else { + return; + }; + + let open = self + .group + .as_ref() + .and_then(|group| group.upgrade()) + .is_some_and(|current| current.entity_id() == entity.entity_id()); + + if open { + self.group_open = !self.group_open; + } else { + self.group = Some(entity.downgrade()); + self.group_open = true; + } + cx.notify(); } /// Leave the group view, returning the sidebar to its tab list. - fn reset_group(&mut self, cx: &mut Context) { - if self.group.take().is_none() { + fn hide_group(&mut self, cx: &mut Context) { + if !self.group_open { return; } + + self.group_open = false; cx.notify(); } @@ -430,7 +460,7 @@ impl Sidebar { .when_some(AutoUpdater::try_global(cx), |this, updater| { this.child(self.render_updater(updater, cx)) }) - .when(self.group.is_some(), |this| { + .when(self.group_open, |this| { this.child( Button::new("sidebar-back") .icon(IconName::ArrowLeft) @@ -438,7 +468,7 @@ impl Sidebar { .ghost() .small() .on_click(cx.listener(|this, _event, _window, cx| { - this.reset_group(cx); + this.hide_group(cx); })), ) }) @@ -935,7 +965,9 @@ impl Sidebar { let metadata = group.metadata(); ( - metadata.and_then(|metadata| metadata.banner.clone()), + metadata.and_then(|metadata| { + metadata.banner.clone().or_else(|| metadata.picture.clone()) + }), group.display_name(), group.key().cache_tag(), group.display_image(), @@ -950,6 +982,18 @@ impl Sidebar { .min_h_0() .w_full() .gap_2() + .when_some(banner, |this, banner| { + this.child( + div().px_2().flex_shrink_0().child( + div() + .h_40() + .w_full() + .overflow_hidden() + .rounded(cx.theme().radius_lg) + .child(img(banner).size_full().object_fit(ObjectFit::Cover)), + ), + ) + }) .child( h_flex() .px_2() @@ -966,17 +1010,6 @@ impl Sidebar { .child(name), ), ) - .when_some(banner, |this, banner| { - this.child( - div().px_2().flex_shrink_0().child( - img(banner) - .w_full() - .h_20() - .rounded(cx.theme().radius_lg) - .object_fit(ObjectFit::Cover), - ), - ) - }) .child( div() .min_h_0() @@ -1073,6 +1106,11 @@ impl Sidebar { .child(SharedString::from(roles.join(", "))), ) }) + .on_click({ + let public_key = *public_key; + + move |_event, window, cx| profile::open(public_key, window, cx) + }) .into_any_element() } } @@ -1175,8 +1213,8 @@ impl Render for Sidebar { ) }) .map(|this| match group { - Some(group) => this.child(self.render_group(group, cx)), - None => this.child(self.render_tabs(cx)), + Some(group) if self.group_open => this.child(self.render_group(group, cx)), + _ => this.child(self.render_tabs(cx)), }) .when(loading && logged_in, |this| { this.child( diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index e1142c69..0955887c 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -591,11 +591,17 @@ before they reach the consumer, so a replay cannot double-hydrate. 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. - Opening a group also fills the sidebar with the group itself — avatar, banner - and the collapsible `Admins`/`Members` lists — with a back button in the title - bar, the same shape the sidebar used for a community before the migration. - What a given account may actually do is the relay's call; the client shows the - refusal verbatim. + Opening a group also fills the sidebar with the group itself — banner (the + `39000` `banner` tag, falling back to its `picture`), avatar and name, and the + collapsible `Admins`/`Members` lists. Members are clickable and open a profile + dialog. The panel header shows the group's `about` instead of its name, plus a + button that shows or hides the sidebar's group information. What a given account + may actually do is the relay's call; the client shows the refusal verbatim. +- **The composer uploads to the configured blossom server.** `GroupPanel`'s + paperclip uploads one file at a time, unencrypted, and appends its URL to the + message on send — the same plain-attachment path the chat composer uses when a + message is already being typed. The server comes from + `AppSettings::get_file_server`; the upload itself is `state::upload`. - **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