add group header, moderation menu, and pins strip

This commit is contained in:
2026-09-30 16:07:21 +07:00
parent 5b87b2fe27
commit ce1cff83ff
7 changed files with 619 additions and 14 deletions
+2
View File
@@ -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
+268
View File
@@ -0,0 +1,268 @@
use gpui::{
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
};
use nip29::{Group, GroupMetadata};
use nostr_sdk::prelude::PublicKey;
use person::PersonRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, cx| {
this.width(px(460.))
.max_h(px(600.))
.show_close(true)
.title("Group details")
.child(body(&group, cx))
});
}
fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
let Some(entity) = group.upgrade() else {
return div().into_any_element();
};
let group = entity.read(cx);
let mut body = v_flex().gap_3().text_sm().child(header(group, cx));
if let Some(metadata) = group.metadata() {
if let Some(about) = metadata.about.as_ref() {
body = body.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from(about.clone())),
);
}
let flags = flags(metadata);
if !flags.is_empty() {
body = body.child(section(
"Flags",
wrap(flags.into_iter().map(|f| chip(f, cx))),
cx,
));
}
if let Some(parent) = metadata.parent.as_ref() {
body = body.child(section(
"Parent",
div()
.child(SharedString::from(parent.as_str().to_owned()))
.into_any_element(),
cx,
));
}
if !metadata.children.is_empty() {
body = body.child(section(
"Children",
wrap(
metadata
.children
.iter()
.map(|child| chip(child.as_str().to_owned(), cx)),
),
cx,
));
}
if let Some(kinds) = metadata.supported_kinds.as_ref() {
let items: Vec<AnyElement> = kinds
.iter()
.map(|kind| chip(kind.to_string(), cx))
.collect();
body = body.child(section(
"Supported kinds",
if items.is_empty() {
div()
.text_color(cx.theme().text_placeholder)
.child("None")
.into_any_element()
} else {
wrap(items)
},
cx,
));
}
}
let admins = group.admins().entries();
if !admins.is_empty() {
body = body.child(section(
"Admins",
v_flex()
.gap_1()
.children(admins.iter().map(|(key, roles)| admin(key, roles, cx)))
.into_any_element(),
cx,
));
}
let roles = group.roles().roles();
if !roles.is_empty() {
body = body.child(section(
"Roles",
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
cx,
));
}
let members: Vec<&PublicKey> = group.members().iter().collect();
if !members.is_empty() {
body = body.child(section(
"Members",
v_flex()
.gap_1()
.children(members.into_iter().map(|key| person(key, cx)))
.into_any_element(),
cx,
));
}
let pins = group.pins().pins();
if !pins.is_empty() {
body = body.child(section(
"Pinned",
v_flex()
.gap_1()
.children(
pins.iter()
.map(|pin| {
div()
.truncate()
.text_color(cx.theme().text_muted)
.child(crate::pin_label(group, pin))
.into_any_element()
})
.collect::<Vec<_>>(),
)
.into_any_element(),
cx,
));
}
body.into_any_element()
}
fn header(group: &Group, cx: &App) -> AnyElement {
let key = group.key();
h_flex()
.gap_3()
.items_center()
.child(
Avatar::from_source(group.display_image())
.seed(key.cache_tag())
.large(),
)
.child(
v_flex()
.min_w_0()
.child(
div()
.text_base()
.font_semibold()
.truncate()
.child(group.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.truncate()
.child(SharedString::from(format!(
"{} · {}",
key.id().as_str(),
key.relay().as_str()
))),
),
)
.into_any_element()
}
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_medium()
.text_color(cx.theme().text_muted)
.child(label),
)
.child(content)
.into_any_element()
}
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
h_flex()
.flex_wrap()
.gap_1()
.children(items)
.into_any_element()
}
fn admin(public_key: &PublicKey, roles: &[String], cx: &App) -> AnyElement {
h_flex()
.justify_between()
.gap_2()
.child(person(public_key, cx))
.children(roles.iter().map(|role| chip(role.clone(), cx)))
.into_any_element()
}
fn person(public_key: &PublicKey, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
h_flex()
.gap_2()
.min_w_0()
.child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.xsmall(),
)
.child(div().truncate().child(person.name()))
.into_any_element()
}
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
div()
.px_1p5()
.py_0p5()
.rounded(cx.theme().radius)
.bg(cx.theme().element_background)
.text_xs()
.child(label.into())
.into_any_element()
}
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
let mut flags = Vec::new();
if metadata.private {
flags.push("Private");
}
if metadata.restricted {
flags.push("Restricted");
}
if metadata.hidden {
flags.push("Hidden");
}
if metadata.closed {
flags.push("Closed");
}
if metadata.livekit {
flags.push("LiveKit");
}
flags
}
+274 -5
View File
@@ -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<Group>, window: &mut Window, cx: &mut App) -> Entity<G
cx.new(|cx| GroupPanel::new(group, window, cx))
}
pub(crate) fn pin_label(group: &Group, pin: &Pin) -> 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::<String>()),
None => SharedString::from(id.to_hex().chars().take(8).collect::<String>()),
}
}
}
}
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<Group>,
id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Group>,
id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Group>, 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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()
+61 -3
View File
@@ -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<Group>,
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
}
pub(crate) fn render(
ix: usize,
message: &Message,
content: AnyElement,
show_author: bool,
actions: Actions,
cx: &App,
) -> AnyElement {
let persons = PersonRegistry::global(cx);
let author = persons.read(cx).get(&message.author, cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
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 {