feat: add support for NIP-29 #56
Generated
+2
@@ -4621,11 +4621,13 @@ dependencies = [
|
|||||||
"chat_ui",
|
"chat_ui",
|
||||||
"common",
|
"common",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
|
"log",
|
||||||
"nip29",
|
"nip29",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
"settings",
|
"settings",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"state",
|
||||||
"theme",
|
"theme",
|
||||||
"ui",
|
"ui",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ common = { path = "../common" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
state = { path = "../state" }
|
||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|||||||
@@ -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
@@ -9,21 +9,24 @@ use gpui::{
|
|||||||
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
||||||
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
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 nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
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::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||||
use ui::markdown::RenderedText;
|
use ui::markdown::RenderedText;
|
||||||
use ui::message::WelcomeMessage;
|
use ui::message::WelcomeMessage;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
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;
|
mod message;
|
||||||
|
|
||||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
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))
|
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 {
|
pub struct GroupPanel {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -279,6 +302,229 @@ impl GroupPanel {
|
|||||||
.and_then(|group| Notice::of(group.read(cx)))
|
.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 {
|
fn render_welcome(&self, cx: &App) -> AnyElement {
|
||||||
let (name, avatar) = match self.group.upgrade() {
|
let (name, avatar) = match self.group.upgrade() {
|
||||||
Some(group) => {
|
Some(group) => {
|
||||||
@@ -419,7 +665,7 @@ impl GroupPanel {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let show_author = opens_run(messages, ix - 2);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
let content = self
|
let content = self
|
||||||
@@ -430,7 +676,27 @@ impl GroupPanel {
|
|||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.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 {
|
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
@@ -512,6 +778,9 @@ impl Render for GroupPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
|
.child(self.render_header(cx))
|
||||||
|
.child(self.render_banner(cx))
|
||||||
|
.child(self.render_pins(cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|||||||
@@ -3,26 +3,38 @@ use common::TimestampExt;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled,
|
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled,
|
||||||
StyledImage, div, img, px,
|
StyledImage, WeakEntity, div, img, px,
|
||||||
};
|
};
|
||||||
|
use nip29::Group;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
|
use ui::menu::{ContextMenu, PopupMenuItem};
|
||||||
use ui::message::MessageRow;
|
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(
|
pub(crate) fn render(
|
||||||
ix: usize,
|
ix: usize,
|
||||||
message: &Message,
|
message: &Message,
|
||||||
content: AnyElement,
|
content: AnyElement,
|
||||||
show_author: bool,
|
show_author: bool,
|
||||||
|
actions: Actions,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let author = persons.read(cx).get(&message.author, cx);
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
|
||||||
MessageRow::new(ix)
|
let row = MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
.hide_avatar(hide_avatar)
|
.hide_avatar(hide_avatar)
|
||||||
.avatar(
|
.avatar(
|
||||||
@@ -36,7 +48,53 @@ pub(crate) fn render(
|
|||||||
.when(!message.media.is_empty(), |this| {
|
.when(!message.media.is_empty(), |this| {
|
||||||
this.child(media(&message.media, cx))
|
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 {
|
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||||
|
|||||||
@@ -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
|
Read-mostly group management, the surfaces the sidebar gave up in Phase 2 plus the two writes
|
||||||
that need Phase 0.
|
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`),
|
about, flags (`private`, `restricted`, `hidden`, `closed`, `livekit`, `parent`, `children`),
|
||||||
admins with their role labels (`Group::admins().entries()`), the roles the relay advertises
|
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
|
(`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.
|
`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
|
- **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.
|
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
|
on confirm `registry.leave(&key, cx)` and the panel closes (dispatch `ClosePanel`, as the
|
||||||
screening dialog does).
|
screening dialog does).
|
||||||
- **Fork / migration banner** — when `group.elsewhere()` is `Some(moved)`, a banner:
|
- **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.
|
`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.
|
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()`.
|
- `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
|
- The old crates are deleted only at the end, so any phase can be stopped and the app still
|
||||||
runs.
|
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.
|
||||||
|
|||||||
+4
-3
@@ -569,11 +569,12 @@ before they reach the consumer, so a replay cannot double-hydrate.
|
|||||||
|
|
||||||
## Not wired up yet
|
## 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
|
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
|
||||||
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
|
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
|
from a result. The panel now has a read-only details dialog, message delete and
|
||||||
built.
|
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
|
- **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.
|
standing REQ, so only our own accepted deletes evict the row.
|
||||||
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
||||||
|
|||||||
Reference in New Issue
Block a user